Merge branch 'refactoring/new-ui' into develop

This commit is contained in:
pietervdvn 2021-06-19 19:17:39 +02:00
commit 8e22ae9aee
163 changed files with 4624 additions and 6819 deletions

View file

@ -12,12 +12,11 @@ import Combine from "../../UI/Base/Combine";
import {VariableUiElement} from "../../UI/Base/VariableUIElement";
import {UIEventSource} from "../../Logic/UIEventSource";
import {FixedUiElement} from "../../UI/Base/FixedUiElement";
import {UIElement} from "../../UI/UIElement";
import {SubstitutedTranslation} from "../../UI/SubstitutedTranslation";
import SourceConfig from "./SourceConfig";
import {TagsFilter} from "../../Logic/Tags/TagsFilter";
import {Tag} from "../../Logic/Tags/Tag";
import SubstitutingTag from "../../Logic/Tags/SubstitutingTag";
import BaseUIElement from "../../UI/BaseUIElement";
export default class LayerConfig {
@ -290,11 +289,11 @@ export default class LayerConfig {
}
public GenerateLeafletStyle(tags: UIEventSource<any>, clickable: boolean):
public GenerateLeafletStyle(tags: UIEventSource<any>, clickable: boolean, widthHeight= "100%"):
{
icon:
{
html: UIElement,
html: BaseUIElement,
iconSize: [number, number],
iconAnchor: [number, number],
popupAnchor: [number, number],
@ -325,7 +324,7 @@ export default class LayerConfig {
function render(tr: TagRenderingConfig, deflt?: string) {
const str = (tr?.GetRenderValue(tags.data)?.txt ?? deflt);
return SubstitutedTranslation.SubstituteKeys(str, tags.data).replace(/{.*}/g, "");
return Utils.SubstituteKeys(str, tags.data).replace(/{.*}/g, "");
}
const iconSize = render(this.iconSize, "40,40,center").split(",");
@ -361,7 +360,7 @@ export default class LayerConfig {
const iconUrlStatic = render(this.icon);
const self = this;
const mappedHtml = tags.map(tgs => {
function genHtmlFromString(sourcePart: string): UIElement {
function genHtmlFromString(sourcePart: string): BaseUIElement {
if (sourcePart.indexOf("html:") == 0) {
// We use § as a replacement for ;
const html = sourcePart.substring("html:".length)
@ -370,7 +369,7 @@ export default class LayerConfig {
}
const style = `width:100%;height:100%;transform: rotate( ${rotation} );display:block;position: absolute; top: 0; left: 0`;
let html: UIElement = new FixedUiElement(`<img src="${sourcePart}" style="${style}" />`);
let html: BaseUIElement = new FixedUiElement(`<img src="${sourcePart}" style="${style}" />`);
const match = sourcePart.match(/([a-zA-Z0-9_]*):([^;]*)/)
if (match !== null && Svg.All[match[1] + ".svg"] !== undefined) {
html = new Combine([
@ -387,7 +386,7 @@ export default class LayerConfig {
const iconUrl = render(self.icon);
const rotation = render(self.rotation, "0deg");
let htmlParts: UIElement[] = [];
let htmlParts: BaseUIElement[] = [];
let sourceParts = Utils.NoNull(iconUrl.split(";").filter(prt => prt != ""));
for (const sourcePart of sourceParts) {
htmlParts.push(genHtmlFromString(sourcePart))
@ -399,7 +398,7 @@ export default class LayerConfig {
continue;
}
if (iconOverlay.badge) {
const badgeParts: UIElement[] = [];
const badgeParts: BaseUIElement[] = [];
const partDefs = iconOverlay.then.GetRenderValue(tgs).txt.split(";").filter(prt => prt != "");
for (const badgePartStr of partDefs) {
@ -437,7 +436,7 @@ export default class LayerConfig {
} catch (e) {
console.error(e, tgs)
}
return new Combine(htmlParts).Render();
return new Combine(htmlParts);
})

View file

@ -152,11 +152,10 @@ export default class LayoutConfig {
);
}
const defaultClustering = {
this.clustering = {
maxZoom: 16,
minNeededElements: 500
};
this.clustering = defaultClustering;
if (json.clustering) {
this.clustering = {
maxZoom: json.clustering.maxZoom ?? 18,
@ -164,7 +163,7 @@ export default class LayoutConfig {
}
for (const layer of this.layers) {
if (layer.wayHandling !== LayerConfig.WAYHANDLING_CENTER_ONLY) {
console.error("WARNING: In order to allow clustering, every layer must be set to CENTER_ONLY. Layer", layer.id, "does not respect this for layout", this.id);
console.debug("WARNING: In order to allow clustering, every layer must be set to CENTER_ONLY. Layer", layer.id, "does not respect this for layout", this.id);
}
}
}

View file

@ -240,6 +240,46 @@ export default class TagRenderingConfig {
return this.question === null && this.condition === null;
}
/**
* Gets all the render values. Will return multiple render values if 'multianswer' is enabled.
* The result will equal [GetRenderValue] if not 'multiAnswer'
* @param tags
* @constructor
*/
public GetRenderValues(tags: any): Translation[]{
if(!this.multiAnswer){
return [this.GetRenderValue(tags)]
}
// A flag to check that the freeform key isn't matched multiple times
// If it is undefined, it is "used" already, or at least we don't have to check for it anymore
let freeformKeyUsed = this.freeform?.key === undefined;
// We run over all the mappings first, to check if the mapping matches
const applicableMappings: Translation[] = Utils.NoNull((this.mappings ?? [])?.map(mapping => {
if (mapping.if === undefined) {
return mapping.then;
}
if (TagUtils.MatchesMultiAnswer(mapping.if, tags)) {
if(!freeformKeyUsed){
if(mapping.if.usedKeys().indexOf(this.freeform.key) >= 0){
// This mapping matches the freeform key - we mark the freeform key to be ignored!
freeformKeyUsed = true;
}
}
return mapping.then;
}
return undefined;
}))
if (!freeformKeyUsed
&& tags[this.freeform.key] !== undefined) {
applicableMappings.push(this.render)
}
return applicableMappings
}
/**
* Gets the correct rendering value (or undefined if not known)
* @constructor

View file

@ -28,7 +28,16 @@ Note that 'map' will also absorb some changes, e.g. `const someEventSource : UIE
An object which receives an UIEventSource is responsible of responding onto changes of this object. This is especially true for UI-components
UI
--
--```
export default class MyComponent {
constructor(neededParameters, neededUIEventSources) {
}
}
```
The Graphical User Interface is composed of various UI-elements. For every UI-element, there is a BaseUIElement which creates the actual HTMLElement when needed.
@ -177,9 +186,22 @@ export default class MyComponent extends Combine {
```
Assets
------
### Themes
Theme and layer configuration files go into /assets/layers and assets/themes
### Images
Other files (mostly images that are part of the core of mapcomplete) go into 'assets/svg' and are usable with `Svg.image_file_ui()`. Run `npm run generate:images` if you added a new image
Logic
-----
With the
The last part is the business logic of the application, found in 'Logic'. Actors are small objects which react to UIEventSources to update other eventSources.
State.state is a big singleton object containing a lot of the state of the entire application. That one is a bit a mess

View file

@ -1,5 +1,8 @@
Metatags
--------
Metatags
==========
Metatags are extra tags available, in order to display more data or to give better questions.
@ -7,85 +10,155 @@ The are calculated automatically on every feature when the data arrives in the w
**Hint:** when using metatags, add the [query parameter](URL_Parameters.md) `debug=true` to the URL. This will include a box in the popup for features which shows all the properties of the object
### \_lat, \_lon
Metatags calculated by MapComplete
------------------------------------
The following values are always calculated, by default, by MapComplete and are available automatically on all elements in every theme
### _lat, _lon
The latitude and longitude of the point (or centerpoint in the case of a way/area)
### \_surface, \_surface:ha
### _surface, _surface:ha
The surface area of the feature, in square meters and in hectare. Not set on points and ways
### \_length, \_length:km
The total length of a feature in meters (and in kilometers, rounded to one decimal for '\_length:km'). For a surface, the length of the perimeter
### _length, _length:km
The total length of a feature in meters (and in kilometers, rounded to one decimal for '_length:km'). For a surface, the length of the perimeter
### _country
### \_country
The country code of the property (with latlon2country)
### \_isOpen, \_isOpen:description
If 'opening\_hours' is present, it will add the current state of the feature (being 'yes' or 'no')
### _isOpen, _isOpen:description
If 'opening_hours' is present, it will add the current state of the feature (being 'yes' or 'no')
### _width:needed, _width:needed:no_pedestrians, _width:difference
### \_width:needed, \_width:needed:no\_pedestrians, \_width:difference
Legacy for a specific project calculating the needed width for safe traffic on a road. Only activated if 'width:carriageway' is present
### \_direction:numerical, \_direction:leftright
\_direction:numerical is a normalized, numerical direction based on 'camera:direction' or on 'direction'; it is only present if a valid direction is found (e.g. 38.5 or NE). \_direction:leftright is either 'left' or 'right', which is left-looking on the map or 'right-looking' on the map
### _direction:numerical, _direction:leftright
_direction:numerical is a normalized, numerical direction based on 'camera:direction' or on 'direction'; it is only present if a valid direction is found (e.g. 38.5 or NE). _direction:leftright is either 'left' or 'right', which is left-looking on the map or 'right-looking' on the map
### _now:date, _now:datetime, _loaded:date, _loaded:_datetime
### \_now:date, \_now:datetime, \_loaded:date, \_loaded:\_datetime
Adds the time that the data got loaded - pretty much the time of downloading from overpass. The format is YYYY-MM-DD hh:mm, aka 'sortable' aka ISO-8601-but-not-entirely
### \_last\_edit:contributor, \_last\_edit:contributor:uid, \_last\_edit:changeset, \_last\_edit:timestamp, \_version\_number
### _last_edit:contributor, _last_edit:contributor:uid, _last_edit:changeset, _last_edit:timestamp, _version_number
Information about the last edit of this object.
Calculating tags with Javascript
--------------------------------
In some cases, it is useful to have some tags calculated based on other properties. Some useful tags are available by default (e.g. **lat**, **lon**, **\_country**), as detailed above.
Calculating tags with Javascript
----------------------------------
In some cases, it is useful to have some tags calculated based on other properties. Some useful tags are available by default (e.g. `lat`, `lon`, `_country`), as detailed above.
It is also possible to calculate your own tags - but this requires some javascript knowledge.
Before proceeding, some warnings:
* DO NOT DO THIS AS BEGINNER
* **Only do this if all other techniques fail**. This should _not_ be done to create a rendering effect, only to calculate a specific value
* **THIS MIGHT BE DISABLED WITHOUT ANY NOTICE ON UNOFFICIAL THEMES**. As unofficial themes might be loaded from the internet, this is the equivalent of injecting arbitrary code into the client. It'll be disabled if abuse occurs.
In the layer object, add a field **calculatedTags**, e.g.:
"calculatedTags": \[ "\_someKey=javascript-expression", "name=feat.properties.name ?? feat.properties.ref ?? feat.properties.operator", "\_distanceCloserThen3Km=feat.distanceTo( some\_lon, some\_lat) < 3 ? 'yes' : 'no'" \]
- DO NOT DO THIS AS BEGINNER
- **Only do this if all other techniques fail** This should _not_ be done to create a rendering effect, only to calculate a specific value
- **THIS MIGHT BE DISABLED WITHOUT ANY NOTICE ON UNOFFICIAL THEMES** As unofficial themes might be loaded from the internet, this is the equivalent of injecting arbitrary code into the client. It'll be disabled if abuse occurs.
The above code will be executed for every feature in the layer. The feature is accessible as **feat** and is an amended geojson object: - **area** contains the surface area (in square meters) of the object - **lat** and **lon** contain the latitude and longitude Some advanced functions are available on **feat** as well:
* distanceTo
* overlapWith
* closest
* memberships
To enable this feature, add a field `calculatedTags` in the layer object, e.g.:
### distanceTo
````
Calculates the distance between the feature and a specified point in kilometer. The input should either be a pair of coordinates, a geojson feature or the ID of an object
"calculatedTags": [
* longitude
* latitude
"_someKey=javascript-expression",
### overlapWith
"name=feat.properties.name ?? feat.properties.ref ?? feat.properties.operator",
Gives a list of features from the specified layer which this feature (partly) overlaps with. If the current feature is a point, all features that embed the point are given. The returned value is `{ feat: GeoJSONFeature, overlap: number}[]` where `overlap` is the overlapping surface are (in m²) for areas, the overlapping length (in meter) if the current feature is a line or `undefined` if the current feature is a point
"_distanceCloserThen3Km=feat.distanceTo( some_lon, some_lat) < 3 ? 'yes' : 'no'"
* ...layerIds - one or more layer ids of the layer from which every feature is checked for overlap)
]
### closest
````
Given either a list of geojson features or a single layer name, gives the single object which is nearest to the feature. In the case of ways/polygons, only the centerpoint is considered.
* list of features
### memberships
The above code will be executed for every feature in the layer. The feature is accessible as `feat` and is an amended geojson object:
Gives a list of `{role: string, relation: Relation}`\-objects, containing all the relations that this feature is part of. For example: `_part_of_walking_routes=feat.memberships().map(r => r.relation.tags.name).join(';')`
- `area` contains the surface area (in square meters) of the object
- `lat` and `lon` contain the latitude and longitude
Some advanced functions are available on **feat** as well:
- distanceTo
- overlapWith
- closest
- memberships
### distanceTo
Calculates the distance between the feature and a specified point in kilometer. The input should either be a pair of coordinates, a geojson feature or the ID of an object
0. longitude
1. latitude
### overlapWith
Gives a list of features from the specified layer which this feature (partly) overlaps with. If the current feature is a point, all features that embed the point are given. The returned value is `{ feat: GeoJSONFeature, overlap: number}[]` where `overlap` is the overlapping surface are (in m²) for areas, the overlapping length (in meter) if the current feature is a line or `undefined` if the current feature is a point
0. ...layerIds - one or more layer ids of the layer from which every feature is checked for overlap)
### closest
Given either a list of geojson features or a single layer name, gives the single object which is nearest to the feature. In the case of ways/polygons, only the centerpoint is considered.
0. list of features
### memberships
Gives a list of `{role: string, relation: Relation}`-objects, containing all the relations that this feature is part of.
For example: `_part_of_walking_routes=feat.memberships().map(r => r.relation.tags.name).join(';')`
Generated from SimpleMetaTagger, ExtraFunction

View file

@ -60,4 +60,4 @@ Has extra elements to easily input when a POI is opened
## color
Shows a color picker
Shows a color picker Generated from ValidatedTextField.ts

View file

@ -1,61 +1 @@
### Special tag renderings
In a tagrendering, some special values are substituted by an advanced UI-element. This allows advanced features and visualizations to be reused by custom themes or even to query third-party API's.General usage is **{func\_name()}** or **{func\_name(arg, someotherarg)}**. Note that you _do not_ need to use quotes around your arguments, the comma is enough to seperate them. This also implies you cannot use a comma in your args
### all\_tags
Prints all key-value pairs of the object - used for debugging
**Example usage:** {all\_tags()}
### image\_carousel
Creates an image carousel for the given sources. An attempt will be made to guess what source is used. Supported: Wikidata identifiers, Wikipedia pages, Wikimedia categories, IMGUR (with attribution, direct links)
1. **image key/prefix**: The keys given to the images, e.g. if image is given, the first picture URL will be added as image, the second as image:0, the third as image:1, etc... Default: image
2. **smart search**: Also include images given via 'Wikidata', 'wikimedia\_commons' and 'mapillary Default: true
**Example usage:** {image\_carousel(image,true)}
### image\_upload
Creates a button where a user can upload an image to IMGUR
1. **image-key**: Image tag to add the URL to (or image-tag:0, image-tag:1 when multiple images are added) Default: image
**Example usage:** {image\_upload(image)}
### reviews
Adds an overview of the mangrove-reviews of this object. Mangrove.Reviews needs - in order to identify the reviewed object - a coordinate and a name. By default, the name of the object is given, but this can be overwritten
1. **subjectKey**: The key to use to determine the subject. If specified, the subject will be **tags\[subjectKey\]** Default: name
2. **fallback**: The identifier to use, if _tags\[subjectKey\]_ as specified above is not available. This is effectively a fallback value
**Example usage:** **{reviews()} **for a vanilla review, **{reviews(name, play\_forest)}** to review a play forest. If a name is known, the name will be used as identifier, otherwise 'play\_forest' is used****
### ****opening\_hours\_table****
****Creates an opening-hours table. Usage: {opening\_hours\_table(opening\_hours)} to create a table of the tag 'opening\_hours'.
1. **key**: The tagkey from which the table is constructed. Default: opening\_hours
**Example usage:** {opening\_hours\_table(opening\_hours)}
### live
Downloads a JSON from the given URL, e.g. '{live(example.org/data.json, shorthand:x.y.z, other:a.b.c, shorthand)}' will download the given file, will create an object {shorthand: json\[x\]\[y\]\[z\], other: json\[a\]\[b\]\[c\] out of it and will return 'other' or 'json\[a\]\[b\]\[c\]. This is made to use in combination with tags, e.g. {live({url}, {url:format}, needed\_value)}
1. **Url**: The URL to load
2. **Shorthands**: A list of shorthands, of the format 'shorthandname:path.path.path'. Seperated by ;
3. **path**: The path (or shorthand) that should be returned
**Example usage:** {live({url},{url:format},hour)} {live(https://data.mobility.brussels/bike/api/counts/?request=live&featureID=CB2105,hour:data.hour\_cnt;day:data.day\_cnt;year:data.year\_cnt,hour)}
### share\_link
Creates a link that (attempts to) open the native 'share'-screen
1. **url**: The url to share (default: current URL)
**Example usage:** {share\_link()} to share the current page, {share\_link()} to share the given url****
<h3>Special tag renderings</h3> In a tagrendering, some special values are substituted by an advanced UI-element. This allows advanced features and visualizations to be reused by custom themes or even to query third-party API's. General usage is <b>{func_name()}</b> or <b>{func_name(arg, someotherarg)}</b>. Note that you <i>do not</i> need to use quotes around your arguments, the comma is enough to seperate them. This also implies you cannot use a comma in your args <h3>all_tags</h3> Prints all key-value pairs of the object - used for debugging <ol> </ol> <b>Example usage: </b> {all_tags()} <h3>image_carousel</h3> Creates an image carousel for the given sources. An attempt will be made to guess what source is used. Supported: Wikidata identifiers, Wikipedia pages, Wikimedia categories, IMGUR (with attribution, direct links) <ol> <li> <b>image key/prefix</b>: The keys given to the images, e.g. if <span class='literal-code'>image</span> is given, the first picture URL will be added as <span class='literal-code'>image</span>, the second as <span class='literal-code'>image:0</span>, the third as <span class='literal-code'>image:1</span>, etc... Default: <span class='literal-code'>image</span> </li> <li> <b>smart search</b>: Also include images given via 'Wikidata', 'wikimedia_commons' and 'mapillary Default: <span class='literal-code'>true</span> </li> </ol> <b>Example usage: </b> {image_carousel(image,true)} <h3>image_upload</h3> Creates a button where a user can upload an image to IMGUR <ol> <li> <b>image-key</b>: Image tag to add the URL to (or image-tag:0, image-tag:1 when multiple images are added) Default: <span class='literal-code'>image</span> </li> </ol> <b>Example usage: </b> {image_upload(image)} <h3>reviews</h3> Adds an overview of the mangrove-reviews of this object. Mangrove.Reviews needs - in order to identify the reviewed object - a coordinate and a name. By default, the name of the object is given, but this can be overwritten <ol> <li> <b>subjectKey</b>: The key to use to determine the subject. If specified, the subject will be <b>tags[subjectKey]</b> Default: <span class='literal-code'>name</span> </li> <li> <b>fallback</b>: The identifier to use, if <i>tags[subjectKey]</i> as specified above is not available. This is effectively a fallback value </li> </ol> <b>Example usage: </b> <b>{reviews()}<b> for a vanilla review, <b>{reviews(name, play_forest)}</b> to review a play forest. If a name is known, the name will be used as identifier, otherwise 'play_forest' is used <h3>opening_hours_table</h3> Creates an opening-hours table. Usage: {opening_hours_table(opening_hours)} to create a table of the tag 'opening_hours'. <ol> <li> <b>key</b>: The tagkey from which the table is constructed. Default: <span class='literal-code'>opening_hours</span> </li> </ol> <b>Example usage: </b> {opening_hours_table(opening_hours)} <h3>live</h3> Downloads a JSON from the given URL, e.g. '{live(example.org/data.json, shorthand:x.y.z, other:a.b.c, shorthand)}' will download the given file, will create an object {shorthand: json[x][y][z], other: json[a][b][c] out of it and will return 'other' or 'json[a][b][c]. This is made to use in combination with tags, e.g. {live({url}, {url:format}, needed_value)} <ol> <li> <b>Url</b>: The URL to load </li> <li> <b>Shorthands</b>: A list of shorthands, of the format 'shorthandname:path.path.path'. Seperated by ; </li> <li> <b>path</b>: The path (or shorthand) that should be returned </li> </ol> <b>Example usage: </b> {live({url},{url:format},hour)} {live(https://data.mobility.brussels/bike/api/counts/?request=live&featureID=CB2105,hour:data.hour_cnt;day:data.day_cnt;year:data.year_cnt,hour)} <h3>share_link</h3> Creates a link that (attempts to) open the native 'share'-screen <ol> <li> <b>url</b>: The url to share (default: current URL) </li> </ol> <b>Example usage: </b> {share_link()} to share the current page, {share_link(<some_url>)} to share the given url Generated from UI/SpecialVisualisations.ts

View file

@ -33,22 +33,22 @@
},
{
"key": "cyclestreet",
"description": "Layer 'Cyclestreets' shows cyclestreet=yes&maxspeed=30&overtaking:motor_vehicle=no&proposed:cyclestreet= with a fixed text, namely 'This street is a cyclestreet (and has a maxspeeld of 30km/h)' and allows to pick this as a default answer (in the MapComplete.osm.be theme 'Cyclestreets')",
"description": "Layer 'Cyclestreets' shows cyclestreet=yes&maxspeed=30&overtaking:motor_vehicle=no&proposed:cyclestreet= with a fixed text, namely 'This street is a cyclestreet (and has a speed limit of 30 km/h)' and allows to pick this as a default answer (in the MapComplete.osm.be theme 'Cyclestreets')",
"value": "yes"
},
{
"key": "maxspeed",
"description": "Layer 'Cyclestreets' shows cyclestreet=yes&maxspeed=30&overtaking:motor_vehicle=no&proposed:cyclestreet= with a fixed text, namely 'This street is a cyclestreet (and has a maxspeeld of 30km/h)' and allows to pick this as a default answer (in the MapComplete.osm.be theme 'Cyclestreets')",
"description": "Layer 'Cyclestreets' shows cyclestreet=yes&maxspeed=30&overtaking:motor_vehicle=no&proposed:cyclestreet= with a fixed text, namely 'This street is a cyclestreet (and has a speed limit of 30 km/h)' and allows to pick this as a default answer (in the MapComplete.osm.be theme 'Cyclestreets')",
"value": "30"
},
{
"key": "overtaking:motor_vehicle",
"description": "Layer 'Cyclestreets' shows cyclestreet=yes&maxspeed=30&overtaking:motor_vehicle=no&proposed:cyclestreet= with a fixed text, namely 'This street is a cyclestreet (and has a maxspeeld of 30km/h)' and allows to pick this as a default answer (in the MapComplete.osm.be theme 'Cyclestreets')",
"description": "Layer 'Cyclestreets' shows cyclestreet=yes&maxspeed=30&overtaking:motor_vehicle=no&proposed:cyclestreet= with a fixed text, namely 'This street is a cyclestreet (and has a speed limit of 30 km/h)' and allows to pick this as a default answer (in the MapComplete.osm.be theme 'Cyclestreets')",
"value": "no"
},
{
"key": "proposed:cyclestreet",
"description": "Layer 'Cyclestreets' shows cyclestreet=yes&maxspeed=30&overtaking:motor_vehicle=no&proposed:cyclestreet= with a fixed text, namely 'This street is a cyclestreet (and has a maxspeeld of 30km/h)' and allows to pick this as a default answer (in the MapComplete.osm.be theme 'Cyclestreets') Picking this answer will delete the key proposed:cyclestreet.",
"description": "Layer 'Cyclestreets' shows cyclestreet=yes&maxspeed=30&overtaking:motor_vehicle=no&proposed:cyclestreet= with a fixed text, namely 'This street is a cyclestreet (and has a speed limit of 30 km/h)' and allows to pick this as a default answer (in the MapComplete.osm.be theme 'Cyclestreets') Picking this answer will delete the key proposed:cyclestreet.",
"value": ""
},
{
@ -113,22 +113,22 @@
},
{
"key": "cyclestreet",
"description": "Layer 'Future cyclestreet' shows cyclestreet=yes&maxspeed=30&overtaking:motor_vehicle=no&proposed:cyclestreet= with a fixed text, namely 'This street is a cyclestreet (and has a maxspeeld of 30km/h)' and allows to pick this as a default answer (in the MapComplete.osm.be theme 'Cyclestreets')",
"description": "Layer 'Future cyclestreet' shows cyclestreet=yes&maxspeed=30&overtaking:motor_vehicle=no&proposed:cyclestreet= with a fixed text, namely 'This street is a cyclestreet (and has a speed limit of 30 km/h)' and allows to pick this as a default answer (in the MapComplete.osm.be theme 'Cyclestreets')",
"value": "yes"
},
{
"key": "maxspeed",
"description": "Layer 'Future cyclestreet' shows cyclestreet=yes&maxspeed=30&overtaking:motor_vehicle=no&proposed:cyclestreet= with a fixed text, namely 'This street is a cyclestreet (and has a maxspeeld of 30km/h)' and allows to pick this as a default answer (in the MapComplete.osm.be theme 'Cyclestreets')",
"description": "Layer 'Future cyclestreet' shows cyclestreet=yes&maxspeed=30&overtaking:motor_vehicle=no&proposed:cyclestreet= with a fixed text, namely 'This street is a cyclestreet (and has a speed limit of 30 km/h)' and allows to pick this as a default answer (in the MapComplete.osm.be theme 'Cyclestreets')",
"value": "30"
},
{
"key": "overtaking:motor_vehicle",
"description": "Layer 'Future cyclestreet' shows cyclestreet=yes&maxspeed=30&overtaking:motor_vehicle=no&proposed:cyclestreet= with a fixed text, namely 'This street is a cyclestreet (and has a maxspeeld of 30km/h)' and allows to pick this as a default answer (in the MapComplete.osm.be theme 'Cyclestreets')",
"description": "Layer 'Future cyclestreet' shows cyclestreet=yes&maxspeed=30&overtaking:motor_vehicle=no&proposed:cyclestreet= with a fixed text, namely 'This street is a cyclestreet (and has a speed limit of 30 km/h)' and allows to pick this as a default answer (in the MapComplete.osm.be theme 'Cyclestreets')",
"value": "no"
},
{
"key": "proposed:cyclestreet",
"description": "Layer 'Future cyclestreet' shows cyclestreet=yes&maxspeed=30&overtaking:motor_vehicle=no&proposed:cyclestreet= with a fixed text, namely 'This street is a cyclestreet (and has a maxspeeld of 30km/h)' and allows to pick this as a default answer (in the MapComplete.osm.be theme 'Cyclestreets') Picking this answer will delete the key proposed:cyclestreet.",
"description": "Layer 'Future cyclestreet' shows cyclestreet=yes&maxspeed=30&overtaking:motor_vehicle=no&proposed:cyclestreet= with a fixed text, namely 'This street is a cyclestreet (and has a speed limit of 30 km/h)' and allows to pick this as a default answer (in the MapComplete.osm.be theme 'Cyclestreets') Picking this answer will delete the key proposed:cyclestreet.",
"value": ""
},
{
@ -203,22 +203,22 @@
},
{
"key": "cyclestreet",
"description": "Layer 'All streets' shows cyclestreet=yes&maxspeed=30&overtaking:motor_vehicle=no&proposed:cyclestreet= with a fixed text, namely 'This street is a cyclestreet (and has a maxspeeld of 30km/h)' and allows to pick this as a default answer (in the MapComplete.osm.be theme 'Cyclestreets')",
"description": "Layer 'All streets' shows cyclestreet=yes&maxspeed=30&overtaking:motor_vehicle=no&proposed:cyclestreet= with a fixed text, namely 'This street is a cyclestreet (and has a speed limit of 30 km/h)' and allows to pick this as a default answer (in the MapComplete.osm.be theme 'Cyclestreets')",
"value": "yes"
},
{
"key": "maxspeed",
"description": "Layer 'All streets' shows cyclestreet=yes&maxspeed=30&overtaking:motor_vehicle=no&proposed:cyclestreet= with a fixed text, namely 'This street is a cyclestreet (and has a maxspeeld of 30km/h)' and allows to pick this as a default answer (in the MapComplete.osm.be theme 'Cyclestreets')",
"description": "Layer 'All streets' shows cyclestreet=yes&maxspeed=30&overtaking:motor_vehicle=no&proposed:cyclestreet= with a fixed text, namely 'This street is a cyclestreet (and has a speed limit of 30 km/h)' and allows to pick this as a default answer (in the MapComplete.osm.be theme 'Cyclestreets')",
"value": "30"
},
{
"key": "overtaking:motor_vehicle",
"description": "Layer 'All streets' shows cyclestreet=yes&maxspeed=30&overtaking:motor_vehicle=no&proposed:cyclestreet= with a fixed text, namely 'This street is a cyclestreet (and has a maxspeeld of 30km/h)' and allows to pick this as a default answer (in the MapComplete.osm.be theme 'Cyclestreets')",
"description": "Layer 'All streets' shows cyclestreet=yes&maxspeed=30&overtaking:motor_vehicle=no&proposed:cyclestreet= with a fixed text, namely 'This street is a cyclestreet (and has a speed limit of 30 km/h)' and allows to pick this as a default answer (in the MapComplete.osm.be theme 'Cyclestreets')",
"value": "no"
},
{
"key": "proposed:cyclestreet",
"description": "Layer 'All streets' shows cyclestreet=yes&maxspeed=30&overtaking:motor_vehicle=no&proposed:cyclestreet= with a fixed text, namely 'This street is a cyclestreet (and has a maxspeeld of 30km/h)' and allows to pick this as a default answer (in the MapComplete.osm.be theme 'Cyclestreets') Picking this answer will delete the key proposed:cyclestreet.",
"description": "Layer 'All streets' shows cyclestreet=yes&maxspeed=30&overtaking:motor_vehicle=no&proposed:cyclestreet= with a fixed text, namely 'This street is a cyclestreet (and has a speed limit of 30 km/h)' and allows to pick this as a default answer (in the MapComplete.osm.be theme 'Cyclestreets') Picking this answer will delete the key proposed:cyclestreet.",
"value": ""
},
{

View file

@ -1,3 +1,4 @@
URL-parameters and URL-hash
============================
@ -18,125 +19,128 @@ 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.
custom-css (broken)
------------
If specified, the custom css from the given link will be loaded additionaly
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
userlayout
------------
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_
layer-control-toggle
layer-control-toggle
----------------------
Whether or not the layer control is shown
The default value is _false_
tab
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 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 set by the theme
lat
The initial/current zoom level The default value is _0_
lat
-----
The initial/current latitude
The default value is set by the theme
lon
The initial/current latitude The default value is _0_
lon
-----
The initial/current longitude of the app
The default value is set by the theme
fs-userbadge
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 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 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 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 '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 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 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 '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 '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
Disables/Enables the geolocation button The default value is _true_
fs-all-questions
------------------
Always show all questions
The default value is _false_
debug
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
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_
oauth_token
-------------
Used to complete the login
No default value set
The OSM backend to use - can be used to redirect mapcomplete to the testing backend when using 'osm-test' The default value is _osm_
background
custom-css
------------
The id of the background layer to start with
The default value is _OSM_ (overridden by the theme)
layer-<layerid>
--------------
Wether or not layer with layer-id is shown
The default value is _true_
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_
layer-<layer-id>
------------------
Wether or not the layer with id <layer-id> is shown The default value is _true_ Generated from QueryParameters

View file

@ -1,5 +1,5 @@
import {FixedUiElement} from "./UI/Base/FixedUiElement";
import CheckBox from "./UI/Input/CheckBox";
import Toggle from "./UI/Input/Toggle";
import {Basemap} from "./UI/BigComponents/Basemap";
import State from "./State";
import LoadFromOverpass from "./Logic/Actors/OverpassFeatureSource";
@ -21,11 +21,9 @@ import * as L from "leaflet";
import Img from "./UI/Base/Img";
import UserDetails from "./Logic/Osm/OsmConnection";
import Attribution from "./UI/BigComponents/Attribution";
import AvailableBaseLayers from "./Logic/Actors/AvailableBaseLayers";
import LayerResetter from "./Logic/Actors/LayerResetter";
import FullWelcomePaneWithTabs from "./UI/BigComponents/FullWelcomePaneWithTabs";
import LayerControlPanel from "./UI/BigComponents/LayerControlPanel";
import FeatureSwitched from "./UI/Base/FeatureSwitched";
import ShowDataLayer from "./UI/ShowDataLayer";
import Hash from "./Logic/Web/Hash";
import FeaturePipeline from "./Logic/FeatureSource/FeaturePipeline";
@ -39,9 +37,9 @@ import {LayoutConfigJson} from "./Customizations/JSON/LayoutConfigJson";
import AttributionPanel from "./UI/BigComponents/AttributionPanel";
import ContributorCount from "./Logic/ContributorCount";
import FeatureSource from "./Logic/FeatureSource/FeatureSource";
import {AllKnownLayouts} from "./Customizations/AllKnownLayouts";
import AllKnownLayers from "./Customizations/AllKnownLayers";
import LayerConfig from "./Customizations/JSON/LayerConfig";
import AvailableBaseLayers from "./Logic/Actors/AvailableBaseLayers";
export class InitUiElements {
@ -170,13 +168,14 @@ export class InitUiElements {
marker.addTo(State.state.leafletMap.data)
});
const geolocationButton = new FeatureSwitched(
const geolocationButton = new Toggle(
new MapControlButton(
new GeoLocationHandler(
State.state.currentGPSLocation,
State.state.leafletMap,
State.state.layoutToUse
)),
undefined,
State.state.featureSwitchGeolocation);
const plus = new MapControlButton(
@ -193,7 +192,7 @@ export class InitUiElements {
State.state.locationControl.ping();
})
new Combine([plus, min, geolocationButton].map(el => el.SetClass("m-1")))
new Combine([plus, min, geolocationButton].map(el => el.SetClass("m-0.5 md:m-1")))
.SetClass("flex flex-col")
.AttachTo("bottom-right");
@ -211,13 +210,12 @@ export class InitUiElements {
// Reset the loading message once things are loaded
new CenterMessageBox().AttachTo("centermessage");
// At last, zoom to the needed location if the focus is on an element
document.getElementById("centermessage").classList.add("pointer-events-none")
}
static LoadLayoutFromHash(userLayoutParam: UIEventSource<string>) {
static LoadLayoutFromHash(userLayoutParam: UIEventSource<string>): [LayoutConfig, string]{
try {
let hash = location.hash.substr(1);
const layoutFromBase64 = userLayoutParam.data;
@ -249,7 +247,7 @@ export class InitUiElements {
// @ts-ignore
const layoutToUse = new LayoutConfig(json, false);
userLayoutParam.setData(layoutToUse.id);
return layoutToUse;
return [layoutToUse, btoa(Utils.MinifyJSON(JSON.stringify(json)))];
} catch (e) {
new FixedUiElement("Error: could not parse the custom layout:<br/> " + e).AttachTo("centermessage");
@ -272,11 +270,10 @@ export class InitUiElements {
// ?-Button on Desktop, opens panel with close-X.
const help = new MapControlButton(Svg.help_svg());
new CheckBox(
help.onClick(() => isOpened.setData(true))
new Toggle(
fullOptions
.SetClass("welcomeMessage")
.onClick(() => {/*Catch the click*/
}),
.SetClass("welcomeMessage"),
help
, isOpened
).AttachTo("messagesbox");
@ -307,22 +304,23 @@ export class InitUiElements {
)
;
const copyrightButton = new CheckBox(
const copyrightButton = new Toggle(
copyrightNotice,
new MapControlButton(Svg.osm_copyright_svg()),
copyrightNotice.isShown
).SetClass("p-0.5")
).ToggleOnClick()
.SetClass("p-0.5")
const layerControlPanel = new LayerControlPanel(
State.state.layerControlIsOpened)
.SetClass("block p-1 rounded-full");
const layerControlButton = new CheckBox(
const layerControlButton = new Toggle(
layerControlPanel,
new MapControlButton(Svg.layers_svg()),
State.state.layerControlIsOpened
)
).ToggleOnClick()
const layerControl = new CheckBox(
const layerControl = new Toggle(
layerControlButton,
"",
State.state.featureSwitchLayers
@ -351,9 +349,8 @@ export class InitUiElements {
private static InitBaseMap() {
State.state.availableBackgroundLayers = new AvailableBaseLayers(State.state.locationControl).availableEditorLayers;
State.state.backgroundLayer = QueryParameters.GetQueryParameter("background",
State.state.layoutToUse.data.defaultBackgroundId ?? AvailableBaseLayers.osmCarto.id,
"The id of the background layer to start with")
State.state.backgroundLayer = State.state.backgroundLayerId
.map((selectedId: string) => {
const available = State.state.availableBackgroundLayers.data;
for (const layer of available) {
@ -362,9 +359,8 @@ export class InitUiElements {
}
}
return AvailableBaseLayers.osmCarto;
}, [], layer => layer.id);
}, [State.state.availableBackgroundLayers], layer => layer.id);
new LayerResetter(
State.state.backgroundLayer, State.state.locationControl,
State.state.availableBackgroundLayers, State.state.layoutToUse.map((layout: LayoutConfig) => layout.defaultBackgroundId));

View file

@ -6,6 +6,8 @@ import Svg from "../../Svg";
import Img from "../../UI/Base/Img";
import {LocalStorageSource} from "../Web/LocalStorageSource";
import LayoutConfig from "../../Customizations/JSON/LayoutConfig";
import BaseUIElement from "../../UI/BaseUIElement";
import {VariableUiElement} from "../../UI/Base/VariableUIElement";
export default class GeoLocationHandler extends UIElement {
@ -52,19 +54,19 @@ export default class GeoLocationHandler extends UIElement {
private readonly _previousLocationGrant: UIEventSource<string> = LocalStorageSource.Get("geolocation-permissions");
private readonly _layoutToUse: UIEventSource<LayoutConfig>;
private readonly _element: BaseUIElement;
constructor(currentGPSLocation: UIEventSource<{ latlng: any; accuracy: number }>,
leafletMap: UIEventSource<L.Map>,
layoutToUse: UIEventSource<LayoutConfig>) {
super(undefined);
super();
this._currentGPSLocation = currentGPSLocation;
this._leafletMap = leafletMap;
this._layoutToUse = layoutToUse;
this._hasLocation = currentGPSLocation.map((location) => location !== undefined);
this.dumbMode = false;
const self = this;
import("../../vendor/Leaflet.AccuratePosition.js").then(() => {
self.init();
})
const currentPointer = this._isActive.map(isActive => {
if (isActive && !self._hasLocation.data) {
@ -74,62 +76,35 @@ export default class GeoLocationHandler extends UIElement {
}, [this._hasLocation])
currentPointer.addCallbackAndRun(pointerClass => {
self.SetClass(pointerClass);
self.Update()
})
this._element = new VariableUiElement(
this._hasLocation.map(hasLocation => {
if (hasLocation) {
return Svg.crosshair_blue_ui()
}
if (self._isActive.data) {
return Svg.crosshair_blue_center_ui();
}
return Svg.crosshair_ui();
}, [this._isActive])
);
this.onClick(() => self.init(true))
self.init(false)
}
InnerRender(): string {
if (this._hasLocation.data) {
return Svg.crosshair_blue_img;
}
if (this._isActive.data) {
return Svg.crosshair_blue_center_img;
}
return Svg.crosshair_img;
protected InnerRender(): string | BaseUIElement {
return this._element
}
InnerUpdate(htmlElement: HTMLElement) {
super.InnerUpdate(htmlElement);
private init(askPermission: boolean) {
const self = this;
htmlElement.onclick = function () {
self.StartGeolocating();
}
htmlElement.oncontextmenu = function (e) {
self.StartGeolocating();
e.preventDefault();
return false;
}
}
private init() {
this.ListenTo(this._hasLocation);
this.ListenTo(this._isActive);
this.ListenTo(this._permission);
const self = this;
function onAccuratePositionProgress(e) {
self._currentGPSLocation.setData({latlng: e.latlng, accuracy: e.accuracy});
}
function onAccuratePositionFound(e) {
self._currentGPSLocation.setData({latlng: e.latlng, accuracy: e.accuracy});
}
function onAccuratePositionError(e) {
console.log("onerror", e.message);
}
const map = this._leafletMap.data;
map.on('accuratepositionprogress', onAccuratePositionProgress);
map.on('accuratepositionfound', onAccuratePositionFound);
map.on('accuratepositionerror', onAccuratePositionError);
this._currentGPSLocation.addCallback((location) => {
self._previousLocationGrant.setData("granted");
@ -178,12 +153,13 @@ export default class GeoLocationHandler extends UIElement {
} catch (e) {
console.error(e)
}
if (this._previousLocationGrant.data === "granted") {
if (askPermission) {
self.StartGeolocating(true);
} else if (this._previousLocationGrant.data === "granted") {
this._previousLocationGrant.setData("");
self.StartGeolocating(false);
}
this.HideOnEmpty(true);
}
private locate() {
@ -211,7 +187,7 @@ export default class GeoLocationHandler extends UIElement {
private MoveToCurrentLoction(targetZoom = 16) {
const location = this._currentGPSLocation.data;
this._lastUserRequest = undefined;
if (this._currentGPSLocation.data.latlng[0] === 0 && this._currentGPSLocation.data.latlng[1] === 0) {
console.debug("Not moving to GPS-location: it is null island")

View file

@ -53,7 +53,6 @@ export default class OverpassFeatureSource implements FeatureSource {
return false;
}
let minzoom = Math.min(...layoutToUse.data.layers.map(layer => layer.minzoom ?? 18));
console.debug("overpass source: minzoom is ", minzoom)
return location.zoom >= minzoom;
}, [layoutToUse]
);

View file

@ -47,13 +47,13 @@ export default class StrayClickHandler {
popupAnchor: [0, -45]
})
});
const popup = L.popup().setContent(uiToShow.Render());
const popup = L.popup().setContent("<div id='strayclick'></div>");
self._lastMarker.addTo(leafletMap.data);
self._lastMarker.bindPopup(popup);
self._lastMarker.on("click", () => {
uiToShow.AttachTo("strayclick")
uiToShow.Activate();
uiToShow.Update();
});
});

View file

@ -2,12 +2,12 @@ import {UIEventSource} from "../UIEventSource";
import LayoutConfig from "../../Customizations/JSON/LayoutConfig";
import Translations from "../../UI/i18n/Translations";
import Locale from "../../UI/i18n/Locale";
import {UIElement} from "../../UI/UIElement";
import TagRenderingAnswer from "../../UI/Popup/TagRenderingAnswer";
import {ElementStorage} from "../ElementStorage";
import Combine from "../../UI/Base/Combine";
class TitleElement extends UIElement {
class TitleElement extends UIEventSource<string> {
private readonly _layoutToUse: UIEventSource<LayoutConfig>;
private readonly _selectedFeature: UIEventSource<any>;
private readonly _allElementsStorage: ElementStorage;
@ -15,42 +15,44 @@ class TitleElement extends UIElement {
constructor(layoutToUse: UIEventSource<LayoutConfig>,
selectedFeature: UIEventSource<any>,
allElementsStorage: ElementStorage) {
super(layoutToUse);
super("MapComplete");
this._layoutToUse = layoutToUse;
this._selectedFeature = selectedFeature;
this._allElementsStorage = allElementsStorage;
this.ListenTo(Locale.language);
this.ListenTo(this._selectedFeature)
this.dumbMode = false;
}
this.syncWith(
this._selectedFeature.map(
selected => {
const defaultTitle = Translations.WT(this._layoutToUse.data?.title)?.txt ??"MapComplete"
InnerRender(): string {
if(selected === undefined){
return defaultTitle
}
const defaultTitle = Translations.WT(this._layoutToUse.data?.title)?.txt ?? "MapComplete"
const feature = this._selectedFeature.data;
if (feature === undefined) {
return defaultTitle;
}
const layout = layoutToUse.data;
const tags = selected.properties;
const layout = this._layoutToUse.data;
const properties = this._selectedFeature.data.properties;
for (const layer of layout.layers) {
if (layer.title === undefined) {
continue;
}
if (layer.source.osmTags.matchesProperties(tags)) {
const tagsSource = allElementsStorage.getEventSourceById(tags.id)
const title = new TagRenderingAnswer(tagsSource, layer.title)
return new Combine([defaultTitle, " | ", title]).ConstructElement().innerText;
}
}
for (const layer of layout.layers) {
if (layer.title === undefined) {
continue;
}
if (layer.source.osmTags.matchesProperties(properties)) {
const tags = this._allElementsStorage.getEventSourceById(feature.properties.id);
if (tags == undefined) {
return defaultTitle;
return defaultTitle
}
const title = new TagRenderingAnswer(tags, layer.title)
return new Combine([defaultTitle, " | ", title]).Render();
}
}
return defaultTitle;
, [Locale.language, layoutToUse]
)
)
}
}
@ -59,14 +61,8 @@ export default class TitleHandler {
constructor(layoutToUse: UIEventSource<LayoutConfig>,
selectedFeature: UIEventSource<any>,
allElementsStorage: ElementStorage) {
selectedFeature.addCallbackAndRun(_ => {
const title = new TitleElement(layoutToUse, selectedFeature, allElementsStorage)
const d = document.createElement('div');
d.innerHTML = title.InnerRender();
// We pass everything into a div to strip out images etc...
document.title = (d.textContent || d.innerText);
new TitleElement(layoutToUse, selectedFeature, allElementsStorage).addCallbackAndRun(title => {
document.title = title
})
}
}

View file

@ -1,46 +1,48 @@
import {GeoOperations} from "./GeoOperations";
import {UIElement} from "../UI/UIElement";
import Combine from "../UI/Base/Combine";
import {Relation} from "./Osm/ExtractRelations";
import State from "../State";
import {Utils} from "../Utils";
import BaseUIElement from "../UI/BaseUIElement";
import List from "../UI/Base/List";
import Title from "../UI/Base/Title";
export class ExtraFunction {
static readonly intro = `<h2>Calculating tags with Javascript</h2>
static readonly intro = new Combine([
new Title("Calculating tags with Javascript", 2),
"In some cases, it is useful to have some tags calculated based on other properties. Some useful tags are available by default (e.g. `lat`, `lon`, `_country`), as detailed above.",
"It is also possible to calculate your own tags - but this requires some javascript knowledge.",
"",
"Before proceeding, some warnings:",
new List([
"DO NOT DO THIS AS BEGINNER",
"**Only do this if all other techniques fail** This should _not_ be done to create a rendering effect, only to calculate a specific value",
"**THIS MIGHT BE DISABLED WITHOUT ANY NOTICE ON UNOFFICIAL THEMES** As unofficial themes might be loaded from the internet, this is the equivalent of injecting arbitrary code into the client. It'll be disabled if abuse occurs."
]),
"To enable this feature, add a field `calculatedTags` in the layer object, e.g.:",
"````",
"\"calculatedTags\": [",
" \"_someKey=javascript-expression\",",
" \"name=feat.properties.name ?? feat.properties.ref ?? feat.properties.operator\",",
" \"_distanceCloserThen3Km=feat.distanceTo( some_lon, some_lat) < 3 ? 'yes' : 'no'\" ",
" ]",
"````",
"",
"The above code will be executed for every feature in the layer. The feature is accessible as `feat` and is an amended geojson object:",
<p>In some cases, it is useful to have some tags calculated based on other properties. Some useful tags are available by default (e.g. <b>lat</b>, <b>lon</b>, <b>_country</b>), as detailed above.</p>
new List([
"`area` contains the surface area (in square meters) of the object",
"`lat` and `lon` contain the latitude and longitude"
]),
"Some advanced functions are available on **feat** as well:"
]).SetClass("flex-col").AsMarkdown();
<p>It is also possible to calculate your own tags - but this requires some javascript knowledge. </p>
Before proceeding, some warnings:
<ul>
<li> DO NOT DO THIS AS BEGINNER</li>
<li> <b>Only do this if all other techniques fail</b>. This should <i>not</i> be done to create a rendering effect, only to calculate a specific value</li>
<li> <b>THIS MIGHT BE DISABLED WITHOUT ANY NOTICE ON UNOFFICIAL THEMES</b>. As unofficial themes might be loaded from the internet, this is the equivalent of injecting arbitrary code into the client. It'll be disabled if abuse occurs.</li>
</ul>
In the layer object, add a field <b>calculatedTags</b>, e.g.:
<div class="code">
"calculatedTags": [
"_someKey=javascript-expression",
"name=feat.properties.name ?? feat.properties.ref ?? feat.properties.operator",
"_distanceCloserThen3Km=feat.distanceTo( some_lon, some_lat) < 3 ? 'yes' : 'no'"
]
</div>
The above code will be executed for every feature in the layer. The feature is accessible as <b>feat</b> and is an amended geojson object:
- <b>area</b> contains the surface area (in square meters) of the object
- <b>lat</b> and <b>lon</b> contain the latitude and longitude
Some advanced functions are available on <b>feat</b> as well:
`
private static readonly OverlapFunc = new ExtraFunction(
"overlapWith",
"Gives a list of features from the specified layer which this feature (partly) overlaps with. If the current feature is a point, all features that embed the point are given. The returned value is <code>{ feat: GeoJSONFeature, overlap: number}[]</code> where <code>overlap</code> is the overlapping surface are (in m²) for areas, the overlapping length (in meter) if the current feature is a line or <code>undefined</code> if the current feature is a point",
"Gives a list of features from the specified layer which this feature (partly) overlaps with. If the current feature is a point, all features that embed the point are given. The returned value is `{ feat: GeoJSONFeature, overlap: number}[]` where `overlap` is the overlapping surface are (in m²) for areas, the overlapping length (in meter) if the current feature is a line or `undefined` if the current feature is a point",
["...layerIds - one or more layer ids of the layer from which every feature is checked for overlap)"],
(params, feat) => {
return (...layerIds: string[]) => {
@ -72,7 +74,7 @@ Some advanced functions are available on <b>feat</b> as well:
if (typeof arg0 === "string") {
// This is an identifier
const feature = State.state.allElements.ContainingFeatures.get(arg0);
if(feature === undefined){
if (feature === undefined) {
return undefined;
}
arg0 = feature;
@ -138,9 +140,9 @@ Some advanced functions are available on <b>feat</b> as well:
private static readonly Memberships = new ExtraFunction(
"memberships",
"Gives a list of <code>{role: string, relation: Relation}</code>-objects, containing all the relations that this feature is part of. " +
"Gives a list of `{role: string, relation: Relation}`-objects, containing all the relations that this feature is part of. " +
"\n\n" +
"For example: <code>_part_of_walking_routes=feat.memberships().map(r => r.relation.tags.name).join(';')</code>",
"For example: `_part_of_walking_routes=feat.memberships().map(r => r.relation.tags.name).join(';')`",
[],
(params, _) => {
return () => params.relations ?? [];
@ -167,25 +169,19 @@ Some advanced functions are available on <b>feat</b> as well:
}
}
public static HelpText(): UIElement {
public static HelpText(): BaseUIElement {
const elems = []
for (const func of ExtraFunction.allFuncs) {
elems.push(new Title(func._name, 3),
func._doc,
new List(func._args, true))
}
return new Combine([
ExtraFunction.intro,
"<ul>",
...ExtraFunction.allFuncs.map(func =>
new Combine([
"<li>", func._name, "</li>"
])
),
"</ul>",
...ExtraFunction.allFuncs.map(func =>
new Combine([
"<h3>" + func._name + "</h3>",
func._doc,
"<ul>",
...func._args.map(arg => "<li>" + arg + "</li>"),
"</ul>"
])
)
new List(ExtraFunction.allFuncs.map(func => func._name)),
...elems
]);
}

View file

@ -10,12 +10,11 @@ import Constants from "../../Models/Constants";
export class ChangesetHandler {
public readonly currentChangeset: UIEventSource<string>;
private readonly _dryRun: boolean;
private readonly userDetails: UIEventSource<UserDetails>;
private readonly auth: any;
public readonly currentChangeset: UIEventSource<string>;
constructor(layoutName: string, dryRun: boolean, osmConnection: OsmConnection, auth) {
this._dryRun = dryRun;
this.userDetails = osmConnection.userDetails;
@ -27,14 +26,34 @@ export class ChangesetHandler {
}
}
private static parseUploadChangesetResponse(response: XMLDocument, allElements: ElementStorage) {
const nodes = response.getElementsByTagName("node");
// @ts-ignore
for (const node of nodes) {
const oldId = parseInt(node.attributes.old_id.value);
const newId = parseInt(node.attributes.new_id.value);
if (oldId !== undefined && newId !== undefined &&
!isNaN(oldId) && !isNaN(newId)) {
if (oldId == newId) {
continue;
}
console.log("Rewriting id: ", oldId, "-->", newId);
const element = allElements.getEventSourceById("node/" + oldId);
element.data.id = "node/" + newId;
allElements.addElementById("node/" + newId, element);
element.ping();
}
}
}
public UploadChangeset(
layout: LayoutConfig,
allElements: ElementStorage,
generateChangeXML: (csid: string) => string,
continuation: () => void) {
if(this.userDetails.data.csCount == 0){
continuation: () => void) {
if (this.userDetails.data.csCount == 0) {
// The user became a contributor!
this.userDetails.data.csCount = 1;
this.userDetails.ping();
@ -51,7 +70,7 @@ export class ChangesetHandler {
if (this.currentChangeset.data === undefined || this.currentChangeset.data === "") {
// We have to open a new changeset
this.OpenChangeset(layout,(csId) => {
this.OpenChangeset(layout, (csId) => {
this.currentChangeset.setData(csId);
const changeset = generateChangeXML(csId);
console.log(changeset);
@ -86,31 +105,61 @@ export class ChangesetHandler {
}
}
public CloseChangeset(changesetId: string = undefined, continuation: (() => void) = () => {
}) {
if (changesetId === undefined) {
changesetId = this.currentChangeset.data;
}
if (changesetId === undefined) {
return;
}
console.log("closing changeset", changesetId);
this.currentChangeset.setData("");
this.auth.xhr({
method: 'PUT',
path: '/api/0.6/changeset/' + changesetId + '/close',
}, function (err, response) {
if (response == null) {
console.log("err", err);
}
console.log("Closed changeset ", changesetId)
if (continuation !== undefined) {
continuation();
}
});
}
private OpenChangeset(
layout : LayoutConfig,
layout: LayoutConfig,
continuation: (changesetId: string) => void) {
const commentExtra = layout.changesetmessage !== undefined ? " - " + layout.changesetmessage : "";
let surveySource = "";
if (State.state.currentGPSLocation.data !== undefined) {
surveySource = '<tag k="source" v="survey"/>'
}
let path = window.location.pathname;
path = path.substr(1, path.lastIndexOf("/"));
const metadata = [
["created_by", `MapComplete ${Constants.vNumber}`],
["comment", `Adding data with #MapComplete for theme #${layout.id}${commentExtra}`],
["theme", layout.id],
["language", Locale.language.data],
["host", window.location.host],
["path", path],
["source", State.state.currentGPSLocation.data !== undefined ? "survey" : undefined],
["imagery", State.state.backgroundLayer.data.id],
["theme-creator", layout.maintainer]
]
.filter(kv => (kv[1] ?? "") !== "")
.map(kv => `<tag k="${kv[0]}" v="${escapeHtml(kv[1])}"/>`)
.join("\n")
this.auth.xhr({
method: 'PUT',
path: '/api/0.6/changeset/create',
options: {header: {'Content-Type': 'text/xml'}},
content: [`<osm><changeset>`,
`<tag k="created_by" v="MapComplete ${Constants.vNumber}" />`,
`<tag k="comment" v="Adding data with #MapComplete for theme #${layout.id}${commentExtra}"/>`,
`<tag k="theme" v="${layout.id}"/>`,
`<tag k="language" v="${Locale.language.data}"/>`,
`<tag k="host" v="${escapeHtml(window.location.host)}"/>`,
`<tag k="imagery" v="${State.state.backgroundLayer.data.id}"/>`,
surveySource,
(layout.maintainer ?? "") !== "" ? `<tag k="theme-creator" v="${escapeHtml(layout.maintainer)}"/>` : "",
metadata,
`</changeset></osm>`].join("")
}, function (err, response) {
if (response === undefined) {
@ -147,52 +196,5 @@ export class ChangesetHandler {
});
}
public CloseChangeset(changesetId: string = undefined, continuation: (() => void) = () => {
}) {
if (changesetId === undefined) {
changesetId = this.currentChangeset.data;
}
if (changesetId === undefined) {
return;
}
console.log("closing changeset", changesetId);
this.currentChangeset.setData("");
this.auth.xhr({
method: 'PUT',
path: '/api/0.6/changeset/' + changesetId + '/close',
}, function (err, response) {
if (response == null) {
console.log("err", err);
}
console.log("Closed changeset ", changesetId)
if (continuation !== undefined) {
continuation();
}
});
}
private static parseUploadChangesetResponse(response: XMLDocument, allElements: ElementStorage) {
const nodes = response.getElementsByTagName("node");
// @ts-ignore
for (const node of nodes) {
const oldId = parseInt(node.attributes.old_id.value);
const newId = parseInt(node.attributes.new_id.value);
if (oldId !== undefined && newId !== undefined &&
!isNaN(oldId) && !isNaN(newId)) {
if(oldId == newId){
continue;
}
console.log("Rewriting id: ", oldId, "-->", newId);
const element = allElements.getEventSourceById("node/" + oldId);
element.data.id = "node/" + newId;
allElements.addElementById("node/" + newId, element);
element.ping();
}
}
}
}

View file

@ -39,6 +39,7 @@ export class OsmConnection {
}
public auth;
public userDetails: UIEventSource<UserDetails>;
public isLoggedIn: UIEventSource<boolean>
_dryRun: boolean;
public preferencesHandler: OsmPreferences;
public changesetHandler: ChangesetHandler;
@ -64,6 +65,14 @@ export class OsmConnection {
this.userDetails = new UIEventSource<UserDetails>(new UserDetails(), "userDetails");
this.userDetails.data.dryRun = dryRun;
const self =this;
this.isLoggedIn = this.userDetails.map(user => user.loggedIn).addCallback(isLoggedIn => {
if(self.userDetails.data.loggedIn == false && isLoggedIn == true){
// We have an inconsistency: the userdetails say we _didn't_ log in, but this actor says we do
// This means someone attempted to toggle this; so we attempt to login!
self.AttemptLogin()
}
});
this._dryRun = dryRun;
this.updateAuthObject();
@ -215,14 +224,15 @@ export class OsmConnection {
});
}
private CheckForMessagesContinuously() {
const self = this;
window.setTimeout(() => {
if (self.userDetails.data.loggedIn) {
console.log("Checking for messages")
this.AttemptLogin();
}
}, 5 * 60 * 1000);
private CheckForMessagesContinuously(){
const self =this;
UIEventSource.Chronic(5 * 60 * 1000).addCallback(_ => {
if (self.isLoggedIn .data) {
console.log("Checking for messages")
self.AttemptLogin();
}
});
}

View file

@ -5,13 +5,15 @@ import {Tag} from "./Tags/Tag";
import {Or} from "./Tags/Or";
import {Utils} from "../Utils";
import opening_hours from "opening_hours";
import {UIElement} from "../UI/UIElement";
import Combine from "../UI/Base/Combine";
import BaseUIElement from "../UI/BaseUIElement";
import Title from "../UI/Base/Title";
import {FixedUiElement} from "../UI/Base/FixedUiElement";
const cardinalDirections = {
N: 0, NNE: 22.5, NE: 45, ENE: 67.5,
E: 90, ESE: 112.5, SE: 135, SSE: 157.5,
N: 0, NNE: 22.5, NE: 45, ENE: 67.5,
E: 90, ESE: 112.5, SE: 135, SSE: 157.5,
S: 180, SSW: 202.5, SW: 225, WSW: 247.5,
W: 270, WNW: 292.5, NW: 315, NNW: 337.5
}
@ -31,20 +33,20 @@ export default class SimpleMetaTagger {
(feature) => {/*Note: also handled by 'UpdateTagsFromOsmAPI'*/
const tgs = feature.properties;
function move(src: string, target: string){
if(tgs[src] === undefined){
function move(src: string, target: string) {
if (tgs[src] === undefined) {
return;
}
tgs[target] = tgs[src]
delete tgs[src]
}
move("user","_last_edit:contributor")
move("uid","_last_edit:contributor:uid")
move("changeset","_last_edit:changeset")
move("timestamp","_last_edit:timestamp")
move("version","_version_number")
move("user", "_last_edit:contributor")
move("uid", "_last_edit:contributor:uid")
move("changeset", "_last_edit:changeset")
move("timestamp", "_last_edit:timestamp")
move("version", "_version_number")
}
)
private static latlon = new SimpleMetaTagger({
@ -100,9 +102,13 @@ export default class SimpleMetaTagger {
SimpleMetaTagger.GetCountryCodeFor(lon, lat, (countries) => {
try {
const oldCountry = feature.properties["_country"];
feature.properties["_country"] = countries[0].trim().toLowerCase();
const tagsSource = State.state.allElements.getEventSourceById(feature.properties.id);
tagsSource.ping();
if (oldCountry !== feature.properties["_country"]) {
const tagsSource = State.state.allElements.getEventSourceById(feature.properties.id);
tagsSource.ping();
}
} catch (e) {
console.warn(e)
}
@ -375,28 +381,27 @@ export default class SimpleMetaTagger {
SimpleMetaTagger.coder?.GetCountryCodeFor(lon, lat, callback)
}
static HelpText(): UIElement {
const subElements: UIElement[] = [
static HelpText(): BaseUIElement {
const subElements: (string | BaseUIElement)[] = [
new Combine([
"<h2>Metatags</h2>",
"<p>Metatags are extra tags available, in order to display more data or to give better questions.</p>",
"<p>The are calculated automatically on every feature when the data arrives in the webbrowser. This document gives an overview of the available metatags.</p>",
"<p><b>Hint:</b> when using metatags, add the <a href='URL_Parameters.md'>query parameter</a> <code>debug=true</code> to the URL. This will include a box in the popup for features which shows all the properties of the object</p>"
])
new Title("Metatags", 1),
"Metatags are extra tags available, in order to display more data or to give better questions.",
"The are calculated automatically on every feature when the data arrives in the webbrowser. This document gives an overview of the available metatags.",
"**Hint:** when using metatags, add the [query parameter](URL_Parameters.md) `debug=true` to the URL. This will include a box in the popup for features which shows all the properties of the object"
]).SetClass("flex-col")
];
subElements.push(new Title("Metatags calculated by MapComplete", 2))
subElements.push(new FixedUiElement("The following values are always calculated, by default, by MapComplete and are available automatically on all elements in every theme"))
for (const metatag of SimpleMetaTagger.metatags) {
subElements.push(
new Combine([
"<h3>", metatag.keys.join(", "), "</h3>",
metatag.doc]
)
new Title(metatag.keys.join(", "), 3),
metatag.doc
)
}
return new Combine(subElements)
return new Combine(subElements).SetClass("flex-col")
}
addMetaTags(features: { feature: any, freshness: Date }[]) {

View file

@ -92,9 +92,16 @@ export class UIEventSource<T> {
}
}
public map<J>(f: ((T) => J),
/**
* Monoidal map:
* Given a function 'f', will construct a new UIEventSource where the contents will always be "f(this.data)'
* @param f: The transforming function
* @param extraSources: also trigger the update if one of these sources change
* @param g: a 'backfunction to let the sync run in two directions. (data of the new UIEVEntSource, currentData) => newData
*/
public map<J>(f: ((t: T) => J),
extraSources: UIEventSource<any>[] = [],
g: ((J) => T) = undefined): UIEventSource<J> {
g: ((j:J, t:T) => T) = undefined): UIEventSource<J> {
const self = this;
const newSource = new UIEventSource<J>(
@ -113,7 +120,7 @@ export class UIEventSource<T> {
if (g !== undefined) {
newSource.addCallback((latest) => {
self.setData(g(latest));
self.setData(g(latest, self.data));
})
}

View file

@ -0,0 +1,29 @@
import {UIEventSource} from "../UIEventSource";
import {LicenseInfo} from "./Wikimedia";
import BaseUIElement from "../../UI/BaseUIElement";
export default abstract class ImageAttributionSource {
private _cache = new Map<string, UIEventSource<LicenseInfo>>()
GetAttributionFor(url: string): UIEventSource<LicenseInfo> {
const cached = this._cache.get(url);
if (cached !== undefined) {
return cached;
}
const src = this.DownloadAttribution(url)
this._cache.set(url, src)
return src;
}
public abstract SourceIcon(backlinkSource?: string) : BaseUIElement;
protected abstract DownloadAttribution(url: string): UIEventSource<LicenseInfo>;
public PrepareUrl(value: string): string{
return value;
}
}

View file

@ -1,20 +1,25 @@
// @ts-ignore
import $ from "jquery"
import {LicenseInfo} from "./Wikimedia";
import ImageAttributionSource from "./ImageAttributionSource";
import {UIEventSource} from "../UIEventSource";
import BaseUIElement from "../../UI/BaseUIElement";
export class Imgur {
export class Imgur extends ImageAttributionSource {
public static readonly singleton = new Imgur();
private constructor() {
super();
}
static uploadMultiple(
title: string, description: string, blobs: FileList,
handleSuccessfullUpload: ((imageURL: string) => void),
allDone: (() => void),
onFail: ((reason: string) => void),
offset:number) {
offset: number = 0) {
if(offset === undefined){
throw "Offset undefined - not uploading to prevent to much uploads!"
}
if (blobs.length == offset) {
allDone();
return;
@ -35,55 +40,11 @@ export class Imgur {
);
}
static getDescriptionOfImage(url: string,
handleDescription: ((license: LicenseInfo) => void)) {
const hash = url.substr("https://i.imgur.com/".length).split(".jpg")[0];
const apiUrl = 'https://api.imgur.com/3/image/'+hash;
const apiKey = '7070e7167f0a25a';
const settings = {
async: true,
crossDomain: true,
processData: false,
contentType: false,
type: 'GET',
url: apiUrl,
headers: {
Authorization: 'Client-ID ' + apiKey,
Accept: 'application/json',
},
};
// @ts-ignore
$.ajax(settings).done(function (response) {
const descr: string = response.data.description ?? "";
const data: any = {};
for (const tag of descr.split("\n")) {
const kv = tag.split(":");
const k = kv[0];
const v = kv[1].replace("\r", "");
data[k] = v;
}
const licenseInfo = new LicenseInfo();
licenseInfo.licenseShortName = data.license;
licenseInfo.artist = data.author;
handleDescription(licenseInfo);
}).fail((reason) => {
console.log("Getting metadata from to IMGUR failed", reason)
});
}
static uploadImage(title: string, description: string, blob,
handleSuccessfullUpload: ((imageURL: string) => void),
onFail: (reason:string) => void) {
onFail: (reason: string) => void) {
const apiUrl = 'https://api.imgur.com/3/image';
const apiKey = '7070e7167f0a25a';
@ -121,4 +82,55 @@ export class Imgur {
});
}
SourceIcon(): BaseUIElement {
return undefined;
}
protected DownloadAttribution(url: string): UIEventSource<LicenseInfo> {
const src = new UIEventSource<LicenseInfo>(undefined)
const hash = url.substr("https://i.imgur.com/".length).split(".jpg")[0];
const apiUrl = 'https://api.imgur.com/3/image/' + hash;
const apiKey = '7070e7167f0a25a';
const settings = {
async: true,
crossDomain: true,
processData: false,
contentType: false,
type: 'GET',
url: apiUrl,
headers: {
Authorization: 'Client-ID ' + apiKey,
Accept: 'application/json',
},
};
// @ts-ignore
$.ajax(settings).done(function (response) {
const descr: string = response.data.description ?? "";
const data: any = {};
for (const tag of descr.split("\n")) {
const kv = tag.split(":");
const k = kv[0];
data[k] = kv[1].replace("\r", "");
}
const licenseInfo = new LicenseInfo();
licenseInfo.licenseShortName = data.license;
licenseInfo.artist = data.author;
src.setData(licenseInfo)
}).fail((reason) => {
console.log("Getting metadata from to IMGUR failed", reason)
});
return src;
}
}

View file

@ -0,0 +1,41 @@
import {UIEventSource} from "../UIEventSource";
import {Imgur} from "./Imgur";
export default class ImgurUploader {
public readonly queue: UIEventSource<string[]> = new UIEventSource<string[]>([]);
public readonly failed: UIEventSource<string[]> = new UIEventSource<string[]>([]);
public readonly success: UIEventSource<string[]> = new UIEventSource<string[]>([]);
private readonly _handleSuccessUrl: (string) => void;
constructor(handleSuccessUrl: (string) => void) {
this._handleSuccessUrl = handleSuccessUrl;
}
public uploadMany(title: string, description: string, files: FileList) {
for (let i = 0; i < files.length; i++) {
this.queue.data.push(files.item(i).name)
}
this.queue.ping()
const self = this;
this.queue.setData([...self.queue.data])
Imgur.uploadMultiple(title,
description,
files,
function (url) {
console.log("File saved at", url);
self.success.setData([...self.success.data, url]);
self._handleSuccessUrl(url);
},
function () {
console.log("All uploads completed");
},
function (failReason) {
console.log("Upload failed due to ", failReason)
self.failed.setData([...self.failed.data, failReason])
}
);
}
}

View file

@ -1,26 +1,57 @@
import $ from "jquery"
import {LicenseInfo} from "./Wikimedia";
import ImageAttributionSource from "./ImageAttributionSource";
import BaseUIElement from "../../UI/BaseUIElement";
import {UIEventSource} from "../UIEventSource";
import Svg from "../../Svg";
export class Mapillary {
export class Mapillary extends ImageAttributionSource {
public static readonly singleton = new Mapillary();
static getDescriptionOfImage(key: string,
handleDescription: ((license: LicenseInfo) => void)) {
const url = `https://a.mapillary.com/v3/images/${key}?client_id=TXhLaWthQ1d4RUg0czVxaTVoRjFJZzowNDczNjUzNmIyNTQyYzI2`
private constructor() {
super();
}
const settings = {
async: true,
type: 'GET',
url: url
};
$.getJSON(url, function(data) {
private static ExtractKeyFromURL(value: string) {
if (value.startsWith("https://a.mapillary.com")) {
return value.substring(0, value.lastIndexOf("?")).substring(value.lastIndexOf("/") + 1);
}
const matchApi = value.match(/https?:\/\/images.mapillary.com\/([^/]*)/)
if (matchApi !== null) {
return matchApi[1];
}
if (value.toLowerCase().startsWith("https://www.mapillary.com/map/im/")) {
// Extract the key of the image
value = value.substring("https://www.mapillary.com/map/im/".length);
}
return value;
}
SourceIcon(backlinkSource?: string): BaseUIElement {
return Svg.mapillary_svg();
}
PrepareUrl(value: string): string {
const key = Mapillary.ExtractKeyFromURL(value)
return `https://images.mapillary.com/${key}/thumb-640.jpg?client_id=TXhLaWthQ1d4RUg0czVxaTVoRjFJZzowNDczNjUzNmIyNTQyYzI2`
}
protected DownloadAttribution(url: string): UIEventSource<LicenseInfo> {
const key = Mapillary.ExtractKeyFromURL(url)
const metadataURL = `https://a.mapillary.com/v3/images/${key}?client_id=TXhLaWthQ1d4RUg0czVxaTVoRjFJZzowNDczNjUzNmIyNTQyYzI2`
const source = new UIEventSource<LicenseInfo>(undefined)
$.getJSON(metadataURL, function (data) {
const license = new LicenseInfo();
license.artist = data.properties?.username;
license.licenseShortName = "CC BY-SA 4.0";
license.license = "Creative Commons Attribution-ShareAlike 4.0 International License";
license.attributionRequired = true;
handleDescription(license);
source.setData(license);
})
return source
}
}

View file

@ -3,6 +3,9 @@
*/
import {UIEventSource} from "../UIEventSource";
import Hash from "./Hash";
import {Utils} from "../../Utils";
import Title from "../../UI/Base/Title";
import Combine from "../../UI/Base/Combine";
export class QueryParameters {
@ -12,6 +15,58 @@ export class QueryParameters {
private static defaults = {}
private static documentation = {}
private static QueryParamDocsIntro = "\n" +
"URL-parameters and URL-hash\n" +
"============================\n" +
"\n" +
"This document gives an overview of which URL-parameters can be used to influence MapComplete.\n" +
"\n" +
"What is a URL parameter?\n" +
"------------------------\n" +
"\n" +
"URL-parameters are extra parts of the URL used to set the state.\n" +
"\n" +
"For example, if the url is `https://mapcomplete.osm.be/cyclofix?lat=51.0&lon=4.3&z=5&test=true#node/1234`,\n" +
"the URL-parameters are stated in the part between the `?` and the `#`. There are multiple, all seperated by `&`, namely:\n" +
"\n" +
"- The url-parameter `lat` is `51.0` in this instance\n" +
"- The url-parameter `lon` is `4.3` in this instance\n" +
"- The url-parameter `z` is `5` in this instance\n" +
"- The url-parameter `test` is `true` in this instance\n" +
"\n" +
"Finally, the URL-hash is the part after the `#`. It is `node/1234` in this case."
public static GetQueryParameter(key: string, deflt: string, documentation?: string): UIEventSource<string> {
if (!this.initialized) {
this.init();
}
QueryParameters.documentation[key] = documentation;
if (deflt !== undefined) {
QueryParameters.defaults[key] = deflt;
}
if (QueryParameters.knownSources[key] !== undefined) {
return QueryParameters.knownSources[key];
}
QueryParameters.addOrder(key);
const source = new UIEventSource<string>(deflt, "&" + key);
QueryParameters.knownSources[key] = source;
source.addCallback(() => QueryParameters.Serialize())
return source;
}
public static GenerateQueryParameterDocs(): string {
const docs = [QueryParameters.QueryParamDocsIntro];
for (const key in QueryParameters.documentation) {
const c = new Combine([
new Title(key, 2),
QueryParameters.documentation[key],
QueryParameters.defaults[key] === undefined ? "No default value set" : `The default value is _${QueryParameters.defaults[key]}_`
])
docs.push(c.AsMarkdown())
}
return docs.join("\n\n");
}
private static addOrder(key) {
if (this.order.indexOf(key) < 0) {
@ -25,7 +80,11 @@ export class QueryParameters {
return;
}
this.initialized = true;
if (Utils.runningFromConsole) {
return;
}
if (window?.location?.search) {
const params = window.location.search.substr(1).split("&");
for (const param of params) {
@ -38,7 +97,7 @@ export class QueryParameters {
QueryParameters.knownSources[key] = source;
}
}
window["mapcomplete_query_parameter_overview"] = () => {
console.log(QueryParameters.GenerateQueryParameterDocs())
}
@ -50,7 +109,7 @@ export class QueryParameters {
if (QueryParameters.knownSources[key]?.data === undefined) {
continue;
}
if (QueryParameters.knownSources[key].data === "undefined") {
continue;
}
@ -62,41 +121,8 @@ export class QueryParameters {
parts.push(encodeURIComponent(key) + "=" + encodeURIComponent(QueryParameters.knownSources[key].data))
}
// Don't pollute the history every time a parameter changes
history.replaceState(null, "", "?" + parts.join("&") + Hash.Current());
}
public static GetQueryParameter(key: string, deflt: string, documentation?: string): UIEventSource<string> {
if(!this.initialized){
this.init();
}
QueryParameters.documentation[key] = documentation;
if (deflt !== undefined) {
QueryParameters.defaults[key] = deflt;
}
if (QueryParameters.knownSources[key] !== undefined) {
return QueryParameters.knownSources[key];
}
QueryParameters.addOrder(key);
const source = new UIEventSource<string>(deflt, "&"+key);
QueryParameters.knownSources[key] = source;
source.addCallback(() => QueryParameters.Serialize())
return source;
}
public static GenerateQueryParameterDocs(): string {
const docs = [];
for (const key in QueryParameters.documentation) {
docs.push([
" "+key+" ",
"-".repeat(key.length + 2),
QueryParameters.documentation[key],
QueryParameters.defaults[key] === undefined ? "No default value set" : `The default value is _${QueryParameters.defaults[key]}_`
].join("\n"))
}
return docs.join("\n\n");
}
}

View file

@ -1,47 +1,28 @@
import * as $ from "jquery"
import ImageAttributionSource from "./ImageAttributionSource";
import BaseUIElement from "../../UI/BaseUIElement";
import Svg from "../../Svg";
import {UIEventSource} from "../UIEventSource";
import Link from "../../UI/Base/Link";
/**
* This module provides endpoints for wikipedia/wikimedia and others
*/
export class Wikimedia {
export class Wikimedia extends ImageAttributionSource {
public static readonly singleton = new Wikimedia();
private constructor() {
super();
}
private static knownLicenses = {};
static ImageNameToUrl(filename: string, width: number = 500, height: number = 200): string {
filename = encodeURIComponent(filename);
return "https://commons.wikimedia.org/wiki/Special:FilePath/" + filename + "?width=" + width + "&height=" + height;
}
static LicenseData(filename: string, handle: ((LicenseInfo) => void)): void {
if (filename in this.knownLicenses) {
return this.knownLicenses[filename];
}
if (filename === "") {
return;
}
const url = "https://en.wikipedia.org/w/" +
"api.php?action=query&prop=imageinfo&iiprop=extmetadata&" +
"titles=" + filename +
"&format=json&origin=*";
$.getJSON(url, function (data) {
const licenseInfo = new LicenseInfo();
const license = data.query.pages[-1].imageinfo[0].extmetadata;
licenseInfo.artist = license.Artist?.value;
licenseInfo.license = license.License?.value;
licenseInfo.copyrighted = license.Copyrighted?.value;
licenseInfo.attributionRequired = license.AttributionRequired?.value;
licenseInfo.usageTerms = license.UsageTerms?.value;
licenseInfo.licenseShortName = license.LicenseShortName?.value;
licenseInfo.credit = license.Credit?.value;
licenseInfo.description = license.ImageDescription?.value;
Wikimedia.knownLicenses[filename] = licenseInfo;
handle(licenseInfo);
});
}
static GetCategoryFiles(categoryName: string, handleCategory: ((ImagesInCategory: ImagesInCategory) => void),
alreadyLoaded = 0,
continueParameter: { k: string, param: string } = undefined) {
@ -111,6 +92,71 @@ export class Wikimedia {
});
}
private static ExtractFileName(url: string) {
if (!url.startsWith("http")) {
return url;
}
const path = new URL(url).pathname
return path.substring(path.lastIndexOf("/") + 1);
}
SourceIcon(backlink: string): BaseUIElement {
const img = Svg.wikimedia_commons_white_svg()
.SetStyle("width:2em;height: 2em");
if (backlink === undefined) {
return img
}
return new Link(Svg.wikimedia_commons_white_img,
`https://commons.wikimedia.org/wiki/${backlink}`, true)
}
PrepareUrl(value: string): string {
if (value.toLowerCase().startsWith("https://commons.wikimedia.org/wiki/")) {
return value;
}
return Wikimedia.ImageNameToUrl(value, 500, 400)
.replace(/'/g, '%27');
}
protected DownloadAttribution(filename: string): UIEventSource<LicenseInfo> {
const source = new UIEventSource<LicenseInfo>(undefined);
filename = Wikimedia.ExtractFileName(filename)
if (filename === "") {
return source;
}
const url = "https://en.wikipedia.org/w/" +
"api.php?action=query&prop=imageinfo&iiprop=extmetadata&" +
"titles=" + filename +
"&format=json&origin=*";
console.log("Getting attribution at ", url)
$.getJSON(url, function (data) {
const licenseInfo = new LicenseInfo();
const license = data.query.pages[-1].imageinfo[0].extmetadata;
licenseInfo.artist = license.Artist?.value;
licenseInfo.license = license.License?.value;
licenseInfo.copyrighted = license.Copyrighted?.value;
licenseInfo.attributionRequired = license.AttributionRequired?.value;
licenseInfo.usageTerms = license.UsageTerms?.value;
licenseInfo.licenseShortName = license.LicenseShortName?.value;
licenseInfo.credit = license.Credit?.value;
licenseInfo.description = license.ImageDescription?.value;
source.setData(licenseInfo);
});
return source;
}
}

View file

@ -2,11 +2,10 @@ import { Utils } from "../Utils";
export default class Constants {
public static vNumber = "0.7.5b";
public static vNumber = "0.8.0-rc0";
// The user journey states thresholds when a new feature gets unlocked
public static userJourney = {
addNewPointsUnlock: 0,
moreScreenUnlock: 1,
personalLayoutUnlock: 15,
historyLinkVisible: 20,

View file

@ -70,10 +70,6 @@ export default class State {
readonly layerDef: LayerConfig;
}[]>([])
/**
* The message that should be shown at the center of the screen
*/
public readonly centerMessage = new UIEventSource<string>("");
/**
The latest element that was selected
@ -106,6 +102,8 @@ export default class State {
*/
public readonly locationControl = new UIEventSource<Loc>(undefined);
public backgroundLayer;
public readonly backgroundLayerId: UIEventSource<string>;
/* Last location where a click was registered
*/
public readonly LastClickLocation: UIEventSource<{ lat: number, lon: number }> = new UIEventSource<{ lat: number, lon: number }>(undefined)
@ -127,7 +125,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>(
str => isNaN(Number(str)) ? 0 : Number(str), [], n => "" + n
);
constructor(layoutToUse: LayoutConfig) {
const self = this;
@ -214,8 +212,25 @@ export default class State {
"The OSM backend to use - can be used to redirect mapcomplete to the testing backend when using 'osm-test'")
}
{
// Some other feature switches
const customCssQP = QueryParameters.GetQueryParameter("custom-css", "", "If specified, the custom css from the given link will be loaded additionaly");
if (customCssQP.data !== undefined && customCssQP.data !== "") {
Utils.LoadCustomCss(customCssQP.data);
}
this.backgroundLayerId = QueryParameters.GetQueryParameter("background",
layoutToUse?.defaultBackgroundId ?? "osm",
"The id of the background layer to start with")
}
if(Utils.runningFromConsole){
return;
}
this.osmConnection = new OsmConnection(
this.featureSwitchIsTesting.data,
QueryParameters.GetQueryParameter("oauth_token", undefined,

126
Svg.ts

File diff suppressed because one or more lines are too long

View file

@ -1,38 +1,29 @@
import {UIElement} from "../UIElement";
import Locale from "../i18n/Locale";
import Translations from "../i18n/Translations";
import BaseUIElement from "../BaseUIElement";
export class Button extends UIElement {
private _text: UIElement;
export class Button extends BaseUIElement {
private _text: BaseUIElement;
private _onclick: () => void;
private _clss: string;
constructor(text: string | UIElement, onclick: (() => void), clss: string = "") {
super(Locale.language);
constructor(text: string | UIElement, onclick: (() => void)) {
super();
this._text = Translations.W(text);
this._onclick = onclick;
if (clss !== "") {
this._clss = "class='" + clss + "'";
}else{
this._clss = "";
}
}
InnerRender(): string {
return "<form>" +
"<button id='button-"+this.id+"' type='button' "+this._clss+">" + this._text.Render() + "</button>" +
"</form>";
}
InnerUpdate(htmlElement: HTMLElement) {
super.InnerUpdate(htmlElement);
const self = this;
document.getElementById("button-"+this.id).onclick = function(){
self._onclick();
protected InnerConstructElement(): HTMLElement {
const el = this._text.ConstructElement();
if(el === undefined){
return undefined;
}
const form = document.createElement("form")
const button = document.createElement("button")
button.type = "button"
button.appendChild(el)
button.onclick = this._onclick
form.appendChild(button)
return form;
}
}

View file

@ -1,11 +1,11 @@
import {UIElement} from "../UIElement";
import {FixedUiElement} from "./FixedUiElement";
import {Utils} from "../../Utils";
import BaseUIElement from "../BaseUIElement";
export default class Combine extends UIElement {
private readonly uiElements: UIElement[];
export default class Combine extends BaseUIElement {
private readonly uiElements: BaseUIElement[];
constructor(uiElements: (string | UIElement)[]) {
constructor(uiElements: (string | BaseUIElement)[]) {
super();
this.uiElements = Utils.NoNull(uiElements)
.map(el => {
@ -15,18 +15,33 @@ export default class Combine extends UIElement {
return el;
});
}
protected InnerConstructElement(): HTMLElement {
const el = document.createElement("span")
InnerRender(): string {
return this.uiElements.map(ui => {
if(ui === undefined || ui === null){
return "";
try{
for (const subEl of this.uiElements) {
if(subEl === undefined || subEl === null){
continue;
}
if(ui.Render === undefined){
console.error("Not a UI-element", ui);
return "";
const subHtml = subEl.ConstructElement()
if(subHtml !== undefined){
el.appendChild(subHtml)
}
return ui.Render();
}).join("");
}
}catch(e){
const domExc = e as DOMException
console.error("DOMException: ", domExc.name)
el.appendChild(new FixedUiElement("Could not generate this combine!").SetClass("alert").ConstructElement())
}
return el;
}
AsMarkdown(): string {
return this.uiElements.map(el => el.AsMarkdown()).join(this.HasClass("flex-col") ? "\n\n" : " ");
}
}

View file

@ -1,22 +0,0 @@
import {UIElement} from "../UIElement";
import {UIEventSource} from "../../Logic/UIEventSource";
export default class FeatureSwitched extends UIElement{
private readonly _upstream: UIElement;
private readonly _swtch: UIEventSource<boolean>;
constructor(upstream :UIElement,
swtch: UIEventSource<boolean>) {
super(swtch);
this._upstream = upstream;
this._swtch = swtch;
}
InnerRender(): string {
if(this._swtch.data){
return this._upstream.Render();
}
return "";
}
}

View file

@ -1,15 +1,25 @@
import {UIElement} from "../UIElement";
import BaseUIElement from "../BaseUIElement";
export class FixedUiElement extends UIElement {
export class FixedUiElement extends BaseUIElement {
private _html: string;
constructor(html: string) {
super(undefined);
super();
this._html = html ?? "";
}
InnerRender(): string {
return this._html;
}
protected InnerConstructElement(): HTMLElement {
const e = document.createElement("span")
e.innerHTML = this._html
return e;
}
AsMarkdown(): string {
return this._html;
}
}

View file

@ -1,19 +1,41 @@
import Constants from "../../Models/Constants";
import {Utils} from "../../Utils";
import BaseUIElement from "../BaseUIElement";
export default class Img {
export default class Img extends BaseUIElement {
private _src: string;
private readonly _rawSvg: boolean;
public static runningFromConsole = false;
constructor(src: string, rawSvg = false) {
super();
this._src = src;
this._rawSvg = rawSvg;
}
static AsData(source:string){
if(Utils.runningFromConsole){
return source;
}
return `data:image/svg+xml;base64,${(btoa(source))}`;
}
static AsData(source: string) {
if (Utils.runningFromConsole) {
return source;
}
return `data:image/svg+xml;base64,${(btoa(source))}`;
}
static AsImageElement(source: string, css_class: string = "", style=""): string{
static AsImageElement(source: string, css_class: string = "", style = ""): string {
return `<img class="${css_class}" style="${style}" alt="" src="${Img.AsData(source)}">`;
}
protected InnerConstructElement(): HTMLElement {
if (this._rawSvg) {
const e = document.createElement("div")
e.innerHTML = this._src
return e;
}
const el = document.createElement("img")
el.src = this._src;
el.onload = () => {
el.style.opacity = "1"
}
return el;
}
}

View file

@ -1,36 +0,0 @@
import {UIElement} from "../UIElement";
export default class LazyElement extends UIElement {
public Activate: () => void;
private _content: UIElement = undefined;
private readonly _loadingContent: string;
constructor(content: (() => UIElement), loadingContent = "Rendering...") {
super();
this._loadingContent = loadingContent;
this.dumbMode = false;
const self = this;
this.Activate = () => {
if (this._content === undefined) {
self._content = content();
}
self.Update();
// @ts-ignore
if (this._content.Activate) {
// THis is ugly - I know
// @ts-ignore
this._content.Activate();
}
}
}
InnerRender(): string {
if (this._content === undefined) {
return this._loadingContent;
}
return this._content.Render();
}
}

View file

@ -1,24 +1,44 @@
import {UIElement} from "../UIElement";
import Translations from "../i18n/Translations";
import BaseUIElement from "../BaseUIElement";
import {UIEventSource} from "../../Logic/UIEventSource";
export default class Link extends UIElement {
private readonly _embeddedShow: UIElement;
private readonly _target: string;
private readonly _newTab: string;
export default class Link extends BaseUIElement {
private readonly _href: string | UIEventSource<string>;
private readonly _embeddedShow: BaseUIElement;
private readonly _newTab: boolean;
constructor(embeddedShow: UIElement | string, target: string, newTab: boolean = false) {
constructor(embeddedShow: BaseUIElement | string, href: string | UIEventSource<string>, newTab: boolean = false) {
super();
this._embeddedShow = Translations.W(embeddedShow);
this._target = target;
this._newTab = "";
if (newTab) {
this._newTab = "target='_blank'"
}
this._embeddedShow =Translations.W(embeddedShow);
this._href = href;
this._newTab = newTab;
}
InnerRender(): string {
return `<a href="${this._target}" ${this._newTab}>${this._embeddedShow.Render()}</a>`;
protected InnerConstructElement(): HTMLElement {
const embeddedShow = this._embeddedShow?.ConstructElement();
if(embeddedShow === undefined){
return undefined;
}
const el = document.createElement("a")
if(typeof this._href === "string"){
el.href = this._href
}else{
this._href.addCallbackAndRun(href => {
el.href = href;
})
}
if (this._newTab) {
el.target = "_blank"
}
el.appendChild(embeddedShow)
return el;
}
AsMarkdown(): string {
// @ts-ignore
return `[${this._embeddedShow.AsMarkdown()}](${this._href.data ?? this._href})`;
}
}

43
UI/Base/List.ts Normal file
View file

@ -0,0 +1,43 @@
import {Utils} from "../../Utils";
import BaseUIElement from "../BaseUIElement";
import Translations from "../i18n/Translations";
export default class List extends BaseUIElement {
private readonly uiElements: BaseUIElement[];
private readonly _ordered: boolean;
constructor(uiElements: (string | BaseUIElement)[], ordered = false) {
super();
this._ordered = ordered;
this.uiElements = Utils.NoNull(uiElements)
.map(Translations.W);
}
protected InnerConstructElement(): HTMLElement {
const el = document.createElement(this._ordered ? "ol" : "ul")
for (const subEl of this.uiElements) {
if(subEl === undefined || subEl === null){
continue;
}
const subHtml = subEl.ConstructElement()
if(subHtml !== undefined){
const item = document.createElement("li")
item.appendChild(subHtml)
el.appendChild(item)
}
}
return el;
}
AsMarkdown(): string {
if(this._ordered){
return "\n\n"+this.uiElements.map((el, i) => " "+i+". "+el.AsMarkdown().replace(/\n/g, ' \n') ).join("\n") + "\n"
}else{
return "\n\n"+this.uiElements.map(el => " - "+el.AsMarkdown().replace(/\n/g, ' \n') ).join("\n")+"\n"
}
}
}

View file

@ -1,7 +1,4 @@
import {UIElement} from "../UIElement";
import {UIEventSource} from "../../Logic/UIEventSource";
import Svg from "../../Svg";
import State from "../../State";
export default class Ornament extends UIElement {

View file

@ -1,20 +0,0 @@
import {UIElement} from "../UIElement";
export default class PageSplit extends UIElement{
private _left: UIElement;
private _right: UIElement;
private _leftPercentage: number;
constructor(left: UIElement, right:UIElement,
leftPercentage: number = 50) {
super();
this._left = left;
this._right = right;
this._leftPercentage = leftPercentage;
}
InnerRender(): string {
return `<span class="page-split" style="height: min-content"><span style="flex:0 0 ${this._leftPercentage}%">${this._left.Render()}</span><span style="flex: 0 0 ${100-this._leftPercentage}%">${this._right.Render()}</span></span>`;
}
}

View file

@ -5,20 +5,27 @@ import Ornament from "./Ornament";
import {FixedUiElement} from "./FixedUiElement";
import {UIEventSource} from "../../Logic/UIEventSource";
import Hash from "../../Logic/Web/Hash";
import BaseUIElement from "../BaseUIElement";
/**
* Wraps some contents into a panel that scrolls the content _under_ the title
*
* The scrollableFullScreen is a bit of a peculiar component:
* - It shows a title and some contents, constructed from the respective functions passed into the constructor
* - When the element is 'activated', one clone of title+contents is attached to the fullscreen
* - The element itself will - upon rendering - also show the title and contents (allthough it'll be a different clone)
*
*
*/
export default class ScrollableFullScreen extends UIElement {
private static readonly empty = new FixedUiElement("");
public isShown: UIEventSource<boolean>;
private _component: UIElement;
private _fullscreencomponent: UIElement;
private _component: BaseUIElement;
private _fullscreencomponent: BaseUIElement;
private static readonly _actor = ScrollableFullScreen.InitActor();
private _hashToSet: string;
private static _currentlyOpen : ScrollableFullScreen;
constructor(title: ((mode: string) => UIElement), content: ((mode: string) => UIElement),
constructor(title: ((mode: string) => BaseUIElement), content: ((mode: string) => BaseUIElement),
hashToSet: string,
isShown: UIEventSource<boolean> = new UIEventSource<boolean>(false)
) {
@ -29,7 +36,6 @@ export default class ScrollableFullScreen extends UIElement {
this._component = this.BuildComponent(title("desktop"), content("desktop"), isShown)
.SetClass("hidden md:block");
this._fullscreencomponent = this.BuildComponent(title("mobile"), content("mobile"), isShown);
this.dumbMode = false;
const self = this;
isShown.addCallback(isShown => {
if (isShown) {
@ -40,8 +46,8 @@ export default class ScrollableFullScreen extends UIElement {
})
}
InnerRender(): string {
return this._component.Render();
InnerRender(): BaseUIElement {
return this._component;
}
Activate(): void {
@ -55,7 +61,7 @@ export default class ScrollableFullScreen extends UIElement {
fs.classList.remove("hidden")
}
private BuildComponent(title: UIElement, content: UIElement, isShown: UIEventSource<boolean>) {
private BuildComponent(title: BaseUIElement, content:BaseUIElement, isShown: UIEventSource<boolean>) {
const returnToTheMap =
new Combine([
Svg.back_svg().SetClass("block md:hidden"),

View file

@ -1,57 +1,58 @@
import {UIElement} from "../UIElement";
import Translations from "../i18n/Translations";
import Combine from "./Combine";
import {FixedUiElement} from "./FixedUiElement";
import BaseUIElement from "../BaseUIElement";
import Link from "./Link";
import Img from "./Img";
import {UIEventSource} from "../../Logic/UIEventSource";
import {UIElement} from "../UIElement";
export class SubtleButton extends Combine {
export class SubtleButton extends UIElement {
constructor(imageUrl: string | UIElement, message: string | UIElement, linkTo: { url: string, newTab?: boolean } = undefined) {
super(SubtleButton.generateContent(imageUrl, message, linkTo));
private readonly _element: BaseUIElement
constructor(imageUrl: string | BaseUIElement, message: string | BaseUIElement, linkTo: { url: string | UIEventSource<string>, newTab?: boolean } = undefined) {
super();
this._element = SubtleButton.generateContent(imageUrl, message, linkTo)
this.SetClass("block flex p-3 my-2 bg-blue-100 rounded-lg hover:shadow-xl hover:bg-blue-200 link-no-underline")
}
private static generateContent(imageUrl: string | UIElement, messageT: string | UIElement, linkTo: { url: string, newTab?: boolean } = undefined): (UIElement | string)[] {
private static generateContent(imageUrl: string | BaseUIElement, messageT: string | BaseUIElement, linkTo: { url: string | UIEventSource<string>, newTab?: boolean } = undefined): BaseUIElement {
const message = Translations.W(messageT);
if (message !== null) {
message.dumbMode = false;
}
message
let img;
if ((imageUrl ?? "") === "") {
img = new FixedUiElement("");
img = undefined;
} else if (typeof (imageUrl) === "string") {
img = new FixedUiElement(`<img style="width: 100%;" src="${imageUrl}" alt="">`);
img = new Img(imageUrl)
} else {
img = imageUrl;
}
img.SetClass("block flex items-center justify-center h-11 w-11 flex-shrink0")
img?.SetClass("block flex items-center justify-center h-11 w-11 flex-shrink0 mr-4")
const image = new Combine([img])
.SetClass("flex-shrink-0");
if (message !== null && message.IsEmpty()) {
// Message == null: special case to force empty text
return [];
}
if (linkTo != undefined) {
return [
`<a class='flex group' href="${linkTo.url}" ${linkTo.newTab ? 'target="_blank"' : ""}>`,
if (linkTo == undefined) {
return new Combine([
image,
`<div class='ml-4 overflow-ellipsis'>`,
message,
`</div>`,
`</a>`
];
message?.SetClass("block overflow-ellipsis"),
]).SetClass("flex group w-full");
}
return [
image,
message,
];
return new Link(
new Combine([
image,
message?.SetClass("block overflow-ellipsis")
]).SetClass("flex group w-full"),
linkTo.url,
linkTo.newTab ?? false
)
}
protected InnerRender(): string | BaseUIElement {
return this._element;
}

View file

@ -1,39 +1,42 @@
import {UIElement} from "../UIElement";
import Translations from "../i18n/Translations";
import {UIEventSource} from "../../Logic/UIEventSource";
import Combine from "./Combine";
import BaseUIElement from "../BaseUIElement";
import {VariableUiElement} from "./VariableUIElement";
export class TabbedComponent extends UIElement {
export class TabbedComponent extends Combine {
private headers: UIElement[] = [];
private content: UIElement[] = [];
constructor(elements: { header: BaseUIElement | string, content: BaseUIElement | string }[], openedTab: (UIEventSource<number> | number) = 0) {
constructor(elements: { header: UIElement | string, content: UIElement | string }[], openedTab: (UIEventSource<number> | number) = 0) {
super(typeof (openedTab) === "number" ? new UIEventSource(openedTab) : (openedTab ?? new UIEventSource<number>(0)));
const self = this;
const openedTabSrc = typeof (openedTab) === "number" ? new UIEventSource(openedTab) : (openedTab ?? new UIEventSource<number>(0))
const tabs: BaseUIElement[] = []
const contentElements: BaseUIElement[] = [];
for (let i = 0; i < elements.length; i++) {
let element = elements[i];
this.headers.push(Translations.W(element.header).onClick(() => self._source.setData(i)));
const header = Translations.W(element.header).onClick(() => openedTabSrc.setData(i))
openedTabSrc.addCallbackAndRun(selected => {
if(selected === i){
header.SetClass("tab-active")
header.RemoveClass("tab-non-active")
}else{
header.SetClass("tab-non-active")
header.RemoveClass("tab-active")
}
})
const content = Translations.W(element.content)
this.content.push(content);
}
}
InnerRender(): string {
let headerBar = "";
for (let i = 0; i < this.headers.length; i++) {
let header = this.headers[i];
if (!this.content[i].IsEmpty()) {
headerBar += `<div class=\'tab-single-header ${i == this._source.data ? 'tab-active' : 'tab-non-active'}\'>` +
header.Render() + "</div>"
}
content.SetClass("relative p-4 w-full inline-block")
contentElements.push(content);
const tab = header.SetClass("block tab-single-header")
tabs.push(tab)
}
const header = new Combine(tabs).SetClass("block tabs-header-bar")
const actualContent = new VariableUiElement(
openedTabSrc.map(i => contentElements[i])
)
super([header, actualContent])
headerBar = "<div class='tabs-header-bar'>" + headerBar + "</div>"
const content = this.content[this._source.data];
return headerBar + "<div class='tab-content'>" + (content?.Render() ?? "") + "</div>";
}
}

71
UI/Base/Table.ts Normal file
View file

@ -0,0 +1,71 @@
import BaseUIElement from "../BaseUIElement";
import {Utils} from "../../Utils";
import Translations from "../i18n/Translations";
export default class Table extends BaseUIElement {
private readonly _header: BaseUIElement[];
private readonly _contents: BaseUIElement[][];
private readonly _contentStyle: string[][];
constructor(header: (BaseUIElement | string)[],
contents: (BaseUIElement | string)[][],
contentStyle?: string[][]) {
super();
this._contentStyle = contentStyle ?? [];
this._header = header?.map(Translations.W);
this._contents = contents.map(row => row.map(Translations.W));
}
protected InnerConstructElement(): HTMLElement {
const table = document.createElement("table")
const headerElems = Utils.NoNull((this._header ?? []).map(elems => elems.ConstructElement()))
if (headerElems.length > 0) {
const tr = document.createElement("tr");
headerElems.forEach(headerElem => {
const td = document.createElement("th")
td.appendChild(headerElem)
tr.appendChild(td)
})
table.appendChild(tr)
}
for (let i = 0; i < this._contents.length; i++){
let row = this._contents[i];
const tr = document.createElement("tr")
for (let j = 0; j < row.length; j++){
let elem = row[j];
const htmlElem = elem?.ConstructElement()
if (htmlElem === undefined) {
continue;
}
let style = undefined;
if(this._contentStyle !== undefined && this._contentStyle[i] !== undefined && this._contentStyle[j]!== undefined){
style = this._contentStyle[i][j]
}
const td = document.createElement("td")
td.style.cssText = style;
td.appendChild(htmlElem)
tr.appendChild(td)
}
table.appendChild(tr)
}
return table;
}
AsMarkdown(): string {
const headerMarkdownParts = this._header.map(hel => hel?.AsMarkdown() ?? " ")
const header =headerMarkdownParts.join(" | ");
const headerSep = headerMarkdownParts.map(part => '-'.repeat(part.length + 2)).join("|")
const table = this._contents.map(row => row.map(el => el.AsMarkdown()?? " ").join("|")).join("\n")
return [header, headerSep, table, ""].join("\n")
}
}

37
UI/Base/Title.ts Normal file
View file

@ -0,0 +1,37 @@
import {UIElement} from "../UIElement";
import BaseUIElement from "../BaseUIElement";
import Translations from "../i18n/Translations";
export default class Title extends BaseUIElement{
private readonly _embedded: BaseUIElement;
private readonly _level: number;
constructor(embedded: string | BaseUIElement, level: number =3 ) {
super()
this._embedded = Translations.W(embedded);
this._level = level;
}
protected InnerConstructElement(): HTMLElement {
const el = this._embedded.ConstructElement()
if(el === undefined){
return undefined;
}
const h = document.createElement("h"+this._level)
h.appendChild(el)
return h;
}
AsMarkdown(): string {
const embedded = " " +this._embedded.AsMarkdown()+" ";
if(this._level == 1){
return "\n"+embedded+"\n"+"=".repeat(embedded.length)+"\n\n"
}
if(this._level == 2){
return "\n"+embedded+"\n"+"-".repeat(embedded.length)+"\n\n"
}
return "\n"+"#".repeat( this._level)+embedded +"\n\n";
}
}

View file

@ -1,16 +1,46 @@
import {UIElement} from "../UIElement";
import {UIEventSource} from "../../Logic/UIEventSource";
import BaseUIElement from "../BaseUIElement";
export class VariableUiElement extends UIElement {
private _html: UIEventSource<string>;
export class VariableUiElement extends BaseUIElement {
constructor(html: UIEventSource<string>) {
super(html);
this._html = html;
private _element : HTMLElement;
constructor(contents: UIEventSource<string | BaseUIElement | BaseUIElement[]>) {
super();
this._element = document.createElement("span")
const el = this._element
contents.addCallbackAndRun(contents => {
while (el.firstChild) {
el.removeChild(
el.lastChild
)
}
if (contents === undefined) {
return el;
}
if (typeof contents === "string") {
el.innerHTML = contents
} else if (contents instanceof Array) {
for (const content of contents) {
const c = content.ConstructElement();
if (c !== undefined && c !== null) {
el.appendChild(c)
}
}
} else {
const c = contents.ConstructElement();
if (c !== undefined && c !== null) {
el.appendChild(c)
}
}
})
}
InnerRender(): string {
return this._html.data;
protected InnerConstructElement(): HTMLElement {
return this._element;
}
}

View file

@ -1,20 +0,0 @@
import {UIElement} from "../UIElement";
export class VerticalCombine extends UIElement {
private readonly _elements: UIElement[];
constructor(elements: UIElement[]) {
super(undefined);
this._elements = elements;
}
InnerRender(): string {
let html = "";
for (const element of this._elements) {
if (element !== undefined && !element.IsEmpty()) {
html += "<div>" + element.Render() + "</div>";
}
}
return html;
}
}

166
UI/BaseUIElement.ts Normal file
View file

@ -0,0 +1,166 @@
import {Utils} from "../Utils";
import {UIEventSource} from "../Logic/UIEventSource";
/**
* A thin wrapper around a html element, which allows to generate a HTML-element.
*
* Assumes a read-only configuration, so it has no 'ListenTo'
*/
export default abstract class BaseUIElement {
private clss: Set<string> = new Set<string>();
private style: string;
private _onClick: () => void;
private _onHover: UIEventSource<boolean>;
protected _constructedHtmlElement: HTMLElement;
protected abstract InnerConstructElement(): HTMLElement;
public onClick(f: (() => void)) {
this._onClick = f;
this.SetClass("clickable")
if(this._constructedHtmlElement !== undefined){
this._constructedHtmlElement.onclick = f;
}
return this;
}
AttachTo(divId: string) {
let element = document.getElementById(divId);
if (element === null) {
throw "SEVERE: could not attach UIElement to " + divId;
}
while (element.firstChild) {
//The list is LIVE so it will re-index each call
element.removeChild(element.firstChild);
}
const el = this.ConstructElement();
if(el !== undefined){
element.appendChild(el)
}
return this;
}
/**
* Adds all the relevant classes, space seperated
*/
public SetClass(clss: string) {
const all = clss.split(" ").map(clsName => clsName.trim());
let recordedChange = false;
for (let c of all) {
c = c.trim();
if (this.clss.has(clss)) {
continue;
}
if(c === undefined || c === ""){
continue;
}
this.clss.add(c);
recordedChange = true;
}
if (recordedChange) {
this._constructedHtmlElement?.classList.add(...Array.from(this.clss));
}
return this;
}
public RemoveClass(clss: string): BaseUIElement {
if (this.clss.has(clss)) {
this.clss.delete(clss);
this._constructedHtmlElement?.classList.remove(clss)
}
return this;
}
public HasClass(clss: string): boolean{
return this.clss.has(clss)
}
public SetStyle(style: string): BaseUIElement {
this.style = style;
if(this._constructedHtmlElement !== undefined){
this._constructedHtmlElement.style.cssText = style;
}
return this;
}
/**
* The same as 'Render', but creates a HTML element instead of the HTML representation
*/
public ConstructElement(): HTMLElement {
if (Utils.runningFromConsole) {
return undefined;
}
if (this._constructedHtmlElement !== undefined) {
return this._constructedHtmlElement
}
if(this.InnerConstructElement === undefined){
throw "ERROR! This is not a correct baseUIElement: "+this.constructor.name
}
try{
const el = this.InnerConstructElement();
if(el === undefined){
return undefined;
}
this._constructedHtmlElement = el;
const style = this.style
if (style !== undefined && style !== "") {
el.style.cssText = style
}
if (this.clss.size > 0) {
try{
el.classList.add(...Array.from(this.clss))
}catch(e){
console.error("Invalid class name detected in:", Array.from(this.clss).join(" "),"\nErr msg is ",e)
}
}
if (this._onClick !== undefined) {
const self = this;
el.onclick = (e) => {
// @ts-ignore
if (e.consumed) {
return;
}
self._onClick();
// @ts-ignore
e.consumed = true;
}
el.style.pointerEvents = "all";
el.style.cursor = "pointer";
}
if (this._onHover !== undefined) {
const self = this;
el.addEventListener('mouseover', () => self._onHover.setData(true));
el.addEventListener('mouseout', () => self._onHover.setData(false));
}
if (this._onHover !== undefined) {
const self = this;
el.addEventListener('mouseover', () => self._onHover.setData(true));
el.addEventListener('mouseout', () => self._onHover.setData(false));
}
return el}catch(e){
const domExc = e as DOMException;
if(domExc){
console.log("An exception occured", domExc.code, domExc.message, domExc.name )
}
console.error(e)
}
}
public AsMarkdown(): string{
throw "AsMarkdown is not implemented by "+this.constructor.name
}
}

View file

@ -1,4 +1,3 @@
import {UIElement} from "../UIElement";
import Link from "../Base/Link";
import Svg from "../../Svg";
import Combine from "../Base/Combine";
@ -8,67 +7,57 @@ import Constants from "../../Models/Constants";
import LayoutConfig from "../../Customizations/JSON/LayoutConfig";
import Loc from "../../Models/Loc";
import * as L from "leaflet"
import {VariableUiElement} from "../Base/VariableUIElement";
/**
* The bottom right attribution panel in the leaflet map
*/
export default class Attribution extends UIElement {
private readonly _location: UIEventSource<Loc>;
private readonly _layoutToUse: UIEventSource<LayoutConfig>;
private readonly _userDetails: UIEventSource<UserDetails>;
private readonly _leafletMap: UIEventSource<L.Map>;
export default class Attribution extends Combine {
constructor(location: UIEventSource<Loc>,
userDetails: UIEventSource<UserDetails>,
layoutToUse: UIEventSource<LayoutConfig>,
leafletMap: UIEventSource<L.Map>) {
super(location);
this._layoutToUse = layoutToUse;
this.ListenTo(layoutToUse);
this._userDetails = userDetails;
this._leafletMap = leafletMap;
this.ListenTo(userDetails);
this._location = location;
this.SetClass("map-attribution");
}
InnerRender(): string {
const location: Loc = this._location?.data;
const userDetails = this._userDetails?.data;
const mapComplete = new Link(`Mapcomplete ${Constants.vNumber}`, 'https://github.com/pietervdvn/MapComplete', true);
const reportBug = new Link(Svg.bug_img, "https://github.com/pietervdvn/MapComplete/issues", true);
const reportBug = new Link(Svg.bug_ui().SetClass("small-image"), "https://github.com/pietervdvn/MapComplete/issues", true);
const layoutId = this._layoutToUse?.data?.id;
const layoutId = layoutToUse?.data?.id;
const osmChaLink = `https://osmcha.org/?filters=%7B%22comment%22%3A%5B%7B%22label%22%3A%22%23${layoutId}%22%2C%22value%22%3A%22%23${layoutId}%22%7D%5D%2C%22date__gte%22%3A%5B%7B%22label%22%3A%222020-07-05%22%2C%22value%22%3A%222020-07-05%22%7D%5D%2C%22editor%22%3A%5B%7B%22label%22%3A%22MapComplete%22%2C%22value%22%3A%22MapComplete%22%7D%5D%7D`
const stats = new Link(Svg.statistics_img, osmChaLink, true)
let editHere: (UIElement | string) = "";
let mapillary: UIElement = undefined;
if (location !== undefined) {
const idLink = `https://www.openstreetmap.org/edit?editor=id#map=${location.zoom}/${location.lat}/${location.lon}`
editHere = new Link(Svg.pencil_img, idLink, true);
const mapillaryLink: string = `https://www.mapillary.com/app/?focus=map&lat=${location.lat}&lng=${location.lon}&z=${Math.max(location.zoom - 1, 1)}`;
mapillary = new Link(Svg.mapillary_black_img, mapillaryLink, true);
}
const stats = new Link(Svg.statistics_ui().SetClass("small-image"), osmChaLink, true)
let editWithJosm: (UIElement | string) = ""
if (location !== undefined &&
this._leafletMap?.data !== undefined &&
userDetails.csCount >= Constants.userJourney.tagsVisibleAndWikiLinked) {
const bounds: any = this._leafletMap.data.getBounds();
const top = bounds.getNorth();
const bottom = bounds.getSouth();
const right = bounds.getEast();
const left = bounds.getWest();
const idLink = location.map(location => `https://www.openstreetmap.org/edit?editor=id#map=${location?.zoom ?? 0}/${location?.lat ?? 0}/${location?.lon ?? 0}`)
const editHere = new Link(Svg.pencil_ui().SetClass("small-image"), idLink, true)
const mapillaryLink = location.map(location => `https://www.mapillary.com/app/?focus=map&lat=${location?.lat ?? 0}&lng=${location?.lon ?? 0}&z=${Math.max((location?.zoom ?? 2) - 1, 1)}`)
const mapillary = new Link(Svg.mapillary_black_ui().SetClass("small-image"), mapillaryLink, true);
let editWithJosm = new VariableUiElement(
userDetails.map(userDetails => {
if (userDetails.csCount >= Constants.userJourney.tagsVisibleAndWikiLinked) {
return undefined;
}
const bounds: any = leafletMap?.data?.getBounds();
if(bounds === undefined){
return undefined
}
const top = bounds.getNorth();
const bottom = bounds.getSouth();
const right = bounds.getEast();
const left = bounds.getWest();
const josmLink = `http://127.0.0.1:8111/load_and_zoom?left=${left}&right=${right}&top=${top}&bottom=${bottom}`
return new Link(Svg.josm_logo_ui().SetClass("small-image"), josmLink, true);
},
[location]
)
)
super([mapComplete, reportBug, stats, editHere, editWithJosm, mapillary]);
const josmLink = `http://127.0.0.1:8111/load_and_zoom?left=${left}&right=${right}&top=${top}&bottom=${bottom}`
editWithJosm = new Link(Svg.josm_logo_img, josmLink, true);
}
return new Combine([mapComplete, reportBug, stats, editHere, editWithJosm, mapillary]).Render();
}

View file

@ -10,8 +10,8 @@ import SmallLicense from "../../Models/smallLicense";
import {Utils} from "../../Utils";
import Link from "../Base/Link";
import {VariableUiElement} from "../Base/VariableUIElement";
import {UIElement} from "../UIElement";
import * as contributors from "../../assets/contributors.json"
import BaseUIElement from "../BaseUIElement";
/**
* The attribution panel shown on mobile
@ -26,7 +26,7 @@ export default class AttributionPanel extends Combine {
((layoutToUse.data.maintainer ?? "") == "") ? "" : Translations.t.general.attribution.themeBy.Subs({author: layoutToUse.data.maintainer}),
layoutToUse.data.credits,
"<br/>",
new Attribution(undefined, undefined, State.state.layoutToUse, undefined),
new Attribution(State.state.locationControl, State.state.osmConnection.userDetails, State.state.layoutToUse, State.state.leafletMap),
"<br/>",
new VariableUiElement(contributions.map(contributions => {
@ -44,16 +44,14 @@ export default class AttributionPanel extends Combine {
const contribs = links.join(", ")
if (hiddenCount == 0) {
return Translations.t.general.attribution.mapContributionsBy.Subs({
contributors: contribs
}).InnerRender()
})
} else {
return Translations.t.general.attribution.mapContributionsByAndHidden.Subs({
contributors: contribs,
hiddenCount: hiddenCount
}).InnerRender();
});
}
@ -68,7 +66,7 @@ export default class AttributionPanel extends Combine {
this.SetStyle("max-width: calc(100vw - 5em); width: 40em;")
}
private static CodeContributors(): UIElement {
private static CodeContributors(): BaseUIElement {
const total = contributors.contributors.length;
let filtered = contributors.contributors
@ -89,7 +87,7 @@ export default class AttributionPanel extends Combine {
});
}
private static IconAttribution(iconPath: string): UIElement {
private static IconAttribution(iconPath: string): BaseUIElement {
if (iconPath.startsWith("http")) {
iconPath = "." + new URL(iconPath).pathname;
}

View file

@ -1,38 +1,35 @@
import {UIElement} from "../UIElement";
import {DropDown} from "../Input/DropDown";
import Translations from "../i18n/Translations";
import State from "../../State";
import {UIEventSource} from "../../Logic/UIEventSource";
import BaseLayer from "../../Models/BaseLayer";
import {VariableUiElement} from "../Base/VariableUIElement";
export default class BackgroundSelector extends UIElement {
private _dropdown: UIElement;
private readonly _availableLayers: UIEventSource<BaseLayer[]>;
export default class BackgroundSelector extends VariableUiElement {
constructor() {
super();
const self = this;
this._availableLayers = State.state.availableBackgroundLayers;
this._availableLayers.addCallbackAndRun(available => self.CreateDropDown(available));
}
const available = State.state.availableBackgroundLayers.map(available => {
const baseLayers: { value: BaseLayer, shown: string }[] = [];
for (const i in available) {
if(!available.hasOwnProperty(i)){
continue;
}
const layer: BaseLayer = available[i];
baseLayers.push({value: layer, shown: layer.name ?? "id:" + layer.id});
}
return baseLayers
}
)
private CreateDropDown(available) {
if(available.length === 0){
return;
}
const baseLayers: { value: BaseLayer, shown: string }[] = [];
for (const i in available) {
const layer: BaseLayer = available[i];
baseLayers.push({value: layer, shown: layer.name ?? "id:" + layer.id});
}
super(
available.map(baseLayers => {
if (baseLayers.length <= 1) {
return undefined;
}
return new DropDown(Translations.t.general.backgroundMap.Clone(), baseLayers, State.state.backgroundLayer)
}
)
)
this._dropdown = new DropDown(Translations.t.general.backgroundMap, baseLayers, State.state.backgroundLayer);
}
InnerRender(): string {
return this._dropdown.Render();
}
}

View file

@ -1,8 +1,8 @@
import * as L from "leaflet"
import {UIEventSource} from "../../Logic/UIEventSource";
import Loc from "../../Models/Loc";
import {UIElement} from "../UIElement";
import BaseLayer from "../../Models/BaseLayer";
import BaseUIElement from "../BaseUIElement";
export class Basemap {
@ -12,14 +12,14 @@ export class Basemap {
constructor(leafletElementId: string,
location: UIEventSource<Loc>,
currentLayer: UIEventSource<BaseLayer>,
lastClickLocation: UIEventSource<{ lat: number, lon: number }>,
extraAttribution: UIElement) {
lastClickLocation?: UIEventSource<{ lat: number, lon: number }>,
extraAttribution?: BaseUIElement) {
this.map = L.map(leafletElementId, {
center: [location.data.lat ?? 0, location.data.lon ?? 0],
zoom: location.data.zoom ?? 2,
layers: [currentLayer.data.layer],
zoomControl: false
zoomControl: false,
attributionControl: extraAttribution !== undefined
});
L.control.scale(
@ -35,9 +35,11 @@ export class Basemap {
this.map.setMaxBounds(
[[-100, -200], [100, 200]]
);
this.map.attributionControl.setPrefix(
extraAttribution.Render() + " | <a href='https://osm.org'>OpenStreetMap</a>");
this.map.attributionControl.setPrefix(
"<span id='leaflet-attribution'></span> | <a href='https://osm.org'>OpenStreetMap</a>");
extraAttribution.AttachTo('leaflet-attribution')
const self = this;
let previousLayer = currentLayer.data;
@ -69,12 +71,12 @@ export class Basemap {
this.map.on("click", function (e) {
// @ts-ignore
lastClickLocation.setData({lat: e.latlng.lat, lon: e.latlng.lng})
lastClickLocation?.setData({lat: e.latlng.lat, lon: e.latlng.lng})
});
this.map.on("contextmenu", function (e) {
// @ts-ignore
lastClickLocation.setData({lat: e.latlng.lat, lon: e.latlng.lng});
lastClickLocation?.setData({lat: e.latlng.lat, lon: e.latlng.lng});
});

View file

@ -1,4 +1,3 @@
import {UIElement} from "../UIElement";
import State from "../../State";
import ThemeIntroductionPanel from "./ThemeIntroductionPanel";
import * as personal from "../../assets/themes/personalLayout/personalLayout.json";
@ -7,47 +6,39 @@ import Svg from "../../Svg";
import Translations from "../i18n/Translations";
import ShareScreen from "./ShareScreen";
import MoreScreen from "./MoreScreen";
import {VariableUiElement} from "../Base/VariableUIElement";
import Constants from "../../Models/Constants";
import Combine from "../Base/Combine";
import Locale from "../i18n/Locale";
import {TabbedComponent} from "../Base/TabbedComponent";
import {UIEventSource} from "../../Logic/UIEventSource";
import LayoutConfig from "../../Customizations/JSON/LayoutConfig";
import UserDetails from "../../Logic/Osm/OsmConnection";
import ScrollableFullScreen from "../Base/ScrollableFullScreen";
import BaseUIElement from "../BaseUIElement";
import Toggle from "../Input/Toggle";
export default class FullWelcomePaneWithTabs extends UIElement {
private readonly _layoutToUse: UIEventSource<LayoutConfig>;
private readonly _userDetails: UIEventSource<UserDetails>;
export default class FullWelcomePaneWithTabs extends ScrollableFullScreen {
private readonly _component: UIElement;
constructor(isShown: UIEventSource<boolean>) {
super(State.state.layoutToUse);
this._layoutToUse = State.state.layoutToUse;
this._userDetails = State.state.osmConnection.userDetails;
const layoutToUse = this._layoutToUse.data;
this._component = new ScrollableFullScreen(
const layoutToUse = State.state.layoutToUse.data;
super (
() => layoutToUse.title.Clone(),
() => FullWelcomePaneWithTabs.GenerateContents(layoutToUse, State.state.osmConnection.userDetails),
() => FullWelcomePaneWithTabs.GenerateContents(layoutToUse, State.state.osmConnection.userDetails, isShown),
"welcome" ,isShown
)
}
private static ConstructBaseTabs(layoutToUse: LayoutConfig, isShown: UIEventSource<boolean>): { header: string | BaseUIElement; content: BaseUIElement }[]{
private static GenerateContents(layoutToUse: LayoutConfig, userDetails: UIEventSource<UserDetails>) {
let welcome: UIElement = new ThemeIntroductionPanel();
let welcome: BaseUIElement = new ThemeIntroductionPanel(isShown);
if (layoutToUse.id === personal.id) {
welcome = new PersonalLayersPanel();
}
const tabs = [
const tabs: { header: string | BaseUIElement, content: BaseUIElement }[] = [
{header: `<img src='${layoutToUse.icon}'>`, content: welcome},
{
header: Svg.osm_logo_img,
content: Translations.t.general.openStreetMapIntro.Clone().SetClass("link-underline") as UIElement
content: Translations.t.general.openStreetMapIntro.Clone().SetClass("link-underline")
},
]
@ -64,25 +55,27 @@ export default class FullWelcomePaneWithTabs extends UIElement {
});
}
return tabs;
}
tabs.push({
private static GenerateContents(layoutToUse: LayoutConfig, userDetails: UIEventSource<UserDetails>, isShown: UIEventSource<boolean>) {
const tabs = FullWelcomePaneWithTabs.ConstructBaseTabs(layoutToUse, isShown)
const tabsWithAboutMc = [...FullWelcomePaneWithTabs.ConstructBaseTabs(layoutToUse, isShown)]
tabsWithAboutMc.push({
header: Svg.help,
content: new VariableUiElement(userDetails.map(userdetails => {
if (userdetails.csCount < Constants.userJourney.mapCompleteHelpUnlock) {
return ""
}
return new Combine([Translations.t.general.aboutMapcomplete, "<br/>Version " + Constants.vNumber]).SetClass("link-underline").Render();
}, [Locale.language]))
content: new Combine([Translations.t.general.aboutMapcomplete.Clone(), "<br/>Version " + Constants.vNumber])
.SetClass("link-underline")
}
);
return new TabbedComponent(tabs, State.state.welcomeMessageOpenedTab)
.ListenTo(userDetails);
}
InnerRender(): string {
return this._component.Render();
return new Toggle(
new TabbedComponent(tabsWithAboutMc, State.state.welcomeMessageOpenedTab),
new TabbedComponent(tabs, State.state.welcomeMessageOpenedTab),
userDetails.map((userdetails: UserDetails) =>
userdetails.loggedIn &&
userdetails.csCount >= Constants.userJourney.mapCompleteHelpUnlock)
)
}
}

View file

@ -1,4 +1,3 @@
import {UIElement} from "../UIElement";
import Combine from "../Base/Combine";
import Translations from "../i18n/Translations";
import {FixedUiElement} from "../Base/FixedUiElement";

View file

@ -1,4 +1,3 @@
import {UIElement} from "../UIElement";
import State from "../../State";
import BackgroundSelector from "./BackgroundSelector";
import LayerSelection from "./LayerSelection";
@ -7,6 +6,7 @@ import {FixedUiElement} from "../Base/FixedUiElement";
import ScrollableFullScreen from "../Base/ScrollableFullScreen";
import Translations from "../i18n/Translations";
import {UIEventSource} from "../../Logic/UIEventSource";
import BaseUIElement from "../BaseUIElement";
export default class LayerControlPanel extends ScrollableFullScreen {
@ -14,13 +14,12 @@ export default class LayerControlPanel extends ScrollableFullScreen {
super(LayerControlPanel.GenTitle, LayerControlPanel.GeneratePanel, "layers", isShown);
}
private static GenTitle(): UIElement {
const title = Translations.t.general.layerSelection.title.SetClass("text-2xl break-words font-bold p-2")
return title.Clone();
private static GenTitle():BaseUIElement {
return Translations.t.general.layerSelection.title.Clone().SetClass("text-2xl break-words font-bold p-2")
}
private static GeneratePanel() {
let layerControlPanel: UIElement = new FixedUiElement("");
private static GeneratePanel() : BaseUIElement {
let layerControlPanel: BaseUIElement = new FixedUiElement("");
if (State.state.layoutToUse.data.enableBackgroundLayerSelection) {
layerControlPanel = new BackgroundSelector();
layerControlPanel.SetStyle("margin:1em");

View file

@ -1,84 +1,69 @@
import {UIEventSource} from "../../Logic/UIEventSource";
import {UIElement} from "../UIElement";
import {VariableUiElement} from "../Base/VariableUIElement";
import State from "../../State";
import CheckBox from "../Input/CheckBox";
import Toggle from "../Input/Toggle";
import Combine from "../Base/Combine";
import {FixedUiElement} from "../Base/FixedUiElement";
import Translations from "../i18n/Translations";
import LayerConfig from "../../Customizations/JSON/LayerConfig";
import BaseUIElement from "../BaseUIElement";
/**
* Shows the panel with all layers and a toggle for each of them
*/
export default class LayerSelection extends UIElement {
export default class LayerSelection extends Combine {
private _checkboxes: UIElement[];
private activeLayers: UIEventSource<{
readonly isDisplayed: UIEventSource<boolean>,
readonly layerDef: LayerConfig;
}[]>;
constructor(activeLayers: UIEventSource<{
readonly isDisplayed: UIEventSource<boolean>,
readonly layerDef: LayerConfig;
}[]>) {
super(activeLayers);
if(activeLayers === undefined){
if (activeLayers === undefined) {
throw "ActiveLayers should be defined..."
}
this.activeLayers = activeLayers;
}
InnerRender(): string {
const checkboxes: BaseUIElement[] = [];
this._checkboxes = [];
for (const layer of this.activeLayers.data) {
for (const layer of activeLayers.data) {
const leafletStyle = layer.layerDef.GenerateLeafletStyle(
new UIEventSource<any>({id: "node/-1"}),
false)
const leafletHtml = leafletStyle.icon.html;
const icon =
new FixedUiElement(leafletHtml.Render())
.SetClass("single-layer-selection-toggle")
let iconUnselected: UIElement = new FixedUiElement(leafletHtml.Render())
const icon = new Combine([leafletStyle.icon.html]).SetClass("single-layer-selection-toggle")
let iconUnselected: BaseUIElement = new Combine([leafletStyle.icon.html])
.SetClass("single-layer-selection-toggle")
.SetStyle("opacity:0.2;");
const name = Translations.WT(layer.layerDef.name)?.Clone()
?.SetStyle("font-size:large;margin-left: 0.5em;");
if((name ?? "") === ""){
if ((name ?? "") === "") {
continue
}
const zoomStatus = new VariableUiElement(State.state.locationControl.map(location => {
if (location.zoom < layer.layerDef.minzoom) {
return Translations.t.general.layerSelection.zoomInToSeeThisLayer
return Translations.t.general.layerSelection.zoomInToSeeThisLayer.Clone()
.SetClass("alert")
.SetStyle("display: block ruby;width:min-content;")
.Render();
}
return ""
}))
const style = "display:flex;align-items:center;"
const styleWhole = "display:flex; flex-wrap: wrap"
this._checkboxes.push(new CheckBox(
checkboxes.push(new Toggle(
new Combine([new Combine([icon, name]).SetStyle(style), zoomStatus])
.SetStyle(styleWhole),
new Combine([new Combine([iconUnselected, "<del>", name, "</del>"]).SetStyle(style), zoomStatus])
.SetStyle(styleWhole),
layer.isDisplayed)
layer.isDisplayed).ToggleOnClick()
.SetStyle("margin:0.3em;")
);
}
return new Combine(this._checkboxes)
.SetStyle("display:flex;flex-direction:column;")
.Render();
}
super(checkboxes)
this.SetStyle("display:flex;flex-direction:column;")
}
}

View file

@ -0,0 +1,20 @@
import {DropDown} from "../Input/DropDown";
import Translations from "../i18n/Translations";
import State from "../../State";
import {UIEventSource} from "../../Logic/UIEventSource";
export default class LicensePicker extends DropDown<string>{
constructor() {
super(Translations.t.image.willBePublished.Clone(),
[
{value: "CC0", shown: Translations.t.image.cco.Clone()},
{value: "CC-BY-SA 4.0", shown: Translations.t.image.ccbs.Clone()},
{value: "CC-BY 4.0", shown: Translations.t.image.ccb.Clone()}
],
State.state?.osmConnection?.GetPreference("pictures-license") ?? new UIEventSource<string>("CC0")
)
this.SetClass("flex flex-col sm:flex-row").SetStyle("float:left");
}
}

View file

@ -1,4 +1,3 @@
import {UIElement} from "../UIElement";
import {VariableUiElement} from "../Base/VariableUIElement";
import LayoutConfig from "../../Customizations/JSON/LayoutConfig";
import {AllKnownLayouts} from "../../Customizations/AllKnownLayouts";
@ -11,87 +10,94 @@ import * as personal from "../../assets/themes/personalLayout/personalLayout.jso
import Constants from "../../Models/Constants";
import LanguagePicker from "../LanguagePicker";
import IndexText from "./IndexText";
import BaseUIElement from "../BaseUIElement";
export default class MoreScreen extends UIElement {
private readonly _onMainScreen: boolean;
private _component: UIElement;
export default class MoreScreen extends Combine {
constructor(onMainScreen: boolean = false) {
super(State.state.locationControl);
this._onMainScreen = onMainScreen;
this.ListenTo(State.state.osmConnection.userDetails);
this.ListenTo(State.state.installedThemes);
super(MoreScreen.Init(onMainScreen, State.state));
}
InnerRender(): string {
private static Init(onMainScreen: boolean, state: State): BaseUIElement [] {
const tr = Translations.t.general.morescreen;
const els: UIElement[] = []
const themeButtons: UIElement[] = []
for (const layout of AllKnownLayouts.layoutsList) {
if (layout.id === personal.id) {
if (State.state.osmConnection.userDetails.data.csCount < Constants.userJourney.personalLayoutUnlock) {
continue;
}
}
themeButtons.push(this.createLinkButton(layout));
}
els.push(new VariableUiElement(
State.state.osmConnection.userDetails.map(userDetails => {
if (userDetails.csCount < Constants.userJourney.themeGeneratorReadOnlyUnlock) {
return new SubtleButton(null, tr.requestATheme, {url:"https://github.com/pietervdvn/MapComplete/issues", newTab: true}).Render();
}
return new SubtleButton(Svg.pencil_ui(), tr.createYourOwnTheme, {
url: "./customGenerator.html",
newTab: false
}).Render();
})
));
els.push(new Combine(themeButtons))
const customThemesNames = State.state.installedThemes.data ?? [];
if (customThemesNames.length > 0) {
els.push(Translations.t.general.customThemeIntro)
for (const installed of State.state.installedThemes.data) {
els.push(this.createLinkButton(installed.layout, installed.definition));
}
}
let intro: UIElement = tr.intro;
const themeButtonsElement = new Combine(els)
if (this._onMainScreen) {
let intro: BaseUIElement = tr.intro.Clone();
let themeButtonStyle = ""
let themeListStyle = ""
if (onMainScreen) {
intro = new Combine([
LanguagePicker.CreateLanguagePicker(Translations.t.index.title.SupportedLanguages())
.SetClass("absolute top-2 right-3"),
new IndexText()
])
themeButtons.map(e => e?.SetClass("h-32 min-h-32 max-h-32 overflow-ellipsis overflow-hidden"))
themeButtonsElement.SetClass("md:grid md:grid-flow-row md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-g4 gap-4")
themeButtonStyle = "h-32 min-h-32 max-h-32 overflow-ellipsis overflow-hidden"
themeListStyle = "md:grid md:grid-flow-row md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-g4 gap-4"
}
this._component = new Combine([
return[
intro,
themeButtonsElement,
tr.streetcomplete.SetClass("block text-base mx-10 my-3 mb-10")
]);
return this._component.Render();
MoreScreen.createOfficialThemesList(state, themeButtonStyle).SetClass(themeListStyle),
MoreScreen.createUnofficialThemeList(themeButtonStyle)?.SetClass(themeListStyle),
tr.streetcomplete.Clone().SetClass("block text-base mx-10 my-3 mb-10")
];
}
private static createUnofficialThemeList(buttonClass: string): BaseUIElement{
return new VariableUiElement(State.state.installedThemes.map(customThemes => {
const els : BaseUIElement[] = []
if (customThemes.length > 0) {
els.push(Translations.t.general.customThemeIntro.Clone())
const customThemesElement = new Combine(
customThemes.map(theme => MoreScreen.createLinkButton(theme.layout, theme.definition)?.SetClass(buttonClass))
)
els.push(customThemesElement)
}
return els;
}));
}
private createLinkButton(layout: LayoutConfig, customThemeDefinition: string = undefined) {
private static createOfficialThemesList(state: State, buttonClass: string): BaseUIElement {
let officialThemes = AllKnownLayouts.layoutsList
if (State.state.osmConnection.userDetails.data.csCount < Constants.userJourney.personalLayoutUnlock) {
officialThemes = officialThemes.filter(theme => theme.id !== personal.id)
}
let buttons = officialThemes.map((layout) => MoreScreen.createLinkButton(layout)?.SetClass(buttonClass))
let customGeneratorLink = MoreScreen.createCustomGeneratorButton(state)
buttons.splice(0, 0, customGeneratorLink);
return new Combine(buttons)
}
/*
* Returns either a link to the issue tracker or a link to the custom generator, depending on the achieved number of changesets
* */
private static createCustomGeneratorButton(state: State): VariableUiElement {
const tr = Translations.t.general.morescreen;
return new VariableUiElement(
state.osmConnection.userDetails.map(userDetails => {
if (userDetails.csCount < Constants.userJourney.themeGeneratorReadOnlyUnlock) {
return new SubtleButton(null, tr.requestATheme.Clone(), {
url: "https://github.com/pietervdvn/MapComplete/issues",
newTab: true
});
}
return new SubtleButton(Svg.pencil_ui(), tr.createYourOwnTheme.Clone(), {
url: "https://pietervdvn.github.io/mc/legacy/070/customGenerator.html",
newTab: false
});
})
)
}
/**
* Creates a button linking to the given theme
* @param layout
* @param customThemeDefinition
* @private
*/
private static createLinkButton(layout: LayoutConfig, customThemeDefinition: string = undefined): BaseUIElement {
if (layout === undefined) {
return undefined;
}
@ -100,17 +106,14 @@ export default class MoreScreen extends UIElement {
return undefined;
}
if (layout.hideFromOverview) {
const pref = State.state.osmConnection.GetPreference("hidden-theme-" + layout.id + "-enabled");
this.ListenTo(pref);
if (pref.data !== "true") {
return undefined;
}
return undefined;
}
if (layout.id === State.state.layoutToUse.data?.id) {
return undefined;
}
const currentLocation = State.state.locationControl.data;
const currentLocation = State.state.locationControl;
let path = window.location.pathname;
// Path starts with a '/' and contains everything, e.g. '/dir/dir/page.html'
path = path.substr(0, path.lastIndexOf("/"));
@ -119,29 +122,42 @@ export default class MoreScreen extends UIElement {
path = "."
}
const params = `z=${currentLocation.zoom ?? 1}&lat=${currentLocation.lat ?? 0}&lon=${currentLocation.lon ?? 0}`
let linkText =
`${path}/${layout.id.toLowerCase()}.html?${params}`
let linkPrefix = `${path}/${layout.id.toLowerCase()}.html?`
let linkSuffix = ""
if (location.hostname === "localhost" || location.hostname === "127.0.0.1") {
linkText = `${path}/index.html?layout=${layout.id}&${params}`
linkPrefix = `${path}/index.html?layout=${layout.id}&`
}
if (customThemeDefinition) {
linkText = `${path}/index.html?userlayout=${layout.id}&${params}#${customThemeDefinition}`
linkPrefix = `${path}/index.html?userlayout=${layout.id}&`
linkSuffix = `#${customThemeDefinition}`
}
let description = Translations.W(layout.shortDescription);
const linkText = currentLocation.map(currentLocation => {
const params = [
["z", currentLocation?.zoom],
["lat", currentLocation?.lat],
["lon",currentLocation?.lon]
].filter(part => part[1] !== undefined)
.map(part => part[0]+"="+part[1])
.join("&")
return `${linkPrefix}${params}${linkSuffix}`;
})
let description = Translations.WT(layout.shortDescription).Clone();
return new SubtleButton(layout.icon,
new Combine([
`<dt class='text-lg leading-6 font-medium text-gray-900 group-hover:text-blue-800'>`,
Translations.W(layout.title),
Translations.WT(layout.title).Clone(),
`</dt>`,
`<dd class='mt-1 text-base text-gray-500 group-hover:text-blue-900 overflow-ellipsis'>`,
description ?? "",
description.Clone().SetClass("subtle") ?? "",
`</dd>`,
]), {url: linkText, newTab: false});
}
}

View file

@ -1,137 +1,123 @@
import {UIEventSource} from "../../Logic/UIEventSource";
import {UIElement} from "../UIElement";
import LayoutConfig from "../../Customizations/JSON/LayoutConfig";
import {AllKnownLayouts} from "../../Customizations/AllKnownLayouts";
import Svg from "../../Svg";
import State from "../../State";
import Combine from "../Base/Combine";
import CheckBox from "../Input/CheckBox";
import Toggle from "../Input/Toggle";
import {SubtleButton} from "../Base/SubtleButton";
import {FixedUiElement} from "../Base/FixedUiElement";
import Translations from "../i18n/Translations";
import * as personal from "../../assets/themes/personalLayout/personalLayout.json"
import Locale from "../i18n/Locale";
export default class PersonalLayersPanel extends UIElement {
private checkboxes: UIElement[] = [];
import BaseUIElement from "../BaseUIElement";
import {VariableUiElement} from "../Base/VariableUIElement";
import LayerConfig from "../../Customizations/JSON/LayerConfig";
import Img from "../Base/Img";
import {UIEventSource} from "../../Logic/UIEventSource";
export default class PersonalLayersPanel extends VariableUiElement {
constructor() {
super(State.state.favouriteLayers);
this.ListenTo(State.state.osmConnection.userDetails);
this.ListenTo(Locale.language);
this.UpdateView([]);
const self = this;
State.state.installedThemes.addCallback(extraThemes => {
self.UpdateView(extraThemes.map(layout => layout.layout));
self.Update();
})
}
super(
State.state.installedThemes.map(installedThemes => {
const t = Translations.t.favourite;
// Lets get all the layers
const allThemes = AllKnownLayouts.layoutsList.concat(installedThemes.map(layout => layout.layout))
.filter(theme => !theme.hideFromOverview)
private UpdateView(extraThemes: LayoutConfig[]) {
this.checkboxes = [];
const favs = State.state.favouriteLayers.data ?? [];
const controls = new Map<string, UIEventSource<boolean>>();
const allLayouts = AllKnownLayouts.layoutsList.concat(extraThemes);
for (const layout of allLayouts) {
if (layout.id === personal.id) {
continue;
}
if(layout.hideFromOverview){
continue;
}
const header =
new Combine([
`<img style="max-width: 3em;max-height: 3em; float: left; padding: 0.1em; margin-right: 0.3em;" src='${layout.icon}'>`,
"<b>",
layout.title,
"</b><br/>",
layout.shortDescription ?? ""
]).SetClass("block p1 overflow-auto rounded")
.SetStyle("background: #eee;")
this.checkboxes.push(header);
for (const layer of layout.layers) {
if(layer === undefined){
console.warn("Undefined layer for ",layout.id)
continue;
}
if (typeof layer === "string") {
continue;
}
let icon :UIElement = layer.GenerateLeafletStyle(new UIEventSource<any>({id:"node/-1"}), false).icon.html
?? Svg.checkmark_svg();
let iconUnset =new FixedUiElement(icon.Render());
icon.SetClass("single-layer-selection-toggle")
iconUnset.SetClass("single-layer-selection-toggle")
let name = layer.name ?? layer.id;
if (name === undefined) {
continue;
}
const content = new Combine([
"<b>",
name,
"</b> ",
layer.description !== undefined ? new Combine(["<br/>", layer.description]) : "",
])
const cb = new CheckBox(
new SubtleButton(
icon,
content),
new SubtleButton(
iconUnset.SetStyle("opacity:0.1"),
new Combine(["<del>",
content,
"</del>"
])),
controls[layer.id] ?? (favs.indexOf(layer.id) >= 0)
);
cb.SetClass("custom-layer-checkbox");
controls[layer.id] = cb.isEnabled;
cb.isEnabled.addCallback((isEnabled) => {
const favs = State.state.favouriteLayers;
if (isEnabled) {
if(favs.data.indexOf(layer.id)>= 0){
return; // Already added
const allLayers = []
{
const seenLayers = new Set<string>()
for (const layers of allThemes.map(theme => theme.layers)) {
for (const layer of layers) {
if (seenLayers.has(layer.id)) {
continue
}
seenLayers.add(layer.id)
allLayers.push(layer)
}
favs.data.push(layer.id);
} else {
favs.data.splice(favs.data.indexOf(layer.id), 1);
}
favs.ping();
})
}
this.checkboxes.push(cb);
// Time to create a panel based on them!
const panel: BaseUIElement = new Combine(allLayers.map(PersonalLayersPanel.CreateLayerToggle));
}
}
State.state.favouriteLayers.addCallback((layers) => {
for (const layerId of layers) {
controls[layerId]?.setData(true);
}
});
return new Toggle(
new Combine([
t.panelIntro.Clone(),
panel
]).SetClass("flex flex-col"),
new SubtleButton(
Svg.osm_logo_ui(),
t.loginNeeded.Clone().SetClass("text-center")
).onClick(() => State.state.osmConnection.AttemptLogin()),
State.state.osmConnection.isLoggedIn
)
})
)
}
InnerRender(): string {
const t = Translations.t.favourite;
const userDetails = State.state.osmConnection.userDetails.data;
if(!userDetails.loggedIn){
return t.loginNeeded.Render();
}
/***
* Creates a toggle for the given layer, which'll update State.state.favouriteLayers right away
* @param layer
* @constructor
* @private
*/
private static CreateLayerToggle(layer: LayerConfig): Toggle {
const iconUrl = layer.icon.GetRenderValue({id: "node/-1"}).txt
let icon :BaseUIElement =new Combine([ layer.GenerateLeafletStyle(
new UIEventSource<any>({id: "node/-1"}),
false,
"2em"
).icon.html]).SetClass("relative")
let iconUnset =new Combine([ layer.GenerateLeafletStyle(
new UIEventSource<any>({id: "node/-1"}),
false,
"2em"
).icon.html]).SetClass("relative")
return new Combine([
t.panelIntro,
...this.checkboxes
]).Render();
iconUnset.SetStyle("opacity:0.1")
let name = layer.name ;
if (name === undefined) {
return undefined;
}
const content = new Combine([
Translations.WT(name).Clone().SetClass("font-bold"),
Translations.WT(layer.description)?.Clone()
]).SetClass("flex flex-col")
const contentUnselected = new Combine([
Translations.WT(name).Clone().SetClass("font-bold"),
Translations.WT(layer.description)?.Clone()
]).SetClass("flex flex-col line-through")
return new Toggle(
new SubtleButton(
icon,
content ),
new SubtleButton(
iconUnset,
contentUnselected
),
State.state.favouriteLayers.map(favLayers => {
return favLayers.indexOf(layer.id) >= 0
}, [], (selected, current) => {
if (!selected && current.indexOf(layer.id) <= 0) {
// Not selected and not contained: nothing to change: we return current as is
return current;
}
if (selected && current.indexOf(layer.id) >= 0) {
// Selected and contained: this is fine!
return current;
}
const clone = [...current]
if (selected) {
clone.push(layer.id)
} else {
clone.splice(clone.indexOf(layer.id), 1)
}
return clone
})
).ToggleOnClick();
}

View file

@ -1,6 +1,5 @@
import Locale from "../i18n/Locale";
import {UIEventSource} from "../../Logic/UIEventSource";
import {UIElement} from "../UIElement";
import {Translation} from "../i18n/Translation";
import {VariableUiElement} from "../Base/VariableUIElement";
import Svg from "../../Svg";
@ -9,75 +8,76 @@ import {TextField} from "../Input/TextField";
import {Geocoding} from "../../Logic/Osm/Geocoding";
import Translations from "../i18n/Translations";
import Hash from "../../Logic/Web/Hash";
import Combine from "../Base/Combine";
export default class SearchAndGo extends UIElement {
private _placeholder = new UIEventSource<Translation>(Translations.t.general.search.search)
private _searchField = new TextField({
placeholder: new VariableUiElement(
this._placeholder.map(uiElement => uiElement.InnerRender(), [Locale.language])
),
value: new UIEventSource<string>("")
}
);
private _foundEntries = new UIEventSource([]);
private _goButton = Svg.search_ui().SetClass('w-8 h-8 full-rounded border-black float-right');
export default class SearchAndGo extends Combine {
constructor() {
super(undefined);
this.ListenTo(this._foundEntries);
const goButton = Svg.search_ui().SetClass('w-8 h-8 full-rounded border-black float-right');
const self = this;
this._searchField.enterPressed.addCallback(() => {
self.RunSearch();
});
const placeholder = new UIEventSource<Translation>(Translations.t.general.search.search)
const searchField = new TextField({
placeholder: new VariableUiElement(
placeholder.map(uiElement => uiElement, [Locale.language])
),
value: new UIEventSource<string>(""),
inputStyle: " background: transparent;\n" +
" border: none;\n" +
" font-size: large;\n" +
" width: 100%;\n" +
" box-sizing: border-box;\n" +
" color: var(--foreground-color);"
}
);
searchField.SetClass("relative float-left mt-0 ml-2")
searchField.SetStyle("width: calc(100% - 3em)")
this._goButton.onClick(function () {
self.RunSearch();
});
super([searchField, goButton])
}
this.SetClass("block h-8")
this.SetStyle("background: var(--background-color); color: var(--foreground-color); pointer-evetns:all;")
InnerRender(): string {
return this._searchField.Render() +
this._goButton.Render();
}
// Triggered by 'enter' or onclick
function runSearch() {
const searchString = searchField.GetValue().data;
if (searchString === undefined || searchString === "") {
return;
}
searchField.GetValue().setData("");
placeholder.setData(Translations.t.general.search.searching);
Geocoding.Search(searchString, (result) => {
console.log("Search result", result)
if (result.length == 0) {
placeholder.setData(Translations.t.general.search.nothing);
return;
}
const poi = result[0];
const bb = poi.boundingbox;
const bounds: [[number, number], [number, number]] = [
[bb[0], bb[2]],
[bb[1], bb[3]]
]
State.state.selectedElement.setData(undefined);
Hash.hash.setData(poi.osm_type + "/" + poi.osm_id);
State.state.leafletMap.data.fitBounds(bounds);
placeholder.setData(Translations.t.general.search.search);
},
() => {
searchField.GetValue().setData("");
placeholder.setData(Translations.t.general.search.error);
});
// Triggered by 'enter' or onclick
private RunSearch() {
const searchString = this._searchField.GetValue().data;
if (searchString === undefined || searchString === "") {
return;
}
this._searchField.GetValue().setData("");
this._placeholder.setData(Translations.t.general.search.searching);
const self = this;
Geocoding.Search(searchString, (result) => {
console.log("Search result", result)
if (result.length == 0) {
self._placeholder.setData(Translations.t.general.search.nothing);
return;
}
const poi = result[0];
const bb = poi.boundingbox;
const bounds: [[number, number], [number, number]] = [
[bb[0], bb[2]],
[bb[1], bb[3]]
]
State.state.selectedElement. setData(undefined);
Hash.hash.setData(poi.osm_type+"/"+poi.osm_id);
State.state.leafletMap.data.fitBounds(bounds);
self._placeholder.setData(Translations.t.general.search.search);
},
() => {
self._searchField.GetValue().setData("");
self._placeholder.setData(Translations.t.general.search.error);
});
searchField.enterPressed.addCallback(runSearch);
goButton.onClick(runSearch);
}

View file

@ -1,29 +1,28 @@
import {UIElement} from "../UIElement";
import BaseUIElement from "../BaseUIElement";
export default class ShareButton extends UIElement{
private _embedded: UIElement;
private _shareData: { text: string; title: string; url: string };
export default class ShareButton extends BaseUIElement{
private _embedded: BaseUIElement;
private _shareData: () => { text: string; title: string; url: string };
constructor(embedded: UIElement, shareData: {
constructor(embedded: BaseUIElement, generateShareData: () => {
text: string,
title: string,
url: string
}) {
super();
this._embedded = embedded;
this._shareData = shareData;
}
InnerRender(): string {
return `<button type="button" class="share-button" id="${this.id}">${this._embedded.Render()}</button>`
this._shareData = generateShareData;
this.SetClass("share-button")
}
protected InnerUpdate(htmlElement: HTMLElement) {
super.InnerUpdate(htmlElement);
const self= this;
htmlElement.addEventListener('click', () => {
protected InnerConstructElement(): HTMLElement {
const e = document.createElement("button")
e.type = "button"
e.appendChild(this._embedded.ConstructElement())
e.addEventListener('click', () => {
if (navigator.share) {
navigator.share(self._shareData).then(() => {
navigator.share(this._shareData()).then(() => {
console.log('Thanks for sharing!');
})
.catch(err => {
@ -33,6 +32,9 @@ export default class ShareButton extends UIElement{
console.log('web share not supported');
}
});
return e;
}
}

View file

@ -1,5 +1,3 @@
import {VerticalCombine} from "../Base/VerticalCombine";
import {UIElement} from "../UIElement";
import {VariableUiElement} from "../Base/VariableUIElement";
import {Translation} from "../i18n/Translation";
import LayoutConfig from "../../Customizations/JSON/LayoutConfig";
@ -9,29 +7,23 @@ import {SubtleButton} from "../Base/SubtleButton";
import {UIEventSource} from "../../Logic/UIEventSource";
import {Utils} from "../../Utils";
import State from "../../State";
import CheckBox from "../Input/CheckBox";
import Toggle from "../Input/Toggle";
import {FixedUiElement} from "../Base/FixedUiElement";
import Translations from "../i18n/Translations";
import Constants from "../../Models/Constants";
import LayerConfig from "../../Customizations/JSON/LayerConfig";
import BaseUIElement from "../BaseUIElement";
export default class ShareScreen extends UIElement {
private readonly _options: UIElement;
private readonly _iframeCode: UIElement;
public iframe: UIEventSource<string>;
private readonly _link: UIElement;
private readonly _linkStatus: UIEventSource<string | UIElement>;
private readonly _editLayout: UIElement;
export default class ShareScreen extends Combine {
constructor(layout: LayoutConfig = undefined, layoutDefinition: string = undefined) {
super(undefined)
layout = layout ?? State.state?.layoutToUse?.data;
layoutDefinition = layoutDefinition ?? State.state?.layoutDefinition;
const tr = Translations.t.general.sharescreen;
const optionCheckboxes: UIElement[] = []
const optionCheckboxes: BaseUIElement[] = []
const optionParts: (UIEventSource<string>)[] = [];
this.SetClass("link-underline")
function check() {
return Svg.checkmark_svg().SetStyle("width: 1.5em; display:inline-block;");
}
@ -40,11 +32,11 @@ export default class ShareScreen extends UIElement {
return Svg.no_checkmark_svg().SetStyle("width: 1.5em; display: inline-block;");
}
const includeLocation = new CheckBox(
new Combine([check(), tr.fsIncludeCurrentLocation]),
new Combine([nocheck(), tr.fsIncludeCurrentLocation]),
true
)
const includeLocation = new Toggle(
new Combine([check(), tr.fsIncludeCurrentLocation.Clone()]),
new Combine([nocheck(), tr.fsIncludeCurrentLocation.Clone()]),
new UIEventSource<boolean>(true)
).ToggleOnClick()
optionCheckboxes.push(includeLocation);
const currentLocation = State.state?.locationControl;
@ -54,7 +46,10 @@ export default class ShareScreen extends UIElement {
return null;
}
if (includeL) {
return `z=${currentLocation.data.zoom}&lat=${currentLocation.data.lat}&lon=${currentLocation.data.lon}`
return [["z", currentLocation.data?.zoom], ["lat", currentLocation.data?.lat], ["lon", currentLocation.data?.lon]]
.filter(p => p[1] !== undefined)
.map(p => p[0]+"="+p[1])
.join("&")
} else {
return null;
}
@ -73,13 +68,13 @@ export default class ShareScreen extends UIElement {
const currentLayer: UIEventSource<{ id: string, name: string, layer: any }> = State.state.backgroundLayer;
const currentBackground = new VariableUiElement(currentLayer.map(layer => {
return tr.fsIncludeCurrentBackgroundMap.Subs({name: layer?.name ?? ""}).Render();
return tr.fsIncludeCurrentBackgroundMap.Subs({name: layer?.name ?? ""});
}));
const includeCurrentBackground = new CheckBox(
const includeCurrentBackground = new Toggle(
new Combine([check(), currentBackground]),
new Combine([nocheck(), currentBackground]),
true
)
new UIEventSource<boolean>(true)
).ToggleOnClick()
optionCheckboxes.push(includeCurrentBackground);
optionParts.push(includeCurrentBackground.isEnabled.map((includeBG) => {
if (includeBG) {
@ -90,11 +85,11 @@ export default class ShareScreen extends UIElement {
}, [currentLayer]));
const includeLayerChoices = new CheckBox(
new Combine([check(), tr.fsIncludeCurrentLayers]),
new Combine([nocheck(), tr.fsIncludeCurrentLayers]),
true
)
const includeLayerChoices = new Toggle(
new Combine([check(), tr.fsIncludeCurrentLayers.Clone()]),
new Combine([nocheck(), tr.fsIncludeCurrentLayers.Clone()]),
new UIEventSource<boolean>(true)
).ToggleOnClick()
optionCheckboxes.push(includeLayerChoices);
optionParts.push(includeLayerChoices.isEnabled.map((includeLayerSelection) => {
@ -120,10 +115,11 @@ export default class ShareScreen extends UIElement {
for (const swtch of switches) {
const checkbox = new CheckBox(
new Combine([check(), Translations.W(swtch.human)]),
new Combine([nocheck(), Translations.W(swtch.human)]), !swtch.reverse
);
const checkbox = new Toggle(
new Combine([check(), Translations.W(swtch.human.Clone())]),
new Combine([nocheck(), Translations.W(swtch.human.Clone())]),
new UIEventSource<boolean>(!swtch.reverse)
).ToggleOnClick();
optionCheckboxes.push(checkbox);
optionParts.push(checkbox.isEnabled.map((isEn) => {
if (isEn) {
@ -143,7 +139,7 @@ export default class ShareScreen extends UIElement {
}
this._options = new VerticalCombine(optionCheckboxes)
const options = new Combine(optionCheckboxes).SetClass("flex flex-col")
const url = (currentLocation ?? new UIEventSource(undefined)).map(() => {
const host = window.location.host;
@ -173,12 +169,10 @@ export default class ShareScreen extends UIElement {
}, optionParts);
this.iframe = url.map(url => `&lt;iframe src="${url}" width="100%" height="100%" title="${layout?.title?.txt ?? "MapComplete"} with MapComplete"&gt;&lt;/iframe&gt`);
this._iframeCode = new VariableUiElement(
const iframeCode = new VariableUiElement(
url.map((url) => {
return `<span class='literal-code iframe-code-block'>
&lt;iframe src="${url}" width="100%" height="100%" title="${layout.title?.txt ?? "MapComplete"} with MapComplete"&gt;&lt;/iframe&gt
&lt;iframe src="${url}" width="100%" height="100%" style="min-width: 25Opx; min-height: 250ox" title="${layout.title?.txt ?? "MapComplete"} with MapComplete"&gt;&lt;/iframe&gt
</span>`
})
);
@ -186,9 +180,9 @@ export default class ShareScreen extends UIElement {
this._editLayout = new FixedUiElement("");
let editLayout : BaseUIElement= new FixedUiElement("");
if ((layoutDefinition !== undefined && State.state?.osmConnection !== undefined)) {
this._editLayout =
editLayout =
new VariableUiElement(
State.state.osmConnection.userDetails.map(
userDetails => {
@ -197,28 +191,24 @@ export default class ShareScreen extends UIElement {
}
return new SubtleButton(Svg.pencil_ui(),
new Combine([tr.editThisTheme.SetClass("bold"), "<br/>",
tr.editThemeDescription]),
{url: `./customGenerator.html#${State.state.layoutDefinition}`, newTab: true}).Render();
new Combine([tr.editThisTheme.Clone().SetClass("bold"), "<br/>",
tr.editThemeDescription.Clone()]),
{url: `./customGenerator.html#${State.state.layoutDefinition}`, newTab: true});
}
));
}
this._linkStatus = new UIEventSource<string | Translation>("");
this.ListenTo(this._linkStatus);
const self = this;
this._link = new VariableUiElement(
url.map((url) => {
return `<input type="text" value=" ${url}" id="code-link--copyable" style="width:90%">`
})
const linkStatus = new UIEventSource<string | Translation>("");
const link = new VariableUiElement(
url.map((url) => `<input type="text" value=" ${url}" id="code-link--copyable" style="width:90%">`)
).onClick(async () => {
const shareData = {
title: Translations.W(layout.id)?.InnerRender() ?? "",
text: Translations.W(layout.description)?.InnerRender() ?? "",
url: self._link.data,
title: Translations.W(layout.title)?.ConstructElement().innerText ?? "",
text: Translations.W(layout.description)?.ConstructElement().innerText ?? "",
url: url.data,
}
function rejected() {
@ -230,17 +220,17 @@ export default class ShareScreen extends UIElement {
copyText.setSelectionRange(0, 99999); /*For mobile devices*/
document.execCommand("copy");
const copied = tr.copiedToClipboard;
const copied = tr.copiedToClipboard.Clone();
copied.SetClass("thanks")
self._linkStatus.setData(copied);
linkStatus.setData(copied);
}
try {
navigator.share(shareData)
.then(() => {
const thx = tr.thanksForSharing;
const thx = tr.thanksForSharing.Clone();
thx.SetClass("thanks");
this._linkStatus.setData(thx);
linkStatus.setData(thx);
}, rejected)
.catch(rejected)
} catch (err) {
@ -249,22 +239,19 @@ export default class ShareScreen extends UIElement {
});
}
InnerRender(): string {
super ([
editLayout,
tr.intro.Clone(),
link,
new VariableUiElement(linkStatus),
tr.addToHomeScreen.Clone(),
tr.embedIntro.Clone(),
options,
iframeCode,
])
this.SetClass("flex flex-col link-underline")
const tr = Translations.t.general.sharescreen;
return new VerticalCombine([
this._editLayout,
tr.intro,
this._link,
Translations.W(this._linkStatus.data),
tr.addToHomeScreen,
tr.embedIntro,
this._options,
this._iframeCode,
]).Render()
}
}

View file

@ -1,250 +1,232 @@
/**
* Asks to add a feature at the last clicked location, at least if zoom is sufficient
*/
import Locale from "../i18n/Locale";
import {UIEventSource} from "../../Logic/UIEventSource";
import {UIElement} from "../UIElement";
import Svg from "../../Svg";
import {SubtleButton} from "../Base/SubtleButton";
import State from "../../State";
import Combine from "../Base/Combine";
import {FixedUiElement} from "../Base/FixedUiElement";
import Translations from "../i18n/Translations";
import Constants from "../../Models/Constants";
import LayerConfig from "../../Customizations/JSON/LayerConfig";
import {Tag} from "../../Logic/Tags/Tag";
import {TagUtils} from "../../Logic/Tags/TagUtils";
import BaseUIElement from "../BaseUIElement";
import {VariableUiElement} from "../Base/VariableUIElement";
import Toggle from "../Input/Toggle";
import UserDetails from "../../Logic/Osm/OsmConnection";
import {Translation} from "../i18n/Translation";
export default class SimpleAddUI extends UIElement {
private readonly _loginButton: UIElement;
/*
* The SimpleAddUI is a single panel, which can have multiple states:
* - A list of presets which can be added by the user
* - A 'confirm-selection' button (or alternatively: please enable the layer)
* - A 'something is wrong - please soom in further'
* - A 'read your unread messages before adding a point'
*/
private readonly _confirmPreset: UIEventSource<{
description: string | UIElement,
name: string | UIElement,
icon: UIElement,
tags: Tag[],
layerToAddTo: {
layerDef: LayerConfig,
isDisplayed: UIEventSource<boolean>
}
}>
= new UIEventSource(undefined);
interface PresetInfo {
description: string | Translation,
name: string | BaseUIElement,
icon: BaseUIElement,
tags: Tag[],
layerToAddTo: {
layerDef: LayerConfig,
isDisplayed: UIEventSource<boolean>
}
}
private _component: UIElement;
private readonly openLayerControl: UIElement;
private readonly cancelButton: UIElement;
private readonly goToInboxButton: UIElement = new SubtleButton(Svg.envelope_ui(),
Translations.t.general.goToInbox, {url: "https://www.openstreetmap.org/messages/inbox", newTab: false});
export default class SimpleAddUI extends Toggle {
constructor(isShown: UIEventSource<boolean>) {
super(State.state.locationControl.map(loc => loc.zoom));
const self = this;
this.ListenTo(Locale.language);
this.ListenTo(State.state.osmConnection.userDetails);
this.ListenTo(State.state.layerUpdater.runningQuery);
this.ListenTo(this._confirmPreset);
this.ListenTo(State.state.locationControl);
State.state.filteredLayers.data?.map(layer => {
self.ListenTo(layer.isDisplayed)
})
this._loginButton = Translations.t.general.add.pleaseLogin.Clone().onClick(() => State.state.osmConnection.AttemptLogin());
const loginButton = Translations.t.general.add.pleaseLogin.Clone().onClick(State.state.osmConnection.AttemptLogin);
const readYourMessages = new Combine([
Translations.t.general.readYourMessages.Clone().SetClass("alert"),
new SubtleButton(Svg.envelope_ui(),
Translations.t.general.goToInbox, {url: "https://www.openstreetmap.org/messages/inbox", newTab: false})
]);
const selectedPreset = new UIEventSource<PresetInfo>(undefined);
isShown.addCallback(_ => selectedPreset.setData(undefined)) // Clear preset selection when the UI is closed/opened
function createNewPoint(tags: any[]){
const loc = State.state.LastClickLocation.data;
let feature = State.state.changes.createElement(tags, loc.lat, loc.lon);
State.state.selectedElement.setData(feature);
}
const presetsOverview = SimpleAddUI.CreateAllPresetsPanel(selectedPreset)
const addUi = new VariableUiElement(
selectedPreset.map(preset => {
if (preset === undefined) {
return presetsOverview
}
return SimpleAddUI.CreateConfirmButton(preset,
tags => {
createNewPoint(tags)
selectedPreset.setData(undefined)
}, () => {
selectedPreset.setData(undefined)
})
}
))
super(
new Toggle(
new Toggle(
new Toggle(
Translations.t.general.add.stillLoading.Clone().SetClass("alert"),
addUi,
State.state.layerUpdater.runningQuery
),
Translations.t.general.add.zoomInFurther.Clone().SetClass("alert") ,
State.state.locationControl.map(loc => loc.zoom >= Constants.userJourney.minZoomLevelToAddNewPoints)
),
readYourMessages,
State.state.osmConnection.userDetails.map((userdetails: UserDetails) =>
userdetails.csCount >= Constants.userJourney.addNewPointWithUnreadMessagesUnlock ||
userdetails.unreadMessages == 0)
),
loginButton,
State.state.osmConnection.isLoggedIn
)
this.SetStyle("font-size:large");
this.cancelButton = new SubtleButton(Svg.close_ui(),
Translations.t.general.cancel
).onClick(() => {
self._confirmPreset.setData(undefined);
})
this.openLayerControl = new SubtleButton(Svg.layers_ui(),
Translations.t.general.add.openLayerControl
).onClick(() => {
State.state.layerControlIsOpened.setData(true);
})
// IS shown is the state of the dialog - we reset the choice if the dialog dissappears
isShown.addCallback(isShown =>
{
if(!isShown){
self._confirmPreset.setData(undefined)
}
})
// If the click location changes, we reset the dialog as well
State.state.LastClickLocation.addCallback(() => {
self._confirmPreset.setData(undefined)
})
}
InnerRender(): string {
this._component = this.CreateContent();
return this._component.Render();
}
private CreatePresetsPanel(): UIElement {
const userDetails = State.state.osmConnection.userDetails;
if (userDetails === undefined) {
return undefined;
}
if (!userDetails.data.loggedIn) {
return this._loginButton;
}
if (userDetails.data.unreadMessages > 0 && userDetails.data.csCount < Constants.userJourney.addNewPointWithUnreadMessagesUnlock) {
return new Combine([
Translations.t.general.readYourMessages.Clone().SetClass("alert"),
this.goToInboxButton
]);
}
if (userDetails.data.csCount < Constants.userJourney.addNewPointsUnlock) {
return new Combine(["<span class='alert'>",
Translations.t.general.fewChangesBefore,
"</span>"]);
}
if (State.state.locationControl.data.zoom < Constants.userJourney.minZoomLevelToAddNewPoints) {
return Translations.t.general.add.zoomInFurther.SetClass("alert")
}
if (State.state.layerUpdater.runningQuery.data) {
return Translations.t.general.add.stillLoading
}
const presetButtons = this.CreatePresetButtons()
return new Combine(presetButtons)
}
private CreateContent(): UIElement {
const confirmPanel = this.CreateConfirmPanel();
if (confirmPanel !== undefined) {
return confirmPanel;
}
let intro: UIElement = Translations.t.general.add.intro;
private static CreateConfirmButton(preset: PresetInfo,
confirm: (tags: any[]) => void,
cancel: () => void): BaseUIElement {
let testMode: UIElement = undefined;
if (State.state.osmConnection?.userDetails?.data?.dryRun) {
testMode = new Combine([
"<span class='alert'>",
"Test mode - changes won't be saved",
"</span>"
]);
}
let presets = this.CreatePresetsPanel();
return new Combine([intro, testMode, presets])
}
private CreateConfirmPanel(): UIElement {
const preset = this._confirmPreset.data;
if (preset === undefined) {
return undefined;
}
const confirmButton = new SubtleButton(preset.icon,
new Combine([
"<b>",
Translations.t.general.add.confirmButton.Subs({category: preset.name}),
"</b>"])).SetClass("break-words");
confirmButton.onClick(
this.CreatePoint(preset.tags)
);
Translations.t.general.add.addNew.Subs({category: preset.name}),
Translations.t.general.add.warnVisibleForEveryone.Clone().SetClass("alert")
]).SetClass("flex flex-col")
).SetClass("font-bold break-words")
.onClick(() => confirm(preset.tags));
if (!this._confirmPreset.data.layerToAddTo.isDisplayed.data) {
return new Combine([
Translations.t.general.add.layerNotEnabled.Subs({layer: this._confirmPreset.data.layerToAddTo.layerDef.name})
.SetClass("alert"),
this.openLayerControl,
this.cancelButton
]);
}
const openLayerControl =
new SubtleButton(
Svg.layers_ui(),
new Combine([
Translations.t.general.add.layerNotEnabled
.Subs({layer: preset.layerToAddTo.layerDef.name})
.SetClass("alert"),
Translations.t.general.add.openLayerControl
])
)
.onClick(() => State.state.layerControlIsOpened.setData(true))
const openLayerOrConfirm = new Toggle(
confirmButton,
openLayerControl,
preset.layerToAddTo.isDisplayed
)
const tagInfo = SimpleAddUI.CreateTagInfoFor(preset);
let tagInfo = "";
const csCount = State.state.osmConnection.userDetails.data.csCount;
if (csCount > Constants.userJourney.tagsVisibleAt) {
tagInfo = this._confirmPreset.data.tags.map(t => t.asHumanString(csCount > Constants.userJourney.tagsVisibleAndWikiLinked, true)).join("&");
tagInfo = `<br/>More information about the preset: ${tagInfo}`
}
const cancelButton = new SubtleButton(Svg.close_ui(),
Translations.t.general.cancel
).onClick(cancel )
return new Combine([
Translations.t.general.add.confirmIntro.Subs({title: this._confirmPreset.data.name}),
State.state.osmConnection.userDetails.data.dryRun ? "<span class='alert'>TESTING - changes won't be saved</span>" : "",
confirmButton,
this.cancelButton,
Translations.t.general.add.confirmIntro.Subs({title: preset.name}),
State.state.osmConnection.userDetails.data.dryRun ?
Translations.t.general.testing.Clone().SetClass("alert") : undefined ,
openLayerOrConfirm,
cancelButton,
preset.description,
tagInfo
])
]).SetClass("flex flex-col")
}
private CreatePresetButtons() {
private static CreateTagInfoFor(preset: PresetInfo, optionallyLinkToWiki = true) {
const csCount = State.state.osmConnection.userDetails.data.csCount;
return new Toggle(
Translations.t.general.presetInfo.Subs({
tags: preset.tags.map(t => t.asHumanString(optionallyLinkToWiki && csCount > Constants.userJourney.tagsVisibleAndWikiLinked, true)).join("&"),
}).SetStyle("word-break: break-all"),
undefined,
State.state.osmConnection.userDetails.map(userdetails => userdetails.csCount >= Constants.userJourney.tagsVisibleAt)
);
}
private static CreateAllPresetsPanel(selectedPreset: UIEventSource<PresetInfo>): BaseUIElement {
const presetButtons = SimpleAddUI.CreatePresetButtons(selectedPreset)
let intro: BaseUIElement = Translations.t.general.add.intro;
let testMode: BaseUIElement = undefined;
if (State.state.osmConnection?.userDetails?.data?.dryRun) {
testMode = Translations.t.general.testing.Clone().SetClass("alert")
}
return new Combine([intro, testMode, presetButtons]).SetClass("flex flex-col")
}
private static CreatePresetSelectButton(preset: PresetInfo){
const tagInfo =SimpleAddUI.CreateTagInfoFor(preset, false);
return new SubtleButton(
preset.icon,
new Combine([
Translations.t.general.add.addNew.Subs({
category: preset.name
}).SetClass("font-bold"),
Translations.WT(preset.description)?.FirstSentence(),
tagInfo?.SetClass("subtle")
]).SetClass("flex flex-col")
)
}
/*
* Generates the list with all the buttons.*/
private static CreatePresetButtons(selectedPreset: UIEventSource<PresetInfo>): BaseUIElement {
const allButtons = [];
const self = this;
for (const layer of State.state.filteredLayers.data) {
if(layer.isDisplayed.data === false && State.state.featureSwitchLayers){
continue;
}
const presets = layer.layerDef.presets;
for (const preset of presets) {
const tags = TagUtils.KVtoProperties(preset.tags ?? []);
let icon: UIElement = new FixedUiElement(layer.layerDef.GenerateLeafletStyle(new UIEventSource<any>(tags), false).icon.html.Render()).SetClass("simple-add-ui-icon");
const csCount = State.state.osmConnection.userDetails.data.csCount;
let tagInfo = undefined;
if (csCount > Constants.userJourney.tagsVisibleAt) {
const presets = preset.tags.map(t => new Combine([t.asHumanString(false, true), " "]).SetClass("subtle break-words"))
tagInfo = new Combine(presets)
const tags = TagUtils.KVtoProperties(preset.tags ?? []);
let icon: BaseUIElement = layer.layerDef.GenerateLeafletStyle(new UIEventSource<any>(tags), false).icon.html
.SetClass("w-12 h-12 block relative");
const presetInfo: PresetInfo = {
tags: preset.tags,
layerToAddTo: layer,
name: preset.title,
description: preset.description,
icon: icon
}
const button: UIElement =
new SubtleButton(
icon,
new Combine([
"<b>",
preset.title,
"</b>",
preset.description !== undefined ? new Combine(["<br/>", preset.description.FirstSentence()]) : "",
"<br/>",
tagInfo
])
).onClick(
() => {
self._confirmPreset.setData({
tags: preset.tags,
layerToAddTo: layer,
name: preset.title,
description: preset.description,
icon: icon
});
self.Update();
}
)
const button = SimpleAddUI.CreatePresetSelectButton(presetInfo);
button.onClick(() => {
selectedPreset.setData(presetInfo)
})
allButtons.push(button);
}
}
return allButtons;
return new Combine(allButtons).SetClass("flex flex-col");
}
private CreatePoint(tags: Tag[]) {
return () => {
console.log("Create Point Triggered")
const loc = State.state.LastClickLocation.data;
let feature = State.state.changes.createElement(tags, loc.lat, loc.lon);
State.state.selectedElement.setData(feature);
this._confirmPreset.setData(undefined);
}
}
public OnClose(){
console.log("On close triggered")
this._confirmPreset.setData(undefined)
}
}

View file

@ -1,61 +1,72 @@
import Locale from "../i18n/Locale";
import {UIElement} from "../UIElement";
import State from "../../State";
import Combine from "../Base/Combine";
import LanguagePicker from "../LanguagePicker";
import Translations from "../i18n/Translations";
import {VariableUiElement} from "../Base/VariableUIElement";
import LayoutConfig from "../../Customizations/JSON/LayoutConfig";
import Toggle from "../Input/Toggle";
import {SubtleButton} from "../Base/SubtleButton";
import Svg from "../../Svg";
import {UIEventSource} from "../../Logic/UIEventSource";
import {FixedUiElement} from "../Base/FixedUiElement";
export default class ThemeIntroductionPanel extends UIElement {
private languagePicker: UIElement;
export default class ThemeIntroductionPanel extends VariableUiElement {
private readonly loginStatus: UIElement;
private _layout: UIEventSource<LayoutConfig>;
constructor(isShown: UIEventSource<boolean>) {
constructor() {
super(State.state.osmConnection.userDetails);
this.ListenTo(Locale.language);
this.languagePicker = LanguagePicker.CreateLanguagePicker(State.state.layoutToUse.data.language, Translations.t.general.pickLanguage);
this._layout = State.state.layoutToUse;
this.ListenTo(State.state.layoutToUse);
const languagePicker =
new VariableUiElement(
State.state.layoutToUse.map(layout => LanguagePicker.CreateLanguagePicker(layout.language, Translations.t.general.pickLanguage.Clone()))
)
;
const toTheMap = new SubtleButton(
undefined,
Translations.t.general.openTheMap.Clone().SetClass("text-xl font-bold w-full text-center")
).onClick(() =>{
isShown.setData(false)
}).SetClass("only-on-mobile")
const plzLogIn =
Translations.t.general.loginWithOpenStreetMap
new SubtleButton(
Svg.osm_logo_ui(),
new Combine([Translations.t.general.loginWithOpenStreetMap
.Clone().SetClass("text-xl font-bold"),
Translations.t.general.loginOnlyNeededToEdit.Clone().SetClass("font-bold")]
).SetClass("flex flex-col text-center w-full")
)
.onClick(() => {
State.state.osmConnection.AttemptLogin()
});
const welcomeBack = Translations.t.general.welcomeBack.Clone();
const welcomeBack = Translations.t.general.welcomeBack;
this.loginStatus = new VariableUiElement(
State.state.osmConnection.userDetails.map(
userdetails => {
if (State.state.featureSwitchUserbadge.data) {
return "";
}
return (userdetails.loggedIn ? welcomeBack : plzLogIn).Render();
}
const loginStatus =
new Toggle(
new Toggle(
welcomeBack,
plzLogIn,
State.state.osmConnection.isLoggedIn
),
undefined,
State.state.featureSwitchUserbadge
)
)
super(State.state.layoutToUse.map (layout => new Combine([
layout.description.Clone(),
"<br/><br/>",
toTheMap,
loginStatus,
layout.descriptionTail.Clone(),
"<br/>",
languagePicker,
...layout.CustomCodeSnippets()
])))
this.SetClass("link-underline")
}
InnerRender(): string {
const layout : LayoutConfig = this._layout.data;
return new Combine([
layout.description,
"<br/><br/>",
this.loginStatus,
layout.descriptionTail,
"<br/>",
this.languagePicker,
...layout.CustomCodeSnippets()
]).Render()
}
}

View file

@ -0,0 +1,54 @@
import {UIElement} from "../UIElement";
import {UIEventSource} from "../../Logic/UIEventSource";
import BaseUIElement from "../BaseUIElement";
import {VariableUiElement} from "../Base/VariableUIElement";
import Translations from "../i18n/Translations";
/**
* Shows that 'images are uploading', 'all images are uploaded' as relevant...
*/
export default class UploadFlowStateUI extends UIElement{
private readonly _element: BaseUIElement
constructor(queue: UIEventSource<string[]>, failed: UIEventSource<string[]>, success: UIEventSource<string[]>) {
super();
const t = Translations.t.image;
this._element = new VariableUiElement(
queue.map(queue => {
const failedReasons = failed.data
const successCount = success.data.length
const pendingCount = queue.length - successCount - failedReasons.length;
let stateMessages : BaseUIElement[] = []
if(pendingCount == 1){
stateMessages.push(t.uploadingPicture.Clone().SetClass("alert"))
}
if(pendingCount > 1){
stateMessages.push(t.uploadingMultiple.Subs({count: ""+pendingCount}).SetClass("alert"))
}
if(failedReasons.length > 0){
stateMessages.push(t.uploadFailed.Clone().SetClass("alert"))
}
if(successCount > 0 && pendingCount == 0){
stateMessages.push(t.uploadDone.SetClass("thanks"))
}
stateMessages.forEach(msg => msg.SetStyle("display: block ruby"))
return stateMessages
}, [failed, success])
);
}
protected InnerRender(): string | BaseUIElement {
return this._element
}
}

View file

@ -1,10 +1,7 @@
/**
* Handles and updates the user badge
*/
import {UIEventSource} from "../../Logic/UIEventSource";
import {UIElement} from "../UIElement";
import {VariableUiElement} from "../Base/VariableUIElement";
import UserDetails from "../../Logic/Osm/OsmConnection";
import Svg from "../../Svg";
import State from "../../State";
import Combine from "../Base/Combine";
@ -12,133 +9,127 @@ import {FixedUiElement} from "../Base/FixedUiElement";
import LanguagePicker from "../LanguagePicker";
import Translations from "../i18n/Translations";
import Link from "../Base/Link";
import Toggle from "../Input/Toggle";
import Img from "../Base/Img";
export default class UserBadge extends UIElement {
private _userDetails: UIEventSource<UserDetails>;
private _logout: UIElement;
private _homeButton: UIElement;
private _languagePicker: UIElement;
private _loginButton: UIElement;
export default class UserBadge extends Toggle {
constructor() {
super(State.state.osmConnection.userDetails);
this._userDetails = State.state.osmConnection.userDetails;
this._languagePicker = (LanguagePicker.CreateLanguagePicker(State.state.layoutToUse.data.language) ?? new FixedUiElement(""))
.SetStyle("width:min-content;");
this._loginButton = Translations.t.general.loginWithOpenStreetMap
const userDetails = State.state.osmConnection.userDetails;
const loginButton = Translations.t.general.loginWithOpenStreetMap
.Clone()
.SetClass("userbadge-login pt-3 w-full")
.onClick(() => State.state.osmConnection.AttemptLogin());
this._logout =
const logout =
Svg.logout_svg()
.onClick(() => {
State.state.osmConnection.LogOut();
});
this._userDetails.addCallback(function () {
const profilePic = document.getElementById("profile-pic");
if (profilePic) {
profilePic.onload = function () {
profilePic.style.opacity = "1"
};
}
});
const userBadge = userDetails.map(user => {
{
const homeButton = new VariableUiElement(
userDetails.map((userinfo) => {
if (userinfo.home) {
return Svg.home_ui();
}
return " ";
})
).onClick(() => {
const home = State.state.osmConnection.userDetails.data?.home;
if (home === undefined) {
return;
}
State.state.leafletMap.data.setView([home.lat, home.lon], 16);
});
this._homeButton = new VariableUiElement(
this._userDetails.map((userinfo) => {
if (userinfo.home) {
return Svg.home_ui().Render();
const linkStyle = "flex items-baseline"
const languagePicker = (LanguagePicker.CreateLanguagePicker(State.state.layoutToUse.data.language) ?? new FixedUiElement(""))
.SetStyle("width:min-content;");
let messageSpan =
new Link(
new Combine([Svg.envelope, "" + user.totalMessages]).SetClass(linkStyle),
'https://www.openstreetmap.org/messages/inbox',
true
)
const csCount =
new Link(
new Combine([Svg.star, "" + user.csCount]).SetClass(linkStyle),
`https://www.openstreetmap.org/user/${user.name}/history`,
true);
if (user.unreadMessages > 0) {
messageSpan = new Link(
new Combine([Svg.envelope, "" + user.unreadMessages]),
'https://www.openstreetmap.org/messages/inbox',
true
).SetClass("alert")
}
return " ";
})
).onClick(() => {
const home = State.state.osmConnection.userDetails.data?.home;
if (home === undefined) {
return;
let dryrun = new FixedUiElement("");
if (user.dryRun) {
dryrun = new FixedUiElement("TESTING").SetClass("alert");
}
const settings =
new Link(Svg.gear_svg(),
`https://www.openstreetmap.org/user/${encodeURIComponent(user.name)}/account`,
true)
const userIcon = new Link(
new Img(user.img)
.SetClass("rounded-full opacity-0 m-0 p-0 duration-500 w-16 h16 float-left")
,
`https://www.openstreetmap.org/user/${encodeURIComponent(user.name)}`,
true
);
const userName = new Link(
new FixedUiElement(user.name),
`https://www.openstreetmap.org/user/${user.name}`,
true);
const userStats = new Combine([
homeButton,
settings,
messageSpan,
csCount,
languagePicker,
logout
])
.SetClass("userstats")
const usertext = new Combine([
userName,
dryrun,
userStats
]).SetClass("usertext")
return new Combine([
userIcon,
usertext,
]).SetClass("h-16")
}
State.state.leafletMap.data.setView([home.lat, home.lon], 16);
});
}
InnerRender(): string {
const user = this._userDetails.data;
if (!user.loggedIn) {
return this._loginButton.Render();
}
const linkStyle = "flex items-baseline"
let messageSpan: UIElement =
new Link(
new Combine([Svg.envelope, "" + user.totalMessages]).SetClass(linkStyle),
'https://www.openstreetmap.org/messages/inbox',
true
)
const csCount =
new Link(
new Combine([Svg.star, "" + user.csCount]).SetClass(linkStyle),
`https://www.openstreetmap.org/user/${user.name}/history`,
true);
if (user.unreadMessages > 0) {
messageSpan = new Link(
new Combine([Svg.envelope, "" + user.unreadMessages]),
'https://www.openstreetmap.org/messages/inbox',
true
).SetClass("alert")
}
let dryrun: UIElement = new FixedUiElement("");
if (user.dryRun) {
dryrun = new FixedUiElement("TESTING").SetClass("alert");
}
const settings =
new Link(Svg.gear_svg(),
`https://www.openstreetmap.org/user/${encodeURIComponent(user.name)}/account`,
true)
const userIcon = new Link(
new FixedUiElement(`<img id='profile-pic' src='${user.img}' alt='profile-pic'/>`),
`https://www.openstreetmap.org/user/${encodeURIComponent(user.name)}`,
true
);
const userName = new Link(
new FixedUiElement(user.name),
`https://www.openstreetmap.org/user/${user.name}`,
true);
const userStats = new Combine([
this._homeButton,
settings,
messageSpan,
csCount,
this._languagePicker,
this._logout
])
.SetClass("userstats")
const usertext = new Combine([
userName,
dryrun,
userStats
]).SetClass("usertext")
return new Combine([
userIcon,
usertext,
]).Render()
super(
new VariableUiElement(userBadge),
loginButton,
State.state.osmConnection.isLoggedIn
)
}

View file

@ -1,59 +1,46 @@
import {UIElement} from "./UIElement";
import Translations from "./i18n/Translations";
import State from "../State";
import {VariableUiElement} from "./Base/VariableUIElement";
export default class CenterMessageBox extends UIElement {
export default class CenterMessageBox extends VariableUiElement {
constructor() {
super(State.state.centerMessage);
const state = State.state;
const updater = State.state.layerUpdater;
const t = Translations.t.centerMessage;
const message = updater.runningQuery.map(
isRunning => {
if (isRunning) {
return {el: t.loadingData};
}
if (!updater.sufficientlyZoomed.data) {
return {el: t.zoomIn}
}
if (updater.timeout.data > 0) {
return {el: t.retrying.Subs({count: "" + updater.timeout.data})}
}
return {el: t.ready, isDone: true}
this.ListenTo(State.state.locationControl);
this.ListenTo(State.state.layerUpdater.timeout);
this.ListenTo(State.state.layerUpdater.runningQuery);
this.ListenTo(State.state.layerUpdater.sufficientlyZoomed);
}
},
[updater.timeout, updater.sufficientlyZoomed, state.locationControl]
)
super(message.map(toShow => toShow.el))
this.SetClass("block " +
"rounded-3xl bg-white text-xl font-bold text-center pointer-events-none p-4")
this.SetStyle("transition: opacity 750ms linear")
private static prep(): { innerHtml: string, done: boolean } {
if (State.state.centerMessage.data != "") {
return {innerHtml: State.state.centerMessage.data, done: false};
}
const lu = State.state.layerUpdater;
if (lu.timeout.data > 0) {
return {
innerHtml: Translations.t.centerMessage.retrying.Subs({count: "" + lu.timeout.data}).Render(),
done: false
};
}
message.addCallbackAndRun(toShow => {
const isDone = toShow.isDone ?? false;
if (isDone) {
this.SetStyle("transition: opacity 750ms linear; opacity: 0")
} else {
this.SetStyle("transition: opacity 750ms linear; opacity: 0.75")
if (lu.runningQuery.data) {
return {innerHtml: Translations.t.centerMessage.loadingData.Render(), done: false};
}
})
}
if (!lu.sufficientlyZoomed.data) {
return {innerHtml: Translations.t.centerMessage.zoomIn.Render(), done: false};
} else {
return {innerHtml: Translations.t.centerMessage.ready.Render(), done: true};
}
}
InnerRender(): string {
return CenterMessageBox.prep().innerHtml;
}
InnerUpdate(htmlElement: HTMLElement) {
const pstyle = htmlElement.parentElement.style;
if (State.state.centerMessage.data != "") {
pstyle.opacity = "1";
pstyle.pointerEvents = "all";
return;
}
pstyle.pointerEvents = "none";
if (CenterMessageBox.prep().done) {
pstyle.opacity = "0";
} else {
pstyle.opacity = "0.5";
}
}
}

View file

@ -1,113 +0,0 @@
import {UIElement} from "../UIElement";
import {TabbedComponent} from "../Base/TabbedComponent";
import {SubtleButton} from "../Base/SubtleButton";
import {UIEventSource} from "../../Logic/UIEventSource";
import {LayoutConfigJson} from "../../Customizations/JSON/LayoutConfigJson";
import Combine from "../Base/Combine";
import {GenerateEmpty} from "./GenerateEmpty";
import LayerPanelWithPreview from "./LayerPanelWithPreview";
import UserDetails from "../../Logic/Osm/OsmConnection";
import {MultiInput} from "../Input/MultiInput";
import TagRenderingPanel from "./TagRenderingPanel";
import SingleSetting from "./SingleSetting";
import {VariableUiElement} from "../Base/VariableUIElement";
import AvailableBaseLayers from "../../Logic/Actors/AvailableBaseLayers";
import {DropDown} from "../Input/DropDown";
import TagRenderingConfig from "../../Customizations/JSON/TagRenderingConfig";
import Svg from "../../Svg";
export default class AllLayersPanel extends UIElement {
private panel: UIElement;
private readonly _config: UIEventSource<LayoutConfigJson>;
private readonly languages: UIEventSource<string[]>;
private readonly userDetails: UserDetails;
private readonly currentlySelected: UIEventSource<SingleSetting<any>>;
constructor(config: UIEventSource<LayoutConfigJson>,
languages: UIEventSource<any>, userDetails: UserDetails) {
super(undefined);
this.userDetails = userDetails;
this._config = config;
this.languages = languages;
this.createPanels(userDetails);
const self = this;
this.dumbMode = false;
config.map<number>(config => config.layers.length).addCallback(() => self.createPanels(userDetails));
}
private createPanels(userDetails: UserDetails) {
const self = this;
const tabs = [];
const roamingTags = new MultiInput("Add a tagrendering",
() => GenerateEmpty.createEmptyTagRendering(),
() => {
return new TagRenderingPanel(self.languages, self.currentlySelected, self.userDetails)
}, undefined, {allowMovement: true});
new SingleSetting(this._config, roamingTags, "roamingRenderings", "Roaming Renderings", "These tagrenderings are shown everywhere");
const backgroundLayers = AvailableBaseLayers.layerOverview.map(baselayer => ({shown:
baselayer.name, value: baselayer.id}));
const dropDown = new DropDown("Choose the default background layer",
[{value: "osm",shown:"OpenStreetMap <b>(default)</b>"}, ...backgroundLayers])
new SingleSetting(self._config, dropDown, "defaultBackgroundId", "Default background layer",
"Selects the background layer that is used by default. If this layer is not available at the given point, OSM-Carto will be ued");
const layers = this._config.data.layers;
for (let i = 0; i < layers.length; i++) {
tabs.push({
header: new VariableUiElement(this._config.map((config: LayoutConfigJson) => {
const layer = config.layers[i];
if (typeof layer !== "string") {
try {
const iconTagRendering = new TagRenderingConfig(layer["icon"], undefined, "icon")
const icon = iconTagRendering.GetRenderValue({"id": "node/-1"}).txt;
return `<img src='${icon}'>`
} catch (e) {
return Svg.bug_img
// Nothing to do here
}
}
return Svg.help_img;
})),
content: new LayerPanelWithPreview(this._config, this.languages, i, userDetails)
});
}
tabs.push({
header: Svg.layersAdd_img,
content: new Combine([
"<h2>Layer editor</h2>",
"In this tab page, you can add and edit the layers of the theme. Click the layers above or add a new layer to get started.",
new SubtleButton(
Svg.layersAdd_ui(),
"Add a new layer"
).onClick(() => {
self._config.data.layers.push(GenerateEmpty.createEmptyLayer())
self._config.ping();
}),
"<h2>Default background layer</h2>",
dropDown,
"<h2>TagRenderings for every layer</h2>",
"Define tag renderings and questions here that should be shown on every layer of the theme.",
roamingTags
]
),
})
this.panel = new TabbedComponent(tabs, new UIEventSource<number>(Math.max(0, layers.length - 1)));
this.Update();
}
InnerRender(): string {
return this.panel.Render();
}
}

View file

@ -1,118 +0,0 @@
import {UIElement} from "../UIElement";
import UserDetails, {OsmConnection} from "../../Logic/Osm/OsmConnection";
import {UIEventSource} from "../../Logic/UIEventSource";
import SingleSetting from "./SingleSetting";
import GeneralSettings from "./GeneralSettings";
import Combine from "../Base/Combine";
import {VariableUiElement} from "../Base/VariableUIElement";
import {TabbedComponent} from "../Base/TabbedComponent";
import PageSplit from "../Base/PageSplit";
import AllLayersPanel from "./AllLayersPanel";
import SharePanel from "./SharePanel";
import {LayoutConfigJson} from "../../Customizations/JSON/LayoutConfigJson";
import {SubtleButton} from "../Base/SubtleButton";
import {FixedUiElement} from "../Base/FixedUiElement";
import SavePanel from "./SavePanel";
import {LocalStorageSource} from "../../Logic/Web/LocalStorageSource";
import HelpText from "./HelpText";
import Svg from "../../Svg";
import Constants from "../../Models/Constants";
import LZString from "lz-string";
import {Utils} from "../../Utils";
export default class CustomGeneratorPanel extends UIElement {
private mainPanel: UIElement;
private loginButton: UIElement;
private readonly connection: OsmConnection;
constructor(connection: OsmConnection, layout: LayoutConfigJson) {
super(connection.userDetails);
this.connection = connection;
this.SetClass("main-tabs");
this.loginButton = new SubtleButton("", "Login to create a custom theme").onClick(() => connection.AttemptLogin())
const self = this;
self.mainPanel = new FixedUiElement("Attempting to log in...");
connection.OnLoggedIn(userDetails => {
self.InitMainPanel(layout, userDetails, connection);
self.Update();
})
}
private InitMainPanel(layout: LayoutConfigJson, userDetails: UserDetails, connection: OsmConnection) {
const es = new UIEventSource(layout);
const encoded = es.map(config => LZString.compressToBase64(Utils.MinifyJSON(JSON.stringify(config, null, 0))));
encoded.addCallback(encoded => LocalStorageSource.Get("last-custom-theme"))
const liveUrl = encoded.map(encoded => `./index.html?userlayout=${es.data.id}#${encoded}`)
const testUrl = encoded.map(encoded => `./index.html?test=true&userlayout=${es.data.id}#${encoded}`)
const iframe = testUrl.map(url => `<iframe src='${url}' width='100%' height='99%' style="box-sizing: border-box" title='Theme Preview'></iframe>`);
const currentSetting = new UIEventSource<SingleSetting<any>>(undefined)
const generalSettings = new GeneralSettings(es, currentSetting);
const languages = generalSettings.languages;
const chronic = UIEventSource.Chronic(120 * 1000)
.map(date => {
if (es.data.id == undefined) {
return undefined
}
if (es.data.id === "") {
return undefined;
}
const pref = connection.GetLongPreference("installed-theme-" + es.data.id);
pref.setData(encoded.data);
return date;
});
const preview = new Combine([
new VariableUiElement(iframe)
]).SetClass("preview")
this.mainPanel = new TabbedComponent([
{
header: Svg.gear_img,
content:
new PageSplit(
generalSettings.SetStyle("width: 50vw;"),
new Combine([
new HelpText(currentSetting).SetStyle("height:calc(100% - 65vh); width: 100%; display:block; overflow-y: auto"),
preview.SetStyle("height:65vh; width:100%; display:block")
]).SetStyle("position:relative; width: 50%;")
)
},
{
header: Svg.layers_img,
content: new AllLayersPanel(es, languages, userDetails)
},
{
header: Svg.floppy_img,
content: new SavePanel(this.connection, es, chronic)
},
{
header:Svg.share_img,
content: new SharePanel(es, liveUrl, userDetails)
}
])
}
InnerRender(): string {
const ud = this.connection.userDetails.data;
if (!ud.loggedIn) {
return new Combine([
"<h3>Not Logged in</h3>",
"You need to be logged in in order to create a custom theme",
this.loginButton
]).Render();
}
const journey = Constants.userJourney;
if (ud.csCount <= journey.themeGeneratorReadOnlyUnlock) {
return new Combine([
"<h3>Too little experience</h3>",
`<p>Creating your own (readonly) themes can only be done if you have more then <b>${journey.themeGeneratorReadOnlyUnlock}</b> changesets made</p>`,
`<p>Making a theme including survey options can be done at <b>${journey.themeGeneratorFullUnlock}</b> changesets</p>`
]).Render();
}
return this.mainPanel.Render()
}
}

View file

@ -1,88 +0,0 @@
import {UIElement} from "../UIElement";
import {UIEventSource} from "../../Logic/UIEventSource";
import {LayoutConfigJson} from "../../Customizations/JSON/LayoutConfigJson";
import Combine from "../Base/Combine";
import SettingsTable from "./SettingsTable";
import SingleSetting from "./SingleSetting";
import {TextField} from "../Input/TextField";
import MultiLingualTextFields from "../Input/MultiLingualTextFields";
import ValidatedTextField from "../Input/ValidatedTextField";
export default class GeneralSettingsPanel extends UIElement {
private panel: Combine;
public languages : UIEventSource<string[]>;
constructor(configuration: UIEventSource<LayoutConfigJson>, currentSetting: UIEventSource<SingleSetting<any>>) {
super(undefined);
const languagesField =
ValidatedTextField.Mapped(
str => {
console.log("Language from str", str);
return str?.split(";")?.map(str => str.trim().toLowerCase());
},
languages => languages.join(";"));
this.languages = languagesField.GetValue();
const version = new TextField();
const current_datetime = new Date();
let formatted_date = current_datetime.getFullYear() + "-" + (current_datetime.getMonth() + 1) + "-" + current_datetime.getDate() + " " + current_datetime.getHours() + ":" + current_datetime.getMinutes() + ":" + current_datetime.getSeconds()
version.GetValue().setData(formatted_date);
const locationRemark = "<br/>Note that, as soon as an URL-parameter sets the location or a location is known due to a previous visit, that the theme-set location is ignored"
const settingsTable = new SettingsTable(
[
new SingleSetting(configuration, new TextField({placeholder:"id"}), "id",
"Identifier", "The identifier of this theme. This should be a lowercase, unique string"),
new SingleSetting(configuration, version, "version", "Version",
"A version to indicate the theme version. Ideal is the date you created or updated the theme"),
new SingleSetting(configuration, languagesField, "language",
"Supported languages", "Which languages do you want to support in this theme? Type the two letter code representing your language, seperated by <span class='literal-code'>;</span>. For example:<span class='literal-code'>en;nl</span> "),
new SingleSetting(configuration, new MultiLingualTextFields(this.languages), "title",
"Title", "The title as shown in the welcome message, in the browser title bar, in the more screen, ..."),
new SingleSetting(configuration, new MultiLingualTextFields(this.languages), "shortDescription","Short description",
"The short description is shown as subtext in the social preview and on the 'more screen'-buttons. It should be at most one sentence of around ~25words"),
new SingleSetting(configuration, new MultiLingualTextFields(this.languages, true),
"description", "Description", "The description is shown in the welcome-message when opening MapComplete. It is a small text welcoming users"),
new SingleSetting(configuration, new TextField({placeholder: "URL to icon"}), "icon",
"Icon", "A visual representation for your theme; used as logo in the welcomeMessage. If your theme is official, used as favicon and webapp logo",
{
showIconPreview: true
}),
new SingleSetting(configuration, ValidatedTextField.NumberInput("nat", n => n < 23), "startZoom","Initial zoom level",
"When a user first loads MapComplete, this zoomlevel is shown."+locationRemark),
new SingleSetting(configuration, ValidatedTextField.NumberInput("float", n => (n < 90 && n > -90)), "startLat","Initial latitude",
"When a user first loads MapComplete, this latitude is shown as location."+locationRemark),
new SingleSetting(configuration, ValidatedTextField.NumberInput("float", n => (n < 180 && n > -180)), "startLon","Initial longitude",
"When a user first loads MapComplete, this longitude is shown as location."+locationRemark),
new SingleSetting(configuration, ValidatedTextField.NumberInput("pfloat", n => (n < 0.5 )), "widenFactor","Query widening",
"When a query is run, the data within bounds of the visible map is loaded.\n" +
"However, users tend to pan and zoom a lot. It is pretty annoying if every single pan means a reloading of the data.\n" +
"For this, the bounds are widened in order to make a small pan still within bounds of the loaded data.\n" +
"IF widenfactor is 0, this feature is disabled. A recommended value is between 0.5 and 0.01 (the latter for very dense queries)"),
new SingleSetting(configuration, new TextField({placeholder: "URL to social image"}), "socialImage",
"og:image (aka Social Image)", "<span class='alert'>Only works on incorporated themes</span>" +
"The Social Image is set as og:image for the HTML-site and helps social networks to show a preview", {showIconPreview: true})
], currentSetting);
this.panel = new Combine([
"<h3>General theme settings</h3>",
settingsTable
]);
}
InnerRender(): string {
return this.panel.Render();
}
}

View file

@ -1,87 +0,0 @@
import {LayerConfigJson} from "../../Customizations/JSON/LayerConfigJson";
import {LayoutConfigJson} from "../../Customizations/JSON/LayoutConfigJson";
import {TagRenderingConfigJson} from "../../Customizations/JSON/TagRenderingConfigJson";
export class GenerateEmpty {
public static createEmptyLayer(): LayerConfigJson {
return {
id: "yourlayer",
name: {},
minzoom: 12,
overpassTags: {and: [""]},
title: {},
description: {},
tagRenderings: [],
hideUnderlayingFeaturesMinPercentage: 0,
icon: {
render: "./assets/svg/bug.svg"
},
width: {
render: "8"
},
iconSize: {
render: "40,40,center"
},
color:{
render: "#00f"
}
}
}
public static createEmptyLayout(): LayoutConfigJson {
return {
id: "id",
title: {},
shortDescription: {},
description: {},
language: [],
maintainer: "",
icon: "./assets/svg/bug.svg",
version: "0",
startLat: 0,
startLon: 0,
startZoom: 1,
widenFactor: 0.05,
socialImage: "",
layers: [
GenerateEmpty.createEmptyLayer()
]
}
}
public static createTestLayout(): LayoutConfigJson {
return {
id: "test",
title: {"en": "Test layout"},
shortDescription: {},
description: {"en": "A layout for testing"},
language: ["en"],
maintainer: "Pieter Vander Vennet",
icon: "./assets/svg/bug.svg",
version: "0",
startLat: 0,
startLon: 0,
startZoom: 1,
widenFactor: 0.05,
socialImage: "",
layers: [{
id: "testlayer",
name: {en:"Testing layer"},
minzoom: 15,
overpassTags: {and: ["highway=residential"]},
title: {},
description: {"en": "Some Description"},
icon: {render: {en: "./assets/svg/pencil.svg"}},
width: {render: {en: "5"}},
tagRenderings: [{
render: {"en":"Test Rendering"}
}]
}]
}
}
public static createEmptyTagRendering(): TagRenderingConfigJson {
return {};
}
}

View file

@ -1,51 +0,0 @@
import {UIEventSource} from "../../Logic/UIEventSource";
import {UIElement} from "../UIElement";
import {VariableUiElement} from "../Base/VariableUIElement";
import {SubtleButton} from "../Base/SubtleButton";
import Combine from "../Base/Combine";
import SingleSetting from "./SingleSetting";
import Svg from "../../Svg";
export default class HelpText extends UIElement {
private helpText: UIElement;
private returnButton: UIElement;
constructor(currentSetting: UIEventSource<SingleSetting<any>>) {
super();
this.returnButton = new SubtleButton(Svg.close_ui(),
new VariableUiElement(
currentSetting.map(currentSetting => {
if (currentSetting === undefined) {
return "";
}
return "Return to general help";
}
)
))
.ListenTo(currentSetting)
.SetClass("small-button")
.onClick(() => currentSetting.setData(undefined));
this.helpText = new VariableUiElement(currentSetting.map((setting: SingleSetting<any>) => {
if (setting === undefined) {
return "<h1>Welcome to the Custom Theme Builder</h1>" +
"Here, one can make their own custom mapcomplete themes.<br/>" +
"Fill out the fields to get a working mapcomplete theme. More information on the selected field will appear here when you click it.<br/>" +
"Want to see how the quests are doing in number of visits? All the stats are open on <a href='https://pietervdvn.goatcounter.com' target='_blank'>goatcounter</a>";
}
return new Combine(["<h1>", setting._name, "</h1>", setting._description.Render()]).Render();
}))
}
InnerRender(): string {
return new Combine([this.helpText,
this.returnButton,
]).Render();
}
}

View file

@ -1,251 +0,0 @@
import {UIElement} from "../UIElement";
import {UIEventSource} from "../../Logic/UIEventSource";
import {LayoutConfigJson} from "../../Customizations/JSON/LayoutConfigJson";
import SettingsTable from "./SettingsTable";
import SingleSetting from "./SingleSetting";
import {SubtleButton} from "../Base/SubtleButton";
import Combine from "../Base/Combine";
import {TextField} from "../Input/TextField";
import {InputElement} from "../Input/InputElement";
import MultiLingualTextFields from "../Input/MultiLingualTextFields";
import CheckBox from "../Input/CheckBox";
import AndOrTagInput from "../Input/AndOrTagInput";
import TagRenderingPanel from "./TagRenderingPanel";
import {DropDown} from "../Input/DropDown";
import {TagRenderingConfigJson} from "../../Customizations/JSON/TagRenderingConfigJson";
import {MultiInput} from "../Input/MultiInput";
import {LayerConfigJson} from "../../Customizations/JSON/LayerConfigJson";
import PresetInputPanel from "./PresetInputPanel";
import UserDetails from "../../Logic/Osm/OsmConnection";
import {FixedUiElement} from "../Base/FixedUiElement";
import ValidatedTextField from "../Input/ValidatedTextField";
import Svg from "../../Svg";
import Constants from "../../Models/Constants";
/**
* Shows the configuration for a single layer
*/
export default class LayerPanel extends UIElement {
private readonly _config: UIEventSource<LayoutConfigJson>;
private readonly settingsTable: UIElement;
private readonly mapRendering: UIElement;
private readonly deleteButton: UIElement;
public readonly titleRendering: UIElement;
public readonly selectedTagRendering: UIEventSource<TagRenderingPanel>
= new UIEventSource<TagRenderingPanel>(undefined);
private tagRenderings: UIElement;
private presetsPanel: UIElement;
constructor(config: UIEventSource<LayoutConfigJson>,
languages: UIEventSource<string[]>,
index: number,
currentlySelected: UIEventSource<SingleSetting<any>>,
userDetails: UserDetails) {
super();
this._config = config;
this.mapRendering = this.setupRenderOptions(config, languages, index, currentlySelected, userDetails);
const actualDeleteButton = new SubtleButton(
Svg.delete_icon_ui(),
"Yes, delete this layer"
).onClick(() => {
config.data.layers.splice(index, 1);
config.ping();
});
this.deleteButton = new CheckBox(
new Combine(
[
"<h3>Confirm layer deletion</h3>",
new SubtleButton(
Svg.close_ui(),
"No, don't delete"
),
"<span class='alert'>Deleting a layer can not be undone!</span>",
actualDeleteButton
]
),
new SubtleButton(
Svg.delete_icon_ui(),
"Remove this layer"
)
)
function setting(input: InputElement<any>, path: string | string[], name: string, description: string | UIElement): SingleSetting<any> {
let pathPre = ["layers", index];
if (typeof (path) === "string") {
pathPre.push(path);
} else {
pathPre = pathPre.concat(path);
}
return new SingleSetting<any>(config, input, pathPre, name, description);
}
this.settingsTable = new SettingsTable([
setting(new TextField({placeholder:"Layer id"}), "id", "Id", "An identifier for this layer<br/>This should be a simple, lowercase, human readable string that is used to identify the layer."),
setting(new MultiLingualTextFields(languages), "name", "Name", "The human-readable name of this layer<br/>Used in the layer control panel and the 'Personal theme'"),
setting(new MultiLingualTextFields(languages, true), "description", "Description", "A description for this layer.<br/>Shown in the layer selections and in the personal theme"),
setting(ValidatedTextField.NumberInput("nat", n => n < 23), "minzoom", "Minimal zoom",
"The minimum zoomlevel needed to load and show this layer."),
setting(new DropDown("", [
{value: 0, shown: "Show ways and areas as ways and lines"},
{value: 2, shown: "Show both the ways/areas and the centerpoints"},
{value: 1, shown: "Show everything as centerpoint"}]), "wayHandling", "Way handling",
"Describes how ways and areas are represented on the map: areas can be represented as the area itself, or it can be converted into the centerpoint"),
setting(new AndOrTagInput(), ["overpassTags"], "Overpass query",
"The tags of the objects to load from overpass"),
],
currentlySelected);
const self = this;
const popupTitleRendering = new TagRenderingPanel(languages, currentlySelected, userDetails, {
title: "Popup title",
description: "This is the rendering shown as title in the popup for this element",
disableQuestions: true
});
new SingleSetting(config, popupTitleRendering, ["layers", index, "title"], "Popup title", "This is the rendering shown as title in the popup");
this.titleRendering = popupTitleRendering;
this.registerTagRendering(popupTitleRendering);
const renderings = config.map(config => {
const layer = config.layers[index] as LayerConfigJson;
// @ts-ignore
const renderings : TagRenderingConfigJson[] = layer.tagRenderings ;
return renderings;
});
const tagRenderings = new MultiInput<TagRenderingConfigJson>("Add a tag rendering/question",
() => ({}),
() => {
const tagPanel = new TagRenderingPanel(languages, currentlySelected, userDetails)
self.registerTagRendering(tagPanel);
return tagPanel;
}, renderings,
{allowMovement: true});
tagRenderings.GetValue().addCallback(
tagRenderings => {
(config.data.layers[index] as LayerConfigJson).tagRenderings = tagRenderings;
config.ping();
}
)
if (userDetails.csCount >= Constants.userJourney.themeGeneratorFullUnlock) {
const presetPanel = new MultiInput("Add a preset",
() => ({tags: [], title: {}}),
() => new PresetInputPanel(currentlySelected, languages),
undefined, {allowMovement: true});
new SingleSetting(config, presetPanel, ["layers", index, "presets"], "Presets", "")
this.presetsPanel = presetPanel;
} else {
this.presetsPanel = new FixedUiElement(`Creating a custom theme which also edits OSM is only unlocked after ${Constants.userJourney.themeGeneratorFullUnlock} changesets`).SetClass("alert");
}
function loadTagRenderings() {
const values = (config.data.layers[index] as LayerConfigJson).tagRenderings;
const renderings: TagRenderingConfigJson[] = [];
for (const value of values) {
if (typeof (value) !== "string") {
renderings.push(value);
}
}
tagRenderings.GetValue().setData(renderings);
}
loadTagRenderings();
this.tagRenderings = tagRenderings;
}
private setupRenderOptions(config: UIEventSource<LayoutConfigJson>,
languages: UIEventSource<string[]>,
index: number,
currentlySelected: UIEventSource<SingleSetting<any>>,
userDetails: UserDetails
): UIElement {
const iconSelect = new TagRenderingPanel(
languages, currentlySelected, userDetails,
{
title: "Icon",
description: "A visual representation for this layer and for the points on the map.",
disableQuestions: true,
noLanguage: true
});
const size = new TagRenderingPanel(languages, currentlySelected, userDetails,
{
title: "Icon Size",
description: "The size of the icons on the map in pixels. Can vary based on the tagging",
disableQuestions: true,
noLanguage: true
});
const color = new TagRenderingPanel(languages, currentlySelected, userDetails,
{
title: "Way and area color",
description: "The color or a shown way or area. Can vary based on the tagging",
disableQuestions: true,
noLanguage: true
});
const stroke = new TagRenderingPanel(languages, currentlySelected, userDetails,
{
title: "Stroke width",
description: "The width of lines representing ways and the outline of areas. Can vary based on the tags",
disableQuestions: true,
noLanguage: true
});
this.registerTagRendering(iconSelect);
this.registerTagRendering(size);
this.registerTagRendering(color);
this.registerTagRendering(stroke);
function setting(input: InputElement<any>, path, isIcon: boolean = false): SingleSetting<TagRenderingConfigJson> {
return new SingleSetting(config, input, ["layers", index, path], undefined, undefined)
}
return new SettingsTable([
setting(iconSelect, "icon"),
setting(size, "iconSize"),
setting(color, "color"),
setting(stroke, "width")
], currentlySelected);
}
private registerTagRendering(
tagRenderingPanel: TagRenderingPanel) {
tagRenderingPanel.IsHovered().addCallback(isHovering => {
if (!isHovering) {
return;
}
this.selectedTagRendering.setData(tagRenderingPanel);
})
}
InnerRender(): string {
return new Combine([
"<h2>General layer settings</h2>",
this.settingsTable,
"<h2>Popup contents</h2>",
this.titleRendering,
this.tagRenderings,
"<h2>Presets</h2>",
"Does this theme support adding a new point?<br/>If this should be the case, add a preset. Make sure that the preset tags do match the overpass-tags, otherwise it might seem like the newly added points dissapear ",
this.presetsPanel,
"<h2>Map rendering options</h2>",
this.mapRendering,
"<h2>Layer delete</h2>",
this.deleteButton
]).Render();
}
}

View file

@ -1,58 +0,0 @@
import {UIElement} from "../UIElement";
import {UIEventSource} from "../../Logic/UIEventSource";
import SingleSetting from "./SingleSetting";
import LayerPanel from "./LayerPanel";
import HelpText from "./HelpText";
import {MultiTagInput} from "../Input/MultiTagInput";
import {FromJSON} from "../../Customizations/JSON/FromJSON";
import Combine from "../Base/Combine";
import PageSplit from "../Base/PageSplit";
import TagRenderingPreview from "./TagRenderingPreview";
import UserDetails from "../../Logic/Osm/OsmConnection";
export default class LayerPanelWithPreview extends UIElement{
private panel: UIElement;
constructor(config: UIEventSource<any>, languages: UIEventSource<string[]>, index: number, userDetails: UserDetails) {
super();
const currentlySelected = new UIEventSource<(SingleSetting<any>)>(undefined);
const layer = new LayerPanel(config, languages, index, currentlySelected, userDetails);
const helpText = new HelpText(currentlySelected);
const previewTagInput = new MultiTagInput();
previewTagInput.GetValue().setData(["id=123456"]);
const previewTagValue = previewTagInput.GetValue().map(tags => {
const properties = {};
for (const str of tags) {
const tag = FromJSON.SimpleTag(str);
if (tag !== undefined) {
properties[tag.key] = tag.value;
}
}
return properties;
});
const preview = new TagRenderingPreview(layer.selectedTagRendering, previewTagValue);
this.panel = new PageSplit(
layer.SetClass("scrollable"),
new Combine([
helpText,
"</br>",
"<h2>Testing tags</h2>",
previewTagInput,
"<h2>Tag Rendering preview</h2>",
preview
]), 60
);
}
InnerRender(): string {
return this.panel.Render();
}
}

View file

@ -1,64 +0,0 @@
import {InputElement} from "../Input/InputElement";
import {AndOrTagConfigJson} from "../../Customizations/JSON/TagConfigJson";
import {UIEventSource} from "../../Logic/UIEventSource";
import {UIElement} from "../UIElement";
import SettingsTable from "./SettingsTable";
import SingleSetting from "./SingleSetting";
import AndOrTagInput from "../Input/AndOrTagInput";
import MultiLingualTextFields from "../Input/MultiLingualTextFields";
import {DropDown} from "../Input/DropDown";
export default class MappingInput extends InputElement<{ if: AndOrTagConfigJson, then: (string | any), hideInAnswer?: boolean }> {
private readonly _value: UIEventSource<{ if: AndOrTagConfigJson; then: any; hideInAnswer?: boolean }>;
private readonly _panel: UIElement;
constructor(languages: UIEventSource<any>, disableQuestions: boolean = false) {
super();
const currentSelected = new UIEventSource<SingleSetting<any>>(undefined);
this._value = new UIEventSource<{ if: AndOrTagConfigJson, then: any, hideInAnswer?: boolean }>({
if: undefined,
then: undefined
});
const self = this;
function setting(inputElement: InputElement<any>, path: string, name: string, description: string | UIElement) {
return new SingleSetting(self._value, inputElement, path, name, description);
}
const withQuestions = [setting(new DropDown("",
[{value: false, shown: "Can be used as answer"}, {value: true, shown: "Not an answer option"}]),
"hideInAnswer", "Answer option",
"Sometimes, multiple tags for the same meaning are used (e.g. <span class='literal-code'>access=yes</span> and <span class='literal-code'>access=public</span>)." +
"Use this toggle to disable an anwer. Alternatively an implied/assumed rendering can be used. In order to do this:" +
"use a single tag in the 'if' with <i>no</i> value defined, e.g. <span class='literal-code'>indoor=</span>. The mapping will then be shown as default until explicitly changed"
)];
this._panel = new SettingsTable([
setting(new AndOrTagInput(), "if", "If matches", "If this condition matches, the template <b>then</b> below will be used"),
setting(new MultiLingualTextFields(languages),
"then", "Then show", "If the condition above matches, this template <b>then</b> below will be shown to the user."),
...(disableQuestions ? [] : withQuestions)
], currentSelected).SetClass("bordered tag-mapping");
}
InnerRender(): string {
return this._panel.Render();
}
GetValue(): UIEventSource<{ if: AndOrTagConfigJson; then: any; hideInAnswer?: boolean }> {
return this._value;
}
IsSelected: UIEventSource<boolean> = new UIEventSource<boolean>(false);
IsValid(t: { if: AndOrTagConfigJson; then: any; hideInAnswer: boolean }): boolean {
return false;
}
}

View file

@ -1,58 +0,0 @@
import {InputElement} from "../Input/InputElement";
import {UIEventSource} from "../../Logic/UIEventSource";
import {UIElement} from "../UIElement";
import {MultiTagInput} from "../Input/MultiTagInput";
import SettingsTable from "./SettingsTable";
import SingleSetting from "./SingleSetting";
import MultiLingualTextFields from "../Input/MultiLingualTextFields";
import Combine from "../Base/Combine";
export default class PresetInputPanel extends InputElement<{
title: string | any,
tags: string[],
description?: string | any
}> {
private readonly _value: UIEventSource<{
title: string | any,
tags: string[],
description?: string | any
}>;
private readonly panel: UIElement;
constructor(currentlySelected: UIEventSource<SingleSetting<any>>, languages: UIEventSource<string[]>) {
super();
this._value = new UIEventSource({tags: [], title: {}});
const self = this;
function s(input: InputElement<any>, path: string, name: string, description: string){
return new SingleSetting(self._value, input, path, name, description)
}
this.panel = new SettingsTable([
s(new MultiTagInput(), "tags","Preset tags","These tags will be applied on the newly created point"),
s(new MultiLingualTextFields(languages), "title","Preset title","This little text is shown in bold on the 'create new point'-button" ),
s(new MultiLingualTextFields(languages), "description","Description", "This text is shown in the button as description when creating a new point")
], currentlySelected).SetStyle("display: block; border: 1px solid black; border-radius: 1em;padding: 1em;");
}
InnerRender(): string {
return new Combine([this.panel]).Render();
}
GetValue(): UIEventSource<{
title: string | any,
tags: string[],
description?: string | any
}> {
return this._value;
}
IsSelected: UIEventSource<boolean> = new UIEventSource<boolean>(false);
IsValid(t: any): boolean {
return false;
}
}

View file

@ -1,69 +0,0 @@
import {UIElement} from "../UIElement";
import {VariableUiElement} from "../Base/VariableUIElement";
import {UIEventSource} from "../../Logic/UIEventSource";
import {LayoutConfigJson} from "../../Customizations/JSON/LayoutConfigJson";
import Combine from "../Base/Combine";
import {OsmConnection} from "../../Logic/Osm/OsmConnection";
import {FixedUiElement} from "../Base/FixedUiElement";
import {TextField} from "../Input/TextField";
import {SubtleButton} from "../Base/SubtleButton";
import Svg from "../../Svg";
export default class SavePanel extends UIElement {
private json: UIElement;
private lastSaveEl: UIElement;
private loadFromJson: UIElement;
constructor(
connection: OsmConnection,
config: UIEventSource<LayoutConfigJson>,
chronic: UIEventSource<Date>) {
super();
this.lastSaveEl = new VariableUiElement(chronic
.map(date => {
if (date === undefined) {
return new FixedUiElement("Your theme will be saved automatically within two minutes... Click here to force saving").SetClass("alert").Render()
}
return "Your theme was last saved at " + date.toISOString()
})).onClick(() => chronic.setData(new Date()));
const jsonStr = config.map(config =>
JSON.stringify(config, null, 2));
const jsonTextField = new TextField({
placeholder: "JSON Config",
value: jsonStr,
textArea: true,
textAreaRows: 20
});
this.json = jsonTextField;
this.loadFromJson = new SubtleButton(Svg.reload_ui(), "<b>Load the JSON file below</b>")
.onClick(() => {
try{
const json = jsonTextField.GetValue().data;
const parsed : LayoutConfigJson = JSON.parse(json);
config.setData(parsed);
}catch(e){
alert("Invalid JSON: "+e)
}
});
}
InnerRender(): string {
return new Combine([
"<h3>Save your theme</h3>",
this.lastSaveEl,
"<h3>JSON configuration</h3>",
"The url hash is actually no more then a BASE64-encoding of the below JSON. This json contains the full configuration of the theme.<br/>" +
"This configuration is mainly useful for debugging",
"<br/>",
this.loadFromJson,
this.json
]).SetClass("scrollable")
.Render();
}
}

View file

@ -1,58 +0,0 @@
import SingleSetting from "./SingleSetting";
import {UIElement} from "../UIElement";
import {FixedUiElement} from "../Base/FixedUiElement";
import {UIEventSource} from "../../Logic/UIEventSource";
import PageSplit from "../Base/PageSplit";
import Combine from "../Base/Combine";
export default class SettingsTable extends UIElement {
private _col1: UIElement[] = [];
private _col2: UIElement[] = [];
public selectedSetting: UIEventSource<SingleSetting<any>>;
constructor(elements: (SingleSetting<any> | string)[],
currentSelectedSetting?: UIEventSource<SingleSetting<any>>) {
super(undefined);
const self = this;
this.selectedSetting = currentSelectedSetting ?? new UIEventSource<SingleSetting<any>>(undefined);
for (const element of elements) {
if(typeof element === "string"){
this._col1.push(new FixedUiElement(element));
this._col2.push(null);
continue;
}
let title: UIElement = element._name === undefined ? null : new FixedUiElement(element._name);
this._col1.push(title);
this._col2.push(element._value);
element._value.SetStyle("display:block");
element._value.IsSelected.addCallback(isSelected => {
if (isSelected) {
self.selectedSetting.setData(element);
} else if (self.selectedSetting.data === element) {
self.selectedSetting.setData(undefined);
}
})
}
}
InnerRender(): string {
let elements = [];
for (let i = 0; i < this._col1.length; i++) {
if(this._col1[i] !== null && this._col2[i] !== null){
elements.push(new PageSplit(this._col1[i], this._col2[i], 25));
}else if(this._col1[i] !== null){
elements.push(this._col1[i])
}else{
elements.push(this._col2[i])
}
}
return new Combine(elements).Render();
}
}

View file

@ -1,34 +0,0 @@
import {UIElement} from "../UIElement";
import {UIEventSource} from "../../Logic/UIEventSource";
import {LayoutConfigJson} from "../../Customizations/JSON/LayoutConfigJson";
import Combine from "../Base/Combine";
import {VariableUiElement} from "../Base/VariableUIElement";
import UserDetails from "../../Logic/Osm/OsmConnection";
export default class SharePanel extends UIElement {
private _config: UIEventSource<LayoutConfigJson>;
private _panel: UIElement;
constructor(config: UIEventSource<LayoutConfigJson>, liveUrl: UIEventSource<string>, userDetails: UserDetails) {
super(undefined);
this._config = config;
this._panel = new Combine([
"<h2>Share</h2>",
"Share the following link with friends:<br/>",
new VariableUiElement(liveUrl.map(url => `<a href='${url}' target="_blank">${url}</a>`)),
"<h2>Publish on some website</h2>",
"It is possible to load a JSON-file from the wide internet, but you'll need some (public CORS-enabled) server.",
`Put the raw json online, and use ${window.location.host}?userlayout=https://<your-url-here>.json`,
"Please note: it used to be possible to load from the wiki - this is not possible anymore due to technical reasons.",
"</div>"
]);
}
InnerRender(): string {
return this._panel.Render();
}
}

View file

@ -1,89 +0,0 @@
import {UIEventSource} from "../../Logic/UIEventSource";
import {InputElement} from "../Input/InputElement";
import {UIElement} from "../UIElement";
import Translations from "../i18n/Translations";
import Combine from "../Base/Combine";
import {VariableUiElement} from "../Base/VariableUIElement";
export default class SingleSetting<T> {
public _value: InputElement<T>;
public _name: string;
public _description: UIElement;
public _options: { showIconPreview?: boolean };
constructor(config: UIEventSource<any>,
value: InputElement<T>,
path: string | (string | number)[],
name: string,
description: string | UIElement,
options?: {
showIconPreview?: boolean
}
) {
this._value = value;
this._name = name;
this._description = Translations.W(description);
this._options = options ?? {};
if (this._options.showIconPreview) {
this._description = new Combine([
this._description,
"<h3>Icon preview</h3>",
new VariableUiElement(this._value.GetValue().map(url => `<img src='${url}' class="image-large-preview">`))
]);
}
if(typeof (path) === "string"){
path = [path];
}
const lastPart = path[path.length - 1];
path.splice(path.length - 1, 1);
function assignValue(value) {
if (value === undefined) {
return;
}
// We have to rewalk every time as parts might be new
let configPart = config.data;
for (const pathPart of path) {
let newConfigPart = configPart[pathPart];
if (newConfigPart === undefined) {
if (typeof (pathPart) === "string") {
configPart[pathPart] = {};
} else {
configPart[pathPart] = [];
}
newConfigPart = configPart[pathPart];
}
configPart = newConfigPart;
}
configPart[lastPart] = value;
config.ping();
}
function loadValue() {
let configPart = config.data;
for (const pathPart of path) {
configPart = configPart[pathPart];
if (configPart === undefined) {
return;
}
}
const loadedValue = configPart[lastPart];
if (loadedValue !== undefined) {
value.GetValue().setData(loadedValue);
}
}
loadValue();
config.addCallback(() => loadValue());
value.GetValue().addCallback(assignValue);
assignValue(this._value.GetValue().data);
}
}

View file

@ -1,155 +0,0 @@
import {UIElement} from "../UIElement";
import {UIEventSource} from "../../Logic/UIEventSource";
import {InputElement} from "../Input/InputElement";
import SingleSetting from "./SingleSetting";
import SettingsTable from "./SettingsTable";
import {TextField} from "../Input/TextField";
import Combine from "../Base/Combine";
import MultiLingualTextFields from "../Input/MultiLingualTextFields";
import AndOrTagInput from "../Input/AndOrTagInput";
import {MultiTagInput} from "../Input/MultiTagInput";
import {MultiInput} from "../Input/MultiInput";
import MappingInput from "./MappingInput";
import {AndOrTagConfigJson} from "../../Customizations/JSON/TagConfigJson";
import {TagRenderingConfigJson} from "../../Customizations/JSON/TagRenderingConfigJson";
import UserDetails from "../../Logic/Osm/OsmConnection";
import {VariableUiElement} from "../Base/VariableUIElement";
import ValidatedTextField from "../Input/ValidatedTextField";
import SpecialVisualizations from "../SpecialVisualizations";
import TagRenderingConfig from "../../Customizations/JSON/TagRenderingConfig";
import Constants from "../../Models/Constants";
export default class TagRenderingPanel extends InputElement<TagRenderingConfigJson> {
public IsImage = false;
public options: { title?: string; description?: string; disableQuestions?: boolean; isImage?: boolean; };
public readonly validText: UIElement;
IsSelected: UIEventSource<boolean> = new UIEventSource<boolean>(false);
private intro: UIElement;
private settingsTable: UIElement;
private readonly _value: UIEventSource<TagRenderingConfigJson>;
constructor(languages: UIEventSource<string[]>,
currentlySelected: UIEventSource<SingleSetting<any>>,
userDetails: UserDetails,
options?: {
title?: string,
description?: string,
disableQuestions?: boolean,
isImage?: boolean,
noLanguage?: boolean
}) {
super();
this.SetClass("bordered");
this.SetClass("min-height");
this.options = options ?? {};
const questionsNotUnlocked = userDetails.csCount < Constants.userJourney.themeGeneratorFullUnlock;
this.options.disableQuestions =
(this.options.disableQuestions ?? false) ||
questionsNotUnlocked;
this.intro = new Combine(["<h3>", options?.title ?? "TagRendering", "</h3>",
options?.description ?? "A tagrendering converts OSM-tags into a value on screen. Fill out the field 'render' with the text that should appear. Note that `{key}` will be replaced with the corresponding `value`, if present.<br/>For specific known tags (e.g. if `foo=bar`, make a mapping). "])
this.IsImage = options?.isImage ?? false;
const value = new UIEventSource<TagRenderingConfigJson>({});
this._value = value;
function setting(input: InputElement<any>, id: string | string[], name: string, description: string | UIElement): SingleSetting<any> {
return new SingleSetting<any>(value, input, id, name, description);
}
this._value.addCallback(value => {
let doPing = false;
if (value?.freeform?.key == "") {
value.freeform = undefined;
doPing = true;
}
if (value?.render == "") {
value.render = undefined;
doPing = true;
}
if (doPing) {
this._value.ping();
}
})
const questionSettings = [
setting(options?.noLanguage ? new TextField({placeholder: "question"}) : new MultiLingualTextFields(languages)
, "question", "Question", "If the key or mapping doesn't match, this question is asked"),
"<h3>Freeform key</h3>",
setting(ValidatedTextField.KeyInput(true), ["freeform", "key"], "Freeform key<br/>",
"If specified, the rendering will search if this key is present." +
"If it is, the rendering above will be used to display the element.<br/>" +
"The rendering will go into question mode if <ul><li>this key is not present</li><li>No single mapping matches</li><li>A question is given</li>"),
setting(ValidatedTextField.TypeDropdown(), ["freeform", "type"], "Freeform type",
"The type of this freeform text field, in order to validate"),
setting(new MultiTagInput(), ["freeform", "addExtraTags"], "Extra tags on freeform",
"When the freeform text field is used, the user might mean a predefined key. This field allows to add extra tags, e.g. <span class='literal-code'>fixme=User used a freeform field - to check</span>"),
];
const settings: (string | SingleSetting<any>)[] = [
setting(
options?.noLanguage ? new TextField({placeholder: "Rendering"}) :
new MultiLingualTextFields(languages), "render", "Value to show",
"Renders this value. Note that <span class='literal-code'>{key}</span>-parts are substituted by the corresponding values of the element. If neither 'textFieldQuestion' nor 'mappings' are defined, this text is simply shown as default value." +
"<br/><br/>" +
"Furhtermore, some special functions are supported:" + SpecialVisualizations.HelpMessage.Render()),
questionsNotUnlocked ? `You need at least ${Constants.userJourney.themeGeneratorFullUnlock} changesets to unlock the 'question'-field and to use your theme to edit OSM data` : "",
...(options?.disableQuestions ? [] : questionSettings),
"<h3>Mappings</h3>",
setting(new MultiInput<{ if: AndOrTagConfigJson, then: (string | any), hideInAnswer?: boolean }>("Add a mapping",
() => ({if: {and: []}, then: {}}),
() => new MappingInput(languages, options?.disableQuestions ?? false),
undefined, {allowMovement: true}), "mappings",
"If a tag matches, then show the first respective text", ""),
"<h3>Condition</h3>",
setting(new AndOrTagInput(), "condition", "Only show this tagrendering if the following condition applies",
"Only show this tag rendering if these tags matches. Optional field.<br/>Note that the Overpass-tags are already always included in this object"),
];
this.settingsTable = new SettingsTable(settings, currentlySelected);
this.validText = new VariableUiElement(value.map((json: TagRenderingConfigJson) => {
try {
new TagRenderingConfig(json, undefined, options?.title ?? "");
return "";
} catch (e) {
return "<span class='alert'>" + e + "</span>"
}
}));
}
InnerRender(): string {
return new Combine([
this.intro,
this.settingsTable,
this.validText]).Render();
}
GetValue(): UIEventSource<TagRenderingConfigJson> {
return this._value;
}
IsValid(t: TagRenderingConfigJson): boolean {
return false;
}
}

View file

@ -1,70 +0,0 @@
import {UIElement} from "../UIElement";
import {UIEventSource} from "../../Logic/UIEventSource";
import TagRenderingPanel from "./TagRenderingPanel";
import {VariableUiElement} from "../Base/VariableUIElement";
import {FixedUiElement} from "../Base/FixedUiElement";
import Combine from "../Base/Combine";
import TagRenderingConfig from "../../Customizations/JSON/TagRenderingConfig";
import EditableTagRendering from "../Popup/EditableTagRendering";
export default class TagRenderingPreview extends UIElement {
private readonly previewTagValue: UIEventSource<any>;
private selectedTagRendering: UIEventSource<TagRenderingPanel>;
private panel: UIElement;
constructor(selectedTagRendering: UIEventSource<TagRenderingPanel>,
previewTagValue: UIEventSource<any>) {
super(selectedTagRendering);
this.selectedTagRendering = selectedTagRendering;
this.previewTagValue = previewTagValue;
this.panel = this.GetPanel(undefined);
const self = this;
this.selectedTagRendering.addCallback(trp => {
self.panel = self.GetPanel(trp);
self.Update();
})
}
private GetPanel(tagRenderingPanel: TagRenderingPanel): UIElement {
if (tagRenderingPanel === undefined) {
return new FixedUiElement("No tag rendering selected at the moment. Hover over a tag rendering to see what it looks like");
}
let es = tagRenderingPanel.GetValue();
let rendering: UIElement;
const self = this;
try {
rendering =
new VariableUiElement(es.map(tagRenderingConfig => {
try {
const tr = new EditableTagRendering(self.previewTagValue, new TagRenderingConfig(tagRenderingConfig, undefined,"preview"));
return tr.Render();
} catch (e) {
return new Combine(["Could not show this tagrendering:", e.message]).Render();
}
}
));
} catch (e) {
console.error("User defined tag rendering incorrect:", e);
rendering = new FixedUiElement(e).SetClass("alert");
}
return new Combine([
"<h3>",
tagRenderingPanel.options.title ?? "Extra tag rendering",
"</h3>",
tagRenderingPanel.options.description ?? "This tag rendering will appear in the popup",
"<br/><br/>",
rendering]);
}
InnerRender(): string {
return this.panel.Render();
}
}

View file

@ -0,0 +1,19 @@
import Combine from "../Base/Combine";
import Attribution from "./Attribution";
import Img from "../Base/Img";
import ImageAttributionSource from "../../Logic/Web/ImageAttributionSource";
export class AttributedImage extends Combine {
constructor(urlSource: string, imgSource: ImageAttributionSource) {
urlSource = imgSource.PrepareUrl(urlSource)
super([
new Img( urlSource),
new Attribution(imgSource.GetAttributionFor(urlSource), imgSource.SourceIcon())
]);
this.SetClass('block relative h-full');
}
}

View file

@ -1,18 +1,33 @@
import {UIElement} from "../UIElement";
import Combine from "../Base/Combine";
import Translations from "../i18n/Translations";
import BaseUIElement from "../BaseUIElement";
import {VariableUiElement} from "../Base/VariableUIElement";
import {UIEventSource} from "../../Logic/UIEventSource";
import {LicenseInfo} from "../../Logic/Web/Wikimedia";
export default class Attribution extends Combine {
export default class Attribution extends VariableUiElement {
constructor(author: UIElement | string, license: UIElement | string, icon: UIElement) {
super([
icon?.SetClass("block left").SetStyle("height: 2em; width: 2em; padding-right: 0.5em"),
new Combine([
Translations.W(author).SetClass("block font-bold"),
Translations.W((license ?? "") === "undefined" ? "CC0" : (license ?? ""))
]).SetClass("flex flex-col")
]);
this.SetClass("flex flex-row bg-black text-white text-sm absolute bottom-0 left-0 p-0.5 pl-5 pr-3 rounded-lg");
constructor(license: UIEventSource<LicenseInfo>, icon: BaseUIElement) {
if (license === undefined) {
throw "No license source given in the attribution element"
}
super(
license.map((license : LicenseInfo) => {
if (license?.artist === undefined) {
return undefined;
}
return new Combine([
icon?.SetClass("block left").SetStyle("height: 2em; width: 2em; padding-right: 0.5em;"),
new Combine([
Translations.W(license.artist).SetClass("block font-bold"),
Translations.W((license.license ?? "") === "" ? "CC0" : (license.license ?? ""))
]).SetClass("flex flex-col")
]).SetClass("flex flex-row bg-black text-white text-sm absolute bottom-0 left-0 p-0.5 pl-5 pr-3 rounded-lg")
}));
}
}

View file

@ -1,56 +1,55 @@
import {UIElement} from "../UIElement";
import {UIEventSource} from "../../Logic/UIEventSource";
import Translations from "../i18n/Translations";
import CheckBox from "../Input/CheckBox";
import Toggle from "../Input/Toggle";
import Combine from "../Base/Combine";
import State from "../../State";
import Svg from "../../Svg";
import {Tag} from "../../Logic/Tags/Tag";
import BaseUIElement from "../BaseUIElement";
export default class DeleteImage extends UIElement {
private readonly key: string;
private readonly tags: UIEventSource<any>;
private readonly isDeletedBadge: UIElement;
private readonly deleteDialog: UIElement;
export default class DeleteImage extends Toggle {
constructor(key: string, tags: UIEventSource<any>) {
super(tags);
this.tags = tags;
this.key = key;
this.isDeletedBadge = Translations.t.image.isDeleted;
const oldValue = tags.data[key]
const isDeletedBadge = Translations.t.image.isDeleted.Clone()
.SetClass("rounded-full p-1")
.SetStyle("color:white;background:#ff8c8c")
.onClick(() => {
State.state?.changes?.addTag(tags.data.id, new Tag(key, oldValue), tags);
});
const deleteButton = Translations.t.image.doDelete.Clone()
.SetClass("block w-full pl-4 pr-4")
.SetStyle("color:white;background:#ff8c8c; border-top-left-radius:30rem; border-top-right-radius: 30rem;")
.onClick(() => {
State.state?.changes.addTag(tags.data.id, new Tag(key, ""));
State.state?.changes?.addTag(tags.data.id, new Tag(key, ""), tags);
});
const cancelButton = Translations.t.general.cancel.SetClass("bg-white pl-4 pr-4").SetStyle( "border-bottom-left-radius:30rem; border-bottom-right-radius: 30rem;");
this.deleteDialog = new CheckBox(
const cancelButton = Translations.t.general.cancel.Clone().SetClass("bg-white pl-4 pr-4").SetStyle("border-bottom-left-radius:30rem; border-bottom-right-radius: 30rem;");
const openDelete = Svg.delete_icon_svg().SetStyle("width: 2em; height: 2em; display:block;")
const deleteDialog = new Toggle(
new Combine([
deleteButton,
cancelButton
]).SetClass("flex flex-col background-black"),
Svg.delete_icon_svg().SetStyle("width: 2em; height: 2em; display:block;")
openDelete
)
}
cancelButton.onClick(() => deleteDialog.isEnabled.setData(false))
openDelete.onClick(() => deleteDialog.isEnabled.setData(true))
InnerRender(): string {
if(! State.state?.featureSwitchUserbadge?.data){
return "";
}
const value = this.tags.data[this.key];
if (value === undefined || value === "") {
return this.isDeletedBadge.Render();
}
return this.deleteDialog.Render();
super(
new Toggle(
deleteDialog,
isDeletedBadge,
tags.map(tags => (tags[key] ?? "") !== "")
),
undefined /*Login (and thus editing) is disabled*/,
State.state?.featureSwitchUserbadge ?? new UIEventSource<boolean>(true)
)
this.SetClass("cursor-pointer")
}
}

View file

@ -1,39 +1,43 @@
import {UIElement} from "../UIElement";
import {SlideShow} from "./SlideShow";
import {UIEventSource} from "../../Logic/UIEventSource";
import Combine from "../Base/Combine";
import DeleteImage from "./DeleteImage";
import {WikimediaImage} from "./WikimediaImage";
import {ImgurImage} from "./ImgurImage";
import {MapillaryImage} from "./MapillaryImage";
import {SimpleImageElement} from "./SimpleImageElement";
import {AttributedImage} from "./AttributedImage";
import BaseUIElement from "../BaseUIElement";
import Img from "../Base/Img";
import Toggle from "../Input/Toggle";
import ImageAttributionSource from "../../Logic/Web/ImageAttributionSource";
import {Wikimedia} from "../../Logic/Web/Wikimedia";
import {Mapillary} from "../../Logic/Web/Mapillary";
import {Imgur} from "../../Logic/Web/Imgur";
export class ImageCarousel extends UIElement{
export class ImageCarousel extends Toggle {
public readonly slideshow: UIElement;
constructor(images: UIEventSource<{key: string, url:string}[]>, tags: UIEventSource<any>) {
super(images);
const uiElements = images.map((imageURLS: {key: string, url:string}[]) => {
const uiElements: UIElement[] = [];
constructor(images: UIEventSource<{ key: string, url: string }[]>, tags: UIEventSource<any>) {
const uiElements = images.map((imageURLS: { key: string, url: string }[]) => {
const uiElements: BaseUIElement[] = [];
for (const url of imageURLS) {
let image = ImageCarousel.CreateImageElement(url.url)
if(url.key !== undefined){
if (url.key !== undefined) {
image = new Combine([
image,
new DeleteImage(url.key, tags).SetClass("delete-image-marker absolute top-0 left-0 pl-3")
]).SetClass("relative");
}
image
.SetClass("w-full block")
image
.SetClass("w-full block")
.SetStyle("min-width: 50px; background: grey;")
uiElements.push(image);
}
return uiElements;
});
this.slideshow = new SlideShow(uiElements).HideOnEmpty(true);
super(
new SlideShow(uiElements).SetClass("w-full"),
undefined,
uiElements.map(els => els.length > 0)
)
this.SetClass("block w-full");
this.slideshow.SetClass("w-full");
}
/***
@ -41,23 +45,22 @@ export class ImageCarousel extends UIElement{
* @param url
* @constructor
*/
private static CreateImageElement(url: string): UIElement {
private static CreateImageElement(url: string): BaseUIElement {
// @ts-ignore
let attrSource : ImageAttributionSource = undefined;
if (url.startsWith("File:")) {
return new WikimediaImage(url);
attrSource = Wikimedia.singleton
} else if (url.toLowerCase().startsWith("https://commons.wikimedia.org/wiki/")) {
const commons = url.substr("https://commons.wikimedia.org/wiki/".length);
return new WikimediaImage(commons);
attrSource = Wikimedia.singleton;
} else if (url.toLowerCase().startsWith("https://i.imgur.com/")) {
return new ImgurImage(url);
attrSource = Imgur.singleton
} else if (url.toLowerCase().startsWith("https://www.mapillary.com/map/im/")) {
return new MapillaryImage(url);
attrSource = Mapillary.singleton
} else {
return new SimpleImageElement(new UIEventSource<string>(url));
return new Img(url);
}
}
InnerRender(): string {
return this.slideshow.Render();
return new AttributedImage(url, attrSource)
}
}

View file

@ -1,210 +1,103 @@
import $ from "jquery"
import {UIEventSource} from "../../Logic/UIEventSource";
import {UIElement} from "../UIElement";
import State from "../../State";
import Combine from "../Base/Combine";
import {FixedUiElement} from "../Base/FixedUiElement";
import {Imgur} from "../../Logic/Web/Imgur";
import {DropDown} from "../Input/DropDown";
import Translations from "../i18n/Translations";
import Svg from "../../Svg";
import {Tag} from "../../Logic/Tags/Tag";
import BaseUIElement from "../BaseUIElement";
import LicensePicker from "../BigComponents/LicensePicker";
import Toggle from "../Input/Toggle";
import FileSelectorButton from "../Input/FileSelectorButton";
import ImgurUploader from "../../Logic/Web/ImgurUploader";
import UploadFlowStateUI from "../BigComponents/UploadFlowStateUI";
import LayerConfig from "../../Customizations/JSON/LayerConfig";
export class ImageUploadFlow extends UIElement {
private readonly _licensePicker: UIElement;
private readonly _tags: UIEventSource<any>;
private readonly _selectedLicence: UIEventSource<string>;
private readonly _isUploading: UIEventSource<number> = new UIEventSource<number>(0)
private readonly _didFail: UIEventSource<boolean> = new UIEventSource<boolean>(false);
private readonly _allDone: UIEventSource<boolean> = new UIEventSource<boolean>(false);
private readonly _connectButton: UIElement;
private readonly _imagePrefix: string;
export class ImageUploadFlow extends Toggle {
constructor(tags: UIEventSource<any>, imagePrefix: string = "image") {
super(State.state.osmConnection.userDetails);
this._tags = tags;
this._imagePrefix = imagePrefix;
constructor(tagsSource: UIEventSource<any>, imagePrefix: string = "image") {
const uploader = new ImgurUploader(url => {
// A file was uploaded - we add it to the tags of the object
this.ListenTo(this._isUploading);
this.ListenTo(this._didFail);
this.ListenTo(this._allDone);
const tags = tagsSource.data
let key = imagePrefix
if (tags[imagePrefix] !== undefined) {
let freeIndex = 0;
while (tags[imagePrefix + ":" + freeIndex] !== undefined) {
freeIndex++;
}
key = imagePrefix + ":" + freeIndex;
}
console.log("Adding image:" + key, url);
State.state.changes.addTag(tags.id, new Tag(key, url), tagsSource);
})
const licensePicker = new DropDown(Translations.t.image.willBePublished,
[
{value: "CC0", shown: Translations.t.image.cco},
{value: "CC-BY-SA 4.0", shown: Translations.t.image.ccbs},
{value: "CC-BY 4.0", shown: Translations.t.image.ccb}
],
State.state.osmConnection.GetPreference("pictures-license"),
"","",
"flex flex-col sm:flex-row"
);
licensePicker.SetStyle("float:left");
const licensePicker = new LicensePicker()
const t = Translations.t.image;
const label = new Combine([
Svg.camera_plus_ui().SetClass("block w-12 h-12 p-1"),
Translations.t.image.addPicture.Clone().SetClass("block align-middle mt-1 ml-3")
]).SetClass("p-2 border-4 border-black rounded-full text-4xl font-bold h-full align-middle w-full flex justify-center")
const fileSelector = new FileSelectorButton(label)
fileSelector.GetValue().addCallback(filelist => {
if (filelist === undefined) {
return;
}
this._licensePicker = licensePicker;
this._selectedLicence = licensePicker.GetValue();
console.log("Received images from the user, starting upload")
const license = licensePicker.GetValue()?.data ?? "CC0"
this._connectButton = t.pleaseLogin.Clone()
const tags = tagsSource.data;
const layout = State.state?.layoutToUse?.data
let matchingLayer: LayerConfig = undefined
for (const layer of layout?.layers ?? []) {
if (layer.source.osmTags.matchesProperties(tags)) {
matchingLayer = layer;
break;
}
}
const title = matchingLayer?.title?.GetRenderValue(tags)?.ConstructElement().innerText ?? tags.name ?? "Unknown area";
const description = [
"author:" + State.state.osmConnection.userDetails.data.name,
"license:" + license,
"osmid:" + tags.id,
].join("\n");
uploader.uploadMany(title, description, filelist)
})
const uploadStateUi = new UploadFlowStateUI(uploader.queue, uploader.failed, uploader.success)
const uploadFlow: BaseUIElement = new Combine([
fileSelector,
Translations.t.image.respectPrivacy.Clone().SetStyle("font-size:small;"),
licensePicker,
uploadStateUi
]).SetClass("flex flex-col image-upload-flow mt-4 mb-8 text-center")
const pleaseLoginButton = t.pleaseLogin.Clone()
.onClick(() => State.state.osmConnection.AttemptLogin())
.SetClass("login-button-friendly");
}
InnerRender(): string {
if(!State.state.featureSwitchUserbadge.data){
return "";
}
const t = Translations.t.image;
if (State.state.osmConnection.userDetails === undefined) {
return ""; // No user details -> logging in is probably disabled or smthing
}
if (!State.state.osmConnection.userDetails.data.loggedIn) {
return this._connectButton.Render();
}
let currentState: UIElement[] = [];
if (this._isUploading.data == 1) {
currentState.push(t.uploadingPicture);
} else if (this._isUploading.data > 0) {
currentState.push(t.uploadingMultiple.Subs({count: ""+this._isUploading.data}));
}
if (this._didFail.data) {
currentState.push(t.uploadFailed);
}
if (this._allDone.data) {
currentState.push(t.uploadDone)
}
let currentStateHtml : UIElement = new FixedUiElement("");
if (currentState.length > 0) {
currentStateHtml = new Combine(currentState);
if (!this._allDone.data) {
currentStateHtml.SetClass("alert");
}else{
currentStateHtml.SetClass("thanks");
}
currentStateHtml.SetStyle("display:block ruby")
}
const extraInfo = new Combine([
Translations.t.image.respectPrivacy.SetStyle("font-size:small;"),
"<br/>",
this._licensePicker,
"<br/>",
currentStateHtml,
"<br/>"
]);
const label = new Combine([
Svg.camera_plus_svg().SetStyle("width: 36px;height: 36px;padding: 0.1em;margin-top: 5px;border-radius: 0;float: left;display:block"),
Translations.t.image.addPicture
]).SetClass("image-upload-flow-button")
const actualInputElement =
`<input style='display: none' id='fileselector-${this.id}' type='file' accept='image/*' name='picField' multiple='multiple' alt=''/>`;
const form = "<form id='fileselector-form-" + this.id + "'>" +
`<label for='fileselector-${this.id}'>` +
label.Render() +
"</label>" +
actualInputElement +
"</form>";
return new Combine([
form,
extraInfo
]).SetClass("image-upload-flow")
.SetStyle("margin-top: 1em;margin-bottom: 2em;text-align: center;")
.Render();
}
private handleSuccessfulUpload(url) {
const tags = this._tags.data;
let key = this._imagePrefix;
if (tags[this._imagePrefix] !== undefined) {
let freeIndex = 0;
while (tags[this._imagePrefix + ":" + freeIndex] !== undefined) {
freeIndex++;
}
key = this._imagePrefix + ":" + freeIndex;
}
console.log("Adding image:" + key, url);
State.state.changes.addTag(tags.id, new Tag(key, url));
}
private handleFiles(files) {
console.log("Received images from the user, starting upload")
this._isUploading.setData(files.length);
this._allDone.setData(false);
if (this._selectedLicence.data === undefined) {
this._selectedLicence.setData("CC0");
}
const tags = this._tags.data;
const title = tags.name ?? "Unknown area";
const description = [
"author:" + State.state.osmConnection.userDetails.data.name,
"license:" + (this._selectedLicence.data ?? "CC0"),
"wikidata:" + tags.wikidata,
"osmid:" + tags.id,
"name:" + tags.name
].join("\n");
const self = this;
Imgur.uploadMultiple(title,
description,
files,
function (url) {
console.log("File saved at", url);
self._isUploading.setData(self._isUploading.data - 1);
self.handleSuccessfulUpload(url);
},
function () {
console.log("All uploads completed");
self._allDone.setData(true);
},
function (failReason) {
console.log("Upload failed due to ", failReason)
// No need to call something from the options -> we handle this here
self._didFail.setData(true);
self._isUploading.data--;
self._isUploading.ping();
}, 0
super(
new Toggle(
/*We can show the actual upload button!*/
uploadFlow,
/* User not logged in*/ pleaseLoginButton,
State.state?.osmConnection?.isLoggedIn
),
undefined /* Nothing as the user badge is disabled*/,
State.state.featureSwitchUserbadge
)
}
InnerUpdate(htmlElement: HTMLElement) {
super.InnerUpdate(htmlElement);
this._licensePicker.Update()
const form = document.getElementById('fileselector-form-' + this.id) as HTMLFormElement
const selector = document.getElementById('fileselector-' + this.id)
const self = this
function submitHandler() {
self.handleFiles($(selector).prop('files'))
}
if (selector != null && form != null) {
selector.onchange = function () {
submitHandler()
}
form.addEventListener('submit', e => {
e.preventDefault()
submitHandler()
})
}
}
}

View file

@ -1,56 +0,0 @@
import {UIElement} from "../UIElement";
import {UIEventSource} from "../../Logic/UIEventSource";
import {LicenseInfo} from "../../Logic/Web/Wikimedia";
import {Imgur} from "../../Logic/Web/Imgur";
import Combine from "../Base/Combine";
import Attribution from "./Attribution";
import {SimpleImageElement} from "./SimpleImageElement";
export class ImgurImage extends UIElement {
/***
* Dictionary from url to alreayd known license info
*/
private static allLicenseInfos: any = {};
private readonly _imageMeta: UIEventSource<LicenseInfo>;
private readonly _imageLocation: string;
constructor(source: string) {
super(undefined)
this._imageLocation = source;
if (ImgurImage.allLicenseInfos[source] !== undefined) {
this._imageMeta = ImgurImage.allLicenseInfos[source];
} else {
this._imageMeta = new UIEventSource<LicenseInfo>(null);
ImgurImage.allLicenseInfos[source] = this._imageMeta;
const self = this;
Imgur.getDescriptionOfImage(source, (license) => {
self._imageMeta.setData(license)
})
}
this.ListenTo(this._imageMeta);
}
InnerRender(): string {
const image = new SimpleImageElement( new UIEventSource (this._imageLocation));
if(this._imageMeta.data === null){
return image.Render();
}
const meta = this._imageMeta.data;
return new Combine([
image,
new Attribution(meta.artist, meta.license, undefined),
]).SetClass('block relative')
.Render();
}
}

View file

@ -1,60 +0,0 @@
import {UIElement} from "../UIElement";
import {UIEventSource} from "../../Logic/UIEventSource";
import {LicenseInfo} from "../../Logic/Web/Wikimedia";
import {Mapillary} from "../../Logic/Web/Mapillary";
import Svg from "../../Svg";
import {SimpleImageElement} from "./SimpleImageElement";
import Combine from "../Base/Combine";
import Attribution from "./Attribution";
export class MapillaryImage extends UIElement {
/***
* Dictionary from url to already known license info
*/
private static allLicenseInfos: any = {};
private readonly _imageMeta: UIEventSource<LicenseInfo>;
private readonly _imageLocation: string;
constructor(source: string) {
super()
if (source.toLowerCase().startsWith("https://www.mapillary.com/map/im/")) {
source = source.substring("https://www.mapillary.com/map/im/".length);
}
this._imageLocation = source;
if (MapillaryImage.allLicenseInfos[source] !== undefined) {
this._imageMeta = MapillaryImage.allLicenseInfos[source];
} else {
this._imageMeta = new UIEventSource<LicenseInfo>(null);
MapillaryImage.allLicenseInfos[source] = this._imageMeta;
const self = this;
Mapillary.getDescriptionOfImage(source, (license) => {
self._imageMeta.setData(license)
})
}
this.ListenTo(this._imageMeta);
}
InnerRender(): string {
const url = `https://images.mapillary.com/${this._imageLocation}/thumb-640.jpg?client_id=TXhLaWthQ1d4RUg0czVxaTVoRjFJZzowNDczNjUzNmIyNTQyYzI2`;
const image = new SimpleImageElement(new UIEventSource<string>(url))
const meta = this._imageMeta?.data;
if (!meta) {
return image.Render();
}
return new Combine([
image,
new Attribution(meta.artist, meta.license, Svg.mapillary_svg())
]).SetClass("relative block").Render();
}
}

View file

@ -1,15 +0,0 @@
import {UIElement} from "../UIElement";
import {UIEventSource} from "../../Logic/UIEventSource";
export class SimpleImageElement extends UIElement {
constructor(source: UIEventSource<string>) {
super(source);
}
InnerRender(): string {
return "<img src='" + this._source.data + "' alt='img'>";
}
}

View file

@ -1,56 +1,49 @@
import {UIEventSource} from "../../Logic/UIEventSource";
import {UIElement} from "../UIElement";
import BaseUIElement from "../BaseUIElement";
import {Utils} from "../../Utils";
import Combine from "../Base/Combine";
// @ts-ignore
import $ from "jquery"
export class SlideShow extends UIElement {
export class SlideShow extends BaseUIElement {
private readonly _embeddedElements: UIEventSource<UIElement[]>
constructor(
embeddedElements: UIEventSource<UIElement[]>) {
super(embeddedElements);
this._embeddedElements = embeddedElements;
this._embeddedElements.addCallbackAndRun(elements => {
for (const element of elements ?? []) {
element.SetClass("slick-carousel-content")
private readonly embeddedElements: UIEventSource<BaseUIElement[]>;
constructor(embeddedElements: UIEventSource<BaseUIElement[]>) {
super()
this.embeddedElements =embeddedElements;
this.SetStyle("scroll-snap-type: x mandatory; overflow-x: scroll")
}
protected InnerConstructElement(): HTMLElement {
const el = document.createElement("div")
el.style.minWidth = "min-content"
el.style.display = "flex"
el.style.justifyContent = "center"
this.embeddedElements.addCallbackAndRun(elements => {
if(elements.length > 1){
el.style.justifyContent = "unset"
}
while (el.firstChild) {
el.removeChild(el.lastChild)
}
})
}
InnerRender(): string {
return new Combine(
this._embeddedElements.data,
).SetClass("block slick-carousel")
.Render();
}
Update() {
super.Update();
for (const uiElement of this._embeddedElements.data) {
uiElement.Update();
}
}
protected InnerUpdate(htmlElement: HTMLElement) {
super.InnerUpdate(htmlElement);
require("slick-carousel")
if(this._embeddedElements.data.length == 0){
return;
}
// @ts-ignore
$('.slick-carousel').not('.slick-initialized').slick({
autoplay: true,
arrows: true,
dots: true,
lazyLoad: 'progressive',
variableWidth: true,
centerMode: true,
centerPadding: "60px",
adaptive: true
elements = Utils.NoNull(elements).map(el => new Combine([el])
.SetClass("block relative ml-1 bg-gray-200 m-1 rounded slideshow-item")
.SetStyle("min-width: 150px; width: max-content; height: var(--image-carousel-height);max-height: var(--image-carousel-height);scroll-snap-align: start;")
)
for (const element of elements ?? []) {
el.appendChild(element.ConstructElement())
}
});
const wrapper = document.createElement("div")
wrapper.style.maxWidth = "100%"
wrapper.style.overflowX = "auto"
wrapper.appendChild(el)
return wrapper;
}
}

View file

@ -1,58 +0,0 @@
import {UIElement} from "../UIElement";
import {LicenseInfo, Wikimedia} from "../../Logic/Web/Wikimedia";
import {UIEventSource} from "../../Logic/UIEventSource";
import Svg from "../../Svg";
import Link from "../Base/Link";
import Combine from "../Base/Combine";
import {SimpleImageElement} from "./SimpleImageElement";
import Attribution from "./Attribution";
export class WikimediaImage extends UIElement {
static allLicenseInfos: any = {};
private readonly _imageMeta: UIEventSource<LicenseInfo>;
private readonly _imageLocation: string;
constructor(source: string) {
super(undefined)
this._imageLocation = source;
if (WikimediaImage.allLicenseInfos[source] !== undefined) {
this._imageMeta = WikimediaImage.allLicenseInfos[source];
} else {
this._imageMeta = new UIEventSource<LicenseInfo>(new LicenseInfo());
WikimediaImage.allLicenseInfos[source] = this._imageMeta;
const self = this;
Wikimedia.LicenseData(source, (info) => {
self._imageMeta.setData(info);
})
}
this.ListenTo(this._imageMeta);
}
InnerRender(): string {
const url = Wikimedia.ImageNameToUrl(this._imageLocation, 500, 400)
.replace(/'/g, '%27');
const image = new SimpleImageElement(new UIEventSource<string>(url))
const meta = this._imageMeta?.data;
if (!meta) {
return image.Render();
}
new Link(Svg.wikimedia_commons_white_img,
`https://commons.wikimedia.org/wiki/${this._imageLocation}`, true)
.SetStyle("width:2em;height: 2em");
return new Combine([
image,
new Attribution(meta.artist, meta.license, Svg.wikimedia_commons_white_svg())
]).SetClass("relative block").Render()
}
}

View file

@ -1,164 +0,0 @@
import {InputElement} from "./InputElement";
import {UIEventSource} from "../../Logic/UIEventSource";
import {UIElement} from "../UIElement";
import Combine from "../Base/Combine";
import {SubtleButton} from "../Base/SubtleButton";
import CheckBox from "./CheckBox";
import {AndOrTagConfigJson} from "../../Customizations/JSON/TagConfigJson";
import {MultiTagInput} from "./MultiTagInput";
import Svg from "../../Svg";
class AndOrConfig implements AndOrTagConfigJson {
public and: (string | AndOrTagConfigJson)[] = undefined;
public or: (string | AndOrTagConfigJson)[] = undefined;
}
export default class AndOrTagInput extends InputElement<AndOrTagConfigJson> {
private readonly _rawTags = new MultiTagInput();
private readonly _subAndOrs: AndOrTagInput[] = [];
private readonly _isAnd: UIEventSource<boolean> = new UIEventSource<boolean>(true);
private readonly _isAndButton;
private readonly _addBlock: UIElement;
private readonly _value: UIEventSource<AndOrConfig> = new UIEventSource<AndOrConfig>(undefined);
public bottomLeftButton: UIElement;
IsSelected: UIEventSource<boolean>;
constructor() {
super();
const self = this;
this._isAndButton = new CheckBox(
new SubtleButton(Svg.ampersand_ui(), null).SetClass("small-button"),
new SubtleButton(Svg.or_ui(), null).SetClass("small-button"),
this._isAnd);
this._addBlock =
new SubtleButton(Svg.addSmall_ui(), "Add an and/or-expression")
.SetClass("small-button")
.onClick(() => {self.createNewBlock()});
this._isAnd.addCallback(() => self.UpdateValue());
this._rawTags.GetValue().addCallback(() => {
self.UpdateValue()
});
this.IsSelected = this._rawTags.IsSelected;
this._value.addCallback(tags => self.loadFromValue(tags));
}
private createNewBlock(){
const inputEl = new AndOrTagInput();
inputEl.GetValue().addCallback(() => this.UpdateValue());
const deleteButton = this.createDeleteButton(inputEl.id);
inputEl.bottomLeftButton = deleteButton;
this._subAndOrs.push(inputEl);
this.Update();
}
private createDeleteButton(elementId: string): UIElement {
const self = this;
return new SubtleButton(Svg.delete_icon_ui(), null).SetClass("small-button")
.onClick(() => {
for (let i = 0; i < self._subAndOrs.length; i++) {
if (self._subAndOrs[i].id === elementId) {
self._subAndOrs.splice(i, 1);
self.Update();
self.UpdateValue();
return;
}
}
});
}
private loadFromValue(value: AndOrTagConfigJson) {
this._isAnd.setData(value.and !== undefined);
const tags = value.and ?? value.or;
const rawTags: string[] = [];
const subTags: AndOrTagConfigJson[] = [];
for (const tag of tags) {
if (typeof (tag) === "string") {
rawTags.push(tag);
} else {
subTags.push(tag);
}
}
for (let i = 0; i < rawTags.length; i++) {
if (this._rawTags.GetValue().data[i] !== rawTags[i]) {
// For some reason, 'setData' isn't stable as the comparison between the lists fails
// Probably because we generate a new list object every timee
// So we compare again here and update only if we find a difference
this._rawTags.GetValue().setData(rawTags);
break;
}
}
while(this._subAndOrs.length < subTags.length){
this.createNewBlock();
}
for (let i = 0; i < subTags.length; i++){
let subTag = subTags[i];
this._subAndOrs[i].GetValue().setData(subTag);
}
}
private UpdateValue() {
const tags: (string | AndOrTagConfigJson)[] = [];
tags.push(...this._rawTags.GetValue().data);
for (const subAndOr of this._subAndOrs) {
const subAndOrData = subAndOr._value.data;
if (subAndOrData === undefined) {
continue;
}
console.log(subAndOrData);
tags.push(subAndOrData);
}
const tagConfig = new AndOrConfig();
if (this._isAnd.data) {
tagConfig.and = tags;
} else {
tagConfig.or = tags;
}
this._value.setData(tagConfig);
}
GetValue(): UIEventSource<AndOrTagConfigJson> {
return this._value;
}
InnerRender(): string {
const leftColumn = new Combine([
this._isAndButton,
"<br/>",
this.bottomLeftButton ?? ""
]);
const tags = new Combine([
this._rawTags,
...this._subAndOrs,
this._addBlock
]).Render();
return `<span class="bordered"><table><tr><td>${leftColumn.Render()}</td><td>${tags}</td></tr></table></span>`;
}
IsValid(t: AndOrTagConfigJson): boolean {
return true;
}
}

View file

@ -1,32 +0,0 @@
import {UIElement} from "../UIElement";
import Translations from "../../UI/i18n/Translations";
import {UIEventSource} from "../../Logic/UIEventSource";
export default class CheckBox extends UIElement{
public readonly isEnabled: UIEventSource<boolean>;
private readonly _showEnabled: UIElement;
private readonly _showDisabled: UIElement;
constructor(showEnabled: string | UIElement, showDisabled: string | UIElement, data: UIEventSource<boolean> | boolean = false) {
super(undefined);
this.isEnabled =
data instanceof UIEventSource ? data : new UIEventSource(data ?? false);
this.ListenTo(this.isEnabled);
this._showEnabled = Translations.W(showEnabled);
this._showDisabled =Translations.W(showDisabled);
const self = this;
this.onClick(() => {
self.isEnabled.setData(!self.isEnabled.data);
})
}
InnerRender(): string {
if (this.isEnabled.data) {
return Translations.W(this._showEnabled).Render();
} else {
return Translations.W(this._showDisabled).Render();
}
}
}

View file

@ -1,82 +1,98 @@
import {InputElement} from "./InputElement";
import {UIEventSource} from "../../Logic/UIEventSource";
import {Utils} from "../../Utils";
import {UIElement} from "../UIElement";
import BaseUIElement from "../BaseUIElement";
/**
* Supports multi-input
*/
export default class CheckBoxes extends InputElement<number[]> {
private static _nextId = 0;
IsSelected: UIEventSource<boolean> = new UIEventSource<boolean>(false);
private readonly value: UIEventSource<number[]>
private readonly _elements: BaseUIElement[];
private readonly value: UIEventSource<number[]>;
private readonly _elements: UIElement[]
constructor(elements: UIElement[]) {
super(undefined);
constructor(elements: BaseUIElement[], value = new UIEventSource<number[]>([])) {
super();
this.value = value;
this._elements = Utils.NoNull(elements);
this.dumbMode = false;
this.SetClass("flex flex-col")
this.value = new UIEventSource<number[]>([])
this.ListenTo(this.value);
}
IsValid(ts: number[]): boolean {
return ts !== undefined;
}
GetValue(): UIEventSource<number[]> {
return this.value;
}
protected InnerConstructElement(): HTMLElement {
const el = document.createElement("form")
private IdFor(i) {
return 'checkmark-' + this.id + '-' + i;
}
const value = this.value;
const elements = this._elements;
InnerRender(): string {
let body = "";
for (let i = 0; i < this._elements.length; i++) {
let el = this._elements[i];
const htmlElement =
`<input type="checkbox" id="${this.IdFor(i)}"><label for="${this.IdFor(i)}">${el.Render()}</label><br/>`;
body += htmlElement;
for (let i = 0; i < elements.length; i++) {
}
return `<form id='${this.id}'>${body}</form>`;
}
let inputI = elements[i];
const input = document.createElement("input")
const id = CheckBoxes._nextId
CheckBoxes._nextId++;
input.id = "checkbox" + id
protected InnerUpdate(htmlElement: HTMLElement) {
super.InnerUpdate(htmlElement);
const self = this;
input.type = "checkbox"
input.classList.add("p-1","cursor-pointer","m-3","pl-3","mr-0")
for (let i = 0; i < this._elements.length; i++) {
const el = document.getElementById(this.IdFor(i));
const label = document.createElement("label")
label.htmlFor = input.id
label.appendChild(inputI.ConstructElement())
label.classList.add("block","w-full","p-2","cursor-pointer","bg-red")
const wrapper = document.createElement("span")
wrapper.classList.add("flex","w-full","border", "border-gray-400","m-1")
wrapper.appendChild(input)
wrapper.appendChild(label)
el.appendChild(wrapper)
if(this.value.data.indexOf(i) >= 0){
// @ts-ignore
el.checked = true;
}
value.addCallbackAndRun(selectedValues => {
if (selectedValues === undefined) {
return;
}
if (selectedValues.indexOf(i) >= 0) {
input.checked = true;
}
el.onchange = () => {
const index = self.value.data.indexOf(i);
// @ts-ignore
if(el.checked && index < 0){
self.value.data.push(i);
self.value.ping();
}else if(index >= 0){
self.value.data.splice(index,1);
self.value.ping();
if(input.checked){
wrapper.classList.remove("border-gray-400")
wrapper.classList.add("border-black")
}else{
wrapper.classList.add("border-gray-400")
wrapper.classList.remove("border-black")
}
})
input.onchange = () => {
// Index = index in the list of already checked items
const index = value.data.indexOf(i);
if (input.checked && index < 0) {
value.data.push(i);
value.ping();
} else if (index >= 0) {
value.data.splice(index, 1);
value.ping();
}
}
}
return el;
}

View file

@ -1,50 +1,36 @@
import {InputElement} from "./InputElement";
import {UIEventSource} from "../../Logic/UIEventSource";
import {Utils} from "../../Utils";
export default class ColorPicker extends InputElement<string> {
private readonly value: UIEventSource<string>
private readonly _element : HTMLElement
constructor(
value?: UIEventSource<string>
value: UIEventSource<string> = new UIEventSource<string>(undefined)
) {
super();
this.value = value ?? new UIEventSource<string>(undefined);
const self = this;
this.value = value ;
const el = document.createElement("input")
this._element = el;
el.type = "color"
this.value.addCallbackAndRun(v => {
if(v === undefined){
return;
}
self.SetValue(v);
el.value =v
});
el.oninput = () => {
const hex = el.value;
value.setData(hex);
}
}
InnerRender(): string {
return `<span id="${this.id}"><input type='color' id='color-${this.id}'></span>`;
}
private SetValue(color: string){
const field = document.getElementById("color-" + this.id);
if (field === undefined || field === null) {
return;
}
// @ts-ignore
field.value = color;
}
protected InnerUpdate() {
const field = document.getElementById("color-" + this.id);
if (field === undefined || field === null) {
return;
}
const self = this;
field.oninput = () => {
const hex = field["value"];
self.value.setData(hex);
}
protected InnerConstructElement(): HTMLElement {
return this._element;
}
GetValue(): UIEventSource<string> {

View file

@ -1,14 +1,16 @@
import {InputElement} from "./InputElement";
import {UIEventSource} from "../../Logic/UIEventSource";
import Combine from "../Base/Combine";
import {UIElement} from "../UIElement";
import BaseUIElement from "../BaseUIElement";
export default class CombinedInputElement<T> extends InputElement<T> {
protected InnerConstructElement(): HTMLElement {
return this._combined.ConstructElement();
}
private readonly _a: InputElement<T>;
private readonly _b: UIElement;
private readonly _combined: UIElement;
private readonly _b: BaseUIElement;
private readonly _combined: BaseUIElement;
public readonly IsSelected: UIEventSource<boolean>;
constructor(a: InputElement<T>, b: InputElement<T>) {
super();
this._a = a;
@ -23,11 +25,6 @@ export default class CombinedInputElement<T> extends InputElement<T> {
return this._a.GetValue();
}
InnerRender(): string {
return this._combined.Render();
}
IsValid(t: T): boolean {
return this._a.IsValid(t);
}

View file

@ -2,6 +2,7 @@ import {InputElement} from "./InputElement";
import {UIEventSource} from "../../Logic/UIEventSource";
import Combine from "../Base/Combine";
import Svg from "../../Svg";
import {FixedUiElement} from "../Base/FixedUiElement";
/**
@ -9,34 +10,29 @@ import Svg from "../../Svg";
*/
export default class DirectionInput extends InputElement<string> {
private readonly value: UIEventSource<string>;
public readonly IsSelected: UIEventSource<boolean> = new UIEventSource<boolean>(false);
private readonly value: UIEventSource<string>;
constructor(value?: UIEventSource<string>) {
super();
this.dumbMode = false;
this.value = value ?? new UIEventSource<string>(undefined);
this.value.addCallbackAndRun(rotation => {
const selfElement = document.getElementById(this.id);
if (selfElement === null) {
return;
}
const cone = selfElement.getElementsByClassName("direction-svg")[0] as HTMLElement
cone.style.transform = `rotate(${rotation}deg)`;
})
}
GetValue(): UIEventSource<string> {
return this.value;
}
InnerRender(): string {
return new Combine([
`<div id="direction-leaflet-div-${this.id}" style="width:100%;height: 100%;position: absolute;top:0;left:0;border-radius:100%;"></div>`,
IsValid(str: string): boolean {
const t = Number(str);
return !isNaN(t) && t >= 0 && t <= 360;
}
protected InnerConstructElement(): HTMLElement {
const element = new Combine([
new FixedUiElement("").SetClass("w-full h-full absolute top-0 left-O rounded-full"),
Svg.direction_svg().SetStyle(
`position: absolute;top: 0;left: 0;width: 100%;height: 100%;transform:rotate(${this.value.data ?? 0}deg);`)
.SetClass("direction-svg"),
@ -44,11 +40,21 @@ export default class DirectionInput extends InputElement<string> {
"position: absolute;top: 0;left: 0;width: 100%;height: 100%;")
])
.SetStyle("position:relative;display:block;width: min(100%, 25em); padding-top: min(100% , 25em); background:white; border: 1px solid black; border-radius: 999em")
.Render();
.ConstructElement()
this.value.addCallbackAndRun(rotation => {
const cone = element.getElementsByClassName("direction-svg")[0] as HTMLElement
cone.style.transform = `rotate(${rotation}deg)`;
})
this.RegisterTriggers(element)
return element;
}
protected InnerUpdate(htmlElement: HTMLElement) {
super.InnerUpdate(htmlElement);
private RegisterTriggers(htmlElement: HTMLElement) {
const self = this;
function onPosChange(x: number, y: number) {
@ -79,19 +85,16 @@ export default class DirectionInput extends InputElement<string> {
}
htmlElement.onmouseup = (ev) => {
isDown = false; ev.preventDefault();
isDown = false;
ev.preventDefault();
}
htmlElement.onmousemove = (ev: MouseEvent) => {
if (isDown) {
onPosChange(ev.clientX, ev.clientY);
} ev.preventDefault();
}
ev.preventDefault();
}
}
IsValid(str: string): boolean {
const t = Number(str);
return !isNaN(t) && t >= 0 && t <= 360;
}
}

View file

@ -1,50 +1,92 @@
import {UIElement} from "../UIElement";
import {InputElement} from "./InputElement";
import Translations from "../i18n/Translations";
import {UIEventSource} from "../../Logic/UIEventSource";
import BaseUIElement from "../BaseUIElement";
export class DropDown<T> extends InputElement<T> {
private readonly _label: UIElement;
private readonly _values: { value: T; shown: UIElement }[];
private static _nextDropdownId = 0;
public IsSelected: UIEventSource<boolean> = new UIEventSource<boolean>(false);
private readonly _element: HTMLElement;
private readonly _value: UIEventSource<T>;
private readonly _values: { value: T; shown: string | BaseUIElement }[];
public IsSelected: UIEventSource<boolean> = new UIEventSource<boolean>(false);
private readonly _label_class: string;
private readonly _select_class: string;
private _form_style: string;
constructor(label: string | UIElement,
values: { value: T, shown: string | UIElement }[],
constructor(label: string | BaseUIElement,
values: { value: T, shown: string | BaseUIElement }[],
value: UIEventSource<T> = undefined,
label_class: string = "",
select_class: string = "",
form_style: string = "flex") {
super(undefined);
this._form_style = form_style;
this._value = value ?? new UIEventSource<T>(undefined);
this._label = Translations.W(label);
this._label_class = label_class || '';
this._select_class = select_class || '';
this._values = values.map(v => {
return {
value: v.value,
shown: Translations.W(v.shown)
options?: {
select_class?: string
}
}
);
for (const v of this._values) {
this.ListenTo(v.shown._source);
) {
super();
value = value ?? new UIEventSource<T>(undefined)
this._value = value
this._values = values;
if (values.length <= 1) {
return;
}
this.ListenTo(this._value);
this.onClick(() => {}) // by registering a click, the click event is consumed and doesn't bubble furter to other elements, e.g. checkboxes
const id = DropDown._nextDropdownId;
DropDown._nextDropdownId++;
const el = document.createElement("form")
this._element = el;
el.id = "dropdown" + id;
{
const labelEl = Translations.W(label).ConstructElement()
if (labelEl !== undefined) {
const labelHtml = document.createElement("label")
labelHtml.appendChild(labelEl)
labelHtml.htmlFor = el.id;
el.appendChild(labelHtml)
}
}
options = options ?? {}
options.select_class = options.select_class ?? 'bg-indigo-100 p-1 rounded hover:bg-indigo-200'
{
const select = document.createElement("select")
select.classList.add(...(options.select_class.split(" ") ?? []))
for (let i = 0; i < values.length; i++) {
const option = document.createElement("option")
option.value = "" + i
option.appendChild(Translations.W(values[i].shown).ConstructElement())
select.appendChild(option)
}
el.appendChild(select)
select.onchange = (() => {
var index = select.selectedIndex;
value.setData(values[index].value);
});
value.addCallbackAndRun(selected => {
for (let i = 0; i < values.length; i++) {
const value = values[i].value;
if (value === selected) {
select.selectedIndex = i;
}
}
})
}
this.onClick(() => {
}) // by registering a click, the click event is consumed and doesn't bubble further to other elements, e.g. checkboxes
}
GetValue(): UIEventSource<T> {
return this._value;
}
IsValid(t: T): boolean {
for (const value of this._values) {
if (value.value === t) {
@ -54,44 +96,8 @@ export class DropDown<T> extends InputElement<T> {
return false
}
InnerRender(): string {
if(this._values.length <=1){
return "";
}
let options = "";
for (let i = 0; i < this._values.length; i++) {
options += "<option value='" + i + "'>" + this._values[i].shown.InnerRender() + "</option>"
}
return `<form class="${this._form_style}">` +
`<label class='${this._label_class}' for='dropdown-${this.id}'>${this._label.Render()}</label>` +
`<select class='${this._select_class}' name='dropdown-${this.id}' id='dropdown-${this.id}'>` +
options +
`</select>` +
`</form>`;
protected InnerConstructElement(): HTMLElement {
return this._element;
}
protected InnerUpdate(element) {
var e = document.getElementById("dropdown-" + this.id);
if(e === null){
return;
}
const self = this;
e.onchange = (() => {
// @ts-ignore
var index = parseInt(e.selectedIndex);
self._value.setData(self._values[index].value);
});
var t = this._value.data;
for (let i = 0; i < this._values.length ; i++) {
const value = this._values[i].value;
if (value === t) {
// @ts-ignore
e.selectedIndex = i;
}
}
}
}

View file

@ -0,0 +1,66 @@
import BaseUIElement from "../BaseUIElement";
import {InputElement} from "./InputElement";
import {UIEventSource} from "../../Logic/UIEventSource";
export default class FileSelectorButton extends InputElement<FileList> {
private static _nextid;
IsSelected: UIEventSource<boolean>;
private readonly _value = new UIEventSource(undefined);
private readonly _label: BaseUIElement;
private readonly _acceptType: string;
constructor(label: BaseUIElement, acceptType: string = "image/*") {
super();
this._label = label;
this._acceptType = acceptType;
this.SetClass("block cursor-pointer")
label.SetClass("cursor-pointer")
}
GetValue(): UIEventSource<FileList> {
return this._value;
}
IsValid(t: FileList): boolean {
return true;
}
protected InnerConstructElement(): HTMLElement {
const self = this;
const el = document.createElement("form")
const label = document.createElement("label")
label.appendChild(this._label.ConstructElement())
el.appendChild(label)
const actualInputElement = document.createElement("input");
actualInputElement.style.cssText = "display:none";
actualInputElement.type = "file";
actualInputElement.accept = this._acceptType;
actualInputElement.name = "picField";
actualInputElement.multiple = true;
actualInputElement.id = "fileselector" + FileSelectorButton._nextid;
FileSelectorButton._nextid++;
label.htmlFor = actualInputElement.id;
actualInputElement.onchange = () => {
if (actualInputElement.files !== null) {
self._value.setData(actualInputElement.files)
}
}
el.addEventListener('submit', e => {
if (actualInputElement.files !== null) {
self._value.setData(actualInputElement.files)
}
e.preventDefault()
})
el.appendChild(actualInputElement)
return el;
}
}

View file

@ -1,44 +1,46 @@
import {InputElement} from "./InputElement";
import {UIElement} from "../UIElement";
import {FixedUiElement} from "../Base/FixedUiElement";
import {UIEventSource} from "../../Logic/UIEventSource";
import Translations from "../i18n/Translations";
import BaseUIElement from "../BaseUIElement";
export class FixedInputElement<T> extends InputElement<T> {
private readonly rendering: UIElement;
private readonly value: UIEventSource<T>;
public readonly IsSelected : UIEventSource<boolean> = new UIEventSource<boolean>(false);
private readonly _comparator: (t0: T, t1: T) => boolean;
constructor(rendering: UIElement | string,
private readonly _el : HTMLElement;
constructor(rendering: BaseUIElement | string,
value: T,
comparator: ((t0: T, t1: T) => boolean ) = undefined) {
super(undefined);
super();
this._comparator = comparator ?? ((t0, t1) => t0 == t1);
this.value = new UIEventSource<T>(value);
this.rendering = typeof (rendering) === 'string' ? new FixedUiElement(rendering) : rendering;
const self = this;
const selected = this.IsSelected;
this._el = document.createElement("span")
this._el.addEventListener("mouseout", () => selected.setData(false))
const e = Translations.W(rendering)?.ConstructElement()
if(e){
this._el.appendChild( e)
}
this.onClick(() => {
self.IsSelected.setData(true)
selected.setData(true)
})
}
protected InnerConstructElement(): HTMLElement {
return this._el;
}
GetValue(): UIEventSource<T> {
return this.value;
}
InnerRender(): string {
return this.rendering.Render();
}
IsValid(t: T): boolean {
return this._comparator(t, this.value.data);
}
protected InnerUpdate(htmlElement: HTMLElement) {
super.InnerUpdate(htmlElement);
const self = this;
htmlElement.addEventListener("mouseout", () => self.IsSelected.setData(false))
}
}

View file

@ -1,7 +1,7 @@
import {UIElement} from "../UIElement";
import {UIEventSource} from "../../Logic/UIEventSource";
import BaseUIElement from "../BaseUIElement";
export abstract class InputElement<T> extends UIElement{
export abstract class InputElement<T> extends BaseUIElement{
abstract GetValue() : UIEventSource<T>;
abstract IsSelected: UIEventSource<boolean>;

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