Merge branch 'develop'

This commit is contained in:
Pieter Vander Vennet 2021-07-23 15:47:56 +02:00
commit 1216af34e5
71 changed files with 1944 additions and 731 deletions

16
.devcontainer/Dockerfile Normal file
View file

@ -0,0 +1,16 @@
# See here for image contents: https://github.com/microsoft/vscode-dev-containers/tree/v0.187.0/containers/typescript-node/.devcontainer/base.Dockerfile
# [Choice] Node.js version: 16, 14, 12
ARG VARIANT="16-buster"
FROM mcr.microsoft.com/vscode/devcontainers/typescript-node:0-${VARIANT}
# [Optional] Uncomment this section to install additional OS packages.
# RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
# && apt-get -y install --no-install-recommends <your-package-list-here>
# [Optional] Uncomment if you want to install an additional version of node using nvm
# ARG EXTRA_NODE_VERSION=10
# RUN su node -c "source /usr/local/share/nvm/nvm.sh && nvm install ${EXTRA_NODE_VERSION}"
# [Optional] Uncomment if you want to install more global node packages
# RUN su node -c "npm install -g <your-package-list -here>"

View file

@ -0,0 +1,29 @@
// For format details, see https://aka.ms/devcontainer.json. For config options, see the README at:
// https://github.com/microsoft/vscode-dev-containers/tree/v0.187.0/containers/typescript-node
{
"name": "MapComplete",
"build": {
"dockerfile": "Dockerfile",
// Update 'VARIANT' to pick a Node version: 12, 14, 16
"args": {
"VARIANT": "16"
}
},
// Set *default* container specific settings.json values on container create.
"settings": {},
// Add the IDs of extensions you want installed when the container is created.
"extensions": [
],
// Use 'forwardPorts' to make a list of ports inside the container available locally.
"forwardPorts": [1234],
// Use 'postCreateCommand' to run commands after the container is created.
"postCreateCommand": "npm run init",
// Comment out connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root.
"remoteUser": "node"
}

3
.vscode/settings.json vendored Normal file
View file

@ -0,0 +1,3 @@
{
"files.eol": "\n"
}

View file

@ -3,6 +3,7 @@ import UnitConfigJson from "./UnitConfigJson";
import Translations from "../../UI/i18n/Translations"; import Translations from "../../UI/i18n/Translations";
import BaseUIElement from "../../UI/BaseUIElement"; import BaseUIElement from "../../UI/BaseUIElement";
import Combine from "../../UI/Base/Combine"; import Combine from "../../UI/Base/Combine";
import {FixedUiElement} from "../../UI/Base/FixedUiElement";
export class Unit { export class Unit {
public readonly appliesToKeys: Set<string>; public readonly appliesToKeys: Set<string>;
@ -81,7 +82,10 @@ export class Unit {
return undefined; return undefined;
} }
const [stripped, denom] = this.findDenomination(value) const [stripped, denom] = this.findDenomination(value)
const human = denom.human const human = denom?.human
if(human === undefined){
return new FixedUiElement(stripped ?? value);
}
const elems = denom.prefix ? [human, stripped] : [stripped, human]; const elems = denom.prefix ? [human, stripped] : [stripped, human];
return new Combine(elems) return new Combine(elems)
@ -152,7 +156,7 @@ export class Denomination {
if (stripped === null) { if (stripped === null) {
return null; return null;
} }
return stripped + " " + this.canonical.trim() return (stripped + " " + this.canonical.trim()).trim();
} }
/** /**

View file

@ -54,6 +54,7 @@ export default class LayerConfig {
title: Translation, title: Translation,
tags: Tag[], tags: Tag[],
description?: Translation, description?: Translation,
preciseInput?: {preferredBackground?: string}
}[]; }[];
tagRenderings: TagRenderingConfig []; tagRenderings: TagRenderingConfig [];
@ -130,12 +131,19 @@ export default class LayerConfig {
this.minzoom = json.minzoom ?? 0; this.minzoom = json.minzoom ?? 0;
this.maxzoom = json.maxzoom ?? 1000; this.maxzoom = json.maxzoom ?? 1000;
this.wayHandling = json.wayHandling ?? 0; this.wayHandling = json.wayHandling ?? 0;
this.presets = (json.presets ?? []).map((pr, i) => this.presets = (json.presets ?? []).map((pr, i) => {
({ if(pr.preciseInput === true){
pr.preciseInput = {
preferredBackground: undefined
}
}
return ({
title: Translations.T(pr.title, `${context}.presets[${i}].title`), title: Translations.T(pr.title, `${context}.presets[${i}].title`),
tags: pr.tags.map(t => FromJSON.SimpleTag(t)), tags: pr.tags.map(t => FromJSON.SimpleTag(t)),
description: Translations.T(pr.description, `${context}.presets[${i}].description`) description: Translations.T(pr.description, `${context}.presets[${i}].description`),
})) preciseInput: pr.preciseInput
});
})
/** Given a key, gets the corresponding property from the json (or the default if not found /** Given a key, gets the corresponding property from the json (or the default if not found

View file

@ -217,6 +217,16 @@ export interface LayerConfigJson {
* (The first sentence is until the first '.'-character in the description) * (The first sentence is until the first '.'-character in the description)
*/ */
description?: string | any, description?: string | any,
/**
* If set, the user will prompted to confirm the location before actually adding the data.
* THis will be with a 'drag crosshair'-method.
*
* If 'preferredBackgroundCategory' is set, the element will attempt to pick a background layer of that category.
*/
preciseInput?: true | {
preferredBackground: "osmbasedmap" | "photo" | "historicphoto" | "map" | string
}
}[], }[],
/** /**

View file

@ -42,6 +42,7 @@ export default class LayoutConfig {
public readonly enableGeolocation: boolean; public readonly enableGeolocation: boolean;
public readonly enableBackgroundLayerSelection: boolean; public readonly enableBackgroundLayerSelection: boolean;
public readonly enableShowAllQuestions: boolean; public readonly enableShowAllQuestions: boolean;
public readonly enableExportButton: boolean;
public readonly customCss?: string; public readonly customCss?: string;
/* /*
How long is the cache valid, in seconds? How long is the cache valid, in seconds?
@ -152,6 +153,7 @@ export default class LayoutConfig {
this.enableAddNewPoints = json.enableAddNewPoints ?? true; this.enableAddNewPoints = json.enableAddNewPoints ?? true;
this.enableBackgroundLayerSelection = json.enableBackgroundLayerSelection ?? true; this.enableBackgroundLayerSelection = json.enableBackgroundLayerSelection ?? true;
this.enableShowAllQuestions = json.enableShowAllQuestions ?? false; this.enableShowAllQuestions = json.enableShowAllQuestions ?? false;
this.enableExportButton = json.enableExportButton ?? false;
this.customCss = json.customCss; this.customCss = json.customCss;
this.cacheTimeout = json.cacheTimout ?? (60 * 24 * 60 * 60) this.cacheTimeout = json.cacheTimout ?? (60 * 24 * 60 * 60)

View file

@ -15,6 +15,7 @@ import UnitConfigJson from "./UnitConfigJson";
* General remark: a type (string | any) indicates either a fixed or a translatable string. * General remark: a type (string | any) indicates either a fixed or a translatable string.
*/ */
export interface LayoutConfigJson { export interface LayoutConfigJson {
/** /**
* The id of this layout. * The id of this layout.
* *
@ -225,6 +226,10 @@ export interface LayoutConfigJson {
* *
* Not only do we want to write consistent data to OSM, we also want to present this consistently to the user. * Not only do we want to write consistent data to OSM, we also want to present this consistently to the user.
* This is handled by defining units. * This is handled by defining units.
*
* # Rendering
*
* To render a value with long (human) denomination, use {canonical(key)}
* *
* # Usage * # Usage
* *
@ -331,4 +336,5 @@ export interface LayoutConfigJson {
enableGeolocation?: boolean; enableGeolocation?: boolean;
enableBackgroundLayerSelection?: boolean; enableBackgroundLayerSelection?: boolean;
enableShowAllQuestions?: boolean; enableShowAllQuestions?: boolean;
enableExportButton?: boolean;
} }

View file

@ -26,6 +26,9 @@ export default class TagRenderingConfig {
readonly key: string, readonly key: string,
readonly type: string, readonly type: string,
readonly addExtraTags: TagsFilter[]; readonly addExtraTags: TagsFilter[];
readonly inline: boolean,
readonly default?: string,
readonly helperArgs?: (string | number | boolean)[]
}; };
readonly multiAnswer: boolean; readonly multiAnswer: boolean;
@ -73,7 +76,9 @@ export default class TagRenderingConfig {
type: json.freeform.type ?? "string", type: json.freeform.type ?? "string",
addExtraTags: json.freeform.addExtraTags?.map((tg, i) => addExtraTags: json.freeform.addExtraTags?.map((tg, i) =>
FromJSON.Tag(tg, `${context}.extratag[${i}]`)) ?? [], FromJSON.Tag(tg, `${context}.extratag[${i}]`)) ?? [],
inline: json.freeform.inline ?? false,
default: json.freeform.default,
helperArgs: json.freeform.helperArgs
} }
if (json.freeform["extraTags"] !== undefined) { if (json.freeform["extraTags"] !== undefined) {
@ -332,20 +337,20 @@ export default class TagRenderingConfig {
* Note: this might be hidden by conditions * Note: this might be hidden by conditions
*/ */
public hasMinimap(): boolean { public hasMinimap(): boolean {
const translations : Translation[]= Utils.NoNull([this.render, ...(this.mappings ?? []).map(m => m.then)]); const translations: Translation[] = Utils.NoNull([this.render, ...(this.mappings ?? []).map(m => m.then)]);
for (const translation of translations) { for (const translation of translations) {
for (const key in translation.translations) { for (const key in translation.translations) {
if(!translation.translations.hasOwnProperty(key)){ if (!translation.translations.hasOwnProperty(key)) {
continue continue
} }
const template = translation.translations[key] const template = translation.translations[key]
const parts = SubstitutedTranslation.ExtractSpecialComponents(template) const parts = SubstitutedTranslation.ExtractSpecialComponents(template)
const hasMiniMap = parts.filter(part =>part.special !== undefined ).some(special => special.special.func.funcName === "minimap") const hasMiniMap = parts.filter(part => part.special !== undefined).some(special => special.special.func.funcName === "minimap")
if(hasMiniMap){ if (hasMiniMap) {
return true; return true;
} }
} }
} }
return false; return false;
} }
} }

View file

@ -30,6 +30,7 @@ export interface TagRenderingConfigJson {
* Allow freeform text input from the user * Allow freeform text input from the user
*/ */
freeform?: { freeform?: {
/** /**
* If this key is present, then 'render' is used to display the value. * If this key is present, then 'render' is used to display the value.
* If this is undefined, the rendering is _always_ shown * If this is undefined, the rendering is _always_ shown
@ -40,13 +41,30 @@ export interface TagRenderingConfigJson {
* See Docs/SpecialInputElements.md and UI/Input/ValidatedTextField.ts for supported values * See Docs/SpecialInputElements.md and UI/Input/ValidatedTextField.ts for supported values
*/ */
type?: string, type?: string,
/**
* Extra parameters to initialize the input helper arguments.
* For semantics, see the 'SpecialInputElements.md'
*/
helperArgs?: (string | number | boolean)[];
/** /**
* If a value is added with the textfield, these extra tag is addded. * If a value is added with the textfield, these extra tag is addded.
* Useful to add a 'fixme=freeform textfield used - to be checked' * Useful to add a 'fixme=freeform textfield used - to be checked'
**/ **/
addExtraTags?: string[]; addExtraTags?: string[];
/**
* When set, influences the way a question is asked.
* Instead of showing a full-widht text field, the text field will be shown within the rendering of the question.
*
* This combines badly with special input elements, as it'll distort the layout.
*/
inline?: boolean
/**
* default value to enter if no previous tagging is present.
* Normally undefined (aka do not enter anything)
*/
default?: string
}, },
/** /**

View file

@ -18,9 +18,9 @@
Development Development
----------- -----------
**Windows users**: All scripts are made for linux devices. Use the Ubuntu terminal for Windows (or even better - make the switch ;) ). If you are using Visual Studio, open everything in a 'new WSL Window'. **Windows users**: All scripts are made for linux devices. Use the Ubuntu terminal for Windows (or even better - make the switch ;) ). If you are using Visual Studio Code you can use a [WSL Remote](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-wsl) window, or use the Devcontainer (see more details later).
To develop and build MapComplete, yo To develop and build MapComplete, you
0. Make sure you have a recent version of nodejs - at least 12.0, preferably 15 0. Make sure you have a recent version of nodejs - at least 12.0, preferably 15
0. Make a fork and clone the repository. 0. Make a fork and clone the repository.
@ -29,6 +29,30 @@
4. Run `npm run start` to host a local testversion at http://localhost:1234/index.html 4. Run `npm run start` to host a local testversion at http://localhost:1234/index.html
5. By default, a landing page with available themes is served. In order to load a single theme, use `layout=themename` or `userlayout=true#<layout configuration>` as [Query parameter](URL_Parameters.md). Note that the shorter URLs (e.g. `bookcases.html`, `aed.html`, ...) _don't_ exist on the development version. 5. By default, a landing page with available themes is served. In order to load a single theme, use `layout=themename` or `userlayout=true#<layout configuration>` as [Query parameter](URL_Parameters.md). Note that the shorter URLs (e.g. `bookcases.html`, `aed.html`, ...) _don't_ exist on the development version.
Development using Windows
------------------------
For Windows you can use the devcontainer, or the WSL subsystem.
To use the devcontainer in Visual Studio Code:
0. Make sure you have installed the [Remote - Containers](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers) extension and it's dependencies.
1. Make a fork and clone the repository.
2. After cloning, Visual Studio Code will ask you if you want to use the devcontainer.
3. Then you can either clone it again in a volume (for better performance), or open the current folder in a container.
4. By now, you should be able to run `npm run start` to host a local testversion at http://localhost:1234/index.html
5. By default, a landing page with available themes is served. In order to load a single theme, use `layout=themename` or `userlayout=true#<layout configuration>` as [Query parameter](URL_Parameters.md). Note that the shorter URLs (e.g. `bookcases.html`, `aed.html`, ...) _don't_ exist on the development version.
To use the WSL in Visual Studio Code:
0. Make sure you have installed the [Remote - WSL]() extension and it's dependencies.
1. Open a remote WSL window using the button in the bottom left.
2. Make a fork and clone the repository.
3. Install `npm` using `sudo apt install npm`.
4. Run `npm run init` and generate some additional dependencies and generated files. Note that it'll install the dependencies too
5. Run `npm run start` to host a local testversion at http://localhost:1234/index.html
6. By default, a landing page with available themes is served. In order to load a single theme, use `layout=themename` or `userlayout=true#<layout configuration>` as [Query parameter](URL_Parameters.md). Note that the shorter URLs (e.g. `bookcases.html`, `aed.html`, ...) _don't_ exist on the development version.
Automatic deployment Automatic deployment
-------------------- --------------------

View file

@ -20,126 +20,158 @@ the URL-parameters are stated in the part between the `?` and the `#`. There are
Finally, the URL-hash is the part after the `#`. It is `node/1234` in this case. Finally, the URL-hash is the part after the `#`. It is `node/1234` in this case.
layer-control-toggle backend
----------------------
Whether or not the layer control is shown The default value is _false_
tab
-----
The tab that is shown in the welcome-message. 0 = the explanation of the theme,1 = OSM-credits, 2 = sharescreen, 3 = more themes, 4 = about mapcomplete (user must be logged in and have >50 changesets) The default value is _0_
z
---
The initial/current zoom level The default value is _0_
lat
-----
The initial/current latitude The default value is _0_
lon
-----
The initial/current longitude of the app The default value is _0_
fs-userbadge
--------------
Disables/Enables the user information pill (userbadge) at the top left. Disabling this disables logging in and thus disables editing all together, effectively putting MapComplete into read-only mode. The default value is _true_
fs-search
-----------
Disables/Enables the search bar The default value is _true_
fs-layers
-----------
Disables/Enables the layer control The default value is _true_
fs-add-new
------------
Disables/Enables the 'add new feature'-popup. (A theme without presets might not have it in the first place) The default value is _true_
fs-welcome-message
--------------------
Disables/enables the help menu or welcome message The default value is _true_
fs-iframe
-----------
Disables/Enables the iframe-popup The default value is _false_
fs-more-quests
----------------
Disables/Enables the 'More Quests'-tab in the welcome message The default value is _true_
fs-share-screen
-----------------
Disables/Enables the 'Share-screen'-tab in the welcome message The default value is _true_
fs-geolocation
----------------
Disables/Enables the geolocation button The default value is _true_
fs-all-questions
------------------
Always show all questions The default value is _false_
test
------
If true, 'dryrun' mode is activated. The app will behave as normal, except that changes to OSM will be printed onto the console instead of actually uploaded to osm.org The default value is _false_
debug
-------
If true, shows some extra debugging help such as all the available tags on every object The default value is _false_
backend
--------- ---------
The OSM backend to use - can be used to redirect mapcomplete to the testing backend when using 'osm-test' The default value is _osm_ The OSM backend to use - can be used to redirect mapcomplete to the testing backend when using 'osm-test' The default value is _osm_
custom-css test
------
If true, 'dryrun' mode is activated. The app will behave as normal, except that changes to OSM will be printed onto the console instead of actually uploaded to osm.org The default value is _false_
layout
--------
The layout to load into MapComplete The default value is __
userlayout
------------ ------------
If specified, the custom css from the given link will be loaded additionaly The default value is __ If not 'false', a custom (non-official) theme is loaded. This custom layout can be done in multiple ways:
- The hash of the URL contains a base64-encoded .json-file containing the theme definition
- The hash of the URL contains a lz-compressed .json-file, as generated by the custom theme generator
- The parameter itself is an URL, in which case that URL will be downloaded. It should point to a .json of a theme The default value is _false_
background layer-control-toggle
----------------------
Whether or not the layer control is shown The default value is _false_
tab
-----
The tab that is shown in the welcome-message. 0 = the explanation of the theme,1 = OSM-credits, 2 = sharescreen, 3 = more themes, 4 = about mapcomplete (user must be logged in and have >50 changesets) The default value is _0_
z
---
The initial/current zoom level The default value is _14_
lat
-----
The initial/current latitude The default value is _51.2095_
lon
-----
The initial/current longitude of the app The default value is _3.2228_
fs-userbadge
--------------
Disables/Enables the user information pill (userbadge) at the top left. Disabling this disables logging in and thus disables editing all together, effectively putting MapComplete into read-only mode. The default value is _true_
fs-search
-----------
Disables/Enables the search bar The default value is _true_
fs-layers
-----------
Disables/Enables the layer control The default value is _true_
fs-add-new
------------ ------------
The id of the background layer to start with The default value is _osm_ Disables/Enables the 'add new feature'-popup. (A theme without presets might not have it in the first place) The default value is _true_
fs-welcome-message
--------------------
Disables/enables the help menu or welcome message The default value is _true_
fs-iframe
-----------
Disables/Enables the iframe-popup The default value is _false_
fs-more-quests
----------------
Disables/Enables the 'More Quests'-tab in the welcome message The default value is _true_
fs-share-screen
-----------------
Disables/Enables the 'Share-screen'-tab in the welcome message The default value is _true_
fs-geolocation
----------------
Disables/Enables the geolocation button The default value is _true_
fs-all-questions
------------------
Always show all questions The default value is _false_
fs-export
-----------
If set, enables the 'download'-button to download everything as geojson The default value is _false_
fake-user
-----------
If true, 'dryrun' mode is activated and a fake user account is loaded The default value is _false_
debug
-------
If true, shows some extra debugging help such as all the available tags on every object The default value is _false_
custom-css
------------
If specified, the custom css from the given link will be loaded additionaly The default value is __
background
------------
The id of the background layer to start with The default value is _osm_
oauth_token
-------------
Used to complete the login No default value set
layer-<layer-id> layer-<layer-id>
------------------ ------------------

View file

@ -341,7 +341,7 @@ export class InitUiElements {
private static InitBaseMap() { private static InitBaseMap() {
State.state.availableBackgroundLayers = new AvailableBaseLayers(State.state.locationControl).availableEditorLayers; State.state.availableBackgroundLayers = AvailableBaseLayers.AvailableLayersAt(State.state.locationControl);
State.state.backgroundLayer = State.state.backgroundLayerId State.state.backgroundLayer = State.state.backgroundLayerId
.map((selectedId: string) => { .map((selectedId: string) => {
@ -427,6 +427,8 @@ export class InitUiElements {
state.locationControl, state.locationControl,
state.selectedElement); state.selectedElement);
State.state.featurePipeline = source;
new ShowDataLayer(source.features, State.state.leafletMap, State.state.layoutToUse); new ShowDataLayer(source.features, State.state.leafletMap, State.state.layoutToUse);
const selectedFeatureHandler = new SelectedFeatureHandler(Hash.hash, State.state.selectedElement, source, State.state.osmApiFeatureSource); const selectedFeatureHandler = new SelectedFeatureHandler(Hash.hash, State.state.selectedElement, source, State.state.osmApiFeatureSource);

View file

@ -1,11 +1,12 @@
import * as editorlayerindex from "../../assets/editor-layer-index.json" import * as editorlayerindex from "../../assets/editor-layer-index.json"
import BaseLayer from "../../Models/BaseLayer"; import BaseLayer from "../../Models/BaseLayer";
import * as L from "leaflet"; import * as L from "leaflet";
import {TileLayer} from "leaflet";
import * as X from "leaflet-providers"; import * as X from "leaflet-providers";
import {UIEventSource} from "../UIEventSource"; import {UIEventSource} from "../UIEventSource";
import {GeoOperations} from "../GeoOperations"; import {GeoOperations} from "../GeoOperations";
import {TileLayer} from "leaflet";
import {Utils} from "../../Utils"; import {Utils} from "../../Utils";
import Loc from "../../Models/Loc";
/** /**
* Calculates which layers are available at the current location * Calculates which layers are available at the current location
@ -24,45 +25,87 @@ export default class AvailableBaseLayers {
false, false), false, false),
feature: null, feature: null,
max_zoom: 19, max_zoom: 19,
min_zoom: 0 min_zoom: 0,
isBest: false, // This is a lie! Of course OSM is the best map! (But not in this context)
category: "osmbasedmap"
} }
public static layerOverview = AvailableBaseLayers.LoadRasterIndex().concat(AvailableBaseLayers.LoadProviderIndex()); public static layerOverview = AvailableBaseLayers.LoadRasterIndex().concat(AvailableBaseLayers.LoadProviderIndex());
public availableEditorLayers: UIEventSource<BaseLayer[]>;
constructor(location: UIEventSource<{ lat: number, lon: number, zoom: number }>) { public static AvailableLayersAt(location: UIEventSource<Loc>): UIEventSource<BaseLayer[]> {
const self = this; const source = location.map(
this.availableEditorLayers = (currentLocation) => {
location.map(
(currentLocation) => {
if (currentLocation === undefined) { if (currentLocation === undefined) {
return AvailableBaseLayers.layerOverview; return AvailableBaseLayers.layerOverview;
} }
const currentLayers = self.availableEditorLayers?.data; const currentLayers = source?.data; // A bit unorthodox - I know
const newLayers = AvailableBaseLayers.AvailableLayersAt(currentLocation?.lon, currentLocation?.lat); const newLayers = AvailableBaseLayers.CalculateAvailableLayersAt(currentLocation?.lon, currentLocation?.lat);
if (currentLayers === undefined) { if (currentLayers === undefined) {
return newLayers;
}
if (newLayers.length !== currentLayers.length) {
return newLayers;
}
for (let i = 0; i < newLayers.length; i++) {
if (newLayers[i].name !== currentLayers[i].name) {
return newLayers; return newLayers;
} }
if (newLayers.length !== currentLayers.length) { }
return newLayers;
}
for (let i = 0; i < newLayers.length; i++) {
if (newLayers[i].name !== currentLayers[i].name) {
return newLayers;
}
}
return currentLayers;
});
return currentLayers;
});
return source;
} }
private static AvailableLayersAt(lon: number, lat: number): BaseLayer[] { public static SelectBestLayerAccordingTo(location: UIEventSource<Loc>, preferedCategory: UIEventSource<string | string[]>): UIEventSource<BaseLayer> {
return AvailableBaseLayers.AvailableLayersAt(location).map(available => {
// First float all 'best layers' to the top
available.sort((a, b) => {
if (a.isBest && b.isBest) {
return 0;
}
if (!a.isBest) {
return 1
}
return -1;
}
)
if (preferedCategory.data === undefined) {
return available[0]
}
let prefered: string []
if (typeof preferedCategory.data === "string") {
prefered = [preferedCategory.data]
} else {
prefered = preferedCategory.data;
}
prefered.reverse();
for (const category of prefered) {
//Then sort all 'photo'-layers to the top. Stability of the sorting will force a 'best' photo layer on top
available.sort((a, b) => {
if (a.category === category && b.category === category) {
return 0;
}
if (a.category !== category) {
return 1
}
return -1;
}
)
}
return available[0]
})
}
private static CalculateAvailableLayersAt(lon: number, lat: number): BaseLayer[] {
const availableLayers = [AvailableBaseLayers.osmCarto] const availableLayers = [AvailableBaseLayers.osmCarto]
const globalLayers = []; const globalLayers = [];
for (const layerOverviewItem of AvailableBaseLayers.layerOverview) { for (const layerOverviewItem of AvailableBaseLayers.layerOverview) {
@ -140,7 +183,9 @@ export default class AvailableBaseLayers {
min_zoom: props.min_zoom ?? 1, min_zoom: props.min_zoom ?? 1,
name: props.name, name: props.name,
layer: leafletLayer, layer: leafletLayer,
feature: layer feature: layer,
isBest: props.best ?? false,
category: props.category
}); });
} }
return layers; return layers;
@ -152,15 +197,16 @@ export default class AvailableBaseLayers {
function l(id: string, name: string): BaseLayer { function l(id: string, name: string): BaseLayer {
try { try {
const layer: any = () => L.tileLayer.provider(id, undefined); const layer: any = () => L.tileLayer.provider(id, undefined);
const baseLayer: BaseLayer = { return {
feature: null, feature: null,
id: id, id: id,
name: name, name: name,
layer: layer, layer: layer,
min_zoom: layer.minzoom, min_zoom: layer.minzoom,
max_zoom: layer.maxzoom max_zoom: layer.maxzoom,
category: "osmbasedmap",
isBest: false
} }
return baseLayer
} catch (e) { } catch (e) {
console.error("Could not find provided layer", name, e); console.error("Could not find provided layer", name, e);
return null; return null;

View file

@ -1,244 +1,262 @@
import * as L from "leaflet"; import * as L from "leaflet";
import {UIEventSource} from "../UIEventSource"; import { UIEventSource } from "../UIEventSource";
import {Utils} from "../../Utils"; import { Utils } from "../../Utils";
import Svg from "../../Svg"; import Svg from "../../Svg";
import Img from "../../UI/Base/Img"; import Img from "../../UI/Base/Img";
import {LocalStorageSource} from "../Web/LocalStorageSource"; import { LocalStorageSource } from "../Web/LocalStorageSource";
import LayoutConfig from "../../Customizations/JSON/LayoutConfig"; import LayoutConfig from "../../Customizations/JSON/LayoutConfig";
import {VariableUiElement} from "../../UI/Base/VariableUIElement"; import { VariableUiElement } from "../../UI/Base/VariableUIElement";
export default class GeoLocationHandler extends VariableUiElement { export default class GeoLocationHandler extends VariableUiElement {
/**
* Wether or not the geolocation is active, aka the user requested the current location
* @private
*/
private readonly _isActive: UIEventSource<boolean>;
/** /**
* Wether or not the geolocation is active, aka the user requested the current location * Wether or not the geolocation is locked, aka the user requested the current location and wants the crosshair to follow the user
* @private * @private
*/ */
private readonly _isActive: UIEventSource<boolean>; private readonly _isLocked: UIEventSource<boolean>;
/** /**
* The callback over the permission API * The callback over the permission API
* @private * @private
*/ */
private readonly _permission: UIEventSource<string>; private readonly _permission: UIEventSource<string>;
/*** /***
* The marker on the map, in order to update it * The marker on the map, in order to update it
* @private * @private
*/ */
private _marker: L.Marker; private _marker: L.Marker;
/** /**
* Literally: _currentGPSLocation.data != undefined * Literally: _currentGPSLocation.data != undefined
* @private * @private
*/ */
private readonly _hasLocation: UIEventSource<boolean>; private readonly _hasLocation: UIEventSource<boolean>;
private readonly _currentGPSLocation: UIEventSource<{ latlng: any; accuracy: number }>; private readonly _currentGPSLocation: UIEventSource<{
/** latlng: any;
* Kept in order to update the marker accuracy: number;
* @private }>;
*/ /**
private readonly _leafletMap: UIEventSource<L.Map>; * Kept in order to update the marker
/** * @private
* The date when the user requested the geolocation. If we have a location, it'll autozoom to it the first 30 secs */
* @private private readonly _leafletMap: UIEventSource<L.Map>;
*/ /**
private _lastUserRequest: Date; * The date when the user requested the geolocation. If we have a location, it'll autozoom to it the first 30 secs
/** * @private
* A small flag on localstorage. If the user previously granted the geolocation, it will be set. */
* On firefox, the permissions api is broken (probably fingerprint resistiance) and "granted + don't ask again" doesn't stick between sessions. private _lastUserRequest: Date;
* /**
* Instead, we set this flag. If this flag is set upon loading the page, we start geolocating immediately. * A small flag on localstorage. If the user previously granted the geolocation, it will be set.
* If the user denies the geolocation this time, we unset this flag * On firefox, the permissions api is broken (probably fingerprint resistiance) and "granted + don't ask again" doesn't stick between sessions.
* @private *
*/ * Instead, we set this flag. If this flag is set upon loading the page, we start geolocating immediately.
private readonly _previousLocationGrant: UIEventSource<string>; * If the user denies the geolocation this time, we unset this flag
private readonly _layoutToUse: UIEventSource<LayoutConfig>; * @private
*/
private readonly _previousLocationGrant: UIEventSource<string>;
private readonly _layoutToUse: UIEventSource<LayoutConfig>;
constructor(
currentGPSLocation: UIEventSource<{ latlng: any; accuracy: number }>,
leafletMap: UIEventSource<L.Map>,
layoutToUse: UIEventSource<LayoutConfig>
) {
const hasLocation = currentGPSLocation.map(
(location) => location !== undefined
);
const previousLocationGrant = LocalStorageSource.Get(
"geolocation-permissions"
);
const isActive = new UIEventSource<boolean>(false);
const isLocked = new UIEventSource<boolean>(false);
constructor(currentGPSLocation: UIEventSource<{ latlng: any; accuracy: number }>, super(
leafletMap: UIEventSource<L.Map>, hasLocation.map(
layoutToUse: UIEventSource<LayoutConfig>) { (hasLocationData) => {
if (isLocked.data) {
return Svg.crosshair_locked_ui();
} else if (hasLocationData) {
return Svg.crosshair_blue_ui();
} else if (isActive.data) {
return Svg.crosshair_blue_center_ui();
} else {
return Svg.crosshair_ui();
}
},
[isActive, isLocked]
)
);
this._isActive = isActive;
this._isLocked = isLocked;
this._permission = new UIEventSource<string>("");
this._previousLocationGrant = previousLocationGrant;
this._currentGPSLocation = currentGPSLocation;
this._leafletMap = leafletMap;
this._layoutToUse = layoutToUse;
this._hasLocation = hasLocation;
const self = this;
const hasLocation = currentGPSLocation.map((location) => location !== undefined); const currentPointer = this._isActive.map(
const previousLocationGrant = LocalStorageSource.Get("geolocation-permissions") (isActive) => {
const isActive = new UIEventSource<boolean>(false); if (isActive && !self._hasLocation.data) {
return "cursor-wait";
}
return "cursor-pointer";
},
[this._hasLocation]
);
currentPointer.addCallbackAndRun((pointerClass) => {
self.SetClass(pointerClass);
});
super( this.onClick(() => {
hasLocation.map(hasLocation => { if (self._hasLocation.data) {
self._isLocked.setData(!self._isLocked.data);
}
self.init(true);
});
this.init(false);
if (hasLocation) { this._currentGPSLocation.addCallback((location) => {
return Svg.crosshair_blue_ui() self._previousLocationGrant.setData("granted");
}
if (isActive.data) { const timeSinceRequest =
return Svg.crosshair_blue_center_ui(); (new Date().getTime() - (self._lastUserRequest?.getTime() ?? 0)) / 1000;
} if (timeSinceRequest < 30) {
return Svg.crosshair_ui(); self.MoveToCurrentLoction(16);
}, [isActive]) } else if (self._isLocked.data) {
self.MoveToCurrentLoction();
}
let color = "#1111cc";
try {
color = getComputedStyle(document.body).getPropertyValue(
"--catch-detail-color"
); );
this._isActive = isActive; } catch (e) {
this._permission = new UIEventSource<string>("") console.error(e);
this._previousLocationGrant = previousLocationGrant; }
this._currentGPSLocation = currentGPSLocation; const icon = L.icon({
this._leafletMap = leafletMap; iconUrl: Img.AsData(Svg.crosshair.replace(/#000000/g, color)),
this._layoutToUse = layoutToUse; iconSize: [40, 40], // size of the icon
this._hasLocation = hasLocation; iconAnchor: [20, 20], // point of the icon which will correspond to marker's location
const self = this; });
const currentPointer = this._isActive.map(isActive => { const map = self._leafletMap.data;
if (isActive && !self._hasLocation.data) {
return "cursor-wait"
}
return "cursor-pointer"
}, [this._hasLocation])
currentPointer.addCallbackAndRun(pointerClass => {
self.SetClass(pointerClass);
})
const newMarker = L.marker(location.latlng, { icon: icon });
newMarker.addTo(map);
this.onClick(() => self.init(true)) if (self._marker !== undefined) {
this.init(false) map.removeLayer(self._marker);
}
self._marker = newMarker;
});
}
private init(askPermission: boolean) {
const self = this;
if (self._isActive.data) {
self.MoveToCurrentLoction(16);
return;
} }
private init(askPermission: boolean) { try {
navigator?.permissions
const self = this; ?.query({ name: "geolocation" })
const map = this._leafletMap.data; ?.then(function (status) {
console.log("Geolocation is already", status);
this._currentGPSLocation.addCallback((location) => { if (status.state === "granted") {
self._previousLocationGrant.setData("granted");
const timeSinceRequest = (new Date().getTime() - (self._lastUserRequest?.getTime() ?? 0)) / 1000;
if (timeSinceRequest < 30) {
self.MoveToCurrentLoction(16)
}
let color = "#1111cc";
try {
color = getComputedStyle(document.body).getPropertyValue("--catch-detail-color")
} catch (e) {
console.error(e)
}
const icon = L.icon(
{
iconUrl: Img.AsData(Svg.crosshair.replace(/#000000/g, color)),
iconSize: [40, 40], // size of the icon
iconAnchor: [20, 20], // point of the icon which will correspond to marker's location
})
const newMarker = L.marker(location.latlng, {icon: icon});
newMarker.addTo(map);
if (self._marker !== undefined) {
map.removeLayer(self._marker);
}
self._marker = newMarker;
});
try {
navigator?.permissions?.query({name: 'geolocation'})
?.then(function (status) {
console.log("Geolocation is already", status)
if (status.state === "granted") {
self.StartGeolocating(false);
}
self._permission.setData(status.state);
status.onchange = function () {
self._permission.setData(status.state);
}
});
} catch (e) {
console.error(e)
}
if (askPermission) {
self.StartGeolocating(true);
} else if (this._previousLocationGrant.data === "granted") {
this._previousLocationGrant.setData("");
self.StartGeolocating(false); self.StartGeolocating(false);
} }
self._permission.setData(status.state);
status.onchange = function () {
self._permission.setData(status.state);
};
});
} catch (e) {
console.error(e);
} }
private locate() { if (askPermission) {
const self = this; self.StartGeolocating(true);
const map: any = this._leafletMap.data; } else if (this._previousLocationGrant.data === "granted") {
this._previousLocationGrant.setData("");
self.StartGeolocating(false);
}
}
if (navigator.geolocation) { private MoveToCurrentLoction(targetZoom?: number) {
navigator.geolocation.getCurrentPosition(function (position) { const location = this._currentGPSLocation.data;
self._currentGPSLocation.setData({ this._lastUserRequest = undefined;
latlng: [position.coords.latitude, position.coords.longitude],
accuracy: position.coords.accuracy if (
}); this._currentGPSLocation.data.latlng[0] === 0 &&
}, function () { this._currentGPSLocation.data.latlng[1] === 0
console.warn("Could not get location with navigator.geolocation") ) {
}); console.debug("Not moving to GPS-location: it is null island");
return; return;
} else {
map.findAccuratePosition({
maxWait: 10000, // defaults to 10000
desiredAccuracy: 50 // defaults to 20
});
}
} }
private MoveToCurrentLoction(targetZoom = 16) { // We check that the GPS location is not out of bounds
const location = this._currentGPSLocation.data; const b = this._layoutToUse.data.lockLocation;
this._lastUserRequest = undefined; let inRange = true;
if (b) {
if (b !== true) {
// B is an array with our locklocation
inRange =
b[0][0] <= location.latlng[0] &&
location.latlng[0] <= b[1][0] &&
b[0][1] <= location.latlng[1] &&
location.latlng[1] <= b[1][1];
}
}
if (!inRange) {
console.log(
"Not zooming to GPS location: out of bounds",
b,
location.latlng
);
} else {
this._leafletMap.data.setView(location.latlng, targetZoom);
}
}
private StartGeolocating(zoomToGPS = true) {
const self = this;
console.log("Starting geolocation");
if (this._currentGPSLocation.data.latlng[0] === 0 && this._currentGPSLocation.data.latlng[1] === 0) { this._lastUserRequest = zoomToGPS ? new Date() : new Date(0);
console.debug("Not moving to GPS-location: it is null island") if (self._permission.data === "denied") {
return; self._previousLocationGrant.setData("");
} return "";
}
// We check that the GPS location is not out of bounds if (this._currentGPSLocation.data !== undefined) {
const b = this._layoutToUse.data.lockLocation this.MoveToCurrentLoction(16);
let inRange = true;
if (b) {
if (b !== true) {
// B is an array with our locklocation
inRange = b[0][0] <= location.latlng[0] && location.latlng[0] <= b[1][0] &&
b[0][1] <= location.latlng[1] && location.latlng[1] <= b[1][1];
}
}
if (!inRange) {
console.log("Not zooming to GPS location: out of bounds", b, location.latlng)
} else {
this._leafletMap.data.setView(
location.latlng, targetZoom
);
}
} }
private StartGeolocating(zoomToGPS = true) { console.log("Searching location using GPS");
const self = this;
console.log("Starting geolocation")
this._lastUserRequest = zoomToGPS ? new Date() : new Date(0); if (self._isActive.data) {
if (self._permission.data === "denied") { return;
self._previousLocationGrant.setData("");
return "";
}
if (this._currentGPSLocation.data !== undefined) {
this.MoveToCurrentLoction(16)
}
console.log("Searching location using GPS")
this.locate();
if (!self._isActive.data) {
self._isActive.setData(true);
Utils.DoEvery(60000, () => {
if (document.visibilityState !== "visible") {
console.log("Not starting gps: document not visible")
return;
}
this.locate();
})
}
} }
self._isActive.setData(true);
} navigator.geolocation.watchPosition(
function (position) {
self._currentGPSLocation.setData({
latlng: [position.coords.latitude, position.coords.longitude],
accuracy: position.coords.accuracy,
});
},
function () {
console.warn("Could not get location with navigator.geolocation");
}
);
}
}

View file

@ -47,7 +47,12 @@ export default class StrayClickHandler {
popupAnchor: [0, -45] popupAnchor: [0, -45]
}) })
}); });
const popup = L.popup().setContent("<div id='strayclick'></div>"); const popup = L.popup({
autoPan: true,
autoPanPaddingTopLeft: [15,15],
closeOnEscapeKey: true,
autoClose: true
}).setContent("<div id='strayclick' style='height: 65vh'></div>");
self._lastMarker.addTo(leafletMap.data); self._lastMarker.addTo(leafletMap.data);
self._lastMarker.bindPopup(popup); self._lastMarker.bindPopup(popup);

View file

@ -1,9 +1,45 @@
import {UIEventSource} from "../UIEventSource"; import {UIEventSource} from "../UIEventSource";
import {Utils} from "../../Utils";
export default interface FeatureSource { export default interface FeatureSource {
features: UIEventSource<{feature: any, freshness: Date}[]>; features: UIEventSource<{ feature: any, freshness: Date }[]>;
/** /**
* Mainly used for debuging * Mainly used for debuging
*/ */
name: string; name: string;
}
export class FeatureSourceUtils {
/**
* Exports given featurePipeline as a geojson FeatureLists (downloads as a json)
* @param featurePipeline The FeaturePipeline you want to export
* @param options The options object
* @param options.metadata True if you want to include the MapComplete metadata, false otherwise
*/
public static extractGeoJson(featurePipeline: FeatureSource, options: { metadata?: boolean } = {}) {
let defaults = {
metadata: false,
}
options = Utils.setDefaults(options, defaults);
// Select all features, ignore the freshness and other data
let featureList: any[] = featurePipeline.features.data.map((feature) => feature.feature);
if (!options.metadata) {
for (let i = 0; i < featureList.length; i++) {
let feature = featureList[i];
for (let property in feature.properties) {
if (property[0] == "_") {
delete featureList[i]["properties"][property];
}
}
}
}
return {type: "FeatureCollection", features: featureList}
}
} }

View file

@ -273,6 +273,14 @@ export class GeoOperations {
} }
return undefined; return undefined;
} }
/**
* Generates the closest point on a way from a given point
* @param way The road on which you want to find a point
* @param point Point defined as [lon, lat]
*/
public static nearestPoint(way, point: [number, number]){
return turf.nearestPointOnLine(way, point, {units: "kilometers"});
}
} }

View file

@ -6,31 +6,38 @@ import Constants from "../../Models/Constants";
import FeatureSource from "../FeatureSource/FeatureSource"; import FeatureSource from "../FeatureSource/FeatureSource";
import {TagsFilter} from "../Tags/TagsFilter"; import {TagsFilter} from "../Tags/TagsFilter";
import {Tag} from "../Tags/Tag"; import {Tag} from "../Tags/Tag";
import {OsmConnection} from "./OsmConnection";
import {LocalStorageSource} from "../Web/LocalStorageSource";
/** /**
* Handles all changes made to OSM. * Handles all changes made to OSM.
* Needs an authenticator via OsmConnection * Needs an authenticator via OsmConnection
*/ */
export class Changes implements FeatureSource{ export class Changes implements FeatureSource {
private static _nextId = -1; // Newly assigned ID's are negative
public readonly name = "Newly added features" public readonly name = "Newly added features"
/** /**
* The newly created points, as a FeatureSource * The newly created points, as a FeatureSource
*/ */
public features = new UIEventSource<{feature: any, freshness: Date}[]>([]); public features = new UIEventSource<{ feature: any, freshness: Date }[]>([]);
private static _nextId = -1; // Newly assigned ID's are negative
/** /**
* All the pending changes * All the pending changes
*/ */
public readonly pending: UIEventSource<{ elementId: string, key: string, value: string }[]> = public readonly pending = LocalStorageSource.GetParsed<{ elementId: string, key: string, value: string }[]>("pending-changes", [])
new UIEventSource<{elementId: string; key: string; value: string}[]>([]);
/**
* All the pending new objects to upload
*/
private readonly newObjects = LocalStorageSource.GetParsed<{ id: number, lat: number, lon: number }[]>("newObjects", [])
private readonly isUploading = new UIEventSource(false);
/** /**
* Adds a change to the pending changes * Adds a change to the pending changes
*/ */
private static checkChange(kv: {k: string, v: string}): { k: string, v: string } { private static checkChange(kv: { k: string, v: string }): { k: string, v: string } {
const key = kv.k; const key = kv.k;
const value = kv.v; const value = kv.v;
if (key === undefined || key === null) { if (key === undefined || key === null) {
@ -49,8 +56,7 @@ export class Changes implements FeatureSource{
return {k: key.trim(), v: value.trim()}; return {k: key.trim(), v: value.trim()};
} }
addTag(elementId: string, tagsFilter: TagsFilter, addTag(elementId: string, tagsFilter: TagsFilter,
tags?: UIEventSource<any>) { tags?: UIEventSource<any>) {
const eventSource = tags ?? State.state?.allElements.getEventSourceById(elementId); const eventSource = tags ?? State.state?.allElements.getEventSourceById(elementId);
@ -59,7 +65,7 @@ export class Changes implements FeatureSource{
if (changes.length == 0) { if (changes.length == 0) {
return; return;
} }
for (const change of changes) { for (const change of changes) {
if (elementTags[change.k] !== change.v) { if (elementTags[change.k] !== change.v) {
elementTags[change.k] = change.v; elementTags[change.k] = change.v;
@ -76,16 +82,16 @@ export class Changes implements FeatureSource{
* Uploads all the pending changes in one go. * Uploads all the pending changes in one go.
* Triggered by the 'PendingChangeUploader'-actor in Actors * Triggered by the 'PendingChangeUploader'-actor in Actors
*/ */
public flushChanges(flushreason: string = undefined){ public flushChanges(flushreason: string = undefined) {
if(this.pending.data.length === 0){ if (this.pending.data.length === 0) {
return; return;
} }
if(flushreason !== undefined){ if (flushreason !== undefined) {
console.log(flushreason) console.log(flushreason)
} }
this.uploadAll([], this.pending.data); this.uploadAll();
this.pending.setData([]);
} }
/** /**
* Create a new node element at the given lat/long. * Create a new node element at the given lat/long.
* An internal OsmObject is created to upload later on, a geojson represention is returned. * An internal OsmObject is created to upload later on, a geojson represention is returned.
@ -93,12 +99,12 @@ export class Changes implements FeatureSource{
*/ */
public createElement(basicTags: Tag[], lat: number, lon: number) { public createElement(basicTags: Tag[], lat: number, lon: number) {
console.log("Creating a new element with ", basicTags) console.log("Creating a new element with ", basicTags)
const osmNode = new OsmNode(Changes._nextId); const newId = Changes._nextId;
Changes._nextId--; Changes._nextId--;
const id = "node/" + osmNode.id; const id = "node/" + newId;
osmNode.lat = lat;
osmNode.lon = lon;
const properties = {id: id}; const properties = {id: id};
const geojson = { const geojson = {
@ -118,35 +124,49 @@ export class Changes implements FeatureSource{
// The tags are not yet written into the OsmObject, but this is applied onto a // The tags are not yet written into the OsmObject, but this is applied onto a
const changes = []; const changes = [];
for (const kv of basicTags) { for (const kv of basicTags) {
properties[kv.key] = kv.value;
if (typeof kv.value !== "string") { if (typeof kv.value !== "string") {
throw "Invalid value: don't use a regex in a preset" throw "Invalid value: don't use a regex in a preset"
} }
properties[kv.key] = kv.value;
changes.push({elementId: id, key: kv.key, value: kv.value}) changes.push({elementId: id, key: kv.key, value: kv.value})
} }
console.log("New feature added and pinged") console.log("New feature added and pinged")
this.features.data.push({feature:geojson, freshness: new Date()}); this.features.data.push({feature: geojson, freshness: new Date()});
this.features.ping(); this.features.ping();
State.state.allElements.addOrGetElement(geojson).ping(); State.state.allElements.addOrGetElement(geojson).ping();
this.uploadAll([osmNode], changes); if (State.state.osmConnection.userDetails.data.backend !== OsmConnection.oauth_configs.osm.url) {
properties["_backend"] = State.state.osmConnection.userDetails.data.backend
}
this.newObjects.data.push({id: newId, lat: lat, lon: lon})
this.pending.data.push(...changes)
this.pending.ping();
this.newObjects.ping();
return geojson; return geojson;
} }
private uploadChangesWithLatestVersions( private uploadChangesWithLatestVersions(
knownElements: OsmObject[], newElements: OsmObject[], pending: { elementId: string; key: string; value: string }[]) { knownElements: OsmObject[]) {
const knownById = new Map<string, OsmObject>(); const knownById = new Map<string, OsmObject>();
knownElements.forEach(knownElement => { knownElements.forEach(knownElement => {
knownById.set(knownElement.type + "/" + knownElement.id, knownElement) knownById.set(knownElement.type + "/" + knownElement.id, knownElement)
}) })
const newElements: OsmNode [] = this.newObjects.data.map(spec => {
const newElement = new OsmNode(spec.id);
newElement.lat = spec.lat;
newElement.lon = spec.lon;
return newElement
})
// Here, inside the continuation, we know that all 'neededIds' are loaded in 'knownElements', which maps the ids onto the elements // Here, inside the continuation, we know that all 'neededIds' are loaded in 'knownElements', which maps the ids onto the elements
// We apply the changes on them // We apply the changes on them
for (const change of pending) { for (const change of this.pending.data) {
if (parseInt(change.elementId.split("/")[1]) < 0) { if (parseInt(change.elementId.split("/")[1]) < 0) {
// This is a new element - we should apply this on one of the new elements // This is a new element - we should apply this on one of the new elements
for (const newElement of newElements) { for (const newElement of newElements) {
@ -168,9 +188,17 @@ export class Changes implements FeatureSource{
} }
} }
if (changedElements.length == 0 && newElements.length == 0) { if (changedElements.length == 0 && newElements.length == 0) {
console.log("No changes in any object"); console.log("No changes in any object - clearing");
this.pending.setData([])
this.newObjects.setData([])
return; return;
} }
const self = this;
if (this.isUploading.data) {
return;
}
this.isUploading.setData(true)
console.log("Beginning upload..."); console.log("Beginning upload...");
// At last, we build the changeset and upload // At last, we build the changeset and upload
@ -213,17 +241,22 @@ export class Changes implements FeatureSource{
changes += "</osmChange>"; changes += "</osmChange>";
return changes; return changes;
}); },
() => {
console.log("Upload successfull!")
self.newObjects.setData([])
self.pending.setData([]);
self.isUploading.setData(false)
},
() => self.isUploading.setData(false)
);
}; };
private uploadAll( private uploadAll() {
newElements: OsmObject[],
pending: { elementId: string; key: string; value: string }[]
) {
const self = this; const self = this;
const pending = this.pending.data;
let neededIds: string[] = []; let neededIds: string[] = [];
for (const change of pending) { for (const change of pending) {
const id = change.elementId; const id = change.elementId;
@ -236,8 +269,7 @@ export class Changes implements FeatureSource{
neededIds = Utils.Dedup(neededIds); neededIds = Utils.Dedup(neededIds);
OsmObject.DownloadAll(neededIds).addCallbackAndRunD(knownElements => { OsmObject.DownloadAll(neededIds).addCallbackAndRunD(knownElements => {
console.log("KnownElements:", knownElements) self.uploadChangesWithLatestVersions(knownElements)
self.uploadChangesWithLatestVersions(knownElements, newElements, pending)
}) })
} }

View file

@ -27,7 +27,7 @@ export class ChangesetHandler {
} }
} }
private static parseUploadChangesetResponse(response: XMLDocument, allElements: ElementStorage) { private static parseUploadChangesetResponse(response: XMLDocument, allElements: ElementStorage): void {
const nodes = response.getElementsByTagName("node"); const nodes = response.getElementsByTagName("node");
// @ts-ignore // @ts-ignore
for (const node of nodes) { for (const node of nodes) {
@ -69,7 +69,9 @@ export class ChangesetHandler {
public UploadChangeset( public UploadChangeset(
layout: LayoutConfig, layout: LayoutConfig,
allElements: ElementStorage, allElements: ElementStorage,
generateChangeXML: (csid: string) => string) { generateChangeXML: (csid: string) => string,
whenDone: (csId: string) => void,
onFail: () => void) {
if (this.userDetails.data.csCount == 0) { if (this.userDetails.data.csCount == 0) {
// The user became a contributor! // The user became a contributor!
@ -80,6 +82,7 @@ export class ChangesetHandler {
if (this._dryRun) { if (this._dryRun) {
const changesetXML = generateChangeXML("123456"); const changesetXML = generateChangeXML("123456");
console.log(changesetXML); console.log(changesetXML);
whenDone("123456")
return; return;
} }
@ -93,12 +96,14 @@ export class ChangesetHandler {
console.log(changeset); console.log(changeset);
self.AddChange(csId, changeset, self.AddChange(csId, changeset,
allElements, allElements,
() => { whenDone,
},
(e) => { (e) => {
console.error("UPLOADING FAILED!", e) console.error("UPLOADING FAILED!", e)
onFail()
} }
) )
}, {
onFail: onFail
}) })
} else { } else {
// There still exists an open changeset (or at least we hope so) // There still exists an open changeset (or at least we hope so)
@ -107,15 +112,13 @@ export class ChangesetHandler {
csId, csId,
generateChangeXML(csId), generateChangeXML(csId),
allElements, allElements,
() => { whenDone,
},
(e) => { (e) => {
console.warn("Could not upload, changeset is probably closed: ", e); console.warn("Could not upload, changeset is probably closed: ", e);
// Mark the CS as closed... // Mark the CS as closed...
this.currentChangeset.setData(""); this.currentChangeset.setData("");
// ... and try again. As the cs is closed, no recursive loop can exist // ... and try again. As the cs is closed, no recursive loop can exist
self.UploadChangeset(layout, allElements, generateChangeXML); self.UploadChangeset(layout, allElements, generateChangeXML, whenDone, onFail);
} }
) )
@ -161,18 +164,22 @@ export class ChangesetHandler {
const self = this; const self = this;
this.OpenChangeset(layout, (csId: string) => { this.OpenChangeset(layout, (csId: string) => {
// The cs is open - let us actually upload! // The cs is open - let us actually upload!
const changes = generateChangeXML(csId) const changes = generateChangeXML(csId)
self.AddChange(csId, changes, allElements, (csId) => { self.AddChange(csId, changes, allElements, (csId) => {
console.log("Successfully deleted ", object.id) console.log("Successfully deleted ", object.id)
self.CloseChangeset(csId, continuation) self.CloseChangeset(csId, continuation)
}, (csId) => { }, (csId) => {
alert("Deletion failed... Should not happend") alert("Deletion failed... Should not happend")
// FAILED // FAILED
self.CloseChangeset(csId, continuation) self.CloseChangeset(csId, continuation)
}) })
}, true, reason) }, {
isDeletionCS: true,
deletionReason: reason
}
)
} }
private CloseChangeset(changesetId: string = undefined, continuation: (() => void) = () => { private CloseChangeset(changesetId: string = undefined, continuation: (() => void) = () => {
@ -204,15 +211,20 @@ export class ChangesetHandler {
private OpenChangeset( private OpenChangeset(
layout: LayoutConfig, layout: LayoutConfig,
continuation: (changesetId: string) => void, continuation: (changesetId: string) => void,
isDeletionCS: boolean = false, options?: {
deletionReason: string = undefined) { isDeletionCS?: boolean,
deletionReason?: string,
onFail?: () => void
}
) {
options = options ?? {}
options.isDeletionCS = options.isDeletionCS ?? false
const commentExtra = layout.changesetmessage !== undefined ? " - " + layout.changesetmessage : ""; const commentExtra = layout.changesetmessage !== undefined ? " - " + layout.changesetmessage : "";
let comment = `Adding data with #MapComplete for theme #${layout.id}${commentExtra}` let comment = `Adding data with #MapComplete for theme #${layout.id}${commentExtra}`
if (isDeletionCS) { if (options.isDeletionCS) {
comment = `Deleting a point with #MapComplete for theme #${layout.id}${commentExtra}` comment = `Deleting a point with #MapComplete for theme #${layout.id}${commentExtra}`
if (deletionReason) { if (options.deletionReason) {
comment += ": " + deletionReason; comment += ": " + options.deletionReason;
} }
} }
@ -221,7 +233,7 @@ export class ChangesetHandler {
const metadata = [ const metadata = [
["created_by", `MapComplete ${Constants.vNumber}`], ["created_by", `MapComplete ${Constants.vNumber}`],
["comment", comment], ["comment", comment],
["deletion", isDeletionCS ? "yes" : undefined], ["deletion", options.isDeletionCS ? "yes" : undefined],
["theme", layout.id], ["theme", layout.id],
["language", Locale.language.data], ["language", Locale.language.data],
["host", window.location.host], ["host", window.location.host],
@ -244,7 +256,9 @@ export class ChangesetHandler {
}, function (err, response) { }, function (err, response) {
if (response === undefined) { if (response === undefined) {
console.log("err", err); console.log("err", err);
alert("Could not upload change (opening failed). Please file a bug report") if(options.onFail){
options.onFail()
}
return; return;
} else { } else {
continuation(response); continuation(response);
@ -265,7 +279,7 @@ export class ChangesetHandler {
private AddChange(changesetId: string, private AddChange(changesetId: string,
changesetXML: string, changesetXML: string,
allElements: ElementStorage, allElements: ElementStorage,
continuation: ((changesetId: string, idMapping: any) => void), continuation: ((changesetId: string) => void),
onFail: ((changesetId: string, reason: string) => void) = undefined) { onFail: ((changesetId: string, reason: string) => void) = undefined) {
this.auth.xhr({ this.auth.xhr({
method: 'POST', method: 'POST',
@ -280,9 +294,9 @@ export class ChangesetHandler {
} }
return; return;
} }
const mapping = ChangesetHandler.parseUploadChangesetResponse(response, allElements); ChangesetHandler.parseUploadChangesetResponse(response, allElements);
console.log("Uploaded changeset ", changesetId); console.log("Uploaded changeset ", changesetId);
continuation(changesetId, mapping); continuation(changesetId);
}); });
} }

View file

@ -30,7 +30,7 @@ export default class UserDetails {
export class OsmConnection { export class OsmConnection {
public static readonly _oauth_configs = { public static readonly oauth_configs = {
"osm": { "osm": {
oauth_consumer_key: 'hivV7ec2o49Two8g9h8Is1VIiVOgxQ1iYexCbvem', oauth_consumer_key: 'hivV7ec2o49Two8g9h8Is1VIiVOgxQ1iYexCbvem',
oauth_secret: 'wDBRTCem0vxD7txrg1y6p5r8nvmz8tAhET7zDASI', oauth_secret: 'wDBRTCem0vxD7txrg1y6p5r8nvmz8tAhET7zDASI',
@ -47,6 +47,7 @@ export class OsmConnection {
public auth; public auth;
public userDetails: UIEventSource<UserDetails>; public userDetails: UIEventSource<UserDetails>;
public isLoggedIn: UIEventSource<boolean> public isLoggedIn: UIEventSource<boolean>
private fakeUser: boolean;
_dryRun: boolean; _dryRun: boolean;
public preferencesHandler: OsmPreferences; public preferencesHandler: OsmPreferences;
public changesetHandler: ChangesetHandler; public changesetHandler: ChangesetHandler;
@ -59,20 +60,31 @@ export class OsmConnection {
url: string url: string
}; };
constructor(dryRun: boolean, oauth_token: UIEventSource<string>, constructor(dryRun: boolean,
fakeUser: boolean,
oauth_token: UIEventSource<string>,
// Used to keep multiple changesets open and to write to the correct changeset // Used to keep multiple changesets open and to write to the correct changeset
layoutName: string, layoutName: string,
singlePage: boolean = true, singlePage: boolean = true,
osmConfiguration: "osm" | "osm-test" = 'osm' osmConfiguration: "osm" | "osm-test" = 'osm'
) { ) {
this.fakeUser = fakeUser;
this._singlePage = singlePage; this._singlePage = singlePage;
this._oauth_config = OsmConnection._oauth_configs[osmConfiguration] ?? OsmConnection._oauth_configs.osm; this._oauth_config = OsmConnection.oauth_configs[osmConfiguration] ?? OsmConnection.oauth_configs.osm;
console.debug("Using backend", this._oauth_config.url) console.debug("Using backend", this._oauth_config.url)
OsmObject.SetBackendUrl(this._oauth_config.url + "/") OsmObject.SetBackendUrl(this._oauth_config.url + "/")
this._iframeMode = Utils.runningFromConsole ? false : window !== window.top; this._iframeMode = Utils.runningFromConsole ? false : window !== window.top;
this.userDetails = new UIEventSource<UserDetails>(new UserDetails(this._oauth_config.url), "userDetails"); this.userDetails = new UIEventSource<UserDetails>(new UserDetails(this._oauth_config.url), "userDetails");
this.userDetails.data.dryRun = dryRun; this.userDetails.data.dryRun = dryRun || fakeUser;
if(fakeUser){
const ud = this.userDetails.data;
ud.csCount = 5678
ud.loggedIn= true;
ud.unreadMessages = 0
ud.name = "Fake user"
ud.totalMessages = 42;
}
const self =this; const self =this;
this.isLoggedIn = this.userDetails.map(user => user.loggedIn).addCallback(isLoggedIn => { this.isLoggedIn = this.userDetails.map(user => user.loggedIn).addCallback(isLoggedIn => {
if(self.userDetails.data.loggedIn == false && isLoggedIn == true){ if(self.userDetails.data.loggedIn == false && isLoggedIn == true){
@ -110,8 +122,10 @@ export class OsmConnection {
public UploadChangeset( public UploadChangeset(
layout: LayoutConfig, layout: LayoutConfig,
allElements: ElementStorage, allElements: ElementStorage,
generateChangeXML: (csid: string) => string) { generateChangeXML: (csid: string) => string,
this.changesetHandler.UploadChangeset(layout, allElements, generateChangeXML); whenDone: (csId: string) => void,
onFail: () => {}) {
this.changesetHandler.UploadChangeset(layout, allElements, generateChangeXML, whenDone, onFail);
} }
public GetPreference(key: string, prefix: string = "mapcomplete-"): UIEventSource<string> { public GetPreference(key: string, prefix: string = "mapcomplete-"): UIEventSource<string> {
@ -136,6 +150,10 @@ export class OsmConnection {
} }
public AttemptLogin() { public AttemptLogin() {
if(this.fakeUser){
console.log("AttemptLogin called, but ignored as fakeUser is set")
return;
}
const self = this; const self = this;
console.log("Trying to log in..."); console.log("Trying to log in...");
this.updateAuthObject(); this.updateAuthObject();

View file

@ -5,7 +5,8 @@ import {UIEventSource} from "../UIEventSource";
export abstract class OsmObject { export abstract class OsmObject {
protected static backendURL = "https://www.openstreetmap.org/" private static defaultBackend = "https://www.openstreetmap.org/"
protected static backendURL = OsmObject.defaultBackend;
private static polygonFeatures = OsmObject.constructPolygonFeatures() private static polygonFeatures = OsmObject.constructPolygonFeatures()
private static objectCache = new Map<string, UIEventSource<OsmObject>>(); private static objectCache = new Map<string, UIEventSource<OsmObject>>();
private static referencingWaysCache = new Map<string, UIEventSource<OsmWay[]>>(); private static referencingWaysCache = new Map<string, UIEventSource<OsmWay[]>>();
@ -37,15 +38,15 @@ export abstract class OsmObject {
} }
static DownloadObject(id: string, forceRefresh: boolean = false): UIEventSource<OsmObject> { static DownloadObject(id: string, forceRefresh: boolean = false): UIEventSource<OsmObject> {
let src : UIEventSource<OsmObject>; let src: UIEventSource<OsmObject>;
if (OsmObject.objectCache.has(id)) { if (OsmObject.objectCache.has(id)) {
src = OsmObject.objectCache.get(id) src = OsmObject.objectCache.get(id)
if(forceRefresh){ if (forceRefresh) {
src.setData(undefined) src.setData(undefined)
}else{ } else {
return src; return src;
} }
}else{ } else {
src = new UIEventSource<OsmObject>(undefined) src = new UIEventSource<OsmObject>(undefined)
} }
const splitted = id.split("/"); const splitted = id.split("/");
@ -157,7 +158,7 @@ export abstract class OsmObject {
const minlat = bounds[1][0] const minlat = bounds[1][0]
const maxlat = bounds[0][0]; const maxlat = bounds[0][0];
const url = `${OsmObject.backendURL}api/0.6/map.json?bbox=${minlon},${minlat},${maxlon},${maxlat}` const url = `${OsmObject.backendURL}api/0.6/map.json?bbox=${minlon},${minlat},${maxlon},${maxlat}`
Utils.downloadJson(url).then( data => { Utils.downloadJson(url).then(data => {
const elements: any[] = data.elements; const elements: any[] = data.elements;
const objects = OsmObject.ParseObjects(elements) const objects = OsmObject.ParseObjects(elements)
callback(objects); callback(objects);
@ -291,6 +292,7 @@ export abstract class OsmObject {
self.LoadData(element) self.LoadData(element)
self.SaveExtraData(element, nodes); self.SaveExtraData(element, nodes);
const meta = { const meta = {
"_last_edit:contributor": element.user, "_last_edit:contributor": element.user,
"_last_edit:contributor:uid": element.uid, "_last_edit:contributor:uid": element.uid,
@ -299,6 +301,11 @@ export abstract class OsmObject {
"_version_number": element.version "_version_number": element.version
} }
if (OsmObject.backendURL !== OsmObject.defaultBackend) {
self.tags["_backend"] = OsmObject.backendURL
meta["_backend"] = OsmObject.backendURL;
}
continuation(self, meta); continuation(self, meta);
} }
); );

View file

@ -84,6 +84,7 @@ export default class SimpleMetaTagger {
}, },
(feature => { (feature => {
const units = State.state.layoutToUse.data.units ?? []; const units = State.state.layoutToUse.data.units ?? [];
let rewritten = false;
for (const key in feature.properties) { for (const key in feature.properties) {
if (!feature.properties.hasOwnProperty(key)) { if (!feature.properties.hasOwnProperty(key)) {
continue; continue;
@ -95,16 +96,23 @@ export default class SimpleMetaTagger {
const value = feature.properties[key] const value = feature.properties[key]
const [, denomination] = unit.findDenomination(value) const [, denomination] = unit.findDenomination(value)
let canonical = denomination?.canonicalValue(value) ?? undefined; let canonical = denomination?.canonicalValue(value) ?? undefined;
console.log("Rewritten ", key, " from", value, "into", canonical) if(canonical === value){
break;
}
console.log("Rewritten ", key, ` from '${value}' into '${canonical}'`)
if(canonical === undefined && !unit.eraseInvalid) { if(canonical === undefined && !unit.eraseInvalid) {
break; break;
} }
feature.properties[key] = canonical; feature.properties[key] = canonical;
rewritten = true;
break; break;
} }
} }
if(rewritten){
State.state.allElements.getEventSourceById(feature.id).ping();
}
}) })
) )

View file

@ -4,6 +4,22 @@ import {UIEventSource} from "../UIEventSource";
* UIEventsource-wrapper around localStorage * UIEventsource-wrapper around localStorage
*/ */
export class LocalStorageSource { export class LocalStorageSource {
static GetParsed<T>(key: string, defaultValue : T) : UIEventSource<T>{
return LocalStorageSource.Get(key).map(
str => {
if(str === undefined){
return defaultValue
}
try{
return JSON.parse(str)
}catch{
return defaultValue
}
}, [],
value => JSON.stringify(value)
)
}
static Get(key: string, defaultValue: string = undefined): UIEventSource<string> { static Get(key: string, defaultValue: string = undefined): UIEventSource<string> {
try { try {

View file

@ -7,4 +7,6 @@ export default interface BaseLayer {
max_zoom: number, max_zoom: number,
min_zoom: number; min_zoom: number;
feature: any, feature: any,
isBest?: boolean,
category?: "map" | "osmbasedmap" | "photo" | "historicphoto" | string
} }

View file

@ -2,7 +2,7 @@ import { Utils } from "../Utils";
export default class Constants { export default class Constants {
public static vNumber = "0.8.3f"; public static vNumber = "0.8.4-rc3";
// The user journey states thresholds when a new feature gets unlocked // The user journey states thresholds when a new feature gets unlocked
public static userJourney = { public static userJourney = {

8
Models/TileRange.ts Normal file
View file

@ -0,0 +1,8 @@
export interface TileRange {
xstart: number,
ystart: number,
xend: number,
yend: number,
total: number,
zoomlevel: number
}

View file

@ -19,6 +19,7 @@ import TitleHandler from "./Logic/Actors/TitleHandler";
import PendingChangesUploader from "./Logic/Actors/PendingChangesUploader"; import PendingChangesUploader from "./Logic/Actors/PendingChangesUploader";
import {Relation} from "./Logic/Osm/ExtractRelations"; import {Relation} from "./Logic/Osm/ExtractRelations";
import OsmApiFeatureSource from "./Logic/FeatureSource/OsmApiFeatureSource"; import OsmApiFeatureSource from "./Logic/FeatureSource/OsmApiFeatureSource";
import FeaturePipeline from "./Logic/FeatureSource/FeaturePipeline";
/** /**
* Contains the global state: a bunch of UI-event sources * Contains the global state: a bunch of UI-event sources
@ -58,8 +59,8 @@ export default class State {
public favouriteLayers: UIEventSource<string[]>; public favouriteLayers: UIEventSource<string[]>;
public layerUpdater: OverpassFeatureSource; public layerUpdater: OverpassFeatureSource;
public osmApiFeatureSource : OsmApiFeatureSource ; public osmApiFeatureSource: OsmApiFeatureSource;
public filteredLayers: UIEventSource<{ public filteredLayers: UIEventSource<{
@ -80,7 +81,7 @@ export default class State {
* Keeps track of relations: which way is part of which other way? * Keeps track of relations: which way is part of which other way?
* Set by the overpass-updater; used in the metatagging * Set by the overpass-updater; used in the metatagging
*/ */
public readonly knownRelations = new UIEventSource<Map<string, {role: string, relation: Relation}[]>>(undefined, "Relation memberships") public readonly knownRelations = new UIEventSource<Map<string, { role: string, relation: Relation }[]>>(undefined, "Relation memberships")
public readonly featureSwitchUserbadge: UIEventSource<boolean>; public readonly featureSwitchUserbadge: UIEventSource<boolean>;
public readonly featureSwitchSearch: UIEventSource<boolean>; public readonly featureSwitchSearch: UIEventSource<boolean>;
@ -95,6 +96,11 @@ export default class State {
public readonly featureSwitchIsDebugging: UIEventSource<boolean>; public readonly featureSwitchIsDebugging: UIEventSource<boolean>;
public readonly featureSwitchShowAllQuestions: UIEventSource<boolean>; public readonly featureSwitchShowAllQuestions: UIEventSource<boolean>;
public readonly featureSwitchApiURL: UIEventSource<string>; public readonly featureSwitchApiURL: UIEventSource<string>;
public readonly featureSwitchEnableExport: UIEventSource<boolean>;
public readonly featureSwitchFakeUser: UIEventSource<boolean>;
public readonly featurePipeline: FeaturePipeline;
/** /**
@ -125,7 +131,7 @@ export default class State {
public welcomeMessageOpenedTab = QueryParameters.GetQueryParameter("tab", "0", `The tab that is shown in the welcome-message. 0 = the explanation of the theme,1 = OSM-credits, 2 = sharescreen, 3 = more themes, 4 = about mapcomplete (user must be logged in and have >${Constants.userJourney.mapCompleteHelpUnlock} changesets)`).map<number>( public welcomeMessageOpenedTab = QueryParameters.GetQueryParameter("tab", "0", `The tab that is shown in the welcome-message. 0 = the explanation of the theme,1 = OSM-credits, 2 = sharescreen, 3 = more themes, 4 = about mapcomplete (user must be logged in and have >${Constants.userJourney.mapCompleteHelpUnlock} changesets)`).map<number>(
str => isNaN(Number(str)) ? 0 : Number(str), [], n => "" + n str => isNaN(Number(str)) ? 0 : Number(str), [], n => "" + n
); );
constructor(layoutToUse: LayoutConfig) { constructor(layoutToUse: LayoutConfig) {
const self = this; const self = this;
@ -187,6 +193,12 @@ export default class State {
"Disables/Enables the layer control"); "Disables/Enables the layer control");
this.featureSwitchAddNew = featSw("fs-add-new", (layoutToUse) => layoutToUse?.enableAddNewPoints ?? true, this.featureSwitchAddNew = featSw("fs-add-new", (layoutToUse) => layoutToUse?.enableAddNewPoints ?? true,
"Disables/Enables the 'add new feature'-popup. (A theme without presets might not have it in the first place)"); "Disables/Enables the 'add new feature'-popup. (A theme without presets might not have it in the first place)");
this.featureSwitchUserbadge.addCallbackAndRun(userbadge => {
if (!userbadge) {
this.featureSwitchAddNew.setData(false)
}
})
this.featureSwitchWelcomeMessage = featSw("fs-welcome-message", () => true, this.featureSwitchWelcomeMessage = featSw("fs-welcome-message", () => true,
"Disables/enables the help menu or welcome message"); "Disables/enables the help menu or welcome message");
this.featureSwitchIframe = featSw("fs-iframe", () => false, this.featureSwitchIframe = featSw("fs-iframe", () => false,
@ -199,16 +211,22 @@ export default class State {
"Disables/Enables the geolocation button"); "Disables/Enables the geolocation button");
this.featureSwitchShowAllQuestions = featSw("fs-all-questions", (layoutToUse) => layoutToUse?.enableShowAllQuestions ?? false, this.featureSwitchShowAllQuestions = featSw("fs-all-questions", (layoutToUse) => layoutToUse?.enableShowAllQuestions ?? false,
"Always show all questions"); "Always show all questions");
this.featureSwitchEnableExport = featSw("fs-export", (layoutToUse) => layoutToUse?.enableExportButton ?? false,
"If set, enables the 'download'-button to download everything as geojson")
this.featureSwitchIsTesting = QueryParameters.GetQueryParameter("test", "false", this.featureSwitchIsTesting = QueryParameters.GetQueryParameter("test", "false",
"If true, 'dryrun' mode is activated. The app will behave as normal, except that changes to OSM will be printed onto the console instead of actually uploaded to osm.org") "If true, 'dryrun' mode is activated. The app will behave as normal, except that changes to OSM will be printed onto the console instead of actually uploaded to osm.org")
.map(str => str === "true", [], b => "" + b); .map(str => str === "true", [], b => "" + b);
this.featureSwitchIsDebugging = QueryParameters.GetQueryParameter("debug","false", this.featureSwitchFakeUser = QueryParameters.GetQueryParameter("fake-user", "false",
"If true, 'dryrun' mode is activated and a fake user account is loaded")
.map(str => str === "true", [], b => "" + b);
this.featureSwitchIsDebugging = QueryParameters.GetQueryParameter("debug", "false",
"If true, shows some extra debugging help such as all the available tags on every object") "If true, shows some extra debugging help such as all the available tags on every object")
.map(str => str === "true", [], b => "" + b) .map(str => str === "true", [], b => "" + b)
this.featureSwitchApiURL = QueryParameters.GetQueryParameter("backend","osm", this.featureSwitchApiURL = QueryParameters.GetQueryParameter("backend", "osm",
"The OSM backend to use - can be used to redirect mapcomplete to the testing backend when using 'osm-test'") "The OSM backend to use - can be used to redirect mapcomplete to the testing backend when using 'osm-test'")
} }
@ -221,18 +239,19 @@ export default class State {
this.backgroundLayerId = QueryParameters.GetQueryParameter("background", this.backgroundLayerId = QueryParameters.GetQueryParameter("background",
layoutToUse?.defaultBackgroundId ?? "osm", layoutToUse?.defaultBackgroundId ?? "osm",
"The id of the background layer to start with") "The id of the background layer to start with")
} }
if(Utils.runningFromConsole){ if (Utils.runningFromConsole) {
return; return;
} }
this.osmConnection = new OsmConnection( this.osmConnection = new OsmConnection(
this.featureSwitchIsTesting.data, this.featureSwitchIsTesting.data,
this.featureSwitchFakeUser.data,
QueryParameters.GetQueryParameter("oauth_token", undefined, QueryParameters.GetQueryParameter("oauth_token", undefined,
"Used to complete the login"), "Used to complete the login"),
layoutToUse?.id, layoutToUse?.id,
@ -245,7 +264,7 @@ export default class State {
this.allElements = new ElementStorage(); this.allElements = new ElementStorage();
this.changes = new Changes(); this.changes = new Changes();
this.osmApiFeatureSource = new OsmApiFeatureSource() this.osmApiFeatureSource = new OsmApiFeatureSource()
new PendingChangesUploader(this.changes, this.selectedElement); new PendingChangesUploader(this.changes, this.selectedElement);
this.mangroveIdentity = new MangroveIdentity( this.mangroveIdentity = new MangroveIdentity(

17
Svg.ts

File diff suppressed because one or more lines are too long

View file

@ -5,6 +5,7 @@ import Loc from "../../Models/Loc";
import BaseLayer from "../../Models/BaseLayer"; import BaseLayer from "../../Models/BaseLayer";
import AvailableBaseLayers from "../../Logic/Actors/AvailableBaseLayers"; import AvailableBaseLayers from "../../Logic/Actors/AvailableBaseLayers";
import {Map} from "leaflet"; import {Map} from "leaflet";
import {Utils} from "../../Utils";
export default class Minimap extends BaseUIElement { export default class Minimap extends BaseUIElement {
@ -15,11 +16,13 @@ export default class Minimap extends BaseUIElement {
private readonly _location: UIEventSource<Loc>; private readonly _location: UIEventSource<Loc>;
private _isInited = false; private _isInited = false;
private _allowMoving: boolean; private _allowMoving: boolean;
private readonly _leafletoptions: any;
constructor(options?: { constructor(options?: {
background?: UIEventSource<BaseLayer>, background?: UIEventSource<BaseLayer>,
location?: UIEventSource<Loc>, location?: UIEventSource<Loc>,
allowMoving?: boolean allowMoving?: boolean,
leafletOptions?: any
} }
) { ) {
super() super()
@ -28,10 +31,11 @@ export default class Minimap extends BaseUIElement {
this._location = options?.location ?? new UIEventSource<Loc>(undefined) this._location = options?.location ?? new UIEventSource<Loc>(undefined)
this._id = "minimap" + Minimap._nextId; this._id = "minimap" + Minimap._nextId;
this._allowMoving = options.allowMoving ?? true; this._allowMoving = options.allowMoving ?? true;
this._leafletoptions = options.leafletOptions ?? {}
Minimap._nextId++ Minimap._nextId++
} }
protected InnerConstructElement(): HTMLElement { protected InnerConstructElement(): HTMLElement {
const div = document.createElement("div") const div = document.createElement("div")
div.id = this._id; div.id = this._id;
@ -44,7 +48,6 @@ export default class Minimap extends BaseUIElement {
const self = this; const self = this;
// @ts-ignore // @ts-ignore
const resizeObserver = new ResizeObserver(_ => { const resizeObserver = new ResizeObserver(_ => {
console.log("Change in size detected!")
self.InitMap(); self.InitMap();
self.leafletMap?.data?.invalidateSize() self.leafletMap?.data?.invalidateSize()
}); });
@ -72,8 +75,8 @@ export default class Minimap extends BaseUIElement {
const location = this._location; const location = this._location;
let currentLayer = this._background.data.layer() let currentLayer = this._background.data.layer()
const map = L.map(this._id, { const options = {
center: [location.data?.lat ?? 0, location.data?.lon ?? 0], center: <[number, number]> [location.data?.lat ?? 0, location.data?.lon ?? 0],
zoom: location.data?.zoom ?? 2, zoom: location.data?.zoom ?? 2,
layers: [currentLayer], layers: [currentLayer],
zoomControl: false, zoomControl: false,
@ -82,8 +85,14 @@ export default class Minimap extends BaseUIElement {
scrollWheelZoom: this._allowMoving, scrollWheelZoom: this._allowMoving,
doubleClickZoom: this._allowMoving, doubleClickZoom: this._allowMoving,
keyboard: this._allowMoving, keyboard: this._allowMoving,
touchZoom: this._allowMoving touchZoom: this._allowMoving,
}); // Disabling this breaks the geojson layer - don't ask me why! zoomAnimation: this._allowMoving,
fadeAnimation: this._allowMoving
}
Utils.Merge(this._leafletoptions, options)
const map = L.map(this._id, options);
map.setMaxBounds( map.setMaxBounds(
[[-100, -200], [100, 200]] [[-100, -200], [100, 200]]

View file

@ -3,6 +3,7 @@ import {UIEventSource} from "../../Logic/UIEventSource";
import Loc from "../../Models/Loc"; import Loc from "../../Models/Loc";
import BaseLayer from "../../Models/BaseLayer"; import BaseLayer from "../../Models/BaseLayer";
import BaseUIElement from "../BaseUIElement"; import BaseUIElement from "../BaseUIElement";
import {FixedUiElement} from "../Base/FixedUiElement";
export class Basemap { export class Basemap {
@ -35,9 +36,8 @@ export class Basemap {
); );
this.map.attributionControl.setPrefix( this.map.attributionControl.setPrefix(
"<span id='leaflet-attribution'></span> | <a href='https://osm.org'>OpenStreetMap</a>"); "<span id='leaflet-attribution'>A</span>");
extraAttribution.AttachTo('leaflet-attribution')
const self = this; const self = this;
currentLayer.addCallbackAndRun(layer => { currentLayer.addCallbackAndRun(layer => {
@ -77,6 +77,7 @@ export class Basemap {
lastClickLocation?.setData({lat: e.latlng.lat, lon: e.latlng.lng}); lastClickLocation?.setData({lat: e.latlng.lat, lon: e.latlng.lng});
}); });
extraAttribution.AttachTo('leaflet-attribution')
} }

View file

@ -0,0 +1,21 @@
import {SubtleButton} from "../Base/SubtleButton";
import Svg from "../../Svg";
import Translations from "../i18n/Translations";
import State from "../../State";
import {FeatureSourceUtils} from "../../Logic/FeatureSource/FeatureSource";
import {Utils} from "../../Utils";
import Combine from "../Base/Combine";
export class ExportDataButton extends Combine {
constructor() {
const t = Translations.t.general.download
const button = new SubtleButton(Svg.floppy_ui(), t.downloadGeojson.Clone().SetClass("font-bold"))
.onClick(() => {
const geojson = FeatureSourceUtils.extractGeoJson(State.state.featurePipeline)
const name = State.state.layoutToUse.data.id;
Utils.offerContentsAsDownloadableFile(JSON.stringify(geojson), `MapComplete_${name}_export_${new Date().toISOString().substr(0,19)}.geojson`);
})
super([button, t.licenseInfo.Clone().SetClass("link-underline")])
}
}

View file

@ -2,11 +2,12 @@ import State from "../../State";
import BackgroundSelector from "./BackgroundSelector"; import BackgroundSelector from "./BackgroundSelector";
import LayerSelection from "./LayerSelection"; import LayerSelection from "./LayerSelection";
import Combine from "../Base/Combine"; import Combine from "../Base/Combine";
import {FixedUiElement} from "../Base/FixedUiElement";
import ScrollableFullScreen from "../Base/ScrollableFullScreen"; import ScrollableFullScreen from "../Base/ScrollableFullScreen";
import Translations from "../i18n/Translations"; import Translations from "../i18n/Translations";
import {UIEventSource} from "../../Logic/UIEventSource"; import {UIEventSource} from "../../Logic/UIEventSource";
import BaseUIElement from "../BaseUIElement"; import BaseUIElement from "../BaseUIElement";
import Toggle from "../Input/Toggle";
import {ExportDataButton} from "./ExportDataButton";
export default class LayerControlPanel extends ScrollableFullScreen { export default class LayerControlPanel extends ScrollableFullScreen {
@ -14,27 +15,34 @@ export default class LayerControlPanel extends ScrollableFullScreen {
super(LayerControlPanel.GenTitle, LayerControlPanel.GeneratePanel, "layers", isShown); super(LayerControlPanel.GenTitle, LayerControlPanel.GeneratePanel, "layers", isShown);
} }
private static GenTitle():BaseUIElement { private static GenTitle(): BaseUIElement {
return Translations.t.general.layerSelection.title.Clone().SetClass("text-2xl break-words font-bold p-2") return Translations.t.general.layerSelection.title.Clone().SetClass("text-2xl break-words font-bold p-2")
} }
private static GeneratePanel() : BaseUIElement { private static GeneratePanel(): BaseUIElement {
let layerControlPanel: BaseUIElement = new FixedUiElement(""); const elements: BaseUIElement[] = []
if (State.state.layoutToUse.data.enableBackgroundLayerSelection) { if (State.state.layoutToUse.data.enableBackgroundLayerSelection) {
layerControlPanel = new BackgroundSelector(); const backgroundSelector = new BackgroundSelector();
layerControlPanel.SetStyle("margin:1em"); backgroundSelector.SetStyle("margin:1em");
layerControlPanel.onClick(() => { backgroundSelector.onClick(() => {
}); });
elements.push(backgroundSelector)
} }
if (State.state.filteredLayers.data.length > 1) { elements.push(new Toggle(
const layerSelection = new LayerSelection(State.state.filteredLayers); new LayerSelection(State.state.filteredLayers),
layerSelection.onClick(() => { undefined,
}); State.state.filteredLayers.map(layers => layers.length > 1)
layerControlPanel = new Combine([layerSelection, "<br/>", layerControlPanel]); ))
}
return layerControlPanel; elements.push(new Toggle(
new ExportDataButton(),
undefined,
State.state.featureSwitchEnableExport
))
return new Combine(elements).SetClass("flex flex-col")
} }
} }

View file

@ -74,7 +74,6 @@ export default class LayerSelection extends Combine {
); );
} }
super(checkboxes) super(checkboxes)
this.SetStyle("display:flex;flex-direction:column;") this.SetStyle("display:flex;flex-direction:column;")

View file

@ -62,6 +62,10 @@ export default class MoreScreen extends Combine {
let officialThemes = AllKnownLayouts.layoutsList let officialThemes = AllKnownLayouts.layoutsList
let buttons = officialThemes.map((layout) => { let buttons = officialThemes.map((layout) => {
if(layout === undefined){
console.trace("Layout is undefined")
return undefined
}
const button = MoreScreen.createLinkButton(layout)?.SetClass(buttonClass); const button = MoreScreen.createLinkButton(layout)?.SetClass(buttonClass);
if(layout.id === personal.id){ if(layout.id === personal.id){
return new VariableUiElement( return new VariableUiElement(

View file

@ -16,6 +16,10 @@ import {VariableUiElement} from "../Base/VariableUIElement";
import Toggle from "../Input/Toggle"; import Toggle from "../Input/Toggle";
import UserDetails from "../../Logic/Osm/OsmConnection"; import UserDetails from "../../Logic/Osm/OsmConnection";
import {Translation} from "../i18n/Translation"; import {Translation} from "../i18n/Translation";
import LocationInput from "../Input/LocationInput";
import {InputElement} from "../Input/InputElement";
import Loc from "../../Models/Loc";
import AvailableBaseLayers from "../../Logic/Actors/AvailableBaseLayers";
/* /*
* The SimpleAddUI is a single panel, which can have multiple states: * The SimpleAddUI is a single panel, which can have multiple states:
@ -25,14 +29,18 @@ import {Translation} from "../i18n/Translation";
* - A 'read your unread messages before adding a point' * - A 'read your unread messages before adding a point'
*/ */
/*private*/
interface PresetInfo { interface PresetInfo {
description: string | Translation, description: string | Translation,
name: string | BaseUIElement, name: string | BaseUIElement,
icon: BaseUIElement, icon: () => BaseUIElement,
tags: Tag[], tags: Tag[],
layerToAddTo: { layerToAddTo: {
layerDef: LayerConfig, layerDef: LayerConfig,
isDisplayed: UIEventSource<boolean> isDisplayed: UIEventSource<boolean>
},
preciseInput?: {
preferredBackground?: string
} }
} }
@ -48,18 +56,16 @@ export default class SimpleAddUI extends Toggle {
new SubtleButton(Svg.envelope_ui(), new SubtleButton(Svg.envelope_ui(),
Translations.t.general.goToInbox, {url: "https://www.openstreetmap.org/messages/inbox", newTab: false}) Translations.t.general.goToInbox, {url: "https://www.openstreetmap.org/messages/inbox", newTab: false})
]); ]);
const selectedPreset = new UIEventSource<PresetInfo>(undefined); const selectedPreset = new UIEventSource<PresetInfo>(undefined);
isShown.addCallback(_ => selectedPreset.setData(undefined)) // Clear preset selection when the UI is closed/opened isShown.addCallback(_ => selectedPreset.setData(undefined)) // Clear preset selection when the UI is closed/opened
function createNewPoint(tags: any[]){ function createNewPoint(tags: any[], location: { lat: number, lon: number }) {
const loc = State.state.LastClickLocation.data; let feature = State.state.changes.createElement(tags, location.lat, location.lon);
let feature = State.state.changes.createElement(tags, loc.lat, loc.lon);
State.state.selectedElement.setData(feature); State.state.selectedElement.setData(feature);
} }
const presetsOverview = SimpleAddUI.CreateAllPresetsPanel(selectedPreset) const presetsOverview = SimpleAddUI.CreateAllPresetsPanel(selectedPreset)
const addUi = new VariableUiElement( const addUi = new VariableUiElement(
@ -68,8 +74,8 @@ export default class SimpleAddUI extends Toggle {
return presetsOverview return presetsOverview
} }
return SimpleAddUI.CreateConfirmButton(preset, return SimpleAddUI.CreateConfirmButton(preset,
tags => { (tags, location) => {
createNewPoint(tags) createNewPoint(tags, location)
selectedPreset.setData(undefined) selectedPreset.setData(undefined)
}, () => { }, () => {
selectedPreset.setData(undefined) selectedPreset.setData(undefined)
@ -86,7 +92,7 @@ export default class SimpleAddUI extends Toggle {
addUi, addUi,
State.state.layerUpdater.runningQuery State.state.layerUpdater.runningQuery
), ),
Translations.t.general.add.zoomInFurther.Clone().SetClass("alert") , Translations.t.general.add.zoomInFurther.Clone().SetClass("alert"),
State.state.locationControl.map(loc => loc.zoom >= Constants.userJourney.minZoomLevelToAddNewPoints) State.state.locationControl.map(loc => loc.zoom >= Constants.userJourney.minZoomLevelToAddNewPoints)
), ),
readYourMessages, readYourMessages,
@ -103,22 +109,48 @@ export default class SimpleAddUI extends Toggle {
} }
private static CreateConfirmButton(preset: PresetInfo, private static CreateConfirmButton(preset: PresetInfo,
confirm: (tags: any[]) => void, confirm: (tags: any[], location: { lat: number, lon: number }) => void,
cancel: () => void): BaseUIElement { cancel: () => void): BaseUIElement {
let location = State.state.LastClickLocation;
let preciseInput: InputElement<Loc> = undefined
if (preset.preciseInput !== undefined) {
const locationSrc = new UIEventSource({
lat: location.data.lat,
lon: location.data.lon,
zoom: 19
});
let backgroundLayer = undefined;
if(preset.preciseInput.preferredBackground){
backgroundLayer= AvailableBaseLayers.SelectBestLayerAccordingTo(locationSrc, new UIEventSource<string | string[]>(preset.preciseInput.preferredBackground))
}
preciseInput = new LocationInput({
mapBackground: backgroundLayer,
centerLocation:locationSrc
})
preciseInput.SetClass("h-32 rounded-xl overflow-hidden border border-gray").SetStyle("height: 12rem;")
}
const confirmButton = new SubtleButton(preset.icon,
let confirmButton: BaseUIElement = new SubtleButton(preset.icon(),
new Combine([ new Combine([
Translations.t.general.add.addNew.Subs({category: preset.name}), Translations.t.general.add.addNew.Subs({category: preset.name}),
Translations.t.general.add.warnVisibleForEveryone.Clone().SetClass("alert") Translations.t.general.add.warnVisibleForEveryone.Clone().SetClass("alert")
]).SetClass("flex flex-col") ]).SetClass("flex flex-col")
).SetClass("font-bold break-words") ).SetClass("font-bold break-words")
.onClick(() => confirm(preset.tags)); .onClick(() => {
confirm(preset.tags, (preciseInput?.GetValue() ?? location).data);
});
if (preciseInput !== undefined) {
confirmButton = new Combine([preciseInput, confirmButton])
}
const openLayerControl =
const openLayerControl =
new SubtleButton( new SubtleButton(
Svg.layers_ui(), Svg.layers_ui(),
new Combine([ new Combine([
@ -128,9 +160,9 @@ export default class SimpleAddUI extends Toggle {
Translations.t.general.add.openLayerControl Translations.t.general.add.openLayerControl
]) ])
) )
.onClick(() => State.state.layerControlIsOpened.setData(true)) .onClick(() => State.state.layerControlIsOpened.setData(true))
const openLayerOrConfirm = new Toggle( const openLayerOrConfirm = new Toggle(
confirmButton, confirmButton,
openLayerControl, openLayerControl,
@ -140,12 +172,12 @@ export default class SimpleAddUI extends Toggle {
const cancelButton = new SubtleButton(Svg.close_ui(), const cancelButton = new SubtleButton(Svg.close_ui(),
Translations.t.general.cancel Translations.t.general.cancel
).onClick(cancel ) ).onClick(cancel)
return new Combine([ return new Combine([
Translations.t.general.add.confirmIntro.Subs({title: preset.name}), Translations.t.general.add.confirmIntro.Subs({title: preset.name}),
State.state.osmConnection.userDetails.data.dryRun ? State.state.osmConnection.userDetails.data.dryRun ?
Translations.t.general.testing.Clone().SetClass("alert") : undefined , Translations.t.general.testing.Clone().SetClass("alert") : undefined,
openLayerOrConfirm, openLayerOrConfirm,
cancelButton, cancelButton,
preset.description, preset.description,
@ -180,11 +212,11 @@ export default class SimpleAddUI extends Toggle {
} }
private static CreatePresetSelectButton(preset: PresetInfo){ private static CreatePresetSelectButton(preset: PresetInfo) {
const tagInfo =SimpleAddUI.CreateTagInfoFor(preset, false); const tagInfo = SimpleAddUI.CreateTagInfoFor(preset, false);
return new SubtleButton( return new SubtleButton(
preset.icon, preset.icon(),
new Combine([ new Combine([
Translations.t.general.add.addNew.Subs({ Translations.t.general.add.addNew.Subs({
category: preset.name category: preset.name
@ -194,29 +226,30 @@ export default class SimpleAddUI extends Toggle {
]).SetClass("flex flex-col") ]).SetClass("flex flex-col")
) )
} }
/* /*
* Generates the list with all the buttons.*/ * Generates the list with all the buttons.*/
private static CreatePresetButtons(selectedPreset: UIEventSource<PresetInfo>): BaseUIElement { private static CreatePresetButtons(selectedPreset: UIEventSource<PresetInfo>): BaseUIElement {
const allButtons = []; const allButtons = [];
for (const layer of State.state.filteredLayers.data) { for (const layer of State.state.filteredLayers.data) {
if(layer.isDisplayed.data === false && State.state.featureSwitchLayers){ if (layer.isDisplayed.data === false && State.state.featureSwitchLayers) {
continue; continue;
} }
const presets = layer.layerDef.presets; const presets = layer.layerDef.presets;
for (const preset of presets) { for (const preset of presets) {
const tags = TagUtils.KVtoProperties(preset.tags ?? []); const tags = TagUtils.KVtoProperties(preset.tags ?? []);
let icon: BaseUIElement = layer.layerDef.GenerateLeafletStyle(new UIEventSource<any>(tags), false).icon.html let icon:() => BaseUIElement = () => layer.layerDef.GenerateLeafletStyle(new UIEventSource<any>(tags), false).icon.html
.SetClass("w-12 h-12 block relative"); .SetClass("w-12 h-12 block relative");
const presetInfo: PresetInfo = { const presetInfo: PresetInfo = {
tags: preset.tags, tags: preset.tags,
layerToAddTo: layer, layerToAddTo: layer,
name: preset.title, name: preset.title,
description: preset.description, description: preset.description,
icon: icon icon: icon,
preciseInput: preset.preciseInput
} }
const button = SimpleAddUI.CreatePresetSelectButton(presetInfo); const button = SimpleAddUI.CreatePresetSelectButton(presetInfo);

View file

@ -66,6 +66,7 @@ export default class DirectionInput extends InputElement<string> {
}) })
this.RegisterTriggers(element) this.RegisterTriggers(element)
element.style.overflow = "hidden"
return element; return element;
} }

View file

@ -0,0 +1,35 @@
import {InputElement} from "./InputElement";
import {UIEventSource} from "../../Logic/UIEventSource";
import BaseUIElement from "../BaseUIElement";
import {Translation} from "../i18n/Translation";
import {SubstitutedTranslation} from "../SubstitutedTranslation";
export default class InputElementWrapper<T> extends InputElement<T> {
public readonly IsSelected: UIEventSource<boolean>;
private readonly _inputElement: InputElement<T>;
private readonly _renderElement: BaseUIElement
constructor(inputElement: InputElement<T>, translation: Translation, key: string, tags: UIEventSource<any>) {
super()
this._inputElement = inputElement;
this.IsSelected = inputElement.IsSelected
const mapping = new Map<string, BaseUIElement>()
mapping.set(key, inputElement)
this._renderElement = new SubstitutedTranslation(translation, tags, mapping)
}
GetValue(): UIEventSource<T> {
return this._inputElement.GetValue();
}
IsValid(t: T): boolean {
return this._inputElement.IsValid(t);
}
protected InnerConstructElement(): HTMLElement {
return this._renderElement.ConstructElement();
}
}

185
UI/Input/LengthInput.ts Normal file
View file

@ -0,0 +1,185 @@
import {InputElement} from "./InputElement";
import {UIEventSource} from "../../Logic/UIEventSource";
import Combine from "../Base/Combine";
import Svg from "../../Svg";
import {Utils} from "../../Utils";
import Loc from "../../Models/Loc";
import {GeoOperations} from "../../Logic/GeoOperations";
import DirectionInput from "./DirectionInput";
import {RadioButton} from "./RadioButton";
import {FixedInputElement} from "./FixedInputElement";
/**
* Selects a length after clicking on the minimap, in meters
*/
export default class LengthInput extends InputElement<string> {
private readonly _location: UIEventSource<Loc>;
public readonly IsSelected: UIEventSource<boolean> = new UIEventSource<boolean>(false);
private readonly value: UIEventSource<string>;
private background;
constructor(mapBackground: UIEventSource<any>,
location: UIEventSource<Loc>,
value?: UIEventSource<string>) {
super();
this._location = location;
this.value = value ?? new UIEventSource<string>(undefined);
this.background = mapBackground;
this.SetClass("block")
}
GetValue(): UIEventSource<string> {
return this.value;
}
IsValid(str: string): boolean {
const t = Number(str)
return !isNaN(t) && t >= 0 && t <= 360;
}
protected InnerConstructElement(): HTMLElement {
const modeElement = new RadioButton([
new FixedInputElement("Measure", "measure"),
new FixedInputElement("Move", "move")
])
// @ts-ignore
let map = undefined
if (!Utils.runningFromConsole) {
map = DirectionInput.constructMinimap({
background: this.background,
allowMoving: false,
location: this._location,
leafletOptions: {
tap: true
}
})
}
const element = new Combine([
new Combine([Svg.length_crosshair_svg().SetStyle(
`position: absolute;top: 0;left: 0;transform:rotate(${this.value.data ?? 0}deg);`)
])
.SetClass("block length-crosshair-svg relative")
.SetStyle("z-index: 1000; visibility: hidden"),
map?.SetClass("w-full h-full block absolute top-0 left-O overflow-hidden"),
])
.SetClass("relative block bg-white border border-black rounded-3xl overflow-hidden")
.ConstructElement()
this.RegisterTriggers(element, map?.leafletMap)
element.style.overflow = "hidden"
element.style.display = "block"
return element
}
private RegisterTriggers(htmlElement: HTMLElement, leafletMap: UIEventSource<L.Map>) {
let firstClickXY: [number, number] = undefined
let lastClickXY: [number, number] = undefined
const self = this;
function onPosChange(x: number, y: number, isDown: boolean, isUp?: boolean) {
if (x === undefined || y === undefined) {
// Touch end
firstClickXY = undefined;
lastClickXY = undefined;
return;
}
const rect = htmlElement.getBoundingClientRect();
// From the central part of location
const dx = x - rect.left;
const dy = y - rect.top;
if (isDown) {
if (lastClickXY === undefined && firstClickXY === undefined) {
firstClickXY = [dx, dy];
} else if (firstClickXY !== undefined && lastClickXY === undefined) {
lastClickXY = [dx, dy]
} else if (firstClickXY !== undefined && lastClickXY !== undefined) {
// we measure again
firstClickXY = [dx, dy]
lastClickXY = undefined;
}
}
if (isUp) {
const distance = Math.sqrt((dy - firstClickXY[1]) * (dy - firstClickXY[1]) + (dx - firstClickXY[0]) * (dx - firstClickXY[0]))
if (distance > 15) {
lastClickXY = [dx, dy]
}
} else if (lastClickXY !== undefined) {
return;
}
const measurementCrosshair = htmlElement.getElementsByClassName("length-crosshair-svg")[0] as HTMLElement
const measurementCrosshairInner: HTMLElement = <HTMLElement>measurementCrosshair.firstChild
if (firstClickXY === undefined) {
measurementCrosshair.style.visibility = "hidden"
} else {
measurementCrosshair.style.visibility = "unset"
measurementCrosshair.style.left = firstClickXY[0] + "px";
measurementCrosshair.style.top = firstClickXY[1] + "px"
const angle = 180 * Math.atan2(firstClickXY[1] - dy, firstClickXY[0] - dx) / Math.PI;
const angleGeo = (angle + 270) % 360
measurementCrosshairInner.style.transform = `rotate(${angleGeo}deg)`;
const distance = Math.sqrt((dy - firstClickXY[1]) * (dy - firstClickXY[1]) + (dx - firstClickXY[0]) * (dx - firstClickXY[0]))
measurementCrosshairInner.style.width = (distance * 2) + "px"
measurementCrosshairInner.style.marginLeft = -distance + "px"
measurementCrosshairInner.style.marginTop = -distance + "px"
const leaflet = leafletMap?.data
if (leaflet) {
const first = leaflet.layerPointToLatLng(firstClickXY)
const last = leaflet.layerPointToLatLng([dx, dy])
const geoDist = Math.floor(GeoOperations.distanceBetween([first.lng, first.lat], [last.lng, last.lat]) * 100000) / 100
self.value.setData("" + geoDist)
}
}
}
htmlElement.ontouchstart = (ev: TouchEvent) => {
onPosChange(ev.touches[0].clientX, ev.touches[0].clientY, true);
ev.preventDefault();
}
htmlElement.ontouchmove = (ev: TouchEvent) => {
onPosChange(ev.touches[0].clientX, ev.touches[0].clientY, false);
ev.preventDefault();
}
htmlElement.ontouchend = (ev: TouchEvent) => {
onPosChange(undefined, undefined, false, true);
ev.preventDefault();
}
htmlElement.onmousedown = (ev: MouseEvent) => {
onPosChange(ev.clientX, ev.clientY, true);
ev.preventDefault();
}
htmlElement.onmouseup = (ev) => {
onPosChange(ev.clientX, ev.clientY, false, true);
ev.preventDefault();
}
htmlElement.onmousemove = (ev: MouseEvent) => {
onPosChange(ev.clientX, ev.clientY, false);
ev.preventDefault();
}
}
}

76
UI/Input/LocationInput.ts Normal file
View file

@ -0,0 +1,76 @@
import {InputElement} from "./InputElement";
import Loc from "../../Models/Loc";
import {UIEventSource} from "../../Logic/UIEventSource";
import Minimap from "../Base/Minimap";
import BaseLayer from "../../Models/BaseLayer";
import Combine from "../Base/Combine";
import Svg from "../../Svg";
import State from "../../State";
export default class LocationInput extends InputElement<Loc> {
IsSelected: UIEventSource<boolean> = new UIEventSource<boolean>(false);
private _centerLocation: UIEventSource<Loc>;
private readonly mapBackground : UIEventSource<BaseLayer>;
constructor(options?: {
mapBackground?: UIEventSource<BaseLayer>,
centerLocation?: UIEventSource<Loc>,
}) {
super();
options = options ?? {}
options.centerLocation = options.centerLocation ?? new UIEventSource<Loc>({lat: 0, lon: 0, zoom: 1})
this._centerLocation = options.centerLocation;
this.mapBackground = options.mapBackground ?? State.state.backgroundLayer
this.SetClass("block h-full")
}
GetValue(): UIEventSource<Loc> {
return this._centerLocation;
}
IsValid(t: Loc): boolean {
return t !== undefined;
}
protected InnerConstructElement(): HTMLElement {
const map = new Minimap(
{
location: this._centerLocation,
background: this.mapBackground
}
)
map.leafletMap.addCallbackAndRunD(leaflet => {
console.log(leaflet.getBounds(), leaflet.getBounds().pad(0.15))
leaflet.setMaxBounds(
leaflet.getBounds().pad(0.15)
)
})
this.mapBackground.map(layer => {
const leaflet = map.leafletMap.data
if (leaflet === undefined || layer === undefined) {
return;
}
leaflet.setMaxZoom(layer.max_zoom)
leaflet.setMinZoom(layer.max_zoom - 3)
leaflet.setZoom(layer.max_zoom - 1)
}, [map.leafletMap])
return new Combine([
new Combine([
Svg.crosshair_empty_ui()
.SetClass("block relative")
.SetStyle("left: -1.25rem; top: -1.25rem; width: 2.5rem; height: 2.5rem")
]).SetClass("block w-0 h-0 z-10 relative")
.SetStyle("background: rgba(255, 128, 128, 0.21); left: 50%; top: 50%"),
map
.SetClass("z-0 relative block w-full h-full bg-gray-100")
]).ConstructElement();
}
}

View file

@ -103,7 +103,7 @@ export class RadioButton<T> extends InputElement<T> {
const block = document.createElement("div") const block = document.createElement("div")
block.appendChild(input) block.appendChild(input)
block.appendChild(label) block.appendChild(label)
block.classList.add("flex","w-full","border", "rounded-full", "border-gray-400","m-1") block.classList.add("flex","w-full","border", "rounded-3xl", "border-gray-400","m-1")
wrappers.push(block) wrappers.push(block)
form.appendChild(block) form.appendChild(block)

View file

@ -36,11 +36,11 @@ export class TextField extends InputElement<string> {
this.SetClass("form-text-field") this.SetClass("form-text-field")
let inputEl: HTMLElement let inputEl: HTMLElement
if (options.htmlType === "area") { if (options.htmlType === "area") {
this.SetClass("w-full box-border max-w-full")
const el = document.createElement("textarea") const el = document.createElement("textarea")
el.placeholder = placeholder el.placeholder = placeholder
el.rows = options.textAreaRows el.rows = options.textAreaRows
el.cols = 50 el.cols = 50
el.style.cssText = "max-width: 100%; width: 100%; box-sizing: border-box"
inputEl = el; inputEl = el;
} else { } else {
const el = document.createElement("input") const el = document.createElement("input")

View file

@ -13,6 +13,8 @@ import {Utils} from "../../Utils";
import Loc from "../../Models/Loc"; import Loc from "../../Models/Loc";
import {Unit} from "../../Customizations/JSON/Denomination"; import {Unit} from "../../Customizations/JSON/Denomination";
import BaseUIElement from "../BaseUIElement"; import BaseUIElement from "../BaseUIElement";
import LengthInput from "./LengthInput";
import {GeoOperations} from "../../Logic/GeoOperations";
interface TextFieldDef { interface TextFieldDef {
name: string, name: string,
@ -21,14 +23,16 @@ interface TextFieldDef {
reformat?: ((s: string, country?: () => string) => string), reformat?: ((s: string, country?: () => string) => string),
inputHelper?: (value: UIEventSource<string>, options?: { inputHelper?: (value: UIEventSource<string>, options?: {
location: [number, number], location: [number, number],
mapBackgroundLayer?: UIEventSource<any> mapBackgroundLayer?: UIEventSource<any>,
args: (string | number | boolean)[]
feature?: any
}) => InputElement<string>, }) => InputElement<string>,
inputmode?: string inputmode?: string
} }
export default class ValidatedTextField { export default class ValidatedTextField {
public static bestLayerAt: (location: UIEventSource<Loc>, preferences: UIEventSource<string[]>) => any
public static tpList: TextFieldDef[] = [ public static tpList: TextFieldDef[] = [
ValidatedTextField.tp( ValidatedTextField.tp(
@ -63,6 +67,83 @@ export default class ValidatedTextField {
return [year, month, day].join('-'); return [year, month, day].join('-');
}, },
(value) => new SimpleDatePicker(value)), (value) => new SimpleDatePicker(value)),
ValidatedTextField.tp(
"direction",
"A geographical direction, in degrees. 0° is north, 90° is east, ... Will return a value between 0 (incl) and 360 (excl)",
(str) => {
str = "" + str;
return str !== undefined && str.indexOf(".") < 0 && !isNaN(Number(str)) && Number(str) >= 0 && Number(str) <= 360
}, str => str,
(value, options) => {
const args = options.args ?? []
let zoom = 19
if (args[0]) {
zoom = Number(args[0])
if (isNaN(zoom)) {
throw "Invalid zoom level for argument at 'length'-input"
}
}
const location = new UIEventSource<Loc>({
lat: options.location[0],
lon: options.location[1],
zoom: zoom
})
if (args[1]) {
// We have a prefered map!
options.mapBackgroundLayer = ValidatedTextField.bestLayerAt(
location, new UIEventSource<string[]>(args[1].split(","))
)
}
const di = new DirectionInput(options.mapBackgroundLayer, location, value)
di.SetStyle("height: 20rem;");
return di;
},
"numeric"
),
ValidatedTextField.tp(
"length",
"A geographical length in meters (rounded at two points). Will give an extra minimap with a measurement tool. Arguments: [ zoomlevel, preferredBackgroundMapType (comma seperated) ], e.g. `[\"21\", \"map,photo\"]",
(str) => {
const t = Number(str)
return !isNaN(t)
},
str => str,
(value, options) => {
const args = options.args ?? []
let zoom = 19
if (args[0]) {
zoom = Number(args[0])
if (isNaN(zoom)) {
throw "Invalid zoom level for argument at 'length'-input"
}
}
// Bit of a hack: we project the centerpoint to the closes point on the road - if available
if(options.feature){
const lonlat: [number, number] = [...options.location]
lonlat.reverse()
options.location = <[number,number]> GeoOperations.nearestPoint(options.feature, lonlat).geometry.coordinates
options.location.reverse()
}
options.feature
const location = new UIEventSource<Loc>({
lat: options.location[0],
lon: options.location[1],
zoom: zoom
})
if (args[1]) {
// We have a prefered map!
options.mapBackgroundLayer = ValidatedTextField.bestLayerAt(
location, new UIEventSource<string[]>(args[1].split(","))
)
}
const li = new LengthInput(options.mapBackgroundLayer, location, value)
li.SetStyle("height: 20rem;")
return li;
}
),
ValidatedTextField.tp( ValidatedTextField.tp(
"wikidata", "wikidata",
"A wikidata identifier, e.g. Q42", "A wikidata identifier, e.g. Q42",
@ -113,22 +194,6 @@ export default class ValidatedTextField {
undefined, undefined,
undefined, undefined,
"numeric"), "numeric"),
ValidatedTextField.tp(
"direction",
"A geographical direction, in degrees. 0° is north, 90° is east, ... Will return a value between 0 (incl) and 360 (excl)",
(str) => {
str = "" + str;
return str !== undefined && str.indexOf(".") < 0 && !isNaN(Number(str)) && Number(str) >= 0 && Number(str) <= 360
}, str => str,
(value, options) => {
return new DirectionInput(options.mapBackgroundLayer , new UIEventSource<Loc>({
lat: options.location[0],
lon: options.location[1],
zoom: 19
}),value);
},
"numeric"
),
ValidatedTextField.tp( ValidatedTextField.tp(
"float", "float",
"A decimal", "A decimal",
@ -222,6 +287,7 @@ export default class ValidatedTextField {
* {string (typename) --> TextFieldDef} * {string (typename) --> TextFieldDef}
*/ */
public static AllTypes = ValidatedTextField.allTypesDict(); public static AllTypes = ValidatedTextField.allTypesDict();
public static InputForType(type: string, options?: { public static InputForType(type: string, options?: {
placeholder?: string | BaseUIElement, placeholder?: string | BaseUIElement,
value?: UIEventSource<string>, value?: UIEventSource<string>,
@ -233,7 +299,9 @@ export default class ValidatedTextField {
country?: () => string, country?: () => string,
location?: [number /*lat*/, number /*lon*/], location?: [number /*lat*/, number /*lon*/],
mapBackgroundLayer?: UIEventSource<any>, mapBackgroundLayer?: UIEventSource<any>,
unit?: Unit unit?: Unit,
args?: (string | number | boolean)[] // Extra arguments for the inputHelper,
feature?: any
}): InputElement<string> { }): InputElement<string> {
options = options ?? {}; options = options ?? {};
options.placeholder = options.placeholder ?? type; options.placeholder = options.placeholder ?? type;
@ -247,7 +315,7 @@ export default class ValidatedTextField {
if (str === undefined) { if (str === undefined) {
return false; return false;
} }
if(options.unit) { if (options.unit) {
str = options.unit.stripUnitParts(str) str = options.unit.stripUnitParts(str)
} }
return isValidTp(str, country ?? options.country) && optValid(str, country ?? options.country); return isValidTp(str, country ?? options.country) && optValid(str, country ?? options.country);
@ -268,7 +336,7 @@ export default class ValidatedTextField {
}) })
} }
if(options.unit) { if (options.unit) {
// We need to apply a unit. // We need to apply a unit.
// This implies: // This implies:
// We have to create a dropdown with applicable denominations, and fuse those values // We have to create a dropdown with applicable denominations, and fuse those values
@ -282,23 +350,22 @@ export default class ValidatedTextField {
}) })
) )
unitDropDown.GetValue().setData(unit.defaultDenom) unitDropDown.GetValue().setData(unit.defaultDenom)
unitDropDown.SetStyle("width: min-content") unitDropDown.SetClass("w-min")
input = new CombinedInputElement( input = new CombinedInputElement(
input, input,
unitDropDown, unitDropDown,
// combine the value from the textfield and the dropdown into the resulting value that should go into OSM // combine the value from the textfield and the dropdown into the resulting value that should go into OSM
(text, denom) => denom?.canonicalValue(text, true) ?? undefined, (text, denom) => denom?.canonicalValue(text, true) ?? undefined,
(valueWithDenom: string) => { (valueWithDenom: string) => {
// Take the value from OSM and feed it into the textfield and the dropdown // Take the value from OSM and feed it into the textfield and the dropdown
const withDenom = unit.findDenomination(valueWithDenom); const withDenom = unit.findDenomination(valueWithDenom);
if(withDenom === undefined) if (withDenom === undefined) {
{
// Not a valid value at all - we give it undefined and leave the details up to the other elements // Not a valid value at all - we give it undefined and leave the details up to the other elements
return [undefined, undefined] return [undefined, undefined]
} }
const [strippedText, denom] = withDenom const [strippedText, denom] = withDenom
if(strippedText === undefined){ if (strippedText === undefined) {
return [undefined, undefined] return [undefined, undefined]
} }
return [strippedText, denom] return [strippedText, denom]
@ -306,18 +373,20 @@ export default class ValidatedTextField {
).SetClass("flex") ).SetClass("flex")
} }
if (tp.inputHelper) { if (tp.inputHelper) {
const helper = tp.inputHelper(input.GetValue(), { const helper = tp.inputHelper(input.GetValue(), {
location: options.location, location: options.location,
mapBackgroundLayer: options.mapBackgroundLayer mapBackgroundLayer: options.mapBackgroundLayer,
args: options.args,
feature: options.feature
}) })
input = new CombinedInputElement(input, helper, input = new CombinedInputElement(input, helper,
(a, _) => a, // We can ignore b, as they are linked earlier (a, _) => a, // We can ignore b, as they are linked earlier
a => [a, a] a => [a, a]
); );
} }
return input; return input;
} }
public static HelpText(): string { public static HelpText(): string {
const explanations = ValidatedTextField.tpList.map(type => ["## " + type.name, "", type.explanation].join("\n")).join("\n\n") const explanations = ValidatedTextField.tpList.map(type => ["## " + type.name, "", type.explanation].join("\n")).join("\n\n")
return "# Available types for text fields\n\nThe listed types here trigger a special input element. Use them in `tagrendering.freeform.type` of your tagrendering to activate them\n\n" + explanations return "# Available types for text fields\n\nThe listed types here trigger a special input element. Use them in `tagrendering.freeform.type` of your tagrendering to activate them\n\n" + explanations
@ -329,7 +398,9 @@ export default class ValidatedTextField {
reformat?: ((s: string, country?: () => string) => string), reformat?: ((s: string, country?: () => string) => string),
inputHelper?: (value: UIEventSource<string>, options?: { inputHelper?: (value: UIEventSource<string>, options?: {
location: [number, number], location: [number, number],
mapBackgroundLayer: UIEventSource<any> mapBackgroundLayer: UIEventSource<any>,
args: string[],
feature: any
}) => InputElement<string>, }) => InputElement<string>,
inputmode?: string): TextFieldDef { inputmode?: string): TextFieldDef {

View file

@ -36,7 +36,7 @@ export default class FeatureInfoBox extends ScrollableFullScreen {
.SetClass("break-words font-bold sm:p-0.5 md:p-1 sm:p-1.5 md:p-2"); .SetClass("break-words font-bold sm:p-0.5 md:p-1 sm:p-1.5 md:p-2");
const titleIcons = new Combine( const titleIcons = new Combine(
layerConfig.titleIcons.map(icon => new TagRenderingAnswer(tags, icon, layerConfig.titleIcons.map(icon => new TagRenderingAnswer(tags, icon,
"block w-8 h-8 align-baseline box-content sm:p-0.5") "block w-8 h-8 align-baseline box-content sm:p-0.5", "width: 2rem;")
)) ))
.SetClass("flex flex-row flex-wrap pt-0.5 sm:pt-1 items-center mr-2") .SetClass("flex flex-row flex-wrap pt-0.5 sm:pt-1 items-center mr-2")

View file

@ -16,31 +16,31 @@ export default class TagRenderingAnswer extends VariableUiElement {
throw "Trying to generate a tagRenderingAnswer without configuration..." throw "Trying to generate a tagRenderingAnswer without configuration..."
} }
super(tagsSource.map(tags => { super(tagsSource.map(tags => {
if(tags === undefined){ if (tags === undefined) {
return undefined; return undefined;
} }
if(configuration.condition){ if (configuration.condition) {
if(!configuration.condition.matchesProperties(tags)){ if (!configuration.condition.matchesProperties(tags)) {
return undefined; return undefined;
} }
} }
const trs = Utils.NoNull(configuration.GetRenderValues(tags));
if(trs.length === 0){
return undefined;
}
const valuesToRender: BaseUIElement[] = trs.map(tr => new SubstitutedTranslation(tr, tagsSource))
if(valuesToRender.length === 1){
return valuesToRender[0];
}else if(valuesToRender.length > 1){
return new List(valuesToRender)
}
return undefined;
}).map((element : BaseUIElement) => element?.SetClass(contentClasses)?.SetStyle(contentStyle)))
this.SetClass("flex items-center flex-row text-lg link-underline tag-renering-answer") const trs = Utils.NoNull(configuration.GetRenderValues(tags));
if (trs.length === 0) {
return undefined;
}
const valuesToRender: BaseUIElement[] = trs.map(tr => new SubstitutedTranslation(tr, tagsSource))
if (valuesToRender.length === 1) {
return valuesToRender[0];
} else if (valuesToRender.length > 1) {
return new List(valuesToRender)
}
return undefined;
}).map((element: BaseUIElement) => element?.SetClass(contentClasses)?.SetStyle(contentStyle)))
this.SetClass("flex items-center flex-row text-lg link-underline")
this.SetStyle("word-wrap: anywhere;"); this.SetStyle("word-wrap: anywhere;");
} }

View file

@ -24,6 +24,7 @@ import {TagUtils} from "../../Logic/Tags/TagUtils";
import BaseUIElement from "../BaseUIElement"; import BaseUIElement from "../BaseUIElement";
import {DropDown} from "../Input/DropDown"; import {DropDown} from "../Input/DropDown";
import {Unit} from "../../Customizations/JSON/Denomination"; import {Unit} from "../../Customizations/JSON/Denomination";
import InputElementWrapper from "../Input/InputElementWrapper";
/** /**
* Shows the question element. * Shows the question element.
@ -128,7 +129,7 @@ export default class TagRenderingQuestion extends Combine {
} }
return Utils.NoNull(configuration.mappings?.map((m,i) => excludeIndex === i ? undefined: m.ifnot)) return Utils.NoNull(configuration.mappings?.map((m,i) => excludeIndex === i ? undefined: m.ifnot))
} }
const ff = TagRenderingQuestion.GenerateFreeform(configuration, applicableUnit, tagsSource.data); const ff = TagRenderingQuestion.GenerateFreeform(configuration, applicableUnit, tagsSource);
const hasImages = mappings.filter(mapping => mapping.then.ExtractImages().length > 0).length > 0 const hasImages = mappings.filter(mapping => mapping.then.ExtractImages().length > 0).length > 0
if (mappings.length < 8 || configuration.multiAnswer || hasImages) { if (mappings.length < 8 || configuration.multiAnswer || hasImages) {
@ -289,7 +290,7 @@ export default class TagRenderingQuestion extends Combine {
(t0, t1) => t1.isEquivalent(t0)); (t0, t1) => t1.isEquivalent(t0));
} }
private static GenerateFreeform(configuration: TagRenderingConfig, applicableUnit: Unit, tagsData: any): InputElement<TagsFilter> { private static GenerateFreeform(configuration: TagRenderingConfig, applicableUnit: Unit, tags: UIEventSource<any>): InputElement<TagsFilter> {
const freeform = configuration.freeform; const freeform = configuration.freeform;
if (freeform === undefined) { if (freeform === undefined) {
return undefined; return undefined;
@ -328,20 +329,34 @@ export default class TagRenderingQuestion extends Combine {
return undefined; return undefined;
} }
let input: InputElement<string> = ValidatedTextField.InputForType(configuration.freeform.type, { const tagsData = tags.data;
const feature = State.state.allElements.ContainingFeatures.get(tagsData.id)
const input: InputElement<string> = ValidatedTextField.InputForType(configuration.freeform.type, {
isValid: (str) => (str.length <= 255), isValid: (str) => (str.length <= 255),
country: () => tagsData._country, country: () => tagsData._country,
location: [tagsData._lat, tagsData._lon], location: [tagsData._lat, tagsData._lon],
mapBackgroundLayer: State.state.backgroundLayer, mapBackgroundLayer: State.state.backgroundLayer,
unit: applicableUnit unit: applicableUnit,
args: configuration.freeform.helperArgs,
feature: feature
}); });
input.GetValue().setData(tagsData[configuration.freeform.key]); input.GetValue().setData(tagsData[freeform.key] ?? freeform.default);
return new InputElementMap( let inputTagsFilter : InputElement<TagsFilter> = new InputElementMap(
input, (a, b) => a === b || (a?.isEquivalent(b) ?? false), input, (a, b) => a === b || (a?.isEquivalent(b) ?? false),
pickString, toString pickString, toString
); );
if(freeform.inline){
inputTagsFilter.SetClass("w-16-imp")
inputTagsFilter = new InputElementWrapper(inputTagsFilter, configuration.render, freeform.key, tags)
inputTagsFilter.SetClass("block")
}
return inputTagsFilter;
} }

View file

@ -80,9 +80,7 @@ export default class ShowDataLayer {
if (zoomToFeatures) { if (zoomToFeatures) {
try { try {
mp.fitBounds(geoLayer.getBounds(), {animate: false})
mp.fitBounds(geoLayer.getBounds())
} catch (e) { } catch (e) {
console.error(e) console.error(e)
} }
@ -148,7 +146,9 @@ export default class ShowDataLayer {
const popup = L.popup({ const popup = L.popup({
autoPan: true, autoPan: true,
closeOnEscapeKey: true, closeOnEscapeKey: true,
closeButton: false closeButton: false,
autoPanPaddingTopLeft: [15,15],
}, leafletLayer); }, leafletLayer);
leafletLayer.bindPopup(popup); leafletLayer.bindPopup(popup);

View file

@ -39,7 +39,8 @@ export default class SpecialVisualizations {
static constructMiniMap: (options?: { static constructMiniMap: (options?: {
background?: UIEventSource<BaseLayer>, background?: UIEventSource<BaseLayer>,
location?: UIEventSource<Loc>, location?: UIEventSource<Loc>,
allowMoving?: boolean allowMoving?: boolean,
leafletOptions?: any
}) => BaseUIElement; }) => BaseUIElement;
static constructShowDataLayer: (features: UIEventSource<{ feature: any; freshness: Date }[]>, leafletMap: UIEventSource<any>, layoutToUse: UIEventSource<any>, enablePopups?: boolean, zoomToFeatures?: boolean) => any; static constructShowDataLayer: (features: UIEventSource<{ feature: any; freshness: Date }[]>, leafletMap: UIEventSource<any>, layoutToUse: UIEventSource<any>, enablePopups?: boolean, zoomToFeatures?: boolean) => any;
public static specialVisualizations: SpecialVisualization[] = public static specialVisualizations: SpecialVisualization[] =
@ -369,7 +370,6 @@ export default class SpecialVisualizations {
if (unit === undefined) { if (unit === undefined) {
return value; return value;
} }
return unit.asHumanLongValue(value); return unit.asHumanLongValue(value);
}, },
@ -379,6 +379,7 @@ export default class SpecialVisualizations {
} }
] ]
static HelpMessage: BaseUIElement = SpecialVisualizations.GenHelpMessage(); static HelpMessage: BaseUIElement = SpecialVisualizations.GenHelpMessage();
private static GenHelpMessage() { private static GenHelpMessage() {

View file

@ -7,19 +7,43 @@ import SpecialVisualizations, {SpecialVisualization} from "./SpecialVisualizatio
import {Utils} from "../Utils"; import {Utils} from "../Utils";
import {VariableUiElement} from "./Base/VariableUIElement"; import {VariableUiElement} from "./Base/VariableUIElement";
import Combine from "./Base/Combine"; import Combine from "./Base/Combine";
import BaseUIElement from "./BaseUIElement";
export class SubstitutedTranslation extends VariableUiElement { export class SubstitutedTranslation extends VariableUiElement {
public constructor( public constructor(
translation: Translation, translation: Translation,
tagsSource: UIEventSource<any>) { tagsSource: UIEventSource<any>,
mapping: Map<string, BaseUIElement> = undefined) {
const extraMappings: SpecialVisualization[] = [];
mapping?.forEach((value, key) => {
console.log("KV:", key, value)
extraMappings.push(
{
funcName: key,
constr: (() => {
return value
}),
docs: "Dynamically injected input element",
args: [],
example: ""
}
)
})
super( super(
Locale.language.map(language => { Locale.language.map(language => {
const txt = translation.textFor(language) let txt = translation.textFor(language);
if (txt === undefined) { if (txt === undefined) {
return undefined return undefined
} }
return new Combine(SubstitutedTranslation.ExtractSpecialComponents(txt).map( mapping?.forEach((_, key) => {
txt = txt.replace(new RegExp(`{${key}}`, "g"), `{${key}()}`)
})
return new Combine(SubstitutedTranslation.ExtractSpecialComponents(txt, extraMappings).map(
proto => { proto => {
if (proto.fixed !== undefined) { if (proto.fixed !== undefined) {
return new VariableUiElement(tagsSource.map(tags => Utils.SubstituteKeys(proto.fixed, tags))); return new VariableUiElement(tagsSource.map(tags => Utils.SubstituteKeys(proto.fixed, tags)));
@ -36,30 +60,35 @@ export class SubstitutedTranslation extends VariableUiElement {
}) })
) )
this.SetClass("w-full") this.SetClass("w-full")
} }
public static ExtractSpecialComponents(template: string): { public static ExtractSpecialComponents(template: string, extraMappings: SpecialVisualization[] = []): {
fixed?: string, special?: { fixed?: string,
special?: {
func: SpecialVisualization, func: SpecialVisualization,
args: string[], args: string[],
style: string style: string
} }
}[] { }[] {
for (const knownSpecial of SpecialVisualizations.specialVisualizations) { if (extraMappings.length > 0) {
console.log("Extra mappings are", extraMappings)
}
for (const knownSpecial of SpecialVisualizations.specialVisualizations.concat(extraMappings)) {
// Note: the '.*?' in the regex reads as 'any character, but in a non-greedy way' // Note: the '.*?' in the regex reads as 'any character, but in a non-greedy way'
const matched = template.match(`(.*){${knownSpecial.funcName}\\((.*?)\\)(:.*)?}(.*)`); const matched = template.match(`(.*){${knownSpecial.funcName}\\((.*?)\\)(:.*)?}(.*)`);
if (matched != null) { if (matched != null) {
// We found a special component that should be brought to live // We found a special component that should be brought to live
const partBefore = SubstitutedTranslation.ExtractSpecialComponents(matched[1]); const partBefore = SubstitutedTranslation.ExtractSpecialComponents(matched[1], extraMappings);
const argument = matched[2].trim(); const argument = matched[2].trim();
const style = matched[3]?.substring(1) ?? "" const style = matched[3]?.substring(1) ?? ""
const partAfter = SubstitutedTranslation.ExtractSpecialComponents(matched[4]); const partAfter = SubstitutedTranslation.ExtractSpecialComponents(matched[4], extraMappings);
const args = knownSpecial.args.map(arg => arg.defaultValue ?? ""); const args = knownSpecial.args.map(arg => arg.defaultValue ?? "");
if (argument.length > 0) { if (argument.length > 0) {
const realArgs = argument.split(",").map(str => str.trim()); const realArgs = argument.split(",").map(str => str.trim());
@ -73,11 +102,13 @@ export class SubstitutedTranslation extends VariableUiElement {
} }
let element; let element;
element = {special:{ element = {
args: args, special: {
style: style, args: args,
func: knownSpecial style: style,
}} func: knownSpecial
}
}
return [...partBefore, element, ...partAfter] return [...partBefore, element, ...partAfter]
} }
} }

View file

@ -1,4 +1,5 @@
import * as colors from "./assets/colors.json" import * as colors from "./assets/colors.json"
import {TileRange} from "./Models/TileRange";
export class Utils { export class Utils {
@ -134,7 +135,7 @@ export class Utils {
} }
return newArr; return newArr;
} }
public static MergeTags(a: any, b: any) { public static MergeTags(a: any, b: any) {
const t = {}; const t = {};
for (const k in a) { for (const k in a) {
@ -447,14 +448,12 @@ export class Utils {
b: parseInt(hex.substr(5, 2), 16), b: parseInt(hex.substr(5, 2), 16),
} }
} }
public static setDefaults(options, defaults){
for (let key in defaults){
if (!(key in options)) options[key] = defaults[key];
}
return options;
}
} }
export interface TileRange {
xstart: number,
ystart: number,
xend: number,
yend: number,
total: number,
zoomlevel: number
}

View file

@ -73,7 +73,10 @@
}, },
"tags": [ "tags": [
"amenity=public_bookcase" "amenity=public_bookcase"
] ],
"preciseInput": {
"preferredBackground": "photo"
}
} }
], ],
"tagRenderings": [ "tagRenderings": [
@ -139,7 +142,8 @@
}, },
"freeform": { "freeform": {
"key": "capacity", "key": "capacity",
"type": "nat" "type": "nat",
"inline": true
} }
}, },
{ {

View file

@ -0,0 +1,83 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="100"
height="100"
viewBox="0 0 26.458333 26.458334"
version="1.1"
id="svg8"
inkscape:version="0.92.5 (2060ec1f9f, 2020-04-08)"
sodipodi:docname="crosshair-empty.svg">
<defs
id="defs2" />
<sodipodi:namedview
id="base"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0.0"
inkscape:pageshadow="2"
inkscape:zoom="5.6568542"
inkscape:cx="22.669779"
inkscape:cy="52.573519"
inkscape:document-units="px"
inkscape:current-layer="g848"
showgrid="false"
units="px"
showguides="true"
inkscape:guide-bbox="true"
inkscape:window-width="1920"
inkscape:window-height="999"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1">
<sodipodi:guide
position="13.229167,23.859748"
orientation="1,0"
id="guide815"
inkscape:locked="false" />
<sodipodi:guide
position="14.944824,13.229167"
orientation="0,1"
id="guide817"
inkscape:locked="false" />
</sodipodi:namedview>
<metadata
id="metadata5">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title />
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(0,-270.54165)">
<g
id="g848">
<path
style="color:#000000;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;font-variant-ligatures:normal;font-variant-position:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-alternates:normal;font-feature-settings:normal;text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:#000000;letter-spacing:normal;word-spacing:normal;text-transform:none;writing-mode:lr-tb;direction:ltr;text-orientation:mixed;dominant-baseline:auto;baseline-shift:baseline;text-anchor:start;white-space:normal;shape-padding:0;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;vector-effect:none;fill:#5555ec;fill-opacity:0.98823529;fill-rule:nonzero;stroke:#ffffff;stroke-width:0.26458333;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:0.98823529;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"
d="m 13.162109,273.57617 c -5.6145729,0 -10.1933596,4.58074 -10.193359,10.19531 -6e-7,5.61458 4.5787861,10.19336 10.193359,10.19336 5.614574,0 10.195313,-4.57878 10.195313,-10.19336 0,-5.61457 -4.580739,-10.19531 -10.195313,-10.19531 z m 0,2.64649 c 4.184659,0 7.548829,3.36417 7.548829,7.54882 0,4.18466 -3.36417,7.54883 -7.548829,7.54883 -4.1846584,0 -7.546875,-3.36417 -7.5468746,-7.54883 -4e-7,-4.18465 3.3622162,-7.54882 7.5468746,-7.54882 z"
id="path815"
inkscape:connector-curvature="0" />
<path
id="path839"
style="color:#000000;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;font-variant-ligatures:normal;font-variant-position:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-alternates:normal;font-feature-settings:normal;text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:#000000;letter-spacing:normal;word-spacing:normal;text-transform:none;writing-mode:lr-tb;direction:ltr;text-orientation:mixed;dominant-baseline:auto;baseline-shift:baseline;text-anchor:start;white-space:normal;shape-padding:0;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;vector-effect:none;fill:#0055ec;fill-opacity:0.98823529;fill-rule:nonzero;stroke:#ffffff;stroke-width:0.26458333;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:0.98823529;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"
d="m 13.212891,286.88672 a 1.0487243,1.0487243 0 0 0 -1.033203,1.06445 v 7.94922 a 1.048828,1.048828 0 1 0 2.097656,0 v -7.94922 a 1.0487243,1.0487243 0 0 0 -1.064453,-1.06445 z m 0,-16.36914 a 1.0487243,1.0487243 0 0 0 -1.033203,1.0625 v 7.94922 a 1.048828,1.048828 0 1 0 2.097656,0 v -7.94922 a 1.0487243,1.0487243 0 0 0 -1.064453,-1.0625 z m 4.246093,12.20508 a 1.048825,1.048825 0 1 0 0,2.09765 h 7.949219 a 1.048825,1.048825 0 1 0 0,-2.09765 z m -16.4179684,0 a 1.048825,1.048825 0 1 0 0,2.09765 h 7.9492188 a 1.048825,1.048825 0 1 0 0,-2.09765 z" />
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 5.6 KiB

View file

@ -0,0 +1,106 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="100"
height="100"
viewBox="0 0 26.458333 26.458334"
version="1.1"
id="svg8"
inkscape:version="0.92.5 (2060ec1f9f, 2020-04-08)"
sodipodi:docname="crosshair-locked.svg">
<defs
id="defs2" />
<sodipodi:namedview
id="base"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0.0"
inkscape:pageshadow="2"
inkscape:zoom="5.6568542"
inkscape:cx="27.044982"
inkscape:cy="77.667126"
inkscape:document-units="px"
inkscape:current-layer="layer1"
showgrid="false"
units="px"
showguides="true"
inkscape:guide-bbox="true"
inkscape:window-width="1920"
inkscape:window-height="999"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:snap-global="false">
<sodipodi:guide
position="13.229167,23.859748"
orientation="1,0"
id="guide815"
inkscape:locked="false" />
<sodipodi:guide
position="14.944824,13.229167"
orientation="0,1"
id="guide817"
inkscape:locked="false" />
</sodipodi:namedview>
<metadata
id="metadata5">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title />
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(0,-270.54165)">
<g
id="g827">
<circle
r="8.8715391"
cy="283.77081"
cx="13.16302"
id="path815"
style="fill:none;fill-opacity:1;stroke:#5555ec;stroke-width:2.64583335;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:0.98823529" />
<path
inkscape:connector-curvature="0"
id="path817"
d="M 3.2841366,283.77082 H 1.0418969"
style="fill:none;stroke:#5555ec;stroke-width:2.09723878;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:0.98823529" />
<path
inkscape:connector-curvature="0"
id="path817-3"
d="M 25.405696,283.77082 H 23.286471"
style="fill:none;stroke:#5555ec;stroke-width:2.11666679;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:0.98823529" />
<path
inkscape:connector-curvature="0"
id="path817-3-6"
d="m 13.229167,295.9489 v -2.11763"
style="fill:none;stroke:#5555ec;stroke-width:2.11666679;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:0.98823529" />
<path
inkscape:connector-curvature="0"
id="path817-3-6-7"
d="m 13.229167,275.05759 v -3.44507"
style="fill:none;stroke:#5555ec;stroke-width:2.11666668;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:0.98823529" />
</g>
<path
style="fill:#5555ec;fill-opacity:0.98823529;stroke-width:0.6151033"
inkscape:connector-curvature="0"
d="m 16.850267,281.91543 h -0.65616 v -1.85094 c 0,0 0,-3.08489 -3.066169,-3.08489 -3.066169,0 -3.066169,3.08489 -3.066169,3.08489 v 1.85094 H 9.4056091 a 1.1835412,1.1907685 0 0 0 -1.1835412,1.19077 v 5.02838 a 1.1835412,1.1907685 0 0 0 1.1835412,1.1846 h 7.4446579 a 1.1835412,1.1907685 0 0 0 1.183541,-1.19078 v -5.0222 a 1.1835412,1.1907685 0 0 0 -1.183541,-1.19077 z m -3.722329,4.93583 a 1.2264675,1.233957 0 1 1 1.226468,-1.23395 1.2264675,1.233957 0 0 1 -1.226468,1.23395 z m 1.839702,-4.93583 h -3.679403 v -1.54245 c 0,-0.92546 0,-2.15942 1.839701,-2.15942 1.839702,0 1.839702,1.23396 1.839702,2.15942 z"
id="path822" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.3 KiB

View file

@ -0,0 +1,115 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
version="1.0"
width="859.53607pt"
height="858.4754pt"
viewBox="0 0 859.53607 858.4754"
preserveAspectRatio="xMidYMid meet"
id="svg14"
sodipodi:docname="length-crosshair.svg"
inkscape:version="0.92.5 (2060ec1f9f, 2020-04-08)">
<defs
id="defs18" />
<sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="1920"
inkscape:window-height="999"
id="namedview16"
showgrid="false"
showguides="true"
inkscape:guide-bbox="true"
inkscape:zoom="0.5"
inkscape:cx="307.56567"
inkscape:cy="-35.669379"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="svg14"
inkscape:snap-smooth-nodes="true" />
<metadata
id="metadata2">
Created by potrace 1.15, written by Peter Selinger 2001-2017
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title />
</cc:Work>
</rdf:RDF>
</metadata>
<path
style="fill:none;fill-opacity:1;stroke:#000000;stroke-width:2.99999994;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:71.99999853,71.99999853;stroke-dashoffset:0;stroke-opacity:1"
id="path816"
transform="rotate(-89.47199)"
sodipodi:type="arc"
sodipodi:cx="-425.24921"
sodipodi:cy="433.71375"
sodipodi:rx="428.34982"
sodipodi:ry="427.81949"
sodipodi:start="0"
sodipodi:end="4.7117019"
sodipodi:open="true"
d="M 3.1006165,433.71375 A 428.34982,427.81949 0 0 1 -425.1511,861.53322 428.34982,427.81949 0 0 1 -853.59898,433.90971 428.34982,427.81949 0 0 1 -425.54352,5.8943576" />
<path
style="fill:none;stroke:#000000;stroke-width:4.49999991;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 429.76804,430.08754 0,-429.19968"
id="path820"
inkscape:connector-curvature="0" />
<path
style="fill:none;stroke:#000000;stroke-width:1.49999997;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1;stroke-miterlimit:4;stroke-dasharray:35.99999926,35.99999926;stroke-dashoffset:0"
d="m 857.58749,429.23771 -855.6389371,0 v 0"
id="path822"
inkscape:connector-curvature="0" />
<path
inkscape:connector-curvature="0"
id="path814"
d="M 429.76804,857.30628 V 428.78674"
style="fill:none;stroke:#000000;stroke-width:1.49999997;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1;stroke-miterlimit:4;stroke-dasharray:35.99999926,35.99999926;stroke-dashoffset:0" />
<path
inkscape:connector-curvature="0"
id="path826"
d="M 857.32232,1.0332137 H 1.6833879 v 0"
style="fill:none;stroke:#000000;stroke-width:3;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:17.99999963, 17.99999963;stroke-dashoffset:0;stroke-opacity:1" />
<path
inkscape:connector-curvature="0"
id="path828"
d="M 857.58749,858.2377 H 1.9485529 v 0"
style="fill:none;stroke:#000000;stroke-width:1.5;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:8.99999982, 8.99999982;stroke-dashoffset:0;stroke-opacity:1" />
<path
cx="-429.2377"
cy="429.76804"
rx="428.34982"
ry="427.81949"
transform="rotate(-90)"
id="path825"
style="fill:none;fill-opacity:1;stroke:#000000;stroke-width:11.99999975;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
<path
d="M -5.3639221,-424.71887 A 428.34982,427.81949 0 0 1 -429.83855,3.0831087 428.34982,427.81949 0 0 1 -861.99345,-416.97839"
sodipodi:open="true"
sodipodi:end="3.1234988"
sodipodi:start="0"
sodipodi:ry="427.81949"
sodipodi:rx="428.34982"
sodipodi:cy="-424.71887"
sodipodi:cx="-433.71375"
sodipodi:type="arc"
transform="rotate(-179.47199)"
id="path827"
style="fill:none;fill-opacity:1;stroke:#000000;stroke-width:4.5;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
</svg>

After

Width:  |  Height:  |  Size: 4.7 KiB

View file

@ -162,6 +162,18 @@
"license": "CC0; trivial", "license": "CC0; trivial",
"sources": [] "sources": []
}, },
{
"authors": [],
"path": "crosshair-empty.svg",
"license": "CC0; trivial",
"sources": []
},
{
"authors": [],
"path": "crosshair-locked.svg",
"license": "CC0; trivial",
"sources": []
},
{ {
"authors": [ "authors": [
"Dave Gandy" "Dave Gandy"
@ -204,7 +216,7 @@
"license": "CC0", "license": "CC0",
"sources": [ "sources": [
"https://commons.wikimedia.org/wiki/File:Media-floppy.svg", "https://commons.wikimedia.org/wiki/File:Media-floppy.svg",
" http://tango.freedesktop.org/Tango_Desktop_Project" "http://tango.freedesktop.org/Tango_Desktop_Project"
] ]
}, },
{ {
@ -582,5 +594,15 @@
"sources": [ "sources": [
"https://www.mapillary.com/" "https://www.mapillary.com/"
] ]
},
{
"authors": [
"The Tango! Desktop Project"
],
"path": "floppy.svg",
"license": "CC0",
"sources": [
"https://commons.wikimedia.org/wiki/File:Media-floppy.svg"
]
} }
] ]

View file

@ -1,15 +1,15 @@
{ {
"wikipedialink": { "wikipedialink": {
"render": "<a href='https://wikipedia.org/wiki/{wikipedia}' target='_blank'><img src='./assets/svg/wikipedia.svg' alt='WP'/></a>", "render": "<a href='https://wikipedia.org/wiki/{wikipedia}' target='_blank'><img src='./assets/svg/wikipedia.svg' alt='WP'/></a>",
"condition": "wikipedia~*", "condition": {
"or": [
"wikipedia~*",
"wikidata~*"
]
},
"mappings": [ "mappings": [
{ {
"if": { "if": "wikipedia=",
"and": [
"wikipedia=",
"wikidata~*"
]
},
"then": "<a href='https://www.wikidata.org/wiki/{wikidata}' target='_blank'><img src='./assets/svg/wikidata.svg' alt='WD'/></a>" "then": "<a href='https://www.wikidata.org/wiki/{wikidata}' target='_blank'><img src='./assets/svg/wikidata.svg' alt='WD'/></a>"
} }
] ]
@ -59,8 +59,12 @@
"render": "<a href='https://openstreetmap.org/{id}' target='_blank'><img src='./assets/svg/osm-logo-us.svg'/></a>", "render": "<a href='https://openstreetmap.org/{id}' target='_blank'><img src='./assets/svg/osm-logo-us.svg'/></a>",
"mappings": [ "mappings": [
{ {
"if": "id~=-", "if": "id~.*/-.*",
"then": "<span class='alert'>Uploading...</alert>" "then": ""
},
{
"if": "_backend~*",
"then": "<a href='{_backend}/{id}' target='_blank'><img src='./assets/svg/osm-logo-us.svg'/></a>"
} }
], ],
"condition": "id~(node|way|relation)/[0-9]*" "condition": "id~(node|way|relation)/[0-9]*"

View file

@ -736,7 +736,7 @@
"_contained_climbing_route_ids=JSON.parse(feat.properties._contained_climbing_routes_properties ?? '[]').map(p => p.id)", "_contained_climbing_route_ids=JSON.parse(feat.properties._contained_climbing_routes_properties ?? '[]').map(p => p.id)",
"_difficulty_hist=JSON.parse(feat.properties._contained_climbing_routes_properties ?? '[]').map(p => p['climbing:grade:french'])", "_difficulty_hist=JSON.parse(feat.properties._contained_climbing_routes_properties ?? '[]').map(p => p['climbing:grade:french'])",
"_length_hist=JSON.parse(feat.properties._contained_climbing_routes_properties ?? '[]').map(p => p['climbing:length'])", "_length_hist=JSON.parse(feat.properties._contained_climbing_routes_properties ?? '[]').map(p => p['climbing:length'])",
"_contained_climbing_routes_count=JSON.parse(_contained_climbing_routes).length" "_contained_climbing_routes_count=JSON.parse(feat.properties._contained_climbing_routes_properties ?? '[]').length"
] ]
}, },
{ {
@ -1412,8 +1412,8 @@
"_embedding_feature_properties=feat.overlapWith('climbing').map(f => f.feat.properties).filter(p => p !== undefined).map(p => {return{access: p.access, id: p.id, name: p.name, climbing: p.climbing, 'access:description': p['access:description']}})", "_embedding_feature_properties=feat.overlapWith('climbing').map(f => f.feat.properties).filter(p => p !== undefined).map(p => {return{access: p.access, id: p.id, name: p.name, climbing: p.climbing, 'access:description': p['access:description']}})",
"_embedding_features_with_access=JSON.parse(feat.properties._embedding_feature_properties ?? '[]').filter(p => p.access !== undefined)[0]", "_embedding_features_with_access=JSON.parse(feat.properties._embedding_feature_properties ?? '[]').filter(p => p.access !== undefined)[0]",
"_embedding_feature_with_rock=JSON.parse(feat.properties._embedding_feature_properties ?? '[]').filter(p => p.rock !== undefined)[0] ?? '{}'", "_embedding_feature_with_rock=JSON.parse(feat.properties._embedding_feature_properties ?? '[]').filter(p => p.rock !== undefined)[0] ?? '{}'",
"_embedding_features_with_rock:rock=JSON.parse(_embedding_feature_with_rock)?.rock", "_embedding_features_with_rock:rock=JSON.parse(feat.properties._embedding_feature_with_rock ?? '{}')?.rock",
"_embedding_features_with_rock:id=JSON.parse(_embedding_feature_with_rock)?.id", "_embedding_features_with_rock:id=JSON.parse(feat.properties._embedding_feature_with_rock ?? '{}')?.id",
"_embedding_feature:access=JSON.parse(feat.properties._embedding_features_with_access ?? '{}').access", "_embedding_feature:access=JSON.parse(feat.properties._embedding_features_with_access ?? '{}').access",
"_embedding_feature:access:description=JSON.parse(feat.properties._embedding_features_with_access ?? '{}')['access:description']", "_embedding_feature:access:description=JSON.parse(feat.properties._embedding_features_with_access ?? '{}')['access:description']",
"_embedding_feature:id=JSON.parse(feat.properties._embedding_features_with_access ?? '{}').id" "_embedding_feature:id=JSON.parse(feat.properties._embedding_features_with_access ?? '{}').id"

View file

@ -62,7 +62,8 @@
"en": "What is the power output of this wind turbine? (e.g. 2.3 MW)" "en": "What is the power output of this wind turbine? (e.g. 2.3 MW)"
}, },
"freeform": { "freeform": {
"key": "generator:output:electricity" "key": "generator:output:electricity",
"type": "pfloat"
} }
}, },
{ {
@ -85,7 +86,7 @@
}, },
"freeform": { "freeform": {
"key": "height", "key": "height",
"type": "float" "type": "pfloat"
} }
}, },
{ {
@ -179,6 +180,24 @@
} }
], ],
"eraseInvalidValues": true "eraseInvalidValues": true
},
{
"appliesToKey": [
"height",
"rotor:diameter"
],
"applicableUnits": [
{
"canonicalDenomination": "m",
"alternativeDenomination": [
"meter"
],
"human": {
"en": " meter",
"nl": " meter"
}
}
]
} }
], ],
"defaultBackgroundId": "CartoDB.Voyager" "defaultBackgroundId": "CartoDB.Voyager"

View file

@ -64,7 +64,13 @@
}, },
"tagRenderings": [ "tagRenderings": [
{ {
"render": "Deze straat is <b>{width:carriageway}m</b> breed" "render": "Deze straat is <b>{width:carriageway}m</b> breed",
"question": "Hoe breed is deze straat?",
"freeform": {
"key": "width:carriageway",
"type": "length",
"helperArgs": [21, "map"]
}
}, },
{ {
"render": "Deze straat heeft <span class='alert'>{_width:difference}m</span> te weinig:", "render": "Deze straat heeft <span class='alert'>{_width:difference}m</span> te weinig:",

View file

@ -82,6 +82,10 @@ html, body {
box-sizing: initial !important; box-sizing: initial !important;
} }
.leaflet-control-attribution {
display: block ruby;
}
svg, img { svg, img {
box-sizing: content-box; box-sizing: content-box;
width: 100%; width: 100%;
@ -101,6 +105,10 @@ a {
width: min-content; width: min-content;
} }
.w-16-imp {
width: 4rem !important;
}
.space-between{ .space-between{
justify-content: space-between; justify-content: space-between;
} }

View file

@ -19,10 +19,13 @@ import DirectionInput from "./UI/Input/DirectionInput";
import SpecialVisualizations from "./UI/SpecialVisualizations"; import SpecialVisualizations from "./UI/SpecialVisualizations";
import ShowDataLayer from "./UI/ShowDataLayer"; import ShowDataLayer from "./UI/ShowDataLayer";
import * as L from "leaflet"; import * as L from "leaflet";
import ValidatedTextField from "./UI/Input/ValidatedTextField";
import AvailableBaseLayers from "./Logic/Actors/AvailableBaseLayers";
// Workaround for a stupid crash: inject some functions which would give stupid circular dependencies or crash the other nodejs scripts // Workaround for a stupid crash: inject some functions which would give stupid circular dependencies or crash the other nodejs scripts
SimpleMetaTagger.coder = new CountryCoder("https://pietervdvn.github.io/latlon2country/"); SimpleMetaTagger.coder = new CountryCoder("https://pietervdvn.github.io/latlon2country/");
DirectionInput.constructMinimap = options => new Minimap(options) DirectionInput.constructMinimap = options => new Minimap(options)
ValidatedTextField.bestLayerAt = (location, layerPref) => AvailableBaseLayers.SelectBestLayerAccordingTo(location, layerPref)
SpecialVisualizations.constructMiniMap = options => new Minimap(options) SpecialVisualizations.constructMiniMap = options => new Minimap(options)
SpecialVisualizations.constructShowDataLayer = (features: UIEventSource<{ feature: any, freshness: Date }[]>, SpecialVisualizations.constructShowDataLayer = (features: UIEventSource<{ feature: any, freshness: Date }[]>,
leafletMap: UIEventSource<L.Map>, leafletMap: UIEventSource<L.Map>,

View file

@ -149,6 +149,10 @@
"zoomInToSeeThisLayer": "Zoom in to see this layer", "zoomInToSeeThisLayer": "Zoom in to see this layer",
"title": "Select layers" "title": "Select layers"
}, },
"download": {
"downloadGeojson": "Download visible data as geojson",
"licenseInfo": "<h3>Copyright notice</h3>The provided is available under ODbL. Reusing this data is free for any purpose, but <ul><li>the attribution <b>© OpenStreetMap contributors</b></li><li>Any change to this data must be republished under the same license</li></ul>. Please see the full <a href='https://www.openstreetmap.org/copyright' target='_blank'>copyright notice</a> for details"
},
"weekdays": { "weekdays": {
"abbreviations": { "abbreviations": {
"monday": "Mon", "monday": "Mon",

View file

@ -1143,6 +1143,13 @@
"human": " gigawatts" "human": " gigawatts"
} }
} }
},
"1": {
"applicableUnits": {
"0": {
"human": " meter"
}
}
} }
} }
}, },

View file

@ -951,6 +951,13 @@
"human": " gigawatt" "human": " gigawatt"
} }
} }
},
"1": {
"applicableUnits": {
"0": {
"human": " meter"
}
}
} }
} }
}, },

View file

@ -8,7 +8,7 @@
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
"increase-memory": "export NODE_OPTIONS=--max_old_space_size=4096", "increase-memory": "export NODE_OPTIONS=--max_old_space_size=4096",
"start": "ts-node scripts/generateLayerOverview.ts --no-fail && npm run increase-memory && parcel *.html UI/** Logic/** assets/** assets/**/** assets/**/**/** vendor/* vendor/*/*", "start": "ts-node scripts/generateLayerOverview.ts --no-fail && npm run increase-memory && parcel *.html UI/** Logic/** assets/*.json assets/generated/* assets/layers/*/*.svg assets/tagRendering/*.json assets/themes/*/*.svg assets/themes/*/*.png vendor/* vendor/*/*",
"test": "ts-node test/TestAll.ts", "test": "ts-node test/TestAll.ts",
"init": "npm ci && npm run generate && npm run generate:editor-layer-index && npm run generate:layouts && npm run clean", "init": "npm ci && npm run generate && npm run generate:editor-layer-index && npm run generate:layouts && npm run clean",
"add-weblate-upstream": "git remote add weblate-layers https://hosted.weblate.org/git/mapcomplete/layer-translations/ ; git remote update weblate-layers", "add-weblate-upstream": "git remote add weblate-layers https://hosted.weblate.org/git/mapcomplete/layer-translations/ ; git remote update weblate-layers",

View file

@ -12,7 +12,7 @@ import BaseUIElement from "./UI/BaseUIElement";
import Table from "./UI/Base/Table"; import Table from "./UI/Base/Table";
const connection = new OsmConnection(false, new UIEventSource<string>(undefined), ""); const connection = new OsmConnection(false, false, new UIEventSource<string>(undefined), "");
let rendered = false; let rendered = false;

View file

@ -1,7 +1,7 @@
/** /**
* Generates a collection of geojson files based on an overpass query for a given theme * Generates a collection of geojson files based on an overpass query for a given theme
*/ */
import {TileRange, Utils} from "../Utils"; import {Utils} from "../Utils";
Utils.runningFromConsole = true Utils.runningFromConsole = true
import {Overpass} from "../Logic/Osm/Overpass"; import {Overpass} from "../Logic/Osm/Overpass";
@ -17,6 +17,7 @@ import MetaTagging from "../Logic/MetaTagging";
import LayerConfig from "../Customizations/JSON/LayerConfig"; import LayerConfig from "../Customizations/JSON/LayerConfig";
import {GeoOperations} from "../Logic/GeoOperations"; import {GeoOperations} from "../Logic/GeoOperations";
import {UIEventSource} from "../Logic/UIEventSource"; import {UIEventSource} from "../Logic/UIEventSource";
import {TileRange} from "../Models/TileRange";
function createOverpassObject(theme: LayoutConfig) { function createOverpassObject(theme: LayoutConfig) {

View file

@ -4,7 +4,6 @@ import LayerConfig from "../Customizations/JSON/LayerConfig";
import * as licenses from "../assets/generated/license_info.json" import * as licenses from "../assets/generated/license_info.json"
import LayoutConfig from "../Customizations/JSON/LayoutConfig"; import LayoutConfig from "../Customizations/JSON/LayoutConfig";
import {LayerConfigJson} from "../Customizations/JSON/LayerConfigJson"; import {LayerConfigJson} from "../Customizations/JSON/LayerConfigJson";
import {Translation} from "../UI/i18n/Translation";
import {LayoutConfigJson} from "../Customizations/JSON/LayoutConfigJson"; import {LayoutConfigJson} from "../Customizations/JSON/LayoutConfigJson";
import AllKnownLayers from "../Customizations/AllKnownLayers"; import AllKnownLayers from "../Customizations/AllKnownLayers";
@ -77,63 +76,6 @@ class LayerOverviewUtils {
return errorCount return errorCount
} }
validateTranslationCompletenessOfObject(object: any, expectedLanguages: string[], context: string) {
const missingTranlations = []
const translations: { tr: Translation, context: string }[] = [];
const queue: { object: any, context: string }[] = [{object: object, context: context}]
while (queue.length > 0) {
const item = queue.pop();
const o = item.object
for (const key in o) {
const v = o[key];
if (v === undefined) {
continue;
}
if (v instanceof Translation || v?.translations !== undefined) {
translations.push({tr: v, context: item.context});
} else if (
["string", "function", "boolean", "number"].indexOf(typeof (v)) < 0) {
queue.push({object: v, context: item.context + "." + key})
}
}
}
const missing = {}
const present = {}
for (const ln of expectedLanguages) {
missing[ln] = 0;
present[ln] = 0;
for (const translation of translations) {
if (translation.tr.translations["*"] !== undefined) {
continue;
}
const txt = translation.tr.translations[ln];
const isMissing = txt === undefined || txt === "" || txt.toLowerCase().indexOf("todo") >= 0;
if (isMissing) {
missingTranlations.push(`${translation.context},${ln},${translation.tr.txt}`)
missing[ln]++
} else {
present[ln]++;
}
}
}
let message = `Translation completeness for ${context}`
let isComplete = true;
for (const ln of expectedLanguages) {
const amiss = missing[ln];
const ok = present[ln];
const total = amiss + ok;
message += ` ${ln}: ${ok}/${total}`
if (ok !== total) {
isComplete = false;
}
}
return missingTranlations
}
main(args: string[]) { main(args: string[]) {
const lt = this.loadThemesAndLayers(); const lt = this.loadThemesAndLayers();
@ -156,7 +98,6 @@ class LayerOverviewUtils {
} }
let themeErrorCount = [] let themeErrorCount = []
let missingTranslations = []
for (const themeFile of themeFiles) { for (const themeFile of themeFiles) {
if (typeof themeFile.language === "string") { if (typeof themeFile.language === "string") {
themeErrorCount.push("The theme " + themeFile.id + " has a string as language. Please use a list of strings") themeErrorCount.push("The theme " + themeFile.id + " has a string as language. Please use a list of strings")
@ -165,10 +106,6 @@ class LayerOverviewUtils {
if (typeof layer === "string") { if (typeof layer === "string") {
if (!knownLayerIds.has(layer)) { if (!knownLayerIds.has(layer)) {
themeErrorCount.push(`Unknown layer id: ${layer} in theme ${themeFile.id}`) themeErrorCount.push(`Unknown layer id: ${layer} in theme ${themeFile.id}`)
} else {
const layerConfig = knownLayerIds.get(layer);
missingTranslations.push(...this.validateTranslationCompletenessOfObject(layerConfig, themeFile.language, "Layer " + layer))
} }
} else { } else {
if (layer.builtin !== undefined) { if (layer.builtin !== undefined) {
@ -186,7 +123,6 @@ class LayerOverviewUtils {
.filter(l => typeof l != "string") // We remove all the builtin layer references as they don't work with ts-node for some weird reason .filter(l => typeof l != "string") // We remove all the builtin layer references as they don't work with ts-node for some weird reason
.filter(l => l.builtin === undefined) .filter(l => l.builtin === undefined)
missingTranslations.push(...this.validateTranslationCompletenessOfObject(themeFile, themeFile.language, "Theme " + themeFile.id))
try { try {
const theme = new LayoutConfig(themeFile, true, "test") const theme = new LayoutConfig(themeFile, true, "test")
@ -198,11 +134,6 @@ class LayerOverviewUtils {
} }
} }
if (missingTranslations.length > 0) {
console.log(missingTranslations.length, "missing translations")
writeFileSync("missing_translations.txt", missingTranslations.join("\n"))
}
if (layerErrorCount.length + themeErrorCount.length == 0) { if (layerErrorCount.length + themeErrorCount.length == 0) {
console.log("All good!") console.log("All good!")

35
test.ts
View file

@ -7,6 +7,11 @@ import {UIEventSource} from "./Logic/UIEventSource";
import {Tag} from "./Logic/Tags/Tag"; import {Tag} from "./Logic/Tags/Tag";
import {QueryParameters} from "./Logic/Web/QueryParameters"; import {QueryParameters} from "./Logic/Web/QueryParameters";
import {Translation} from "./UI/i18n/Translation"; import {Translation} from "./UI/i18n/Translation";
import LocationInput from "./UI/Input/LocationInput";
import Loc from "./Models/Loc";
import {VariableUiElement} from "./UI/Base/VariableUIElement";
import LengthInput from "./UI/Input/LengthInput";
import AvailableBaseLayers from "./Logic/Actors/AvailableBaseLayers";
/*import ValidatedTextField from "./UI/Input/ValidatedTextField"; /*import ValidatedTextField from "./UI/Input/ValidatedTextField";
import Combine from "./UI/Base/Combine"; import Combine from "./UI/Base/Combine";
import {VariableUiElement} from "./UI/Base/VariableUIElement"; import {VariableUiElement} from "./UI/Base/VariableUIElement";
@ -148,19 +153,17 @@ function TestMiniMap() {
featureSource.ping() featureSource.ping()
} }
//*/ //*/
QueryParameters.GetQueryParameter("test", "true").setData("true")
State.state= new State(undefined) const loc = new UIEventSource<Loc>({
const id = "node/5414688303" zoom: 24,
State.state.allElements.addElementById(id, new UIEventSource<any>({id: id})) lat: 51.21043,
new Combine([ lon: 3.21389
new DeleteWizard(id, { })
noDeleteOptions: [ const li = new LengthInput(
{ AvailableBaseLayers.SelectBestLayerAccordingTo(loc, new UIEventSource<string | string[]>("map","photo")),
if:[ new Tag("access","private")], loc
then: new Translation({ )
en: "Very private! Delete now or me send lawfull lawyer" li.SetStyle("height: 30rem; background: aliceblue;")
}) .AttachTo("maindiv")
}
] new VariableUiElement(li.GetValue().map(v => JSON.stringify(v, null, " "))).AttachTo("extradiv")
}),
]).AttachTo("maindiv")

View file

@ -15,7 +15,7 @@ export default class OsmConnectionSpec extends T {
super("OsmConnectionSpec-test", [ super("OsmConnectionSpec-test", [
["login on dev", ["login on dev",
() => { () => {
const osmConn = new OsmConnection(false, const osmConn = new OsmConnection(false,false,
new UIEventSource<string>(undefined), new UIEventSource<string>(undefined),
"Unit test", "Unit test",
true, true,

View file

@ -1,10 +0,0 @@
{
"defaultSeverity": "error",
"extends": [
"tslint:recommended",
"tslint-no-circular-imports"
],
"jsRules": {},
"rules": {},
"rulesDirectory": []
}