diff --git a/Customizations/JSON/LayerConfig.ts b/Customizations/JSON/LayerConfig.ts index 0fe05d121..10d8ddfe1 100644 --- a/Customizations/JSON/LayerConfig.ts +++ b/Customizations/JSON/LayerConfig.ts @@ -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, clickable: boolean): + public GenerateLeafletStyle(tags: UIEventSource, 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(``); + let html: BaseUIElement = new FixedUiElement(``); 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); }) diff --git a/Customizations/JSON/LayoutConfig.ts b/Customizations/JSON/LayoutConfig.ts index 00764d490..4bf3ae2a2 100644 --- a/Customizations/JSON/LayoutConfig.ts +++ b/Customizations/JSON/LayoutConfig.ts @@ -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); } } } diff --git a/Customizations/JSON/TagRenderingConfig.ts b/Customizations/JSON/TagRenderingConfig.ts index 157bc2d1b..52e214c37 100644 --- a/Customizations/JSON/TagRenderingConfig.ts +++ b/Customizations/JSON/TagRenderingConfig.ts @@ -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 diff --git a/Docs/Architecture.md b/Docs/Architecture.md index 01a214bda..d42ab7e7f 100644 --- a/Docs/Architecture.md +++ b/Docs/Architecture.md @@ -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 diff --git a/Docs/CalculatedTags.md b/Docs/CalculatedTags.md index 6347c09a8..166f1d792 100644 --- a/Docs/CalculatedTags.md +++ b/Docs/CalculatedTags.md @@ -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(';')` \ No newline at end of file + + + - `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 \ No newline at end of file diff --git a/Docs/SpecialInputElements.md b/Docs/SpecialInputElements.md index 5f807ab47..3eb09bbb2 100644 --- a/Docs/SpecialInputElements.md +++ b/Docs/SpecialInputElements.md @@ -60,4 +60,4 @@ Has extra elements to easily input when a POI is opened ## color -Shows a color picker \ No newline at end of file +Shows a color picker Generated from ValidatedTextField.ts \ No newline at end of file diff --git a/Docs/SpecialRenderings.md b/Docs/SpecialRenderings.md index a0b77e323..4f301afcd 100644 --- a/Docs/SpecialRenderings.md +++ b/Docs/SpecialRenderings.md @@ -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**** \ No newline at end of file +

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 Generated from UI/SpecialVisualisations.ts \ No newline at end of file diff --git a/Docs/TagInfo/mapcomplete_fietsstraten.json b/Docs/TagInfo/mapcomplete_fietsstraten.json index e8b411615..b5b234baf 100644 --- a/Docs/TagInfo/mapcomplete_fietsstraten.json +++ b/Docs/TagInfo/mapcomplete_fietsstraten.json @@ -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": "" }, { diff --git a/Docs/URL_Parameters.md b/Docs/URL_Parameters.md index fa0e0aaea..6f299adcf 100644 --- a/Docs/URL_Parameters.md +++ b/Docs/URL_Parameters.md @@ -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- --------------- -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- +------------------ + + Wether or not the layer with id is shown The default value is _true_ Generated from QueryParameters \ No newline at end of file diff --git a/InitUiElements.ts b/InitUiElements.ts index 45f048068..a5b738e9c 100644 --- a/InitUiElements.ts +++ b/InitUiElements.ts @@ -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) { + static LoadLayoutFromHash(userLayoutParam: UIEventSource): [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:
" + 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)); diff --git a/Logic/Actors/GeoLocationHandler.ts b/Logic/Actors/GeoLocationHandler.ts index 772f57304..740fc77ce 100644 --- a/Logic/Actors/GeoLocationHandler.ts +++ b/Logic/Actors/GeoLocationHandler.ts @@ -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 = LocalStorageSource.Get("geolocation-permissions"); private readonly _layoutToUse: UIEventSource; + + private readonly _element: BaseUIElement; + constructor(currentGPSLocation: UIEventSource<{ latlng: any; accuracy: number }>, leafletMap: UIEventSource, layoutToUse: UIEventSource) { - 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") diff --git a/Logic/Actors/OverpassFeatureSource.ts b/Logic/Actors/OverpassFeatureSource.ts index b9b7866d9..d76ef1206 100644 --- a/Logic/Actors/OverpassFeatureSource.ts +++ b/Logic/Actors/OverpassFeatureSource.ts @@ -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] ); diff --git a/Logic/Actors/StrayClickHandler.ts b/Logic/Actors/StrayClickHandler.ts index 064da814c..b4d630070 100644 --- a/Logic/Actors/StrayClickHandler.ts +++ b/Logic/Actors/StrayClickHandler.ts @@ -47,13 +47,13 @@ export default class StrayClickHandler { popupAnchor: [0, -45] }) }); - const popup = L.popup().setContent(uiToShow.Render()); + const popup = L.popup().setContent("
"); self._lastMarker.addTo(leafletMap.data); self._lastMarker.bindPopup(popup); self._lastMarker.on("click", () => { + uiToShow.AttachTo("strayclick") uiToShow.Activate(); - uiToShow.Update(); }); }); diff --git a/Logic/Actors/TitleHandler.ts b/Logic/Actors/TitleHandler.ts index f03961ae1..16459bac8 100644 --- a/Logic/Actors/TitleHandler.ts +++ b/Logic/Actors/TitleHandler.ts @@ -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 { + private readonly _layoutToUse: UIEventSource; private readonly _selectedFeature: UIEventSource; private readonly _allElementsStorage: ElementStorage; @@ -15,42 +15,44 @@ class TitleElement extends UIElement { constructor(layoutToUse: UIEventSource, selectedFeature: UIEventSource, 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, selectedFeature: UIEventSource, 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 }) - } } \ No newline at end of file diff --git a/Logic/ExtraFunction.ts b/Logic/ExtraFunction.ts index 51f4ff9f9..b94f925d9 100644 --- a/Logic/ExtraFunction.ts +++ b/Logic/ExtraFunction.ts @@ -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 = `

Calculating tags with Javascript

+ 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:", -

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.

+ 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(); -

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'" - ] -
- -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: - -` 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 { 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", + "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 feat 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 feat as well: private static readonly Memberships = new ExtraFunction( "memberships", - "Gives a list of {role: string, relation: Relation}-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: _part_of_walking_routes=feat.memberships().map(r => r.relation.tags.name).join(';')", + "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 feat 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, - "
    ", - ...ExtraFunction.allFuncs.map(func => - new Combine([ - "
  • ", func._name, "
  • " - ]) - ), - "
", - ...ExtraFunction.allFuncs.map(func => - new Combine([ - "

" + func._name + "

", - func._doc, - "
    ", - ...func._args.map(arg => "
  • " + arg + "
  • "), - "
" - ]) - ) + new List(ExtraFunction.allFuncs.map(func => func._name)), + ...elems ]); } diff --git a/Logic/Osm/ChangesetHandler.ts b/Logic/Osm/ChangesetHandler.ts index 18ddaa926..5280510ff 100644 --- a/Logic/Osm/ChangesetHandler.ts +++ b/Logic/Osm/ChangesetHandler.ts @@ -10,12 +10,11 @@ import Constants from "../../Models/Constants"; export class ChangesetHandler { + public readonly currentChangeset: UIEventSource; private readonly _dryRun: boolean; private readonly userDetails: UIEventSource; private readonly auth: any; - public readonly currentChangeset: UIEventSource; - 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 = '' - } + 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 => ``) + .join("\n") this.auth.xhr({ method: 'PUT', path: '/api/0.6/changeset/create', options: {header: {'Content-Type': 'text/xml'}}, content: [``, - ``, - ``, - ``, - ``, - ``, - ``, - surveySource, - (layout.maintainer ?? "") !== "" ? `` : "", + metadata, ``].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(); - - } - } - } - } \ No newline at end of file diff --git a/Logic/Osm/OsmConnection.ts b/Logic/Osm/OsmConnection.ts index a732becd9..c085c5738 100644 --- a/Logic/Osm/OsmConnection.ts +++ b/Logic/Osm/OsmConnection.ts @@ -39,6 +39,7 @@ export class OsmConnection { } public auth; public userDetails: UIEventSource; + public isLoggedIn: UIEventSource _dryRun: boolean; public preferencesHandler: OsmPreferences; public changesetHandler: ChangesetHandler; @@ -64,6 +65,14 @@ export class OsmConnection { this.userDetails = new UIEventSource(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(); + } + }); + } diff --git a/Logic/SimpleMetaTagger.ts b/Logic/SimpleMetaTagger.ts index df40a9195..8e6516c6d 100644 --- a/Logic/SimpleMetaTagger.ts +++ b/Logic/SimpleMetaTagger.ts @@ -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([ - "

Metatags

", - "

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 debug=true to the URL. This will include a box in the popup for features which shows all the properties of the object

" - ]) - + 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([ - "

", metatag.keys.join(", "), "

", - 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 }[]) { diff --git a/Logic/UIEventSource.ts b/Logic/UIEventSource.ts index 63ed45d09..7fa5a432d 100644 --- a/Logic/UIEventSource.ts +++ b/Logic/UIEventSource.ts @@ -92,9 +92,16 @@ export class UIEventSource { } } - public map(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(f: ((t: T) => J), extraSources: UIEventSource[] = [], - g: ((J) => T) = undefined): UIEventSource { + g: ((j:J, t:T) => T) = undefined): UIEventSource { const self = this; const newSource = new UIEventSource( @@ -113,7 +120,7 @@ export class UIEventSource { if (g !== undefined) { newSource.addCallback((latest) => { - self.setData(g(latest)); + self.setData(g(latest, self.data)); }) } diff --git a/Logic/Web/ImageAttributionSource.ts b/Logic/Web/ImageAttributionSource.ts new file mode 100644 index 000000000..689a32c46 --- /dev/null +++ b/Logic/Web/ImageAttributionSource.ts @@ -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>() + + GetAttributionFor(url: string): UIEventSource { + 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; + public PrepareUrl(value: string): string{ + return value; + } + +} \ No newline at end of file diff --git a/Logic/Web/Imgur.ts b/Logic/Web/Imgur.ts index 27a10535d..a1ecd7eee 100644 --- a/Logic/Web/Imgur.ts +++ b/Logic/Web/Imgur.ts @@ -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 { + const src = new UIEventSource(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; + } + + } \ No newline at end of file diff --git a/Logic/Web/ImgurUploader.ts b/Logic/Web/ImgurUploader.ts new file mode 100644 index 000000000..67392086d --- /dev/null +++ b/Logic/Web/ImgurUploader.ts @@ -0,0 +1,41 @@ +import {UIEventSource} from "../UIEventSource"; +import {Imgur} from "./Imgur"; + +export default class ImgurUploader { + + public readonly queue: UIEventSource = new UIEventSource([]); + public readonly failed: UIEventSource = new UIEventSource([]); + public readonly success: UIEventSource = new UIEventSource([]); + 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]) + } + ); + } +} \ No newline at end of file diff --git a/Logic/Web/Mapillary.ts b/Logic/Web/Mapillary.ts index 048e63716..cad71f93b 100644 --- a/Logic/Web/Mapillary.ts +++ b/Logic/Web/Mapillary.ts @@ -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 { + + const key = Mapillary.ExtractKeyFromURL(url) + const metadataURL = `https://a.mapillary.com/v3/images/${key}?client_id=TXhLaWthQ1d4RUg0czVxaTVoRjFJZzowNDczNjUzNmIyNTQyYzI2` + const source = new UIEventSource(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 } } \ No newline at end of file diff --git a/Logic/Web/QueryParameters.ts b/Logic/Web/QueryParameters.ts index d08df0fdf..a7bca6e8d 100644 --- a/Logic/Web/QueryParameters.ts +++ b/Logic/Web/QueryParameters.ts @@ -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 { + 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(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 { - 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(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"); - } - } \ No newline at end of file diff --git a/Logic/Web/Wikimedia.ts b/Logic/Web/Wikimedia.ts index bf68d2255..4668f3511 100644 --- a/Logic/Web/Wikimedia.ts +++ b/Logic/Web/Wikimedia.ts @@ -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 { + + const source = new UIEventSource(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; + + } + } diff --git a/Models/Constants.ts b/Models/Constants.ts index a350603c7..06b2a2c4a 100644 --- a/Models/Constants.ts +++ b/Models/Constants.ts @@ -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, diff --git a/State.ts b/State.ts index 45c72fbcf..8e4322d65 100644 --- a/State.ts +++ b/State.ts @@ -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(""); /** The latest element that was selected @@ -106,6 +102,8 @@ export default class State { */ public readonly locationControl = new UIEventSource(undefined); public backgroundLayer; + public readonly backgroundLayerId: UIEventSource; + /* 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( 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, diff --git a/Svg.ts b/Svg.ts index 53889707c..3f0ff50fe 100644 --- a/Svg.ts +++ b/Svg.ts @@ -6,317 +6,317 @@ export default class Svg { public static SocialImageForeground = " image/svg+xml 010110010011010110010011  010110010011010110010011  " public static SocialImageForeground_img = Img.AsImageElement(Svg.SocialImageForeground) - public static SocialImageForeground_svg() { return new FixedUiElement(Svg.SocialImageForeground);} + public static SocialImageForeground_svg() { return new Img(Svg.SocialImageForeground, true);} public static SocialImageForeground_ui() { return new FixedUiElement(Svg.SocialImageForeground_img);} public static add = " image/svg+xml " public static add_img = Img.AsImageElement(Svg.add) - public static add_svg() { return new FixedUiElement(Svg.add);} + public static add_svg() { return new Img(Svg.add, true);} public static add_ui() { return new FixedUiElement(Svg.add_img);} public static addSmall = " image/svg+xml " public static addSmall_img = Img.AsImageElement(Svg.addSmall) - public static addSmall_svg() { return new FixedUiElement(Svg.addSmall);} + public static addSmall_svg() { return new Img(Svg.addSmall, true);} public static addSmall_ui() { return new FixedUiElement(Svg.addSmall_img);} public static ampersand = " image/svg+xml " public static ampersand_img = Img.AsImageElement(Svg.ampersand) - public static ampersand_svg() { return new FixedUiElement(Svg.ampersand);} + public static ampersand_svg() { return new Img(Svg.ampersand, true);} public static ampersand_ui() { return new FixedUiElement(Svg.ampersand_img);} public static arrow_left_smooth = " image/svg+xml " public static arrow_left_smooth_img = Img.AsImageElement(Svg.arrow_left_smooth) - public static arrow_left_smooth_svg() { return new FixedUiElement(Svg.arrow_left_smooth);} + public static arrow_left_smooth_svg() { return new Img(Svg.arrow_left_smooth, true);} public static arrow_left_smooth_ui() { return new FixedUiElement(Svg.arrow_left_smooth_img);} public static arrow_right_smooth = " image/svg+xml " public static arrow_right_smooth_img = Img.AsImageElement(Svg.arrow_right_smooth) - public static arrow_right_smooth_svg() { return new FixedUiElement(Svg.arrow_right_smooth);} + public static arrow_right_smooth_svg() { return new Img(Svg.arrow_right_smooth, true);} public static arrow_right_smooth_ui() { return new FixedUiElement(Svg.arrow_right_smooth_img);} public static back = " image/svg+xml " public static back_img = Img.AsImageElement(Svg.back) - public static back_svg() { return new FixedUiElement(Svg.back);} + public static back_svg() { return new Img(Svg.back, true);} public static back_ui() { return new FixedUiElement(Svg.back_img);} public static bug = " " public static bug_img = Img.AsImageElement(Svg.bug) - public static bug_svg() { return new FixedUiElement(Svg.bug);} + public static bug_svg() { return new Img(Svg.bug, true);} public static bug_ui() { return new FixedUiElement(Svg.bug_img);} public static camera_plus = " image/svg+xml " public static camera_plus_img = Img.AsImageElement(Svg.camera_plus) - public static camera_plus_svg() { return new FixedUiElement(Svg.camera_plus);} + public static camera_plus_svg() { return new Img(Svg.camera_plus, true);} public static camera_plus_ui() { return new FixedUiElement(Svg.camera_plus_img);} public static checkmark = "" public static checkmark_img = Img.AsImageElement(Svg.checkmark) - public static checkmark_svg() { return new FixedUiElement(Svg.checkmark);} + public static checkmark_svg() { return new Img(Svg.checkmark, true);} public static checkmark_ui() { return new FixedUiElement(Svg.checkmark_img);} public static circle = " " public static circle_img = Img.AsImageElement(Svg.circle) - public static circle_svg() { return new FixedUiElement(Svg.circle);} + public static circle_svg() { return new Img(Svg.circle, true);} public static circle_ui() { return new FixedUiElement(Svg.circle_img);} public static clock = " image/svg+xml " public static clock_img = Img.AsImageElement(Svg.clock) - public static clock_svg() { return new FixedUiElement(Svg.clock);} + public static clock_svg() { return new Img(Svg.clock, true);} public static clock_ui() { return new FixedUiElement(Svg.clock_img);} public static close = " image/svg+xml " public static close_img = Img.AsImageElement(Svg.close) - public static close_svg() { return new FixedUiElement(Svg.close);} + public static close_svg() { return new Img(Svg.close, true);} public static close_ui() { return new FixedUiElement(Svg.close_img);} public static compass = " image/svg+xml N S E W NW SW NE SE " public static compass_img = Img.AsImageElement(Svg.compass) - public static compass_svg() { return new FixedUiElement(Svg.compass);} + public static compass_svg() { return new Img(Svg.compass, true);} public static compass_ui() { return new FixedUiElement(Svg.compass_img);} public static cross_bottom_right = " image/svg+xml " public static cross_bottom_right_img = Img.AsImageElement(Svg.cross_bottom_right) - public static cross_bottom_right_svg() { return new FixedUiElement(Svg.cross_bottom_right);} + public static cross_bottom_right_svg() { return new Img(Svg.cross_bottom_right, true);} public static cross_bottom_right_ui() { return new FixedUiElement(Svg.cross_bottom_right_img);} public static crosshair_blue_center = " image/svg+xml " public static crosshair_blue_center_img = Img.AsImageElement(Svg.crosshair_blue_center) - public static crosshair_blue_center_svg() { return new FixedUiElement(Svg.crosshair_blue_center);} + public static crosshair_blue_center_svg() { return new Img(Svg.crosshair_blue_center, true);} public static crosshair_blue_center_ui() { return new FixedUiElement(Svg.crosshair_blue_center_img);} public static crosshair_blue = " image/svg+xml " public static crosshair_blue_img = Img.AsImageElement(Svg.crosshair_blue) - public static crosshair_blue_svg() { return new FixedUiElement(Svg.crosshair_blue);} + public static crosshair_blue_svg() { return new Img(Svg.crosshair_blue, true);} public static crosshair_blue_ui() { return new FixedUiElement(Svg.crosshair_blue_img);} public static crosshair = " image/svg+xml " public static crosshair_img = Img.AsImageElement(Svg.crosshair) - public static crosshair_svg() { return new FixedUiElement(Svg.crosshair);} + public static crosshair_svg() { return new Img(Svg.crosshair, true);} public static crosshair_ui() { return new FixedUiElement(Svg.crosshair_img);} public static delete_icon = " image/svg+xml " public static delete_icon_img = Img.AsImageElement(Svg.delete_icon) - public static delete_icon_svg() { return new FixedUiElement(Svg.delete_icon);} + public static delete_icon_svg() { return new Img(Svg.delete_icon, true);} public static delete_icon_ui() { return new FixedUiElement(Svg.delete_icon_img);} public static direction = " image/svg+xml " public static direction_img = Img.AsImageElement(Svg.direction) - public static direction_svg() { return new FixedUiElement(Svg.direction);} + public static direction_svg() { return new Img(Svg.direction, true);} public static direction_ui() { return new FixedUiElement(Svg.direction_img);} public static direction_gradient = " image/svg+xml " public static direction_gradient_img = Img.AsImageElement(Svg.direction_gradient) - public static direction_gradient_svg() { return new FixedUiElement(Svg.direction_gradient);} + public static direction_gradient_svg() { return new Img(Svg.direction_gradient, true);} public static direction_gradient_ui() { return new FixedUiElement(Svg.direction_gradient_img);} public static down = " image/svg+xml " public static down_img = Img.AsImageElement(Svg.down) - public static down_svg() { return new FixedUiElement(Svg.down);} + public static down_svg() { return new Img(Svg.down, true);} public static down_ui() { return new FixedUiElement(Svg.down_img);} public static envelope = " image/svg+xml " public static envelope_img = Img.AsImageElement(Svg.envelope) - public static envelope_svg() { return new FixedUiElement(Svg.envelope);} + public static envelope_svg() { return new Img(Svg.envelope, true);} public static envelope_ui() { return new FixedUiElement(Svg.envelope_img);} public static floppy = " " public static floppy_img = Img.AsImageElement(Svg.floppy) - public static floppy_svg() { return new FixedUiElement(Svg.floppy);} + public static floppy_svg() { return new Img(Svg.floppy, true);} public static floppy_ui() { return new FixedUiElement(Svg.floppy_img);} public static gear = " " public static gear_img = Img.AsImageElement(Svg.gear) - public static gear_svg() { return new FixedUiElement(Svg.gear);} + public static gear_svg() { return new Img(Svg.gear, true);} public static gear_ui() { return new FixedUiElement(Svg.gear_img);} public static help = " " public static help_img = Img.AsImageElement(Svg.help) - public static help_svg() { return new FixedUiElement(Svg.help);} + public static help_svg() { return new Img(Svg.help, true);} public static help_ui() { return new FixedUiElement(Svg.help_img);} public static home = " " public static home_img = Img.AsImageElement(Svg.home) - public static home_svg() { return new FixedUiElement(Svg.home);} + public static home_svg() { return new Img(Svg.home, true);} public static home_ui() { return new FixedUiElement(Svg.home_img);} public static home_white_bg = " image/svg+xml " public static home_white_bg_img = Img.AsImageElement(Svg.home_white_bg) - public static home_white_bg_svg() { return new FixedUiElement(Svg.home_white_bg);} + public static home_white_bg_svg() { return new Img(Svg.home_white_bg, true);} public static home_white_bg_ui() { return new FixedUiElement(Svg.home_white_bg_img);} public static josm_logo = " JOSM Logotype 2019 image/svg+xml JOSM Logotype 2019 2019-08-05 Diamond00744 Public Domain " public static josm_logo_img = Img.AsImageElement(Svg.josm_logo) - public static josm_logo_svg() { return new FixedUiElement(Svg.josm_logo);} + public static josm_logo_svg() { return new Img(Svg.josm_logo, true);} public static josm_logo_ui() { return new FixedUiElement(Svg.josm_logo_img);} public static layers = " image/svg+xml " public static layers_img = Img.AsImageElement(Svg.layers) - public static layers_svg() { return new FixedUiElement(Svg.layers);} + public static layers_svg() { return new Img(Svg.layers, true);} public static layers_ui() { return new FixedUiElement(Svg.layers_img);} public static layersAdd = " image/svg+xml " public static layersAdd_img = Img.AsImageElement(Svg.layersAdd) - public static layersAdd_svg() { return new FixedUiElement(Svg.layersAdd);} + public static layersAdd_svg() { return new Img(Svg.layersAdd, true);} public static layersAdd_ui() { return new FixedUiElement(Svg.layersAdd_img);} public static logo = " image/svg+xml " public static logo_img = Img.AsImageElement(Svg.logo) - public static logo_svg() { return new FixedUiElement(Svg.logo);} + public static logo_svg() { return new Img(Svg.logo, true);} public static logo_ui() { return new FixedUiElement(Svg.logo_img);} public static logout = " image/svg+xml " public static logout_img = Img.AsImageElement(Svg.logout) - public static logout_svg() { return new FixedUiElement(Svg.logout);} + public static logout_svg() { return new Img(Svg.logout, true);} public static logout_ui() { return new FixedUiElement(Svg.logout_img);} public static mapcomplete_logo = " OpenStreetMap logo 2011 image/svg+xml OpenStreetMap logo 2011 Ken Vermette April 2011 OpenStreetMap.org Replacement logo for OpenStreetMap Foundation OSM openstreetmap logo http://wiki.openstreetmap.org/wiki/File:Public-images-osm_logo.svg 010110010011010110010011  010110010011010110010011  " public static mapcomplete_logo_img = Img.AsImageElement(Svg.mapcomplete_logo) - public static mapcomplete_logo_svg() { return new FixedUiElement(Svg.mapcomplete_logo);} + public static mapcomplete_logo_svg() { return new Img(Svg.mapcomplete_logo, true);} public static mapcomplete_logo_ui() { return new FixedUiElement(Svg.mapcomplete_logo_img);} public static mapillary = "" public static mapillary_img = Img.AsImageElement(Svg.mapillary) - public static mapillary_svg() { return new FixedUiElement(Svg.mapillary);} + public static mapillary_svg() { return new Img(Svg.mapillary, true);} public static mapillary_ui() { return new FixedUiElement(Svg.mapillary_img);} public static mapillary_black = " image/svg+xml " public static mapillary_black_img = Img.AsImageElement(Svg.mapillary_black) - public static mapillary_black_svg() { return new FixedUiElement(Svg.mapillary_black);} + public static mapillary_black_svg() { return new Img(Svg.mapillary_black, true);} public static mapillary_black_ui() { return new FixedUiElement(Svg.mapillary_black_img);} public static min = " image/svg+xml " public static min_img = Img.AsImageElement(Svg.min) - public static min_svg() { return new FixedUiElement(Svg.min);} + public static min_svg() { return new Img(Svg.min, true);} public static min_ui() { return new FixedUiElement(Svg.min_img);} public static no_checkmark = " " public static no_checkmark_img = Img.AsImageElement(Svg.no_checkmark) - public static no_checkmark_svg() { return new FixedUiElement(Svg.no_checkmark);} + public static no_checkmark_svg() { return new Img(Svg.no_checkmark, true);} public static no_checkmark_ui() { return new FixedUiElement(Svg.no_checkmark_img);} public static or = " image/svg+xml " public static or_img = Img.AsImageElement(Svg.or) - public static or_svg() { return new FixedUiElement(Svg.or);} + public static or_svg() { return new Img(Svg.or, true);} public static or_ui() { return new FixedUiElement(Svg.or_img);} public static osm_copyright = " image/svg+xml " public static osm_copyright_img = Img.AsImageElement(Svg.osm_copyright) - public static osm_copyright_svg() { return new FixedUiElement(Svg.osm_copyright);} + public static osm_copyright_svg() { return new Img(Svg.osm_copyright, true);} public static osm_copyright_ui() { return new FixedUiElement(Svg.osm_copyright_img);} public static osm_logo_us = "" public static osm_logo_us_img = Img.AsImageElement(Svg.osm_logo_us) - public static osm_logo_us_svg() { return new FixedUiElement(Svg.osm_logo_us);} + public static osm_logo_us_svg() { return new Img(Svg.osm_logo_us, true);} public static osm_logo_us_ui() { return new FixedUiElement(Svg.osm_logo_us_img);} public static osm_logo = " OpenStreetMap logo 2011 image/svg+xml OpenStreetMap logo 2011 Ken Vermette April 2011 OpenStreetMap.org Replacement logo for OpenStreetMap Foundation OSM openstreetmap logo http://wiki.openstreetmap.org/wiki/File:Public-images-osm_logo.svg 010110010011010110010011 010110010011010110010011 " public static osm_logo_img = Img.AsImageElement(Svg.osm_logo) - public static osm_logo_svg() { return new FixedUiElement(Svg.osm_logo);} + public static osm_logo_svg() { return new Img(Svg.osm_logo, true);} public static osm_logo_ui() { return new FixedUiElement(Svg.osm_logo_img);} public static pencil = " " public static pencil_img = Img.AsImageElement(Svg.pencil) - public static pencil_svg() { return new FixedUiElement(Svg.pencil);} + public static pencil_svg() { return new Img(Svg.pencil, true);} public static pencil_ui() { return new FixedUiElement(Svg.pencil_img);} public static phone = " image/svg+xml " public static phone_img = Img.AsImageElement(Svg.phone) - public static phone_svg() { return new FixedUiElement(Svg.phone);} + public static phone_svg() { return new Img(Svg.phone, true);} public static phone_ui() { return new FixedUiElement(Svg.phone_img);} public static pin = " image/svg+xml " public static pin_img = Img.AsImageElement(Svg.pin) - public static pin_svg() { return new FixedUiElement(Svg.pin);} + public static pin_svg() { return new Img(Svg.pin, true);} public static pin_ui() { return new FixedUiElement(Svg.pin_img);} public static plus = " image/svg+xml " public static plus_img = Img.AsImageElement(Svg.plus) - public static plus_svg() { return new FixedUiElement(Svg.plus);} + public static plus_svg() { return new Img(Svg.plus, true);} public static plus_ui() { return new FixedUiElement(Svg.plus_img);} public static pop_out = " Svg Vector Icons : http://www.onlinewebfonts.com/icon " public static pop_out_img = Img.AsImageElement(Svg.pop_out) - public static pop_out_svg() { return new FixedUiElement(Svg.pop_out);} + public static pop_out_svg() { return new Img(Svg.pop_out, true);} public static pop_out_ui() { return new FixedUiElement(Svg.pop_out_img);} public static reload = " " public static reload_img = Img.AsImageElement(Svg.reload) - public static reload_svg() { return new FixedUiElement(Svg.reload);} + public static reload_svg() { return new Img(Svg.reload, true);} public static reload_ui() { return new FixedUiElement(Svg.reload_img);} public static ring = " image/svg+xml " public static ring_img = Img.AsImageElement(Svg.ring) - public static ring_svg() { return new FixedUiElement(Svg.ring);} + public static ring_svg() { return new Img(Svg.ring, true);} public static ring_ui() { return new FixedUiElement(Svg.ring_img);} public static search = " " public static search_img = Img.AsImageElement(Svg.search) - public static search_svg() { return new FixedUiElement(Svg.search);} + public static search_svg() { return new Img(Svg.search, true);} public static search_ui() { return new FixedUiElement(Svg.search_img);} public static send_email = " image/svg+xml " public static send_email_img = Img.AsImageElement(Svg.send_email) - public static send_email_svg() { return new FixedUiElement(Svg.send_email);} + public static send_email_svg() { return new Img(Svg.send_email, true);} public static send_email_ui() { return new FixedUiElement(Svg.send_email_img);} public static share = " image/svg+xml " public static share_img = Img.AsImageElement(Svg.share) - public static share_svg() { return new FixedUiElement(Svg.share);} + public static share_svg() { return new Img(Svg.share, true);} public static share_ui() { return new FixedUiElement(Svg.share_img);} public static square = " image/svg+xml " public static square_img = Img.AsImageElement(Svg.square) - public static square_svg() { return new FixedUiElement(Svg.square);} + public static square_svg() { return new Img(Svg.square, true);} public static square_ui() { return new FixedUiElement(Svg.square_img);} public static star = " Created by potrace 1.15, written by Peter Selinger 2001-2017 " public static star_img = Img.AsImageElement(Svg.star) - public static star_svg() { return new FixedUiElement(Svg.star);} + public static star_svg() { return new Img(Svg.star, true);} public static star_ui() { return new FixedUiElement(Svg.star_img);} public static star_half = " Created by potrace 1.15, written by Peter Selinger 2001-2017 image/svg+xml " public static star_half_img = Img.AsImageElement(Svg.star_half) - public static star_half_svg() { return new FixedUiElement(Svg.star_half);} + public static star_half_svg() { return new Img(Svg.star_half, true);} public static star_half_ui() { return new FixedUiElement(Svg.star_half_img);} public static star_outline = " Created by potrace 1.15, written by Peter Selinger 2001-2017 image/svg+xml " public static star_outline_img = Img.AsImageElement(Svg.star_outline) - public static star_outline_svg() { return new FixedUiElement(Svg.star_outline);} + public static star_outline_svg() { return new Img(Svg.star_outline, true);} public static star_outline_ui() { return new FixedUiElement(Svg.star_outline_img);} public static star_outline_half = " Created by potrace 1.15, written by Peter Selinger 2001-2017 image/svg+xml " public static star_outline_half_img = Img.AsImageElement(Svg.star_outline_half) - public static star_outline_half_svg() { return new FixedUiElement(Svg.star_outline_half);} + public static star_outline_half_svg() { return new Img(Svg.star_outline_half, true);} public static star_outline_half_ui() { return new FixedUiElement(Svg.star_outline_half_img);} public static statistics = " Svg Vector Icons : http://www.onlinewebfonts.com/icon " public static statistics_img = Img.AsImageElement(Svg.statistics) - public static statistics_svg() { return new FixedUiElement(Svg.statistics);} + public static statistics_svg() { return new Img(Svg.statistics, true);} public static statistics_ui() { return new FixedUiElement(Svg.statistics_img);} public static translate = " " public static translate_img = Img.AsImageElement(Svg.translate) - public static translate_svg() { return new FixedUiElement(Svg.translate);} + public static translate_svg() { return new Img(Svg.translate, true);} public static translate_ui() { return new FixedUiElement(Svg.translate_img);} public static up = " " public static up_img = Img.AsImageElement(Svg.up) - public static up_svg() { return new FixedUiElement(Svg.up);} + public static up_svg() { return new Img(Svg.up, true);} public static up_ui() { return new FixedUiElement(Svg.up_img);} public static wikidata = " " public static wikidata_img = Img.AsImageElement(Svg.wikidata) - public static wikidata_svg() { return new FixedUiElement(Svg.wikidata);} + public static wikidata_svg() { return new Img(Svg.wikidata, true);} public static wikidata_ui() { return new FixedUiElement(Svg.wikidata_img);} public static wikimedia_commons_white = " Wikimedia Commons Logo " public static wikimedia_commons_white_img = Img.AsImageElement(Svg.wikimedia_commons_white) - public static wikimedia_commons_white_svg() { return new FixedUiElement(Svg.wikimedia_commons_white);} + public static wikimedia_commons_white_svg() { return new Img(Svg.wikimedia_commons_white, true);} public static wikimedia_commons_white_ui() { return new FixedUiElement(Svg.wikimedia_commons_white_img);} public static wikipedia = " Wikipedia logo version 2" public static wikipedia_img = Img.AsImageElement(Svg.wikipedia) - public static wikipedia_svg() { return new FixedUiElement(Svg.wikipedia);} + public static wikipedia_svg() { return new Img(Svg.wikipedia, true);} public static wikipedia_ui() { return new FixedUiElement(Svg.wikipedia_img);} public static All = {"SocialImageForeground.svg": Svg.SocialImageForeground,"add.svg": Svg.add,"addSmall.svg": Svg.addSmall,"ampersand.svg": Svg.ampersand,"arrow-left-smooth.svg": Svg.arrow_left_smooth,"arrow-right-smooth.svg": Svg.arrow_right_smooth,"back.svg": Svg.back,"bug.svg": Svg.bug,"camera-plus.svg": Svg.camera_plus,"checkmark.svg": Svg.checkmark,"circle.svg": Svg.circle,"clock.svg": Svg.clock,"close.svg": Svg.close,"compass.svg": Svg.compass,"cross_bottom_right.svg": Svg.cross_bottom_right,"crosshair-blue-center.svg": Svg.crosshair_blue_center,"crosshair-blue.svg": Svg.crosshair_blue,"crosshair.svg": Svg.crosshair,"delete_icon.svg": Svg.delete_icon,"direction.svg": Svg.direction,"direction_gradient.svg": Svg.direction_gradient,"down.svg": Svg.down,"envelope.svg": Svg.envelope,"floppy.svg": Svg.floppy,"gear.svg": Svg.gear,"help.svg": Svg.help,"home.svg": Svg.home,"home_white_bg.svg": Svg.home_white_bg,"josm_logo.svg": Svg.josm_logo,"layers.svg": Svg.layers,"layersAdd.svg": Svg.layersAdd,"logo.svg": Svg.logo,"logout.svg": Svg.logout,"mapcomplete_logo.svg": Svg.mapcomplete_logo,"mapillary.svg": Svg.mapillary,"mapillary_black.svg": Svg.mapillary_black,"min.svg": Svg.min,"no_checkmark.svg": Svg.no_checkmark,"or.svg": Svg.or,"osm-copyright.svg": Svg.osm_copyright,"osm-logo-us.svg": Svg.osm_logo_us,"osm-logo.svg": Svg.osm_logo,"pencil.svg": Svg.pencil,"phone.svg": Svg.phone,"pin.svg": Svg.pin,"plus.svg": Svg.plus,"pop-out.svg": Svg.pop_out,"reload.svg": Svg.reload,"ring.svg": Svg.ring,"search.svg": Svg.search,"send_email.svg": Svg.send_email,"share.svg": Svg.share,"square.svg": Svg.square,"star.svg": Svg.star,"star_half.svg": Svg.star_half,"star_outline.svg": Svg.star_outline,"star_outline_half.svg": Svg.star_outline_half,"statistics.svg": Svg.statistics,"translate.svg": Svg.translate,"up.svg": Svg.up,"wikidata.svg": Svg.wikidata,"wikimedia-commons-white.svg": Svg.wikimedia_commons_white,"wikipedia.svg": Svg.wikipedia};} diff --git a/UI/Base/Button.ts b/UI/Base/Button.ts index 254a1b042..e671e0979 100644 --- a/UI/Base/Button.ts +++ b/UI/Base/Button.ts @@ -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 "
" + - "" + - "
"; - } - - 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; } } \ No newline at end of file diff --git a/UI/Base/Combine.ts b/UI/Base/Combine.ts index 827e30e3f..d76c83934 100644 --- a/UI/Base/Combine.ts +++ b/UI/Base/Combine.ts @@ -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" : " "); } } \ No newline at end of file diff --git a/UI/Base/FeatureSwitched.ts b/UI/Base/FeatureSwitched.ts deleted file mode 100644 index 6ff095a8a..000000000 --- a/UI/Base/FeatureSwitched.ts +++ /dev/null @@ -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; - - constructor(upstream :UIElement, - swtch: UIEventSource) { - super(swtch); - this._upstream = upstream; - this._swtch = swtch; - } - - InnerRender(): string { - if(this._swtch.data){ - return this._upstream.Render(); - } - return ""; - } - -} \ No newline at end of file diff --git a/UI/Base/FixedUiElement.ts b/UI/Base/FixedUiElement.ts index a0941e5ec..b0552caac 100644 --- a/UI/Base/FixedUiElement.ts +++ b/UI/Base/FixedUiElement.ts @@ -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; + } + } \ No newline at end of file diff --git a/UI/Base/Img.ts b/UI/Base/Img.ts index 76d00c59b..f2628bdb9 100644 --- a/UI/Base/Img.ts +++ b/UI/Base/Img.ts @@ -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 ``; } + + 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; + } } diff --git a/UI/Base/LazyElement.ts b/UI/Base/LazyElement.ts deleted file mode 100644 index ea7d30c69..000000000 --- a/UI/Base/LazyElement.ts +++ /dev/null @@ -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(); - } - -} \ No newline at end of file diff --git a/UI/Base/Link.ts b/UI/Base/Link.ts index dbe164c1e..72357b186 100644 --- a/UI/Base/Link.ts +++ b/UI/Base/Link.ts @@ -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; + private readonly _embeddedShow: BaseUIElement; + private readonly _newTab: boolean; - constructor(embeddedShow: UIElement | string, target: string, newTab: boolean = false) { + constructor(embeddedShow: BaseUIElement | string, href: string | UIEventSource, 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 `${this._embeddedShow.Render()}`; + 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})`; } } \ No newline at end of file diff --git a/UI/Base/List.ts b/UI/Base/List.ts new file mode 100644 index 000000000..9a9bd8776 --- /dev/null +++ b/UI/Base/List.ts @@ -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" + + } + } + +} \ No newline at end of file diff --git a/UI/Base/Ornament.ts b/UI/Base/Ornament.ts index 39f5f1bff..dd715b4df 100644 --- a/UI/Base/Ornament.ts +++ b/UI/Base/Ornament.ts @@ -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 { diff --git a/UI/Base/PageSplit.ts b/UI/Base/PageSplit.ts deleted file mode 100644 index 36e46b4f5..000000000 --- a/UI/Base/PageSplit.ts +++ /dev/null @@ -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 `${this._left.Render()}${this._right.Render()}`; - } - -} \ No newline at end of file diff --git a/UI/Base/ScrollableFullScreen.ts b/UI/Base/ScrollableFullScreen.ts index b6ef10814..7e146f417 100644 --- a/UI/Base/ScrollableFullScreen.ts +++ b/UI/Base/ScrollableFullScreen.ts @@ -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; - 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 = new UIEventSource(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) { + private BuildComponent(title: BaseUIElement, content:BaseUIElement, isShown: UIEventSource) { const returnToTheMap = new Combine([ Svg.back_svg().SetClass("block md:hidden"), diff --git a/UI/Base/SubtleButton.ts b/UI/Base/SubtleButton.ts index 4a2f688d9..a324505f7 100644 --- a/UI/Base/SubtleButton.ts +++ b/UI/Base/SubtleButton.ts @@ -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, 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, 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 = 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 [ - ``, + if (linkTo == undefined) { + return new Combine([ image, - `
`, - message, - `
`, - `
` - ]; + 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; } diff --git a/UI/Base/TabbedComponent.ts b/UI/Base/TabbedComponent.ts index 308d34d5a..46c602132 100644 --- a/UI/Base/TabbedComponent.ts +++ b/UI/Base/TabbedComponent.ts @@ -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) = 0) { - constructor(elements: { header: UIElement | string, content: UIElement | string }[], openedTab: (UIEventSource | number) = 0) { - super(typeof (openedTab) === "number" ? new UIEventSource(openedTab) : (openedTab ?? new UIEventSource(0))); - const self = this; + const openedTabSrc = typeof (openedTab) === "number" ? new UIEventSource(openedTab) : (openedTab ?? new UIEventSource(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 += `
` + - header.Render() + "
" - } + 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 = "
" + headerBar + "
" - - const content = this.content[this._source.data]; - return headerBar + "
" + (content?.Render() ?? "") + "
"; } } \ No newline at end of file diff --git a/UI/Base/Table.ts b/UI/Base/Table.ts new file mode 100644 index 000000000..fe4ee2be7 --- /dev/null +++ b/UI/Base/Table.ts @@ -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") + } + +} \ No newline at end of file diff --git a/UI/Base/Title.ts b/UI/Base/Title.ts new file mode 100644 index 000000000..2fd3b069d --- /dev/null +++ b/UI/Base/Title.ts @@ -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"; + } +} \ No newline at end of file diff --git a/UI/Base/VariableUIElement.ts b/UI/Base/VariableUIElement.ts index 6e5e246ef..e7effe17f 100644 --- a/UI/Base/VariableUIElement.ts +++ b/UI/Base/VariableUIElement.ts @@ -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; +export class VariableUiElement extends BaseUIElement { - constructor(html: UIEventSource) { - super(html); - this._html = html; + private _element : HTMLElement; + + constructor(contents: UIEventSource) { + 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; } } \ No newline at end of file diff --git a/UI/Base/VerticalCombine.ts b/UI/Base/VerticalCombine.ts deleted file mode 100644 index c83b1992b..000000000 --- a/UI/Base/VerticalCombine.ts +++ /dev/null @@ -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 += "
" + element.Render() + "
"; - } - } - return html; - } -} \ No newline at end of file diff --git a/UI/BaseUIElement.ts b/UI/BaseUIElement.ts new file mode 100644 index 000000000..a919f0609 --- /dev/null +++ b/UI/BaseUIElement.ts @@ -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 = new Set(); + private style: string; + private _onClick: () => void; + private _onHover: UIEventSource; + + 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 + } +} \ No newline at end of file diff --git a/UI/BigComponents/Attribution.ts b/UI/BigComponents/Attribution.ts index 630ee5747..bad254726 100644 --- a/UI/BigComponents/Attribution.ts +++ b/UI/BigComponents/Attribution.ts @@ -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; - private readonly _layoutToUse: UIEventSource; - private readonly _userDetails: UIEventSource; - private readonly _leafletMap: UIEventSource; +export default class Attribution extends Combine { constructor(location: UIEventSource, userDetails: UIEventSource, layoutToUse: UIEventSource, leafletMap: UIEventSource) { - 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(); } diff --git a/UI/BigComponents/AttributionPanel.ts b/UI/BigComponents/AttributionPanel.ts index 760ec9b85..191320f99 100644 --- a/UI/BigComponents/AttributionPanel.ts +++ b/UI/BigComponents/AttributionPanel.ts @@ -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, "
", - new Attribution(undefined, undefined, State.state.layoutToUse, undefined), + new Attribution(State.state.locationControl, State.state.osmConnection.userDetails, State.state.layoutToUse, State.state.leafletMap), "
", 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; } diff --git a/UI/BigComponents/BackgroundSelector.ts b/UI/BigComponents/BackgroundSelector.ts index c04eba35d..3387ba092 100644 --- a/UI/BigComponents/BackgroundSelector.ts +++ b/UI/BigComponents/BackgroundSelector.ts @@ -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; +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(); } } \ No newline at end of file diff --git a/UI/BigComponents/Basemap.ts b/UI/BigComponents/Basemap.ts index 9384671b9..5c2844bd0 100644 --- a/UI/BigComponents/Basemap.ts +++ b/UI/BigComponents/Basemap.ts @@ -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, currentLayer: UIEventSource, - 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() + " | OpenStreetMap"); + this.map.attributionControl.setPrefix( + " | OpenStreetMap"); + + 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}); }); diff --git a/UI/BigComponents/FullWelcomePaneWithTabs.ts b/UI/BigComponents/FullWelcomePaneWithTabs.ts index 4622167bd..b05c26d0d 100644 --- a/UI/BigComponents/FullWelcomePaneWithTabs.ts +++ b/UI/BigComponents/FullWelcomePaneWithTabs.ts @@ -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; - private readonly _userDetails: UIEventSource; +export default class FullWelcomePaneWithTabs extends ScrollableFullScreen { - private readonly _component: UIElement; constructor(isShown: UIEventSource) { - 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): { header: string | BaseUIElement; content: BaseUIElement }[]{ - private static GenerateContents(layoutToUse: LayoutConfig, userDetails: UIEventSource) { - - 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: ``, 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, isShown: UIEventSource) { + + 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, "
Version " + Constants.vNumber]).SetClass("link-underline").Render(); - }, [Locale.language])) + content: new Combine([Translations.t.general.aboutMapcomplete.Clone(), "
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) + ) } } \ No newline at end of file diff --git a/UI/BigComponents/IndexText.ts b/UI/BigComponents/IndexText.ts index 6e0399ba5..86e5d8919 100644 --- a/UI/BigComponents/IndexText.ts +++ b/UI/BigComponents/IndexText.ts @@ -1,4 +1,3 @@ -import {UIElement} from "../UIElement"; import Combine from "../Base/Combine"; import Translations from "../i18n/Translations"; import {FixedUiElement} from "../Base/FixedUiElement"; diff --git a/UI/BigComponents/LayerControlPanel.ts b/UI/BigComponents/LayerControlPanel.ts index e945b446c..42a3eda12 100644 --- a/UI/BigComponents/LayerControlPanel.ts +++ b/UI/BigComponents/LayerControlPanel.ts @@ -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"); diff --git a/UI/BigComponents/LayerSelection.ts b/UI/BigComponents/LayerSelection.ts index 0b75a3ac2..bb82a18a5 100644 --- a/UI/BigComponents/LayerSelection.ts +++ b/UI/BigComponents/LayerSelection.ts @@ -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, - readonly layerDef: LayerConfig; - }[]>; constructor(activeLayers: UIEventSource<{ readonly isDisplayed: UIEventSource, 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({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, "", name, ""]).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;") + } } \ No newline at end of file diff --git a/UI/BigComponents/LicensePicker.ts b/UI/BigComponents/LicensePicker.ts new file mode 100644 index 000000000..efdd6aafd --- /dev/null +++ b/UI/BigComponents/LicensePicker.ts @@ -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{ + + 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("CC0") + ) + this.SetClass("flex flex-col sm:flex-row").SetStyle("float:left"); + } + +} \ No newline at end of file diff --git a/UI/BigComponents/MoreScreen.ts b/UI/BigComponents/MoreScreen.ts index f5b4eaecd..8976abb29 100644 --- a/UI/BigComponents/MoreScreen.ts +++ b/UI/BigComponents/MoreScreen.ts @@ -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([ `
`, - Translations.W(layout.title), + Translations.WT(layout.title).Clone(), `
`, `
`, - description ?? "", + description.Clone().SetClass("subtle") ?? "", `
`, ]), {url: linkText, newTab: false}); } + } \ No newline at end of file diff --git a/UI/BigComponents/PersonalLayersPanel.ts b/UI/BigComponents/PersonalLayersPanel.ts index e11aac86e..7c30d916a 100644 --- a/UI/BigComponents/PersonalLayersPanel.ts +++ b/UI/BigComponents/PersonalLayersPanel.ts @@ -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>(); - 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([ - ``, - "", - layout.title, - "
", - 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({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([ - "", - name, - " ", - layer.description !== undefined ? new Combine(["
", layer.description]) : "", - ]) - - - const cb = new CheckBox( - new SubtleButton( - icon, - content), - new SubtleButton( - iconUnset.SetStyle("opacity:0.1"), - new Combine(["", - content, - "" - ])), - 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() + 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({id: "node/-1"}), + false, + "2em" + ).icon.html]).SetClass("relative") + let iconUnset =new Combine([ layer.GenerateLeafletStyle( + new UIEventSource({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(); } diff --git a/UI/BigComponents/SearchAndGo.ts b/UI/BigComponents/SearchAndGo.ts index e6df02930..825ba2c96 100644 --- a/UI/BigComponents/SearchAndGo.ts +++ b/UI/BigComponents/SearchAndGo.ts @@ -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(Translations.t.general.search.search) - private _searchField = new TextField({ - placeholder: new VariableUiElement( - this._placeholder.map(uiElement => uiElement.InnerRender(), [Locale.language]) - ), - value: new UIEventSource("") - } - ); - - 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(Translations.t.general.search.search) + const searchField = new TextField({ + placeholder: new VariableUiElement( + placeholder.map(uiElement => uiElement, [Locale.language]) + ), + value: new UIEventSource(""), + + 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); } diff --git a/UI/BigComponents/ShareButton.ts b/UI/BigComponents/ShareButton.ts index b4c2eba51..7b3d0255b 100644 --- a/UI/BigComponents/ShareButton.ts +++ b/UI/BigComponents/ShareButton.ts @@ -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 `` + 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; } + } \ No newline at end of file diff --git a/UI/BigComponents/ShareScreen.ts b/UI/BigComponents/ShareScreen.ts index dcf087482..7396c1568 100644 --- a/UI/BigComponents/ShareScreen.ts +++ b/UI/BigComponents/ShareScreen.ts @@ -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; - private readonly _link: UIElement; - private readonly _linkStatus: UIEventSource; - 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)[] = []; - 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(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(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(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(!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 => `<iframe src="${url}" width="100%" height="100%" title="${layout?.title?.txt ?? "MapComplete"} with MapComplete"></iframe>`); - - this._iframeCode = new VariableUiElement( + const iframeCode = new VariableUiElement( url.map((url) => { return ` - <iframe src="${url}" width="100%" height="100%" title="${layout.title?.txt ?? "MapComplete"} with MapComplete"></iframe> + <iframe src="${url}" width="100%" height="100%" style="min-width: 25Opx; min-height: 250ox" title="${layout.title?.txt ?? "MapComplete"} with MapComplete"></iframe> ` }) ); @@ -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"), "
", - tr.editThemeDescription]), - {url: `./customGenerator.html#${State.state.layoutDefinition}`, newTab: true}).Render(); + new Combine([tr.editThisTheme.Clone().SetClass("bold"), "
", + tr.editThemeDescription.Clone()]), + {url: `./customGenerator.html#${State.state.layoutDefinition}`, newTab: true}); } )); } - this._linkStatus = new UIEventSource(""); - this.ListenTo(this._linkStatus); - const self = this; - this._link = new VariableUiElement( - url.map((url) => { - return `` - }) + const linkStatus = new UIEventSource(""); + const link = new VariableUiElement( + url.map((url) => ``) ).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() } } \ No newline at end of file diff --git a/UI/BigComponents/SimpleAddUI.ts b/UI/BigComponents/SimpleAddUI.ts index 9f1a109a1..6b0cbf3c4 100644 --- a/UI/BigComponents/SimpleAddUI.ts +++ b/UI/BigComponents/SimpleAddUI.ts @@ -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 - } - }> - = new UIEventSource(undefined); +interface PresetInfo { + description: string | Translation, + name: string | BaseUIElement, + icon: BaseUIElement, + tags: Tag[], + layerToAddTo: { + layerDef: LayerConfig, + isDisplayed: UIEventSource + } +} - 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) { - 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(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(["", - Translations.t.general.fewChangesBefore, - ""]); - } - - 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([ - "", - "Test mode - changes won't be saved", - "" - ]); - } - - 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([ - "", - Translations.t.general.add.confirmButton.Subs({category: preset.name}), - ""])).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 = `
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 ? "TESTING - changes won't be saved" : "", - 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): 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): 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(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(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([ - "", - preset.title, - "", - preset.description !== undefined ? new Combine(["
", preset.description.FirstSentence()]) : "", - "
", - 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) - } } \ No newline at end of file diff --git a/UI/BigComponents/ThemeIntroductionPanel.ts b/UI/BigComponents/ThemeIntroductionPanel.ts index 3b9a8b2b1..f3b66e880 100644 --- a/UI/BigComponents/ThemeIntroductionPanel.ts +++ b/UI/BigComponents/ThemeIntroductionPanel.ts @@ -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; + constructor(isShown: UIEventSource) { - - 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(), + "

", + toTheMap, + loginStatus, + layout.descriptionTail.Clone(), + "
", + languagePicker, + ...layout.CustomCodeSnippets() + ]))) + this.SetClass("link-underline") } - - InnerRender(): string { - const layout : LayoutConfig = this._layout.data; - return new Combine([ - layout.description, - "

", - this.loginStatus, - layout.descriptionTail, - "
", - this.languagePicker, - ...layout.CustomCodeSnippets() - ]).Render() - } - - } diff --git a/UI/BigComponents/UploadFlowStateUI.ts b/UI/BigComponents/UploadFlowStateUI.ts new file mode 100644 index 000000000..9c00649af --- /dev/null +++ b/UI/BigComponents/UploadFlowStateUI.ts @@ -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, failed: UIEventSource, success: UIEventSource) { + 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 + } +} \ No newline at end of file diff --git a/UI/BigComponents/UserBadge.ts b/UI/BigComponents/UserBadge.ts index acb5dfb55..26214661a 100644 --- a/UI/BigComponents/UserBadge.ts +++ b/UI/BigComponents/UserBadge.ts @@ -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; - 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(`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 + ) } diff --git a/UI/CenterMessageBox.ts b/UI/CenterMessageBox.ts index 7c3ed26ff..d136ea1d1 100644 --- a/UI/CenterMessageBox.ts +++ b/UI/CenterMessageBox.ts @@ -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"; - } } } diff --git a/UI/CustomGenerator/AllLayersPanel.ts b/UI/CustomGenerator/AllLayersPanel.ts deleted file mode 100644 index 6f8ce2d93..000000000 --- a/UI/CustomGenerator/AllLayersPanel.ts +++ /dev/null @@ -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; - private readonly languages: UIEventSource; - private readonly userDetails: UserDetails; - private readonly currentlySelected: UIEventSource>; - - constructor(config: UIEventSource, - languages: UIEventSource, userDetails: UserDetails) { - super(undefined); - this.userDetails = userDetails; - this._config = config; - this.languages = languages; - - this.createPanels(userDetails); - const self = this; - this.dumbMode = false; - config.map(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 (default)"}, ...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 `` - } 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([ - "

Layer editor

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

Default background layer

", - dropDown, - "

TagRenderings for every layer

", - "Define tag renderings and questions here that should be shown on every layer of the theme.", - roamingTags - ] - ), - }) - - this.panel = new TabbedComponent(tabs, new UIEventSource(Math.max(0, layers.length - 1))); - this.Update(); - } - - InnerRender(): string { - return this.panel.Render(); - } - -} \ No newline at end of file diff --git a/UI/CustomGenerator/CustomGeneratorPanel.ts b/UI/CustomGenerator/CustomGeneratorPanel.ts deleted file mode 100644 index e1a1592ed..000000000 --- a/UI/CustomGenerator/CustomGeneratorPanel.ts +++ /dev/null @@ -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 => ``); - const currentSetting = new UIEventSource>(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([ - "

Not Logged in

", - "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([ - "

Too little experience

", - `

Creating your own (readonly) themes can only be done if you have more then ${journey.themeGeneratorReadOnlyUnlock} changesets made

`, - `

Making a theme including survey options can be done at ${journey.themeGeneratorFullUnlock} changesets

` - ]).Render(); - } - return this.mainPanel.Render() - } - -} \ No newline at end of file diff --git a/UI/CustomGenerator/GeneralSettings.ts b/UI/CustomGenerator/GeneralSettings.ts deleted file mode 100644 index b37fb31cc..000000000 --- a/UI/CustomGenerator/GeneralSettings.ts +++ /dev/null @@ -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; - - constructor(configuration: UIEventSource, currentSetting: UIEventSource>) { - 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 = "
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 ;. For example:en;nl "), - 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)", "Only works on incorporated themes" + - "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([ - "

General theme settings

", - settingsTable - ]); - } - - - InnerRender(): string { - return this.panel.Render(); - } - - -} \ No newline at end of file diff --git a/UI/CustomGenerator/GenerateEmpty.ts b/UI/CustomGenerator/GenerateEmpty.ts deleted file mode 100644 index 34defb877..000000000 --- a/UI/CustomGenerator/GenerateEmpty.ts +++ /dev/null @@ -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 {}; - } -} \ No newline at end of file diff --git a/UI/CustomGenerator/HelpText.ts b/UI/CustomGenerator/HelpText.ts deleted file mode 100644 index 880fe65e3..000000000 --- a/UI/CustomGenerator/HelpText.ts +++ /dev/null @@ -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>) { - 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) => { - if (setting === undefined) { - return "

Welcome to the Custom Theme Builder

" + - "Here, one can make their own custom mapcomplete themes.
" + - "Fill out the fields to get a working mapcomplete theme. More information on the selected field will appear here when you click it.
" + - "Want to see how the quests are doing in number of visits? All the stats are open on goatcounter"; - } - - return new Combine(["

", setting._name, "

", setting._description.Render()]).Render(); - })) - - - } - - InnerRender(): string { - return new Combine([this.helpText, - this.returnButton, - ]).Render(); - } - -} \ No newline at end of file diff --git a/UI/CustomGenerator/LayerPanel.ts b/UI/CustomGenerator/LayerPanel.ts deleted file mode 100644 index 787de027c..000000000 --- a/UI/CustomGenerator/LayerPanel.ts +++ /dev/null @@ -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; - - private readonly settingsTable: UIElement; - private readonly mapRendering: UIElement; - - private readonly deleteButton: UIElement; - - public readonly titleRendering: UIElement; - - public readonly selectedTagRendering: UIEventSource - = new UIEventSource(undefined); - private tagRenderings: UIElement; - private presetsPanel: UIElement; - - constructor(config: UIEventSource, - languages: UIEventSource, - index: number, - currentlySelected: UIEventSource>, - 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( - [ - "

Confirm layer deletion

", - new SubtleButton( - Svg.close_ui(), - "No, don't delete" - ), - "Deleting a layer can not be undone!", - actualDeleteButton - ] - ), - new SubtleButton( - Svg.delete_icon_ui(), - "Remove this layer" - ) - ) - - function setting(input: InputElement, path: string | string[], name: string, description: string | UIElement): SingleSetting { - let pathPre = ["layers", index]; - if (typeof (path) === "string") { - pathPre.push(path); - } else { - pathPre = pathPre.concat(path); - } - - return new SingleSetting(config, input, pathPre, name, description); - } - - - this.settingsTable = new SettingsTable([ - setting(new TextField({placeholder:"Layer id"}), "id", "Id", "An identifier for this layer
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
Used in the layer control panel and the 'Personal theme'"), - setting(new MultiLingualTextFields(languages, true), "description", "Description", "A description for this layer.
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("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, - languages: UIEventSource, - index: number, - currentlySelected: UIEventSource>, - 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, path, isIcon: boolean = false): SingleSetting { - 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([ - "

General layer settings

", - this.settingsTable, - "

Popup contents

", - this.titleRendering, - this.tagRenderings, - "

Presets

", - "Does this theme support adding a new point?
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, - "

Map rendering options

", - this.mapRendering, - "

Layer delete

", - this.deleteButton - ]).Render(); - } -} \ No newline at end of file diff --git a/UI/CustomGenerator/LayerPanelWithPreview.ts b/UI/CustomGenerator/LayerPanelWithPreview.ts deleted file mode 100644 index 3c0d19cc3..000000000 --- a/UI/CustomGenerator/LayerPanelWithPreview.ts +++ /dev/null @@ -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, languages: UIEventSource, index: number, userDetails: UserDetails) { - super(); - - const currentlySelected = new UIEventSource<(SingleSetting)>(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, - "
", - "

Testing tags

", - previewTagInput, - "

Tag Rendering preview

", - preview - - ]), 60 - ); - - } - - InnerRender(): string { - return this.panel.Render(); - } - -} \ No newline at end of file diff --git a/UI/CustomGenerator/MappingInput.ts b/UI/CustomGenerator/MappingInput.ts deleted file mode 100644 index 7c8ae6b1b..000000000 --- a/UI/CustomGenerator/MappingInput.ts +++ /dev/null @@ -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, disableQuestions: boolean = false) { - super(); - const currentSelected = new UIEventSource>(undefined); - this._value = new UIEventSource<{ if: AndOrTagConfigJson, then: any, hideInAnswer?: boolean }>({ - if: undefined, - then: undefined - }); - const self = this; - - function setting(inputElement: InputElement, 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. access=yes and access=public)." + - "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 no value defined, e.g. indoor=. 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 then below will be used"), - setting(new MultiLingualTextFields(languages), - "then", "Then show", "If the condition above matches, this template then 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 = new UIEventSource(false); - - IsValid(t: { if: AndOrTagConfigJson; then: any; hideInAnswer: boolean }): boolean { - return false; - } - -} \ No newline at end of file diff --git a/UI/CustomGenerator/PresetInputPanel.ts b/UI/CustomGenerator/PresetInputPanel.ts deleted file mode 100644 index edb7a0c64..000000000 --- a/UI/CustomGenerator/PresetInputPanel.ts +++ /dev/null @@ -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>, languages: UIEventSource) { - super(); - this._value = new UIEventSource({tags: [], title: {}}); - - - const self = this; - function s(input: InputElement, 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 = new UIEventSource(false); - - IsValid(t: any): boolean { - return false; - } - -} \ No newline at end of file diff --git a/UI/CustomGenerator/SavePanel.ts b/UI/CustomGenerator/SavePanel.ts deleted file mode 100644 index 333796c60..000000000 --- a/UI/CustomGenerator/SavePanel.ts +++ /dev/null @@ -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, - chronic: UIEventSource) { - 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(), "Load the JSON file below") - .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([ - "

Save your theme

", - this.lastSaveEl, - "

JSON configuration

", - "The url hash is actually no more then a BASE64-encoding of the below JSON. This json contains the full configuration of the theme.
" + - "This configuration is mainly useful for debugging", - "
", - this.loadFromJson, - this.json - ]).SetClass("scrollable") - .Render(); - } - -} \ No newline at end of file diff --git a/UI/CustomGenerator/SettingsTable.ts b/UI/CustomGenerator/SettingsTable.ts deleted file mode 100644 index 28bb4bc75..000000000 --- a/UI/CustomGenerator/SettingsTable.ts +++ /dev/null @@ -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>; - - constructor(elements: (SingleSetting | string)[], - currentSelectedSetting?: UIEventSource>) { - super(undefined); - const self = this; - this.selectedSetting = currentSelectedSetting ?? new UIEventSource>(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(); - } - -} \ No newline at end of file diff --git a/UI/CustomGenerator/SharePanel.ts b/UI/CustomGenerator/SharePanel.ts deleted file mode 100644 index 6f0da7f92..000000000 --- a/UI/CustomGenerator/SharePanel.ts +++ /dev/null @@ -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; - - private _panel: UIElement; - - constructor(config: UIEventSource, liveUrl: UIEventSource, userDetails: UserDetails) { - super(undefined); - this._config = config; - - this._panel = new Combine([ - "

Share

", - "Share the following link with friends:
", - new VariableUiElement(liveUrl.map(url => `${url}`)), - "

Publish on some website

", - - "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://.json`, - "Please note: it used to be possible to load from the wiki - this is not possible anymore due to technical reasons.", - "" - ]); - } - - InnerRender(): string { - return this._panel.Render(); - } - -} \ No newline at end of file diff --git a/UI/CustomGenerator/SingleSetting.ts b/UI/CustomGenerator/SingleSetting.ts deleted file mode 100644 index dbb4ff145..000000000 --- a/UI/CustomGenerator/SingleSetting.ts +++ /dev/null @@ -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 { - public _value: InputElement; - public _name: string; - public _description: UIElement; - public _options: { showIconPreview?: boolean }; - - constructor(config: UIEventSource, - value: InputElement, - 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, - "

Icon preview

", - new VariableUiElement(this._value.GetValue().map(url => ``)) - ]); - } - - 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); - - - } - - - - -} \ No newline at end of file diff --git a/UI/CustomGenerator/TagRenderingPanel.ts b/UI/CustomGenerator/TagRenderingPanel.ts deleted file mode 100644 index 302a1a3b4..000000000 --- a/UI/CustomGenerator/TagRenderingPanel.ts +++ /dev/null @@ -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 { - - public IsImage = false; - public options: { title?: string; description?: string; disableQuestions?: boolean; isImage?: boolean; }; - public readonly validText: UIElement; - IsSelected: UIEventSource = new UIEventSource(false); - private intro: UIElement; - private settingsTable: UIElement; - private readonly _value: UIEventSource; - - constructor(languages: UIEventSource, - currentlySelected: UIEventSource>, - 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(["

", options?.title ?? "TagRendering", "

", - 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.
For specific known tags (e.g. if `foo=bar`, make a mapping). "]) - this.IsImage = options?.isImage ?? false; - - const value = new UIEventSource({}); - this._value = value; - - function setting(input: InputElement, id: string | string[], name: string, description: string | UIElement): SingleSetting { - return new SingleSetting(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"), - - "

Freeform key

", - setting(ValidatedTextField.KeyInput(true), ["freeform", "key"], "Freeform key
", - "If specified, the rendering will search if this key is present." + - "If it is, the rendering above will be used to display the element.
" + - "The rendering will go into question mode if
  • this key is not present
  • No single mapping matches
  • A question is given
  • "), - - 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. fixme=User used a freeform field - to check"), - - ]; - - const settings: (string | SingleSetting)[] = [ - setting( - options?.noLanguage ? new TextField({placeholder: "Rendering"}) : - new MultiLingualTextFields(languages), "render", "Value to show", - "Renders this value. Note that {key}-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." + - "

    " + - "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), - - "

    Mappings

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

    Condition

    ", - setting(new AndOrTagInput(), "condition", "Only show this tagrendering if the following condition applies", - "Only show this tag rendering if these tags matches. Optional field.
    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 "" + e + "" - } - })); - - } - - InnerRender(): string { - return new Combine([ - this.intro, - this.settingsTable, - this.validText]).Render(); - } - - GetValue(): UIEventSource { - return this._value; - } - - IsValid(t: TagRenderingConfigJson): boolean { - return false; - } - - -} \ No newline at end of file diff --git a/UI/CustomGenerator/TagRenderingPreview.ts b/UI/CustomGenerator/TagRenderingPreview.ts deleted file mode 100644 index d758682b1..000000000 --- a/UI/CustomGenerator/TagRenderingPreview.ts +++ /dev/null @@ -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; - private selectedTagRendering: UIEventSource; - private panel: UIElement; - - constructor(selectedTagRendering: UIEventSource, - previewTagValue: UIEventSource) { - 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([ - "

    ", - tagRenderingPanel.options.title ?? "Extra tag rendering", - "

    ", - tagRenderingPanel.options.description ?? "This tag rendering will appear in the popup", - "

    ", - rendering]); - - } - - InnerRender(): string { - return this.panel.Render(); - } - -} \ No newline at end of file diff --git a/UI/Image/AttributedImage.ts b/UI/Image/AttributedImage.ts new file mode 100644 index 000000000..260c5306f --- /dev/null +++ b/UI/Image/AttributedImage.ts @@ -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'); + } + + +} \ No newline at end of file diff --git a/UI/Image/Attribution.ts b/UI/Image/Attribution.ts index 8f0745697..514bc475a 100644 --- a/UI/Image/Attribution.ts +++ b/UI/Image/Attribution.ts @@ -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, 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") + + })); } } \ No newline at end of file diff --git a/UI/Image/DeleteImage.ts b/UI/Image/DeleteImage.ts index 2211fb085..f49deeb1b 100644 --- a/UI/Image/DeleteImage.ts +++ b/UI/Image/DeleteImage.ts @@ -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; - - private readonly isDeletedBadge: UIElement; - private readonly deleteDialog: UIElement; +export default class DeleteImage extends Toggle { constructor(key: string, tags: UIEventSource) { - 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(true) + ) + this.SetClass("cursor-pointer") } } \ No newline at end of file diff --git a/UI/Image/ImageCarousel.ts b/UI/Image/ImageCarousel.ts index c62968ff2..a28d89a2c 100644 --- a/UI/Image/ImageCarousel.ts +++ b/UI/Image/ImageCarousel.ts @@ -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) { - super(images); - const uiElements = images.map((imageURLS: {key: string, url:string}[]) => { - const uiElements: UIElement[] = []; + constructor(images: UIEventSource<{ key: string, url: string }[]>, tags: UIEventSource) { + 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(url)); + return new Img(url); } - } - - InnerRender(): string { - return this.slideshow.Render(); + + return new AttributedImage(url, attrSource) + } } \ No newline at end of file diff --git a/UI/Image/ImageUploadFlow.ts b/UI/Image/ImageUploadFlow.ts index e6a710a45..cf5881f64 100644 --- a/UI/Image/ImageUploadFlow.ts +++ b/UI/Image/ImageUploadFlow.ts @@ -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; - private readonly _selectedLicence: UIEventSource; - private readonly _isUploading: UIEventSource = new UIEventSource(0) - private readonly _didFail: UIEventSource = new UIEventSource(false); - private readonly _allDone: UIEventSource = new UIEventSource(false); - private readonly _connectButton: UIElement; - private readonly _imagePrefix: string; +export class ImageUploadFlow extends Toggle { - constructor(tags: UIEventSource, imagePrefix: string = "image") { - super(State.state.osmConnection.userDetails); - this._tags = tags; - this._imagePrefix = imagePrefix; + constructor(tagsSource: UIEventSource, 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;"), - "
    ", - this._licensePicker, - "
    ", - currentStateHtml, - "
    " - ]); - - 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 = - ``; - - const form = "
    " + - `" + - actualInputElement + - "
    "; - - 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() - }) - } - } } \ No newline at end of file diff --git a/UI/Image/ImgurImage.ts b/UI/Image/ImgurImage.ts deleted file mode 100644 index e8639dae4..000000000 --- a/UI/Image/ImgurImage.ts +++ /dev/null @@ -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; - 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(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(); - - } - - -} \ No newline at end of file diff --git a/UI/Image/MapillaryImage.ts b/UI/Image/MapillaryImage.ts deleted file mode 100644 index 53c0e53e9..000000000 --- a/UI/Image/MapillaryImage.ts +++ /dev/null @@ -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; - 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(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(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(); - - } - - -} \ No newline at end of file diff --git a/UI/Image/SimpleImageElement.ts b/UI/Image/SimpleImageElement.ts deleted file mode 100644 index c17f0fa49..000000000 --- a/UI/Image/SimpleImageElement.ts +++ /dev/null @@ -1,15 +0,0 @@ -import {UIElement} from "../UIElement"; -import {UIEventSource} from "../../Logic/UIEventSource"; - - -export class SimpleImageElement extends UIElement { - - constructor(source: UIEventSource) { - super(source); - } - - InnerRender(): string { - return "img"; - } - -} \ No newline at end of file diff --git a/UI/Image/SlideShow.ts b/UI/Image/SlideShow.ts index 13f7ae59e..fdc892008 100644 --- a/UI/Image/SlideShow.ts +++ b/UI/Image/SlideShow.ts @@ -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 - constructor( - embeddedElements: UIEventSource) { - super(embeddedElements); - this._embeddedElements = embeddedElements; - this._embeddedElements.addCallbackAndRun(elements => { - for (const element of elements ?? []) { - element.SetClass("slick-carousel-content") + private readonly embeddedElements: UIEventSource; + + constructor(embeddedElements: UIEventSource) { + 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; } } \ No newline at end of file diff --git a/UI/Image/WikimediaImage.ts b/UI/Image/WikimediaImage.ts deleted file mode 100644 index ceae32202..000000000 --- a/UI/Image/WikimediaImage.ts +++ /dev/null @@ -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; - 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(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(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() - - } - - -} \ No newline at end of file diff --git a/UI/Input/AndOrTagInput.ts b/UI/Input/AndOrTagInput.ts deleted file mode 100644 index 7f41ab11f..000000000 --- a/UI/Input/AndOrTagInput.ts +++ /dev/null @@ -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 { - - private readonly _rawTags = new MultiTagInput(); - private readonly _subAndOrs: AndOrTagInput[] = []; - private readonly _isAnd: UIEventSource = new UIEventSource(true); - private readonly _isAndButton; - private readonly _addBlock: UIElement; - private readonly _value: UIEventSource = new UIEventSource(undefined); - - public bottomLeftButton: UIElement; - - IsSelected: UIEventSource; - - 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 { - return this._value; - } - - InnerRender(): string { - const leftColumn = new Combine([ - this._isAndButton, - "
    ", - this.bottomLeftButton ?? "" - ]); - const tags = new Combine([ - this._rawTags, - ...this._subAndOrs, - this._addBlock - ]).Render(); - return `
    ${leftColumn.Render()}${tags}
    `; - } - - - IsValid(t: AndOrTagConfigJson): boolean { - return true; - } - - -} \ No newline at end of file diff --git a/UI/Input/CheckBox.ts b/UI/Input/CheckBox.ts deleted file mode 100644 index e34707c7c..000000000 --- a/UI/Input/CheckBox.ts +++ /dev/null @@ -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; - private readonly _showEnabled: UIElement; - private readonly _showDisabled: UIElement; - - constructor(showEnabled: string | UIElement, showDisabled: string | UIElement, data: UIEventSource | 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(); - } - } - -} \ No newline at end of file diff --git a/UI/Input/Checkboxes.ts b/UI/Input/Checkboxes.ts index 4a34ee746..3ae6b5fc6 100644 --- a/UI/Input/Checkboxes.ts +++ b/UI/Input/Checkboxes.ts @@ -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 { + private static _nextId = 0; IsSelected: UIEventSource = new UIEventSource(false); + private readonly value: UIEventSource + private readonly _elements: BaseUIElement[]; - private readonly value: UIEventSource; - private readonly _elements: UIElement[] - - - constructor(elements: UIElement[]) { - super(undefined); + constructor(elements: BaseUIElement[], value = new UIEventSource([])) { + super(); + this.value = value; this._elements = Utils.NoNull(elements); - this.dumbMode = false; + this.SetClass("flex flex-col") - this.value = new UIEventSource([]) - this.ListenTo(this.value); } - IsValid(ts: number[]): boolean { return ts !== undefined; - + } GetValue(): UIEventSource { 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 = - `
    `; - body += htmlElement; + for (let i = 0; i < elements.length; i++) { - } - - return `
    ${body}
    `; - } + 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; } diff --git a/UI/Input/ColorPicker.ts b/UI/Input/ColorPicker.ts index 8a0b5e88d..ea0abda60 100644 --- a/UI/Input/ColorPicker.ts +++ b/UI/Input/ColorPicker.ts @@ -1,50 +1,36 @@ import {InputElement} from "./InputElement"; import {UIEventSource} from "../../Logic/UIEventSource"; -import {Utils} from "../../Utils"; export default class ColorPicker extends InputElement { private readonly value: UIEventSource - +private readonly _element : HTMLElement constructor( - value?: UIEventSource + value: UIEventSource = new UIEventSource(undefined) ) { super(); - this.value = value ?? new UIEventSource(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 ``; - } - - 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 { diff --git a/UI/Input/CombinedInputElement.ts b/UI/Input/CombinedInputElement.ts index b15c0a516..83672e9a6 100644 --- a/UI/Input/CombinedInputElement.ts +++ b/UI/Input/CombinedInputElement.ts @@ -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 extends InputElement { + protected InnerConstructElement(): HTMLElement { + return this._combined.ConstructElement(); + } private readonly _a: InputElement; - private readonly _b: UIElement; - private readonly _combined: UIElement; + private readonly _b: BaseUIElement; + private readonly _combined: BaseUIElement; public readonly IsSelected: UIEventSource; - constructor(a: InputElement, b: InputElement) { super(); this._a = a; @@ -23,11 +25,6 @@ export default class CombinedInputElement extends InputElement { return this._a.GetValue(); } - InnerRender(): string { - return this._combined.Render(); - } - - IsValid(t: T): boolean { return this._a.IsValid(t); } diff --git a/UI/Input/DirectionInput.ts b/UI/Input/DirectionInput.ts index 008d75bcd..4be40b99e 100644 --- a/UI/Input/DirectionInput.ts +++ b/UI/Input/DirectionInput.ts @@ -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 { - private readonly value: UIEventSource; public readonly IsSelected: UIEventSource = new UIEventSource(false); + private readonly value: UIEventSource; constructor(value?: UIEventSource) { super(); - this.dumbMode = false; this.value = value ?? new UIEventSource(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 { return this.value; } - InnerRender(): string { - return new Combine([ - `
    `, + 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 { "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 { } 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; - } - } \ No newline at end of file diff --git a/UI/Input/DropDown.ts b/UI/Input/DropDown.ts index d9ecba05e..fe8f8bc98 100644 --- a/UI/Input/DropDown.ts +++ b/UI/Input/DropDown.ts @@ -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 extends InputElement { - private readonly _label: UIElement; - private readonly _values: { value: T; shown: UIElement }[]; + private static _nextDropdownId = 0; + public IsSelected: UIEventSource = new UIEventSource(false); + + private readonly _element: HTMLElement; private readonly _value: UIEventSource; + private readonly _values: { value: T; shown: string | BaseUIElement }[]; - public IsSelected: UIEventSource = new UIEventSource(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 = undefined, - label_class: string = "", - select_class: string = "", - form_style: string = "flex") { - super(undefined); - this._form_style = form_style; - this._value = value ?? new UIEventSource(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(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 { return this._value; } + IsValid(t: T): boolean { for (const value of this._values) { if (value.value === t) { @@ -54,44 +96,8 @@ export class DropDown extends InputElement { return false } - - InnerRender(): string { - if(this._values.length <=1){ - return ""; - } - - let options = ""; - for (let i = 0; i < this._values.length; i++) { - options += "" - } - - return `
    ` + - `` + - `` + - `
    `; + 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; - } - } - } } \ No newline at end of file diff --git a/UI/Input/FileSelectorButton.ts b/UI/Input/FileSelectorButton.ts new file mode 100644 index 000000000..c6e1cf1f8 --- /dev/null +++ b/UI/Input/FileSelectorButton.ts @@ -0,0 +1,66 @@ +import BaseUIElement from "../BaseUIElement"; +import {InputElement} from "./InputElement"; +import {UIEventSource} from "../../Logic/UIEventSource"; + +export default class FileSelectorButton extends InputElement { + + private static _nextid; + IsSelected: UIEventSource; + 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 { + 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; + } + + +} \ No newline at end of file diff --git a/UI/Input/FixedInputElement.ts b/UI/Input/FixedInputElement.ts index bc2424939..6e46ae5fc 100644 --- a/UI/Input/FixedInputElement.ts +++ b/UI/Input/FixedInputElement.ts @@ -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 extends InputElement { - private readonly rendering: UIElement; private readonly value: UIEventSource; public readonly IsSelected : UIEventSource = new UIEventSource(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(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 { 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)) - - } - } \ No newline at end of file diff --git a/UI/Input/InputElement.ts b/UI/Input/InputElement.ts index 013168862..1b5eea403 100644 --- a/UI/Input/InputElement.ts +++ b/UI/Input/InputElement.ts @@ -1,7 +1,7 @@ -import {UIElement} from "../UIElement"; import {UIEventSource} from "../../Logic/UIEventSource"; +import BaseUIElement from "../BaseUIElement"; -export abstract class InputElement extends UIElement{ +export abstract class InputElement extends BaseUIElement{ abstract GetValue() : UIEventSource; abstract IsSelected: UIEventSource; diff --git a/UI/Input/InputElementMap.ts b/UI/Input/InputElementMap.ts index 7a0286b90..548e50363 100644 --- a/UI/Input/InputElementMap.ts +++ b/UI/Input/InputElementMap.ts @@ -3,13 +3,12 @@ import {UIEventSource} from "../../Logic/UIEventSource"; export default class InputElementMap extends InputElement { - + public readonly IsSelected: UIEventSource; private readonly _inputElement: InputElement; private isSame: (x0: X, x1: X) => boolean; private readonly fromX: (x: X) => T; private readonly toX: (t: T) => X; private readonly _value: UIEventSource; - public readonly IsSelected: UIEventSource; constructor(inputElement: InputElement, isSame: (x0: X, x1: X) => boolean, @@ -41,19 +40,19 @@ export default class InputElementMap extends InputElement { return this._value; } - InnerRender(): string { - return this._inputElement.InnerRender(); - } - IsValid(x: X): boolean { - if(x === undefined){ + if (x === undefined) { return false; } const t = this.fromX(x); - if(t === undefined){ + if (t === undefined) { return false; } return this._inputElement.IsValid(t); } - + + protected InnerConstructElement(): HTMLElement { + return this._inputElement.ConstructElement(); + } + } \ No newline at end of file diff --git a/UI/Input/MultiInput.ts b/UI/Input/MultiInput.ts deleted file mode 100644 index 48826f9be..000000000 --- a/UI/Input/MultiInput.ts +++ /dev/null @@ -1,125 +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 Svg from "../../Svg"; - -export class MultiInput extends InputElement { - - private readonly _value: UIEventSource; - IsSelected: UIEventSource; - private elements: UIElement[] = []; - private inputElements: InputElement[] = []; - private addTag: UIElement; - private _options: { allowMovement?: boolean }; - - constructor( - addAElement: string, - newElement: (() => T), - createInput: (() => InputElement), - value: UIEventSource = undefined, - options?: { - allowMovement?: boolean - }) { - super(undefined); - this._value = value ?? new UIEventSource([]); - value = this._value; - this.ListenTo(value.map((latest : T[]) => latest.length)); - this._options = options ?? {}; - - this.addTag = new SubtleButton(Svg.addSmall_ui(), addAElement) - .SetClass("small-button") - .onClick(() => { - this.IsSelected.setData(true); - value.data.push(newElement()); - value.ping(); - }); - const self = this; - value.map((tags: string[]) => tags.length).addCallback(() => self.createElements(createInput)); - this.createElements(createInput); - - this._value.addCallback(tags => self.load(tags)); - this.IsSelected = new UIEventSource(false); - } - - private load(tags: T[]) { - if (tags === undefined) { - return; - } - for (let i = 0; i < tags.length; i++) { - this.inputElements[i].GetValue().setData(tags[i]); - } - } - - private UpdateIsSelected(){ - this.IsSelected.setData(this.inputElements.map(input => input.IsSelected.data).reduce((a,b) => a && b)) - } - - private createElements(createInput: (() => InputElement)) { - this.inputElements.splice(0, this.inputElements.length); - this.elements = []; - const self = this; - for (let i = 0; i < this._value.data.length; i++) { - const input = createInput(); - input.GetValue().addCallback(tag => { - self._value.data[i] = tag; - self._value.ping(); - } - ); - this.inputElements.push(input); - input.IsSelected.addCallback(() => this.UpdateIsSelected()); - - const moveUpBtn = Svg.up_ui() - .SetClass('small-image').onClick(() => { - const v = self._value.data[i]; - self._value.data[i] = self._value.data[i - 1]; - self._value.data[i - 1] = v; - self._value.ping(); - }); - - const moveDownBtn = - Svg.down_ui() - .SetClass('small-image') .onClick(() => { - const v = self._value.data[i]; - self._value.data[i] = self._value.data[i + 1]; - self._value.data[i + 1] = v; - self._value.ping(); - }); - - const controls = []; - if (i > 0 && this._options.allowMovement) { - controls.push(moveUpBtn); - } - - if (i + 1 < this._value.data.length && this._options.allowMovement) { - controls.push(moveDownBtn); - } - - - const deleteBtn = - Svg.delete_icon_ui().SetClass('small-image') - .onClick(() => { - self._value.data.splice(i, 1); - self._value.ping(); - }); - controls.push(deleteBtn); - this.elements.push(new Combine([input.SetStyle("width: calc(100% - 2em - 5px)"), new Combine(controls).SetStyle("display:flex;flex-direction:column;width:min-content;")]).SetClass("tag-input-row")) - } - - this.Update(); - } - - InnerRender(): string { - return new Combine([...this.elements, this.addTag]).Render(); - } - - IsValid(t: T[]): boolean { - return false; - } - - GetValue(): UIEventSource { - return this._value; - } - -} \ No newline at end of file diff --git a/UI/Input/MultiLingualTextFields.ts b/UI/Input/MultiLingualTextFields.ts deleted file mode 100644 index d1f69fbf1..000000000 --- a/UI/Input/MultiLingualTextFields.ts +++ /dev/null @@ -1,99 +0,0 @@ -import {InputElement} from "./InputElement"; -import {UIEventSource} from "../../Logic/UIEventSource"; -import {TextField} from "./TextField"; - -export default class MultiLingualTextFields extends InputElement { - private _fields: Map = new Map(); - private readonly _value: UIEventSource; - public readonly IsSelected: UIEventSource = new UIEventSource(false); - constructor(languages: UIEventSource, - textArea: boolean = false, - value: UIEventSource>> = undefined) { - super(undefined); - this._value = value ?? new UIEventSource({}); - this._value.addCallbackAndRun(latestData => { - if (typeof (latestData) === "string") { - console.warn("Refusing string for multilingual input", latestData); - self._value.setData({}); - } - }) - - const self = this; - - function setup(languages: string[]) { - if (languages === undefined) { - return; - } - const newFields = new Map(); - for (const language of languages) { - if (language.length != 2) { - continue; - } - - let oldField = self._fields.get(language); - if (oldField === undefined) { - oldField = new TextField({textArea: textArea}); - oldField.GetValue().addCallback(str => { - self._value.data[language] = str; - self._value.ping(); - }); - oldField.GetValue().setData(self._value.data[language]); - - oldField.IsSelected.addCallback(() => { - let selected = false; - self._fields.forEach(value => {selected = selected || value.IsSelected.data}); - self.IsSelected.setData(selected); - }) - - } - newFields.set(language, oldField); - } - self._fields = newFields; - self.Update(); - - - } - - setup(languages.data); - languages.addCallback(setup); - - - function load(latest: any){ - if(latest === undefined){ - return; - } - for (const lang in latest) { - self._fields.get(lang)?.GetValue().setData(latest[lang]); - } - } - this._value.addCallback(load); - load(this._value.data); - } - - protected InnerUpdate(htmlElement: HTMLElement) { - super.InnerUpdate(htmlElement); - this._fields.forEach(value => value.Update()); - } - - GetValue(): UIEventSource>> { - return this._value; - } - - InnerRender(): string { - let html = ""; - this._fields.forEach((field, lang) => { - html += `${lang}${field.Render()}` - }) - if(html === ""){ - return "Please define one or more languages" - } - - return `${html}
    `; - } - - - IsValid(t: any): boolean { - return true; - } - -} \ No newline at end of file diff --git a/UI/Input/MultiTagInput.ts b/UI/Input/MultiTagInput.ts deleted file mode 100644 index 30295bc8a..000000000 --- a/UI/Input/MultiTagInput.ts +++ /dev/null @@ -1,15 +0,0 @@ -import {UIEventSource} from "../../Logic/UIEventSource"; -import TagInput from "./SingleTagInput"; -import {MultiInput} from "./MultiInput"; - -export class MultiTagInput extends MultiInput { - - constructor(value: UIEventSource = new UIEventSource([])) { - super("Add a new tag", - () => "", - () => new TagInput(), - value - ); - } - -} \ No newline at end of file diff --git a/UI/Input/NumberField.ts b/UI/Input/NumberField.ts deleted file mode 100644 index d825bdf2f..000000000 --- a/UI/Input/NumberField.ts +++ /dev/null @@ -1,123 +0,0 @@ -import {UIElement} from "../UIElement"; -import {InputElement} from "./InputElement"; -import Translations from "../i18n/Translations"; -import {UIEventSource} from "../../Logic/UIEventSource"; - -export class NumberField extends InputElement { - private readonly value: UIEventSource; - public readonly enterPressed = new UIEventSource(undefined); - private readonly _placeholder: UIElement; - private options?: { - placeholder?: string | UIElement, - value?: UIEventSource, - isValid?: ((i: number) => boolean), - min?: number, - max?: number - }; - public readonly IsSelected: UIEventSource = new UIEventSource(false); - private readonly _isValid: (i:number) => boolean; - - constructor(options?: { - placeholder?: string | UIElement, - value?: UIEventSource, - isValid?: ((i:number) => boolean), - min?: number, - max?:number - }) { - super(undefined); - this.options = options; - const self = this; - this.value = new UIEventSource(undefined); - this.value = options?.value ?? new UIEventSource(undefined); - - this._isValid = options.isValid ?? ((i) => true); - - this._placeholder = Translations.W(options.placeholder ?? ""); - this.ListenTo(this._placeholder._source); - - this.onClick(() => { - self.IsSelected.setData(true) - }); - this.value.addCallback((t) => { - const field = document.getElementById("txt-"+this.id); - if (field === undefined || field === null) { - return; - } - field.className = self.IsValid(t) ? "" : "invalid"; - - if (t === undefined || t === null) { - return; - } - // @ts-ignore - field.value = t; - }); - this.dumbMode = false; - } - - GetValue(): UIEventSource { - return this.value; - } - - InnerRender(): string { - - const placeholder = this._placeholder.InnerRender().replace("'", "'"); - - let min = ""; - if(this.options.min){ - min = `min='${this.options.min}'`; - } - - let max = ""; - if(this.options.min){ - max = `max='${this.options.max}'`; - } - - return `
    ` + - `` + - `
    `; - } - - InnerUpdate() { - const field = document.getElementById("txt-" + this.id); - const self = this; - field.oninput = () => { - - // How much characters are on the right, not including spaces? - // @ts-ignore - const endDistance = field.value.substring(field.selectionEnd).replace(/ /g,'').length; - // @ts-ignore - let val: number = Number(field.value); - if (!self.IsValid(val)) { - self.value.setData(undefined); - } else { - self.value.setData(val); - } - - }; - - if (this.value.data !== undefined && this.value.data !== null) { - // @ts-ignore - field.value = this.value.data; - } - - field.addEventListener("focusin", () => self.IsSelected.setData(true)); - field.addEventListener("focusout", () => self.IsSelected.setData(false)); - - - field.addEventListener("keyup", function (event) { - if (event.key === "Enter") { - // @ts-ignore - self.enterPressed.setData(field.value); - } - }); - - } - - IsValid(t: number): boolean { - if (t === undefined || t === null) { - return false - } - return this._isValid(t); - } - -} \ No newline at end of file diff --git a/UI/Input/RadioButton.ts b/UI/Input/RadioButton.ts index 3ead32abf..fd5c006c2 100644 --- a/UI/Input/RadioButton.ts +++ b/UI/Input/RadioButton.ts @@ -3,52 +3,137 @@ import {UIEventSource} from "../../Logic/UIEventSource"; import {Utils} from "../../Utils"; export class RadioButton extends InputElement { + private static _nextId = 0; IsSelected: UIEventSource = new UIEventSource(false); - - private readonly _selectedElementIndex: UIEventSource - = new UIEventSource(null); - private readonly value: UIEventSource; - private readonly _elements: InputElement[] - private readonly _selectFirstAsDefault: boolean; + private _elements: InputElement[]; + private _selectFirstAsDefault: boolean; constructor(elements: InputElement[], selectFirstAsDefault = true) { - super(undefined); - this._elements = Utils.NoNull(elements); + super() this._selectFirstAsDefault = selectFirstAsDefault; - const self = this; - - this.value = - UIEventSource.flatten(this._selectedElementIndex.map( + this._elements = Utils.NoNull(elements); + this.value = new UIEventSource(undefined) + } + protected InnerConstructElement(): HTMLElement { + const elements = this._elements; + const selectFirstAsDefault = this._selectFirstAsDefault; + + const selectedElementIndex: UIEventSource = new UIEventSource(null); + const value = + UIEventSource.flatten(selectedElementIndex.map( (selectedIndex) => { if (selectedIndex !== undefined && selectedIndex !== null) { return elements[selectedIndex].GetValue() } } ), elements.map(e => e?.GetValue())); - - this.value.addCallback((t) => { - self?.ShowValue(t); + value.syncWith(this.value) + + if(selectFirstAsDefault){ + + value.addCallbackAndRun(selected =>{ + if(selected === undefined){ + for (const element of elements) { + const v = element.GetValue().data; + if(v !== undefined){ + value.setData(v) + break; + } + } + + + } }) + } for (let i = 0; i < elements.length; i++) { // If an element is clicked, the radio button corresponding with it should be selected as well elements[i]?.onClick(() => { - self._selectedElementIndex.setData(i); + selectedElementIndex.setData(i); }); elements[i].IsSelected.addCallback(isSelected => { if (isSelected) { - self._selectedElementIndex.setData(i); + selectedElementIndex.setData(i); } }) elements[i].GetValue().addCallback(() => { - self._selectedElementIndex.setData(i); + selectedElementIndex.setData(i); }) } - this.dumbMode = false; + + const groupId = "radiogroup" + RadioButton._nextId + RadioButton._nextId++ + + const form = document.createElement("form") + const inputs = [] + const wrappers: HTMLElement[] = [] + + for (let i1 = 0; i1 < elements.length; i1++) { + let element = elements[i1]; + const labelHtml = element.ConstructElement(); + if (labelHtml === undefined) { + continue; + } + + const input = document.createElement("input") + input.id = "radio" + groupId + "-" + i1; + input.name = groupId; + input.type = "radio" + input.classList.add("p-1","cursor-pointer","ml-2","pl-2","pr-0","m-3","mr-0") + + input.onchange = () => { + if(input.checked){ + selectedElementIndex.setData(i1) + } + } + + + inputs.push(input) + + const label = document.createElement("label") + label.appendChild(labelHtml) + label.htmlFor = input.id; + label.classList.add("block","w-full","p-2","cursor-pointer","bg-red") + + + const block = document.createElement("div") + block.appendChild(input) + block.appendChild(label) + block.classList.add("flex","w-full","border", "rounded-full", "border-gray-400","m-1") + wrappers.push(block) + + form.appendChild(block) + } + + + value.addCallbackAndRun( + selected => { + + let somethingChecked = false; + for (let i = 0; i < inputs.length; i++){ + let input = inputs[i]; + input.checked = !somethingChecked && elements[i].IsValid(selected); + somethingChecked = somethingChecked || input.checked + + if(input.checked){ + wrappers[i].classList.remove("border-gray-400") + wrappers[i].classList.add("border-black") + }else{ + wrappers[i].classList.add("border-gray-400") + wrappers[i].classList.remove("border-black") + } + + } + } + ) + + + this.SetClass("flex flex-col") + return form; } IsValid(t: T): boolean { @@ -65,25 +150,8 @@ export class RadioButton extends InputElement { } - private IdFor(i) { - return 'radio-' + this.id + '-' + i; - } - - InnerRender(): string { - let body = ""; - for (let i = 0; i < this._elements.length; i++){ - const el = this._elements[i]; - const htmlElement = - ``; - body += htmlElement; - } - - return `
    ${body}
    `; - } + /* public ShowValue(t: T): boolean { if (t === undefined) { return false; @@ -104,48 +172,7 @@ export class RadioButton extends InputElement { } } - } - - InnerUpdate(htmlElement: HTMLElement) { - const self = this; - - function checkButtons() { - for (let i = 0; i < self._elements.length; i++) { - const el = document.getElementById(self.IdFor(i)); - // @ts-ignore - if (el.checked) { - self._selectedElementIndex.setData(i); - } - } - } - - const el = document.getElementById(this.id); - el.addEventListener("change", - function () { - checkButtons(); - } - ); - if (this._selectedElementIndex.data !== null) { - const el = document.getElementById(this.IdFor(this._selectedElementIndex.data)); - if (el) { - // @ts-ignore - el.checked = true; - checkButtons(); - } - } else if (this._selectFirstAsDefault) { - this.ShowValue(this.value.data); - if (this._selectedElementIndex.data === null || this._selectedElementIndex.data === undefined) { - const el = document.getElementById(this.IdFor(0)); - if (el) { - // @ts-ignore - el.checked = true; - checkButtons(); - } - } - } - - - }; + }*/ } \ No newline at end of file diff --git a/UI/Input/SimpleDatePicker.ts b/UI/Input/SimpleDatePicker.ts index 3a6b203be..78c9db746 100644 --- a/UI/Input/SimpleDatePicker.ts +++ b/UI/Input/SimpleDatePicker.ts @@ -5,48 +5,37 @@ export default class SimpleDatePicker extends InputElement { private readonly value: UIEventSource + private readonly _element: HTMLElement; + constructor( value?: UIEventSource ) { super(); this.value = value ?? new UIEventSource(undefined); const self = this; + + const el = document.createElement("input") + this._element = el; + el.type = "date" + el.oninput = () => { + // Already in YYYY-MM-DD value! + self.value.setData(el.value); + } + + this.value.addCallbackAndRun(v => { if(v === undefined){ return; } - self.SetValue(v); + el.value = v; }); - } - InnerRender(): string { - return ``; - } - - private SetValue(date: string){ - const field = document.getElementById("date-" + this.id); - if (field === undefined || field === null) { - return; - } - // @ts-ignore - field.value = date; - } - - protected InnerUpdate() { - const field = document.getElementById("date-" + this.id); - if (field === undefined || field === null) { - return; - } - const self = this; - field.oninput = () => { - // Already in YYYY-MM-DD value! - // @ts-ignore - self.value.setData(field.value); - } - } + protected InnerConstructElement(): HTMLElement { + return this._element + } GetValue(): UIEventSource { return this.value; } diff --git a/UI/Input/SingleTagInput.ts b/UI/Input/SingleTagInput.ts deleted file mode 100644 index e7525da01..000000000 --- a/UI/Input/SingleTagInput.ts +++ /dev/null @@ -1,113 +0,0 @@ -import {InputElement} from "./InputElement"; -import {UIEventSource} from "../../Logic/UIEventSource"; -import {DropDown} from "./DropDown"; -import {TextField} from "./TextField"; -import Combine from "../Base/Combine"; -import {Utils} from "../../Utils"; -import {UIElement} from "../UIElement"; -import {VariableUiElement} from "../Base/VariableUIElement"; -import {FromJSON} from "../../Customizations/JSON/FromJSON"; -import ValidatedTextField from "./ValidatedTextField"; - -export default class SingleTagInput extends InputElement { - - private readonly _value: UIEventSource; - IsSelected: UIEventSource; - - private key: InputElement; - private value: InputElement; - private operator: DropDown - private readonly helpMessage: UIElement; - - constructor(value: UIEventSource = undefined) { - super(undefined); - this._value = value ?? new UIEventSource(""); - this.helpMessage = new VariableUiElement(this._value.map(tagDef => { - try { - FromJSON.Tag(tagDef, ""); - return ""; - } catch (e) { - return `
    ${e}` - } - } - )); - - this.key = ValidatedTextField.KeyInput(); - - this.value = new TextField({ - placeholder: "value - if blank, matches if key is NOT present", - value: new UIEventSource("") - } - ); - - this.operator = new DropDown("", [ - {value: "=", shown: "="}, - {value: "~", shown: "~"}, - {value: "!~", shown: "!~"} - ]); - this.operator.GetValue().setData("="); - - const self = this; - - function updateValue() { - if (self.key.GetValue().data === undefined || - self.value.GetValue().data === undefined || - self.operator.GetValue().data === undefined) { - return undefined; - } - self._value.setData(self.key.GetValue().data + self.operator.GetValue().data + self.value.GetValue().data); - } - - this.key.GetValue().addCallback(() => updateValue()); - this.operator.GetValue().addCallback(() => updateValue()); - this.value.GetValue().addCallback(() => updateValue()); - - - function loadValue(value: string) { - if (value === undefined) { - return; - } - let parts: string[]; - if (value.indexOf("=") >= 0) { - parts = Utils.SplitFirst(value, "="); - self.operator.GetValue().setData("="); - } else if (value.indexOf("!~") > 0) { - parts = Utils.SplitFirst(value, "!~"); - self.operator.GetValue().setData("!~"); - - } else if (value.indexOf("~") > 0) { - parts = Utils.SplitFirst(value, "~"); - self.operator.GetValue().setData("~"); - } else { - console.warn("Invalid value for tag: ", value) - return; - } - self.key.GetValue().setData(parts[0]); - self.value.GetValue().setData(parts[1]); - } - - self._value.addCallback(loadValue); - loadValue(self._value.data); - this.IsSelected = this.key.IsSelected.map( - isSelected => isSelected || this.value.IsSelected.data, [this.value.IsSelected] - ) - } - - IsValid(t: string): boolean { - return false; - } - - InnerRender(): string { - return new Combine([ - this.key, this.operator, this.value, - this.helpMessage - ]).Render(); - } - - - GetValue(): UIEventSource { - return this._value; - } - - -} \ No newline at end of file diff --git a/UI/Input/TextField.ts b/UI/Input/TextField.ts index ccb4da1e3..4c878ede5 100644 --- a/UI/Input/TextField.ts +++ b/UI/Input/TextField.ts @@ -1,102 +1,89 @@ -import {UIElement} from "../UIElement"; import {InputElement} from "./InputElement"; import Translations from "../i18n/Translations"; import {UIEventSource} from "../../Logic/UIEventSource"; -import Combine from "../Base/Combine"; +import BaseUIElement from "../BaseUIElement"; export class TextField extends InputElement { - private readonly value: UIEventSource; public readonly enterPressed = new UIEventSource(undefined); - private readonly _placeholder: UIElement; public readonly IsSelected: UIEventSource = new UIEventSource(false); - private readonly _htmlType: string; - private readonly _inputMode : string; - private readonly _textAreaRows: number; - - private readonly _isValid: (string,country) => boolean; - private _label: UIElement; + private readonly value: UIEventSource; + private _element: HTMLElement; + private readonly _isValid: (s: string, country?: () => string) => boolean; constructor(options?: { - placeholder?: string | UIElement, + placeholder?: string | BaseUIElement, value?: UIEventSource, textArea?: boolean, htmlType?: string, inputMode?: string, - label?: UIElement, + label?: BaseUIElement, textAreaRows?: number, + inputStyle?: string, isValid?: ((s: string, country?: () => string) => boolean) }) { - super(undefined); + super(); const self = this; - this.value = new UIEventSource(""); options = options ?? {}; - this._htmlType = options.textArea ? "area" : (options.htmlType ?? "text"); this.value = options?.value ?? new UIEventSource(undefined); - - this._label = options.label; - this._textAreaRows = options.textAreaRows; - this._isValid = options.isValid ?? ((str, country) => true); - - this._placeholder = Translations.W(options.placeholder ?? ""); - this._inputMode = options.inputMode; - this.ListenTo(this._placeholder._source); + this._isValid = options.isValid ?? (_ => true); this.onClick(() => { self.IsSelected.setData(true) }); - this.value.addCallback((t) => { - const field = document.getElementById("txt-"+this.id); - if (field === undefined || field === null) { + + + const placeholder = Translations.W(options.placeholder ?? "").ConstructElement().innerText.replace("'", "'"); + + this.SetClass("form-text-field") + let inputEl: HTMLElement + if (options.htmlType === "area") { + const el = document.createElement("textarea") + el.placeholder = placeholder + el.rows = options.textAreaRows + el.cols = 50 + el.style.cssText = "max-width: 100%; width: 100%; box-sizing: border-box" + inputEl = el; + } else { + const el = document.createElement("input") + el.type = options.htmlType ?? "text" + el.inputMode = options.inputMode + el.placeholder = placeholder + el.style.cssText = options.inputStyle + inputEl = el + } + + const form = document.createElement("form") + form.appendChild(inputEl) + form.onsubmit = () => false; + + if (options.label) { + form.appendChild(options.label.ConstructElement()) + } + + this._element = form; + + const field = inputEl; + + + this.value.addCallbackAndRun(value => { + if (!(value !== undefined && value !== null)) { + field["value"] = ""; return; } - field.className = self.IsValid(t) ? "" : "invalid"; - - if (t === undefined || t === null) { - return; + field["value"] = value; + if (self.IsValid(value)) { + self.RemoveClass("invalid") + } else { + self.SetClass("invalid") } - // @ts-ignore - field.value = t; - }); - this.dumbMode = false; - } - GetValue(): UIEventSource { - return this.value; - } + }) - InnerRender(): string { - - const placeholder = this._placeholder.InnerRender().replace("'", "'"); - if (this._htmlType === "area") { - return `` - } - - let label = ""; - if (this._label != undefined) { - label = this._label.Render(); - } - let inputMode = "" - if(this._inputMode !== undefined){ - inputMode = `inputmode="${this._inputMode}" ` - } - return new Combine([ - ``, - `
    `, - label, - ``, - `
    `, - `
    ` - ]).Render(); - } - - InnerUpdate() { - const field = document.getElementById("txt-" + this.id); - const self = this; field.oninput = () => { - + // How much characters are on the right, not including spaces? // @ts-ignore - const endDistance = field.value.substring(field.selectionEnd).replace(/ /g,'').length; + const endDistance = field.value.substring(field.selectionEnd).replace(/ /g, '').length; // @ts-ignore let val: string = field.value; if (!self.IsValid(val)) { @@ -107,25 +94,21 @@ export class TextField extends InputElement { // Setting the value might cause the value to be set again. We keep the distance _to the end_ stable, as phone number formatting might cause the start to change // See https://github.com/pietervdvn/MapComplete/issues/103 // We reread the field value - it might have changed! - + // @ts-ignore val = field.value; let newCursorPos = val.length - endDistance; - while(newCursorPos >= 0 && + while (newCursorPos >= 0 && // We count the number of _actual_ characters (non-space characters) on the right of the new value // This count should become bigger then the end distance val.substr(newCursorPos).replace(/ /g, '').length < endDistance - ){ - newCursorPos --; + ) { + newCursorPos--; } // @ts-ignore - self.SetCursorPosition(newCursorPos); + TextField.SetCursorPosition(field, newCursorPos); }; - if (this.value.data !== undefined && this.value.data !== null) { - // @ts-ignore - field.value = this.value.data; - } field.addEventListener("focusin", () => self.IsSelected.setData(true)); field.addEventListener("focusout", () => self.IsSelected.setData(false)); @@ -137,24 +120,28 @@ export class TextField extends InputElement { self.enterPressed.setData(field.value); } }); - + + } - public SetCursorPosition(i: number) { - const field = document.getElementById('txt-' + this.id); - if(field === undefined || field === null){ + private static SetCursorPosition(textfield: HTMLElement, i: number) { + if (textfield === undefined || textfield === null) { return; } if (i === -1) { // @ts-ignore - i = field.value.length; + i = textfield.value.length; } - field.focus(); + textfield.focus(); // @ts-ignore - field.setSelectionRange(i, i); + textfield.setSelectionRange(i, i); } + GetValue(): UIEventSource { + return this.value; + } + IsValid(t: string): boolean { if (t === undefined || t === null) { return false @@ -162,4 +149,8 @@ export class TextField extends InputElement { return this._isValid(t, undefined); } + protected InnerConstructElement(): HTMLElement { + return this._element; + } + } \ No newline at end of file diff --git a/UI/Input/Toggle.ts b/UI/Input/Toggle.ts new file mode 100644 index 000000000..ca9fd6fba --- /dev/null +++ b/UI/Input/Toggle.ts @@ -0,0 +1,27 @@ +import {UIEventSource} from "../../Logic/UIEventSource"; +import BaseUIElement from "../BaseUIElement"; +import {VariableUiElement} from "../Base/VariableUIElement"; + +/** + * The 'Toggle' is a UIElement showing either one of two elements, depending on the state. + * It can be used to implement e.g. checkboxes or collapsible elements + */ +export default class Toggle extends VariableUiElement{ + + public readonly isEnabled: UIEventSource; + + constructor(showEnabled: string | BaseUIElement, showDisabled: string | BaseUIElement, isEnabled: UIEventSource = new UIEventSource(false)) { + super( + isEnabled.map(isEnabled => isEnabled ? showEnabled : showDisabled) + ); + this.isEnabled = isEnabled + } + + public ToggleOnClick(): Toggle{ + const self = this; + this.onClick(() => { + self. isEnabled.setData(!self.isEnabled.data); + }) + return this; + } +} \ No newline at end of file diff --git a/UI/Input/ValidatedTextField.ts b/UI/Input/ValidatedTextField.ts index 27f1f9051..51b24b2e4 100644 --- a/UI/Input/ValidatedTextField.ts +++ b/UI/Input/ValidatedTextField.ts @@ -39,7 +39,7 @@ export default class ValidatedTextField { undefined, undefined, "text"), - + ValidatedTextField.tp( "date", "A date", @@ -63,7 +63,24 @@ export default class ValidatedTextField { (value) => new SimpleDatePicker(value)), ValidatedTextField.tp( "wikidata", - "A wikidata identifier, e.g. Q42"), + "A wikidata identifier, e.g. Q42", + (str) => { + if (str === undefined) { + return false; + } + return (str.length > 1 && (str.startsWith("q") || str.startsWith("Q")) || str.startsWith("https://www.wikidata.org/wiki/Q")) + }, + (str) => { + if (str === undefined) { + return undefined; + } + const wd = "https://www.wikidata.org/wiki/"; + if (str.startsWith(wd)) { + str = str.substr(wd.length) + } + return str.toUpperCase(); + }), + ValidatedTextField.tp( "int", "A number", @@ -213,8 +230,8 @@ export default class ValidatedTextField { placeholder?: string | UIElement, value?: UIEventSource, htmlType?: string, - textArea?:boolean, - inputMode?:string, + textArea?: boolean, + inputMode?: string, textAreaRows?: number, isValid?: ((s: string, country: () => string) => boolean), country?: () => string, diff --git a/UI/LanguagePicker.ts b/UI/LanguagePicker.ts index 61f276dda..0b30baf3c 100644 --- a/UI/LanguagePicker.ts +++ b/UI/LanguagePicker.ts @@ -1,15 +1,13 @@ -import {UIElement} from "./UIElement"; import {DropDown} from "./Input/DropDown"; import Locale from "./i18n/Locale"; -import Svg from "../Svg"; -import Img from "./Base/Img"; +import BaseUIElement from "./BaseUIElement"; export default class LanguagePicker { - + public static CreateLanguagePicker( languages : string[] , - label: string | UIElement = "") { + label: string | BaseUIElement = "") { if (languages.length <= 1) { return undefined; @@ -18,7 +16,7 @@ export default class LanguagePicker { return new DropDown(label, languages.map(lang => { return {value: lang, shown: lang} } - ), Locale.language, '', 'bg-indigo-100 p-1 rounded hover:bg-indigo-200'); + ), Locale.language); } diff --git a/UI/MapControlButton.ts b/UI/MapControlButton.ts index b5e4b2aac..6276885b1 100644 --- a/UI/MapControlButton.ts +++ b/UI/MapControlButton.ts @@ -1,20 +1,22 @@ import {UIElement} from "./UIElement"; +import BaseUIElement from "./BaseUIElement"; +import Combine from "./Base/Combine"; /** * A button floating above the map, in a uniform style */ export default class MapControlButton extends UIElement { - private _contents: UIElement; + private _contents: BaseUIElement; - constructor(contents: UIElement) { + constructor(contents: BaseUIElement) { super(); - this._contents = contents; + this._contents = new Combine([contents]); this.SetClass("relative block rounded-full w-10 h-10 p-1 pointer-events-auto z-above-map subtle-background") this.SetStyle("box-shadow: 0 0 10px var(--shadow-color);"); } - InnerRender(): string { - return this._contents.Render(); + InnerRender() { + return this._contents; } } \ No newline at end of file diff --git a/UI/OpeningHours/OhVisualization.ts b/UI/OpeningHours/OhVisualization.ts deleted file mode 100644 index e930228aa..000000000 --- a/UI/OpeningHours/OhVisualization.ts +++ /dev/null @@ -1,324 +0,0 @@ -import {UIEventSource} from "../../Logic/UIEventSource"; -import {UIElement} from "../UIElement"; -import Combine from "../Base/Combine"; -import State from "../../State"; -import {FixedUiElement} from "../Base/FixedUiElement"; -import {OH} from "./OpeningHours"; -import Translations from "../i18n/Translations"; -import Constants from "../../Models/Constants"; -import opening_hours from "opening_hours"; - -export default class OpeningHoursVisualization extends UIElement { - private readonly _key: string; - - constructor(tags: UIEventSource, key: string) { - super(tags); - this._key = key; - this.ListenTo(UIEventSource.Chronic(60*1000)); // Automatically reload every minute - this.ListenTo(UIEventSource.Chronic(500, () => { - return tags.data._country === undefined; - })); - - - } - - - - private static GetRanges(oh: any, from: Date, to: Date): ({ - isOpen: boolean, - isSpecial: boolean, - comment: string, - startDate: Date, - endDate: Date - }[])[] { - - - const values = [[], [], [], [], [], [], []]; - - const start = new Date(from); - // We go one day more into the past, in order to force rendering of holidays in the start of the period - start.setDate(from.getDate() - 1); - - const iterator = oh.getIterator(start); - - let prevValue = undefined; - while (iterator.advance(to)) { - - if (prevValue) { - prevValue.endDate = iterator.getDate() as Date - } - const endDate = new Date(iterator.getDate()) as Date; - endDate.setHours(0, 0, 0, 0) - endDate.setDate(endDate.getDate() + 1); - const value = { - isSpecial: iterator.getUnknown(), - isOpen: iterator.getState(), - comment: iterator.getComment(), - startDate: iterator.getDate() as Date, - endDate: endDate // Should be overwritten by the next iteration - } - prevValue = value; - - if (value.comment === undefined && !value.isOpen && !value.isSpecial) { - // simply closed, nothing special here - continue; - } - - if(value.startDate < from){ - continue; - } - // Get day: sunday is 0, monday is 1. We move everything so that monday == 0 - values[(value.startDate.getDay() + 6) % 7].push(value); - } - return values; - } - - private static getMonday(d) { - d = new Date(d); - const day = d.getDay(); - const diff = d.getDate() - day + (day == 0 ? -6 : 1); // adjust when day is sunday - return new Date(d.setDate(diff)); - } - - - private allChangeMoments(ranges: { - isOpen: boolean, - isSpecial: boolean, - comment: string, - startDate: Date, - endDate: Date - }[][]): [number[], string[]] { - const changeHours: number[] = [] - const changeHourText: string[] = []; - const extrachangeHours: number[] = [] - const extrachangeHourText: string[] = []; - - for (const weekday of ranges) { - for (const range of weekday) { - if (!range.isOpen && !range.isSpecial) { - continue; - } - const startOfDay: Date = new Date(range.startDate); - startOfDay.setHours(0, 0, 0, 0); - // @ts-ignore - const changeMoment: number = (range.startDate - startOfDay) / 1000; - if (changeHours.indexOf(changeMoment) < 0) { - changeHours.push(changeMoment); - changeHourText.push(OH.hhmm(range.startDate.getHours(), range.startDate.getMinutes())) - } - - // @ts-ignore - let changeMomentEnd: number = (range.endDate - startOfDay) / 1000; - if (changeMomentEnd >= 24 * 60 * 60) { - if (extrachangeHours.indexOf(changeMomentEnd) < 0) { - extrachangeHours.push(changeMomentEnd); - extrachangeHourText.push(OH.hhmm(range.endDate.getHours(), range.endDate.getMinutes())) - } - } else if (changeHours.indexOf(changeMomentEnd) < 0) { - changeHours.push(changeMomentEnd); - changeHourText.push(OH.hhmm(range.endDate.getHours(), range.endDate.getMinutes())) - } - } - } - - changeHourText.sort(); - changeHours.sort(); - extrachangeHourText.sort(); - extrachangeHours.sort(); - changeHourText.push(...extrachangeHourText); - changeHours.push(...extrachangeHours); - - return [changeHours, changeHourText] - } - - private static readonly weekdays = [ - Translations.t.general.weekdays.abbreviations.monday, - Translations.t.general.weekdays.abbreviations.tuesday, - Translations.t.general.weekdays.abbreviations.wednesday, - Translations.t.general.weekdays.abbreviations.thursday, - Translations.t.general.weekdays.abbreviations.friday, - Translations.t.general.weekdays.abbreviations.saturday, - Translations.t.general.weekdays.abbreviations.sunday, - ] - - InnerRender(): string { - - - const today = new Date(); - today.setHours(0, 0, 0, 0); - - const lastMonday = OpeningHoursVisualization.getMonday(today); - const nextSunday = new Date(lastMonday); - nextSunday.setDate(nextSunday.getDate() + 7); - - const tags = this._source.data; - if (tags._country === undefined) { - return "Loading country information..."; - } - let oh = null; - - try { - oh = new opening_hours(tags[this._key], { - lat: tags._lat, - lon: tags._lon, - address: { - country_code: tags._country - } - }, {tag_key: this._key}); - } catch (e) { - console.log(e); - const msg = new Combine([Translations.t.general.opening_hours.error_loading, - State.state?.osmConnection?.userDetails?.data?.csCount >= Constants.userJourney.tagsVisibleAndWikiLinked ? - `${e}` - : "" - ]); - return msg.Render(); - } - - if (!oh.getState() && !oh.getUnknown()) { - // POI is currently closed - const nextChange: Date = oh.getNextChange(); - if ( - // Shop isn't gonna open anymore in this timerange - nextSunday < nextChange - // And we are already in the weekend to show next week - && (today.getDay() == 0 || today.getDay() == 6) - ) { - // We mover further along - lastMonday.setDate(lastMonday.getDate() + 7); - nextSunday.setDate(nextSunday.getDate() + 7); - } - } - - // ranges[0] are all ranges for monday - const ranges = OpeningHoursVisualization.GetRanges(oh, lastMonday, nextSunday); - if (ranges.map(r => r.length).reduce((a, b) => a + b, 0) == 0) { - // Closed! - const opensAtDate = oh.getNextChange(); - if(opensAtDate === undefined){ - const comm = oh.getComment() ?? oh.getUnknown(); - if(!!comm){ - return new FixedUiElement(comm).SetClass("ohviz-closed").Render(); - } - - if(oh.getState()){ - return Translations.t.general.opening_hours.open_24_7.SetClass("ohviz-closed").Render() - } - return Translations.t.general.opening_hours.closed_permanently.SetClass("ohviz-closed").Render() - } - const moment = `${opensAtDate.getDate()}/${opensAtDate.getMonth() + 1} ${OH.hhmm(opensAtDate.getHours(), opensAtDate.getMinutes())}` - return Translations.t.general.opening_hours.closed_until.Subs({date: moment}).SetClass("ohviz-closed").Render() - } - - const isWeekstable = oh.isWeekStable(); - - let [changeHours, changeHourText] = this.allChangeMoments(ranges); - - // By default, we always show the range between 8 - 19h, in order to give a stable impression - // Ofc, a bigger range is used if needed - const earliestOpen = Math.min(8 * 60 * 60, ...changeHours); - let latestclose = Math.max(...changeHours); - // We always make sure there is 30m of leeway in order to give enough room for the closing entry - latestclose = Math.max(19 * 60 * 60, latestclose + 30 * 60) - - - const rows: UIElement[] = []; - const availableArea = latestclose - earliestOpen; - // @ts-ignore - const now = (100 * (((new Date() - today) / 1000) - earliestOpen)) / availableArea; - - - let header = ""; - - if (now >= 0 && now <= 100) { - header += new FixedUiElement("").SetStyle(`left:${now}%;`).SetClass("ohviz-now").Render() - } - for (const changeMoment of changeHours) { - const offset = 100 * (changeMoment - earliestOpen) / availableArea; - if (offset < 0 || offset > 100) { - continue; - } - const el = new FixedUiElement("").SetStyle(`left:${offset}%;`).SetClass("ohviz-line").Render(); - header += el; - } - - for (let i = 0; i < changeHours.length; i++) { - let changeMoment = changeHours[i]; - const offset = 100 * (changeMoment - earliestOpen) / availableArea; - if (offset < 0 || offset > 100) { - continue; - } - const el = new FixedUiElement( - `
    ${changeHourText[i]}
    ` - ) - .SetStyle(`left:${offset}%`) - .SetClass("ohviz-time-indication").Render(); - header += el; - } - - rows.push(new Combine([` `, - `${header}`])); - - for (let i = 0; i < 7; i++) { - const dayRanges = ranges[i]; - const isToday = (new Date().getDay() + 6) % 7 === i; - let weekday = OpeningHoursVisualization.weekdays[i].Render(); - - let dateToShow = "" - if (!isWeekstable) { - const day = new Date(lastMonday) - day.setDate(day.getDate() + i); - dateToShow = "" + day.getDate() + "/" + (day.getMonth() + 1); - } - - let innerContent: string[] = []; - - // Add the lines - for (const changeMoment of changeHours) { - const offset = 100 * (changeMoment - earliestOpen) / availableArea; - innerContent.push(new FixedUiElement("").SetStyle(`left:${offset}%;`).SetClass("ohviz-line").Render()) - } - - // Add the actual ranges - for (const range of dayRanges) { - if (!range.isOpen && !range.isSpecial) { - innerContent.push( - new FixedUiElement(range.comment ?? dateToShow).SetClass("ohviz-day-off").Render()) - continue; - } - - const startOfDay: Date = new Date(range.startDate); - startOfDay.setHours(0, 0, 0, 0); - // @ts-ignore - const startpoint = (range.startDate - startOfDay) / 1000 - earliestOpen; - // @ts-ignore - const width = (100 * (range.endDate - range.startDate) / 1000) / (latestclose - earliestOpen); - const startPercentage = (100 * startpoint / availableArea); - innerContent.push( - new FixedUiElement(range.comment ?? dateToShow).SetStyle(`left:${startPercentage}%; width:${width}%`).SetClass("ohviz-range").Render()) - } - - // Add line for 'now' - if (now >= 0 && now <= 100) { - innerContent.push(new FixedUiElement("").SetStyle(`left:${now}%;`).SetClass("ohviz-now").Render()) - } - - let clss = "" - if (isToday) { - clss = "ohviz-today" - } - - rows.push(new Combine( - [`${weekday}`, - `${innerContent.join("")}`])) - } - - - return new Combine([ - "", - rows.map(el => "" + el.Render() + "").join(""), - "
    " - ]).SetClass("ohviz-container").Render(); - } - -} \ No newline at end of file diff --git a/UI/OpeningHours/OpeningHours.ts b/UI/OpeningHours/OpeningHours.ts index 5164e963e..1b43f16c7 100644 --- a/UI/OpeningHours/OpeningHours.ts +++ b/UI/OpeningHours/OpeningHours.ts @@ -8,6 +8,9 @@ export interface OpeningHour { endMinutes: number } +/** + * Various utilities manipulating opening hours + */ export class OH { @@ -46,10 +49,10 @@ export class OH { let rangeStart = 0; let rangeEnd = 0; - - function pushRule(){ + + function pushRule() { const rule = stringPerWeekday[rangeStart]; - if(rule === ""){ + if (rule === "") { return; } if (rangeStart == (rangeEnd - 1)) { @@ -58,7 +61,7 @@ export class OH { ); } else { rules.push( - `${OH.days[rangeStart]}-${OH.days[rangeEnd-1]} ${rule}` + `${OH.days[rangeStart]}-${OH.days[rangeEnd - 1]} ${rule}` ); } } @@ -87,13 +90,22 @@ export class OH { * @constructor */ public static MergeTimes(ohs: OpeningHour[]): OpeningHour[] { - const queue = [...ohs]; + const queue = ohs.map(oh => { + if (oh.endHour === 0 && oh.endMinutes === 0) { + const newOh = { + ...oh + } + newOh.endHour = 24 + return newOh + } + return oh; + }); const newList = []; while (queue.length > 0) { let maybeAdd = queue.pop(); let doAddEntry = true; - if(maybeAdd.weekday == undefined){ + if (maybeAdd.weekday == undefined) { doAddEntry = false; } @@ -137,8 +149,8 @@ export class OH { queue.push({ startHour: startHour, startMinutes: startMinutes, - endHour:endHour, - endMinutes:endMinutes, + endHour: endHour, + endMinutes: endMinutes, weekday: guard.weekday }); @@ -163,6 +175,12 @@ export class OH { } + /** + * Gives the number of hours since the start of day. + * E.g. + * startTime({startHour: 9, startMinuts: 15}) == 9.25 + * @param oh + */ public static startTime(oh: OpeningHour): number { return oh.startHour + oh.startMinutes / 60; } @@ -181,21 +199,6 @@ export class OH { OH.endTime(checked) <= OH.endTime(mightLieIn) } - private static parseHHMM(hhmm: string): { hours: number, minutes: number } { - if(hhmm === undefined || hhmm == null){ - return null; - } - const spl = hhmm.trim().split(":"); - if(spl.length != 2){ - return null; - } - const hm = {hours: Number(spl[0].trim()), minutes: Number(spl[1].trim())}; - if(isNaN(hm.hours) || isNaN(hm.minutes) ){ - return null; - } - return hm; - } - public static parseHHMMRange(hhmmhhmm: string): { startHour: number, startMinutes: number, @@ -217,6 +220,241 @@ export class OH { } } + public static ParseRule(rule: string): OpeningHour[] { + try { + if (rule.trim() == "24/7") { + return OH.multiply([0, 1, 2, 3, 4, 5, 6], [{ + startHour: 0, + startMinutes: 0, + endHour: 24, + endMinutes: 0 + }]); + } + + const split = rule.trim().replace(/, */g, ",").split(" "); + if (split.length == 1) { + // First, try to parse this rule as a rule without weekdays + let timeranges = OH.ParseHhmmRanges(rule); + let weekdays = [0, 1, 2, 3, 4, 5, 6]; + return OH.multiply(weekdays, timeranges); + } + + if (split.length == 2) { + const weekdays = OH.ParseWeekdayRanges(split[0]); + const timeranges = OH.ParseHhmmRanges(split[1]); + return OH.multiply(weekdays, timeranges); + } + return null; + } catch (e) { + console.log("Could not parse weekday rule ", rule); + return null; + } + } + + public static ParsePHRule(str: string): { + mode: string, + start?: string, + end?: string + } { + str = str.trim(); + if (!str.startsWith("PH")) { + return null; + } + + str = str.trim(); + if (str === "PH off") { + return { + mode: "off" + } + } + + if (str === "PH open") { + return { + mode: "open" + } + } + + if (!str.startsWith("PH ")) { + return null; + } + try { + + const timerange = OH.parseHHMMRange(str.substring(2)); + if (timerange === null) { + return null; + } + + return { + mode: " ", + start: OH.hhmm(timerange.startHour, timerange.startMinutes), + end: OH.hhmm(timerange.endHour, timerange.endMinutes), + + } + } catch (e) { + return null; + } + } + + static Parse(rules: string) { + if (rules === undefined || rules === "") { + return [] + } + + const ohs = [] + + const split = rules.split(";"); + + for (const rule of split) { + if (rule === "") { + continue; + } + try { + const parsed = OH.ParseRule(rule) + if (parsed !== null) { + ohs.push(...parsed); + } + } catch (e) { + console.error("Could not parse ", rule, ": ", e) + } + } + + return ohs; + } + + /* + This function converts a number of ranges (generated by OpeningHours.js) into all the hours of day that a change occurs. + E.g. + Monday, some business is opended from 9:00 till 17:00 + Tuesday from 9:30 till 18:00 + Wednesday from 9:30 till 12:30 + This function will extract all those moments of change and will return 9:00, 9:30, 12:30, 17:00 and 18:00 + This list will be sorted + */ + public static allChangeMoments(ranges: { + isOpen: boolean, + isSpecial: boolean, + comment: string, + startDate: Date, + endDate: Date + }[][]): [number[], string[]] { + const changeHours: number[] = [] + const changeHourText: string[] = []; + + const extrachangeHours: number[] = [] + const extrachangeHourText: string[] = []; + + for (const weekday of ranges) { + for (const range of weekday) { + if (!range.isOpen && !range.isSpecial) { + continue; + } + const startOfDay: Date = new Date(range.startDate); + startOfDay.setHours(0, 0, 0, 0); + + // The number of seconds since the start of the day + // @ts-ignore + const changeMoment: number = (range.startDate - startOfDay) / 1000; + if (changeHours.indexOf(changeMoment) < 0) { + changeHours.push(changeMoment); + changeHourText.push(OH.hhmm(range.startDate.getHours(), range.startDate.getMinutes())) + } + + // The number of seconds till between the start of the day and closing + // @ts-ignore + let changeMomentEnd: number = (range.endDate - startOfDay) / 1000; + if (changeMomentEnd >= 24 * 60 * 60) { + if (extrachangeHours.indexOf(changeMomentEnd) < 0) { + extrachangeHours.push(changeMomentEnd); + extrachangeHourText.push(OH.hhmm(range.endDate.getHours(), range.endDate.getMinutes())) + } + } else if (changeHours.indexOf(changeMomentEnd) < 0) { + changeHours.push(changeMomentEnd); + changeHourText.push(OH.hhmm(range.endDate.getHours(), range.endDate.getMinutes())) + } + } + } + + // Note that 'changeHours' and 'changeHourText' will be more or less in sync - one is in numbers, the other in 'HH:MM' format. + // But both can be sorted without problem; they'll stay in sync + changeHourText.sort(); + changeHours.sort(); + extrachangeHourText.sort(); + extrachangeHours.sort(); + + changeHourText.push(...extrachangeHourText); + changeHours.push(...extrachangeHours); + + return [changeHours, changeHourText] + } + + /* + Calculates when the business is opened (or on holiday) between two dates. + Returns a matrix of ranges, where [0] is a list of ranges when it is opened on monday, [1] is a list of ranges for tuesday, ... + */ + public static GetRanges(oh: any, from: Date, to: Date): ({ + isOpen: boolean, + isSpecial: boolean, + comment: string, + startDate: Date, + endDate: Date + }[])[] { + + + const values = [[], [], [], [], [], [], []]; + + const start = new Date(from); + // We go one day more into the past, in order to force rendering of holidays in the start of the period + start.setDate(from.getDate() - 1); + + const iterator = oh.getIterator(start); + + let prevValue = undefined; + while (iterator.advance(to)) { + + if (prevValue) { + prevValue.endDate = iterator.getDate() as Date + } + const endDate = new Date(iterator.getDate()) as Date; + endDate.setHours(0, 0, 0, 0) + endDate.setDate(endDate.getDate() + 1); + const value = { + isSpecial: iterator.getUnknown(), + isOpen: iterator.getState(), + comment: iterator.getComment(), + startDate: iterator.getDate() as Date, + endDate: endDate // Should be overwritten by the next iteration + } + prevValue = value; + + if (value.comment === undefined && !value.isOpen && !value.isSpecial) { + // simply closed, nothing special here + continue; + } + + if (value.startDate < from) { + continue; + } + // Get day: sunday is 0, monday is 1. We move everything so that monday == 0 + values[(value.startDate.getDay() + 6) % 7].push(value); + } + return values; + } + + private static parseHHMM(hhmm: string): { hours: number, minutes: number } { + if (hhmm === undefined || hhmm == null) { + return null; + } + const spl = hhmm.trim().split(":"); + if (spl.length != 2) { + return null; + } + const hm = {hours: Number(spl[0].trim()), minutes: Number(spl[1].trim())}; + if (isNaN(hm.hours) || isNaN(hm.minutes)) { + return null; + } + return hm; + } + private static ParseHhmmRanges(hhmms: string): { startHour: number, startMinutes: number, @@ -241,7 +479,7 @@ export class OH { const split = weekdays.split("-"); if (split.length == 1) { const parsed = OH.ParseWeekday(weekdays); - if(parsed == null){ + if (parsed == null) { return null; } return [parsed]; @@ -291,62 +529,5 @@ export class OH { return ohs; } - public static ParseRule(rule: string): OpeningHour[] { - try { - if (rule.trim() == "24/7") { - return OH.multiply([0, 1, 2, 3, 4, 5, 6], [{ - startHour: 0, - startMinutes: 0, - endHour: 24, - endMinutes: 0 - }]); - } - - const split = rule.trim().replace(/, */g, ",").split(" "); - if (split.length == 1) { - // First, try to parse this rule as a rule without weekdays - let timeranges = OH.ParseHhmmRanges(rule); - let weekdays = [0, 1, 2, 3, 4, 5, 6]; - return OH.multiply(weekdays, timeranges); - } - - if (split.length == 2) { - const weekdays = OH.ParseWeekdayRanges(split[0]); - const timeranges = OH.ParseHhmmRanges(split[1]); - return OH.multiply(weekdays, timeranges); - } - return null; - } catch (e) { - console.log("Could not parse weekday rule ", rule); - return null; - } - } - - - static Parse(rules: string) { - if (rules === undefined || rules === "") { - return [] - } - - const ohs = [] - - const split = rules.split(";"); - - for (const rule of split) { - if(rule === ""){ - continue; - } - try { - const parsed = OH.ParseRule(rule) - if (parsed !== null) { - ohs.push(...parsed); - } - } catch (e) { - console.error("Could not parse ", rule, ": ", e) - } - } - - return ohs; - } } diff --git a/UI/OpeningHours/OpeningHoursInput.ts b/UI/OpeningHours/OpeningHoursInput.ts index 8e9a7c9b8..f19a2cf6e 100644 --- a/UI/OpeningHours/OpeningHoursInput.ts +++ b/UI/OpeningHours/OpeningHoursInput.ts @@ -5,7 +5,6 @@ */ import OpeningHoursPicker from "./OpeningHoursPicker"; import {UIEventSource} from "../../Logic/UIEventSource"; -import {UIElement} from "../UIElement"; import {VariableUiElement} from "../Base/VariableUIElement"; import Combine from "../Base/Combine"; import {FixedUiElement} from "../Base/FixedUiElement"; @@ -14,21 +13,20 @@ import {InputElement} from "../Input/InputElement"; import PublicHolidayInput from "./PublicHolidayInput"; import Translations from "../i18n/Translations"; import {Utils} from "../../Utils"; +import BaseUIElement from "../BaseUIElement"; export default class OpeningHoursInput extends InputElement { + public readonly IsSelected: UIEventSource = new UIEventSource(false); private readonly _value: UIEventSource; - - private readonly _ohPicker: UIElement; - private readonly _leftoverWarning: UIElement; - private readonly _phSelector: UIElement; + private readonly _element: BaseUIElement; constructor(value: UIEventSource = new UIEventSource("")) { super(); - + const leftoverRules = value.map(str => { if (str === undefined) { return [] @@ -39,14 +37,14 @@ export default class OpeningHoursInput extends InputElement { if (OH.ParseRule(rule) !== null) { continue; } - if (PublicHolidayInput.LoadValue(rule) !== null) { + if (OH.ParsePHRule(rule) !== null) { continue; } leftOvers.push(rule); } return leftOvers; }) - // NOte: MUST be bound AFTER the leftover rules! + // Note: MUST be bound AFTER the leftover rules! const rulesFromOhPicker = value.map(OH.Parse); const ph = value.map(str => { @@ -55,17 +53,17 @@ export default class OpeningHoursInput extends InputElement { } const rules = str.split(";"); for (const rule of rules) { - if (PublicHolidayInput.LoadValue(rule) !== null) { + if (OH.ParsePHRule(rule) !== null) { return rule; } } return ""; }) - this._phSelector = new PublicHolidayInput(ph); + const phSelector = new PublicHolidayInput(ph); function update() { const regular = OH.ToString(rulesFromOhPicker.data); - const rules : string[] = [ + const rules: string[] = [ regular, ...leftoverRules.data, ph.data @@ -76,39 +74,35 @@ export default class OpeningHoursInput extends InputElement { rulesFromOhPicker.addCallback(update); ph.addCallback(update); - this._leftoverWarning = new VariableUiElement(leftoverRules.map((leftovers: string[]) => { + const leftoverWarning = new VariableUiElement(leftoverRules.map((leftovers: string[]) => { if (leftovers.length == 0) { return ""; } return new Combine([ Translations.t.general.opening_hours.not_all_rules_parsed, - new FixedUiElement(leftovers.map(r => `${r}
    `).join("")).SetClass("subtle") - ]).Render(); + new FixedUiElement(leftovers.map(r => `${r}
    `).join("")).SetClass("subtle") + ]); })) - this._ohPicker = new OpeningHoursPicker(rulesFromOhPicker); - + const ohPicker = new OpeningHoursPicker(rulesFromOhPicker); + this._element = new Combine([ + leftoverWarning, + ohPicker, + phSelector + ]) } + protected InnerConstructElement(): HTMLElement { + return this._element.ConstructElement() + } GetValue(): UIEventSource { return this._value; } - InnerRender(): string { - return new Combine([ - this._leftoverWarning, - this._ohPicker, - this._phSelector - ]).Render(); - } - - - public readonly IsSelected: UIEventSource = new UIEventSource(false); - IsValid(t: string): boolean { return true; } diff --git a/UI/OpeningHours/OpeningHoursPicker.ts b/UI/OpeningHours/OpeningHoursPicker.ts index ad8c0cb5b..e0c339f5b 100644 --- a/UI/OpeningHours/OpeningHoursPicker.ts +++ b/UI/OpeningHours/OpeningHoursPicker.ts @@ -1,65 +1,41 @@ import {UIEventSource} from "../../Logic/UIEventSource"; -import {UIElement} from "../UIElement"; -import OpeningHoursRange from "./OpeningHoursRange"; -import Combine from "../Base/Combine"; import OpeningHoursPickerTable from "./OpeningHoursPickerTable"; import {OH, OpeningHour} from "./OpeningHours"; import {InputElement} from "../Input/InputElement"; +import BaseUIElement from "../BaseUIElement"; export default class OpeningHoursPicker extends InputElement { - private readonly _ohs: UIEventSource; public readonly IsSelected: UIEventSource = new UIEventSource(false); - + private readonly _ohs: UIEventSource; private readonly _backgroundTable: OpeningHoursPickerTable; - private readonly _weekdays: UIEventSource = new UIEventSource([]); constructor(ohs: UIEventSource = new UIEventSource([])) { super(); this._ohs = ohs; - this._backgroundTable = new OpeningHoursPickerTable(this._weekdays, this._ohs); - const self = this; - - - this._ohs.addCallback(ohs => { - self._ohs.setData(OH.MergeTimes(ohs)); + + ohs.addCallback(oh => { + ohs.setData(OH.MergeTimes(oh)); }) - ohs.addCallbackAndRun(ohs => { - const perWeekday: UIElement[][] = []; - for (let i = 0; i < 7; i++) { - perWeekday[i] = []; - } - - for (const oh of ohs) { - const source = new UIEventSource(oh) - source.addCallback(_ => { - self._ohs.setData(OH.MergeTimes(self._ohs.data)) - }) - const r = new OpeningHoursRange(source, `oh-table-${this._backgroundTable.id}`); - perWeekday[oh.weekday].push(r); - } - - for (let i = 0; i < 7; i++) { - self._weekdays.data[i] = new Combine(perWeekday[i]); - } - self._weekdays.ping(); - - }); - + this._backgroundTable = new OpeningHoursPickerTable(this._ohs); + this._backgroundTable.ConstructElement() } - InnerRender(): string { - return this._backgroundTable.Render(); + InnerRender(): BaseUIElement { + return this._backgroundTable; } GetValue(): UIEventSource { return this._ohs } - IsValid(t: OpeningHour[]): boolean { return true; } + protected InnerConstructElement(): HTMLElement { + return this._backgroundTable.ConstructElement(); + } + } \ No newline at end of file diff --git a/UI/OpeningHours/OpeningHoursPickerTable.ts b/UI/OpeningHours/OpeningHoursPickerTable.ts index 5192e7bfc..f9a430ce3 100644 --- a/UI/OpeningHours/OpeningHoursPickerTable.ts +++ b/UI/OpeningHours/OpeningHoursPickerTable.ts @@ -3,17 +3,18 @@ * It will genarate the currently selected opening hour. */ import {UIEventSource} from "../../Logic/UIEventSource"; -import {UIElement} from "../UIElement"; import {Utils} from "../../Utils"; import {OpeningHour} from "./OpeningHours"; import {InputElement} from "../Input/InputElement"; import Translations from "../i18n/Translations"; +import {Translation} from "../i18n/Translation"; +import {FixedUiElement} from "../Base/FixedUiElement"; +import {VariableUiElement} from "../Base/VariableUIElement"; +import Combine from "../Base/Combine"; +import OpeningHoursRange from "./OpeningHoursRange"; export default class OpeningHoursPickerTable extends InputElement { - public readonly IsSelected: UIEventSource; - private readonly weekdays: UIEventSource; - - public static readonly days: UIElement[] = + public static readonly days: Translation[] = [ Translations.t.general.weekdays.abbreviations.monday, Translations.t.general.weekdays.abbreviations.tuesday, @@ -23,53 +24,118 @@ export default class OpeningHoursPickerTable extends InputElement Translations.t.general.weekdays.abbreviations.saturday, Translations.t.general.weekdays.abbreviations.sunday ] - - + public readonly IsSelected: UIEventSource; private readonly source: UIEventSource; + + /* + These html-elements are an overlay over the table columns and act as a host for the ranges in the weekdays + */ + public readonly weekdayElements : HTMLElement[] = Utils.TimesT(7, () => document.createElement("div")) - - constructor(weekdays: UIEventSource, source?: UIEventSource) { - super(weekdays); - this.weekdays = weekdays; + constructor(source?: UIEventSource) { + super(); this.source = source ?? new UIEventSource([]); this.IsSelected = new UIEventSource(false); this.SetStyle("width:100%;height:100%;display:block;"); - } - InnerRender(): string { - let rows = ""; + IsValid(t: OpeningHour[]): boolean { + return true; + } + + GetValue(): UIEventSource { + return this.source; + } + + protected InnerConstructElement(): HTMLElement { + + const table = document.createElement("table") + table.classList.add("oh-table") + + const cellHeightInPx = 14; + + const headerRow = document.createElement("tr") + headerRow.appendChild(document.createElement("th")) + headerRow.classList.add("relative") + for (let i = 0; i < OpeningHoursPickerTable.days.length; i++) { + let weekday = OpeningHoursPickerTable.days[i].Clone(); + const cell = document.createElement("th") + cell.style.width = "14%" + cell.appendChild(weekday.ConstructElement()) + + const fullColumnSpan = this.weekdayElements[i] + fullColumnSpan.classList.add("w-full","relative") + + // We need to round! The table height is rounded as following, we use this to calculate the actual number of pixels afterwards + fullColumnSpan.style.height = ( (cellHeightInPx) * 48) + "px" + + + const ranges = new VariableUiElement( + this.source.map(ohs => ohs.filter((oh : OpeningHour) => oh.weekday === i)) + .map(ohsForToday => { + return new Combine(ohsForToday.map(oh => new OpeningHoursRange(oh, () =>{ + this.source.data.splice(this.source.data.indexOf(oh), 1) + this.source.ping() + }))) + }) + ) + fullColumnSpan.appendChild(ranges.ConstructElement()) + + + + + const fullColumnSpanWrapper = document.createElement("div") + fullColumnSpanWrapper.classList.add("absolute") + fullColumnSpanWrapper.style.zIndex = "10" + fullColumnSpanWrapper.style.width = "13.5%" + fullColumnSpanWrapper.style.pointerEvents = "none" + + fullColumnSpanWrapper.appendChild(fullColumnSpan) + + cell.appendChild(fullColumnSpanWrapper) + headerRow.appendChild(cell) + } + + table.appendChild(headerRow) + const self = this; for (let h = 0; h < 24; h++) { - let hs = "" + h; - if (hs.length == 1) { - hs = "0" + hs; + + const hs = Utils.TwoDigits(h); + const firstCell = document.createElement("td") + firstCell.rowSpan = 2 + firstCell.classList.add("oh-left-col", "oh-timecell-full", "border-box") + firstCell.appendChild(new FixedUiElement(hs + ":00").ConstructElement()) + + const evenRow = document.createElement("tr") + evenRow.appendChild(firstCell); + + for (let weekday = 0; weekday < 7; weekday++) { + const cell = document.createElement("td") + cell.classList.add("oh-timecell", "oh-timecell-full", `oh-timecell-${weekday}`) + evenRow.appendChild(cell) } + evenRow.style.height = (cellHeightInPx)+"px"; + evenRow.style.maxHeight = evenRow.style.height; + evenRow.style.minHeight = evenRow.style.height; + table.appendChild(evenRow) + const oddRow = document.createElement("tr") - rows += `${hs}:00` + - Utils.Times(weekday => ``, 7) + - '' + - Utils.Times(id => ``, 7) + - ''; - } - let days = OpeningHoursPickerTable.days.map((day, i) => { - const innerContent = self.weekdays.data[i]?.Render() ?? ""; - return day.Render() + ""+innerContent+""; - }).join(""); - return `${rows}
    ${days}
    `; - } + for (let weekday = 0; weekday < 7; weekday++) { + const cell = document.createElement("td") + cell.classList.add("oh-timecell", "oh-timecell-half", `oh-timecell-${weekday}`) + oddRow.appendChild(cell) + } + oddRow.style.minHeight = evenRow.style.height; + oddRow.style.maxHeight = evenRow.style.height; - protected InnerUpdate() { - const self = this; - const table = (document.getElementById(`oh-table-${this.id}`) as HTMLTableElement); - if (table === undefined || table === null) { - return; + table.appendChild(oddRow) } - for (const uielement of this.weekdays.data) { - uielement.Update(); - } + + /**** Event handling below ***/ + let mouseIsDown = false; let selectionStart: [number, number] = undefined; @@ -114,6 +180,7 @@ export default class OpeningHoursPickerTable extends InputElement oh.endMinutes = 0; } self.source.data.push(oh); + console.log("Created ", oh) } self.source.ping(); @@ -140,6 +207,7 @@ export default class OpeningHoursPickerTable extends InputElement }; let lastSelectionIend, lastSelectionJEnd; + function selectAllBetween(iEnd, jEnd) { if (lastSelectionIend === iEnd && lastSelectionJEnd === jEnd) { @@ -209,9 +277,9 @@ export default class OpeningHoursPickerTable extends InputElement jStart <= j + offset && j + offset <= jEnd) { cell?.classList?.add("oh-timecell-selected") } else { - cell?.classList?.remove("oh-timecell-selected") + cell?.classList?.remove("oh-timecell-selected") } - + } @@ -254,7 +322,7 @@ export default class OpeningHoursPickerTable extends InputElement ev.preventDefault(); for (const k in ev.targetTouches) { const touch = ev.targetTouches[k]; - if(touch.clientX === undefined || touch.clientY === undefined){ + if (touch.clientX === undefined || touch.clientY === undefined) { continue; } const elUnderTouch = document.elementFromPoint( @@ -278,15 +346,7 @@ export default class OpeningHoursPickerTable extends InputElement } - - } - - IsValid(t: OpeningHour[]): boolean { - return true; - } - - GetValue(): UIEventSource { - return this.source; + return table } } \ No newline at end of file diff --git a/UI/OpeningHours/OpeningHoursRange.ts b/UI/OpeningHours/OpeningHoursRange.ts index 5f67f8e4a..439aac490 100644 --- a/UI/OpeningHours/OpeningHoursRange.ts +++ b/UI/OpeningHours/OpeningHoursRange.ts @@ -1,102 +1,63 @@ /** * A single opening hours range, shown on top of the OH-picker table */ -import {UIEventSource} from "../../Logic/UIEventSource"; -import {UIElement} from "../UIElement"; -import {VariableUiElement} from "../Base/VariableUIElement"; import Svg from "../../Svg"; import {Utils} from "../../Utils"; import Combine from "../Base/Combine"; import {OH, OpeningHour} from "./OpeningHours"; +import BaseUIElement from "../BaseUIElement"; +import {FixedUiElement} from "../Base/FixedUiElement"; -export default class OpeningHoursRange extends UIElement { - private _oh: UIEventSource; +export default class OpeningHoursRange extends BaseUIElement { + private _oh: OpeningHour; - private readonly _startTime: UIElement; - private readonly _endTime: UIElement; - private readonly _deleteRange: UIElement; - private readonly _tableId: string; + private readonly _onDelete: () => void; - constructor(oh: UIEventSource, tableId: string) { - super(oh); - this._tableId = tableId; - const self = this; + constructor(oh: OpeningHour, onDelete: () => void) { + super(); this._oh = oh; + this._onDelete = onDelete; this.SetClass("oh-timerange"); - oh.addCallbackAndRun(() => { - const el = document.getElementById(this.id) as HTMLElement; - self.InnerUpdate(el); - }) - - this._deleteRange = - Svg.delete_icon_ui() - .SetClass("oh-delete-range") - .onClick(() => { - oh.data.weekday = undefined; - oh.ping(); - }); - - - this._startTime = new VariableUiElement(oh.map(oh => { - return Utils.TwoDigits(oh.startHour) + ":" + Utils.TwoDigits(oh.startMinutes); - })).SetClass("oh-timerange-label") - - this._endTime = new VariableUiElement(oh.map(oh => { - return Utils.TwoDigits(oh.endHour) + ":" + Utils.TwoDigits(oh.endMinutes); - })).SetClass("oh-timerange-label") - } - InnerRender(): string { - const oh = this._oh.data; - if (oh === undefined) { - return ""; - } + InnerConstructElement(): HTMLElement { const height = this.getHeight(); + const oh = this._oh; + const startTime = new FixedUiElement(Utils.TwoDigits(oh.startHour) + ":" + Utils.TwoDigits(oh.startMinutes)) + const endTime = new FixedUiElement(Utils.TwoDigits(oh.endHour) + ":" + Utils.TwoDigits(oh.endMinutes)) - let content = [this._deleteRange] + const deleteRange = + Svg.delete_icon_ui() + .SetClass("rounded-full w-6 h-6 block bg-black") + .onClick(() => { + this._onDelete() + }); + + + let content: BaseUIElement; if (height > 2) { - content = [this._startTime, this._deleteRange, this._endTime]; + content = new Combine([startTime, deleteRange, endTime]).SetClass("flex flex-col h-full").SetStyle("justify-content: space-between;"); + } else { + content = new Combine([deleteRange]).SetClass("flex flex-col h-full").SetStyle("flex-content: center; overflow-x: unset;") } - return new Combine(content) - .SetClass("oh-timerange-inner") - .Render(); + const el = new Combine([content]).ConstructElement(); + + el.style.top = `${100 * OH.startTime(oh) / 24}%` + el.style.height = `${100 * this.getHeight() / 24}%` + return el; } + private getHeight(): number { - const oh = this._oh.data; + const oh = this._oh; let endhour = oh.endHour; if (oh.endHour == 0 && oh.endMinutes == 0) { endhour = 24; } - const height = (endhour - oh.startHour + ((oh.endMinutes - oh.startMinutes) / 60)); - return height; - } - - protected InnerUpdate(el: HTMLElement) { - if (el == null) { - return; - } - const oh = this._oh.data; - if (oh === undefined) { - return; - } - - // The header cell containing monday, tuesday, ... - const table = document.getElementById(this._tableId) as HTMLTableElement; - - const bodyRect = document.body.getBoundingClientRect(); - const rangeStart = table.rows[1].cells[1].getBoundingClientRect().top - bodyRect.top; - const rangeEnd = table.rows[table.rows.length - 1].cells[1].getBoundingClientRect().bottom - bodyRect.top; - - const pixelsPerHour = (rangeEnd - rangeStart) / 24; - - el.style.top = (pixelsPerHour * OH.startTime(oh)) + "px"; - el.style.height = (pixelsPerHour * (OH.endTime(oh) - OH.startTime(oh))) + "px"; - + return (endhour - oh.startHour + ((oh.endMinutes - oh.startMinutes) / 60)); } diff --git a/UI/OpeningHours/OpeningHoursVisualization.ts b/UI/OpeningHours/OpeningHoursVisualization.ts new file mode 100644 index 000000000..73d3b08cc --- /dev/null +++ b/UI/OpeningHours/OpeningHoursVisualization.ts @@ -0,0 +1,292 @@ +import {UIEventSource} from "../../Logic/UIEventSource"; +import Combine from "../Base/Combine"; +import State from "../../State"; +import {FixedUiElement} from "../Base/FixedUiElement"; +import {OH} from "./OpeningHours"; +import Translations from "../i18n/Translations"; +import Constants from "../../Models/Constants"; +import opening_hours from "opening_hours"; +import BaseUIElement from "../BaseUIElement"; +import Toggle from "../Input/Toggle"; +import {VariableUiElement} from "../Base/VariableUIElement"; +import Table from "../Base/Table"; +import {Translation} from "../i18n/Translation"; +import {UIElement} from "../UIElement"; + +export default class OpeningHoursVisualization extends UIElement { + private static readonly weekdays: Translation[] = [ + Translations.t.general.weekdays.abbreviations.monday, + Translations.t.general.weekdays.abbreviations.tuesday, + Translations.t.general.weekdays.abbreviations.wednesday, + Translations.t.general.weekdays.abbreviations.thursday, + Translations.t.general.weekdays.abbreviations.friday, + Translations.t.general.weekdays.abbreviations.saturday, + Translations.t.general.weekdays.abbreviations.sunday, + ] + private readonly _tags: UIEventSource; + private readonly _key: string; + + constructor(tags: UIEventSource, key: string) { + super() + this._tags = tags; + this._key = key; + } + + InnerRender(): BaseUIElement { + const tags = this._tags; + const key = this._key; + const tagsDirect = tags.data; + const ohTable = new VariableUiElement(tags + .map(tags => tags[key]) // This mapping will absorb all other changes to tags in order to prevent regeneration + .map(ohtext => { + try { + // noinspection JSPotentiallyInvalidConstructorUsage + const oh = new opening_hours(ohtext, { + lat: tagsDirect._lat, + lon: tagsDirect._lon, + address: { + country_code: tagsDirect._country + } + }, {tag_key: this._key}); + + return OpeningHoursVisualization.CreateFullVisualisation(oh) + } catch (e) { + console.log(e); + return new Combine([Translations.t.general.opening_hours.error_loading, + new Toggle( + new FixedUiElement(e).SetClass("subtle"), + undefined, + State.state?.osmConnection?.userDetails.map(userdetails => userdetails.csCount >= Constants.userJourney.tagsVisibleAndWikiLinked) + ) + ]); + } + + } + )) + + return new Toggle( + ohTable, + Translations.t.general.loadingCountry.Clone(), + tags.map(tgs => tgs._country !== undefined) + ); + } + + private static CreateFullVisualisation(oh: any): BaseUIElement { + + /** First, we determine which range of dates we want to visualize: this week or next week?**/ + + const today = new Date(); + today.setHours(0, 0, 0, 0); + const lastMonday = OpeningHoursVisualization.getMonday(today); + const nextSunday = new Date(lastMonday); + nextSunday.setDate(nextSunday.getDate() + 7); + + if (!oh.getState() && !oh.getUnknown()) { + // POI is currently closed + const nextChange: Date = oh.getNextChange(); + if ( + // Shop isn't gonna open anymore in this timerange + nextSunday < nextChange + // And we are already in the weekend to show next week + && (today.getDay() == 0 || today.getDay() == 6) + ) { + // We move the range to next week! + lastMonday.setDate(lastMonday.getDate() + 7); + nextSunday.setDate(nextSunday.getDate() + 7); + } + } + + + /* We calculate the ranges when it is opened! */ + const ranges = OH.GetRanges(oh, lastMonday, nextSunday); + + /* First, a small sanity check. The business might be permanently closed, 24/7 opened or something other special + * So, we have to handle the case that ranges is completely empty*/ + if (ranges.filter(range => range.length > 0).length === 0) { + return OpeningHoursVisualization.ShowSpecialCase(oh).SetClass("p-4 rounded-full block bg-gray-200") + } + + /** With all the edge cases handled, we can actually construct the table! **/ + + return OpeningHoursVisualization.ConstructVizTable(oh, ranges, lastMonday) + + + } + + private static ConstructVizTable(oh: any, ranges: { isOpen: boolean; isSpecial: boolean; comment: string; startDate: Date; endDate: Date }[][], + rangeStart: Date): BaseUIElement { + + + const isWeekstable: boolean = oh.isWeekStable(); + let [changeHours, changeHourText] = OH.allChangeMoments(ranges); + const today = new Date(); + today.setHours(0, 0, 0, 0); + + // @ts-ignore + const todayIndex = Math.ceil((today - rangeStart) / (1000 * 60 * 60 * 24)) + // By default, we always show the range between 8 - 19h, in order to give a stable impression + // Ofc, a bigger range is used if needed + const earliestOpen = Math.min(8 * 60 * 60, ...changeHours); + let latestclose = Math.max(...changeHours); + // We always make sure there is 30m of leeway in order to give enough room for the closing entry + latestclose = Math.max(19 * 60 * 60, latestclose + 30 * 60) + const availableArea = latestclose - earliestOpen; + + /* + * The OH-visualisation is a table, consisting of 8 rows and 2 columns: + * The first row is a header row (which is NOT passed as header but just as a normal row!) containing empty for the first column and one object giving all the end times + * The other rows are one for each weekday: the first element showing 'mo', 'tu', ..., the second element containing the bars. + * Note that the bars are actually an embedded
    spanning the full width, containing multiple sub-elements + * */ + + const [header, headerHeight] = OpeningHoursVisualization.ConstructHeaderElement(availableArea, changeHours, changeHourText, earliestOpen) + + const weekdays = [] + const weekdayStyles = [] + for (let i = 0; i < 7; i++) { + + const day = OpeningHoursVisualization.weekdays[i].Clone(); + day.SetClass("w-full h-full block") + + const rangesForDay = ranges[i].map(range => + OpeningHoursVisualization.CreateRangeElem(availableArea, earliestOpen, latestclose, range, isWeekstable) + ) + const allRanges = new Combine([ + ...OpeningHoursVisualization.CreateLinesAtChangeHours(changeHours, availableArea, earliestOpen) , + ...rangesForDay]).SetClass("w-full block"); + + let extraStyle = "" + if (todayIndex == i) { + extraStyle = "background-color: var(--subtle-detail-color);" + allRanges.SetClass("ohviz-today") + } else if (i >= 5) { + extraStyle = "background-color: rgba(230, 231, 235, 1);" + } + weekdays.push([day, allRanges]) + weekdayStyles.push(["padding-left: 0.5em;" + extraStyle, `position: relative;` + extraStyle]) + } + return new Table(undefined, + [[" ", header], ...weekdays], + [["width: 5%", `position: relative; height: ${headerHeight}`], ...weekdayStyles] + ).SetClass("w-full") + .SetStyle("border-collapse: collapse; word-break; word-break: normal; word-wrap: normal") + + + } + + private static CreateRangeElem(availableArea: number, earliestOpen: number, latestclose: number, + range: { isOpen: boolean; isSpecial: boolean; comment: string; startDate: Date; endDate: Date }, + isWeekstable: boolean): BaseUIElement { + + const textToShow = range.comment ?? (isWeekstable ? "" : range.startDate.toLocaleDateString()); + + if (!range.isOpen && !range.isSpecial) { + return new FixedUiElement(textToShow).SetClass("ohviz-day-off") + } + + const startOfDay: Date = new Date(range.startDate); + startOfDay.setHours(0, 0, 0, 0); + // @ts-ignore + const startpoint = (range.startDate - startOfDay) / 1000 - earliestOpen; + // @ts-ignore + const width = (100 * (range.endDate - range.startDate) / 1000) / (latestclose - earliestOpen); + const startPercentage = (100 * startpoint / availableArea); + return new FixedUiElement(textToShow).SetStyle(`left:${startPercentage}%; width:${width}%`) + .SetClass("ohviz-range"); + } + + private static CreateLinesAtChangeHours(changeHours: number[], availableArea: number, earliestOpen: number): + BaseUIElement[] { + + const allLines: BaseUIElement[] = [] + for (const changeMoment of changeHours) { + const offset = 100 * (changeMoment - earliestOpen) / availableArea; + if (offset < 0 || offset > 100) { + continue; + } + const el = new FixedUiElement("").SetStyle(`left:${offset}%;`).SetClass("ohviz-line"); + allLines.push(el); + } + return allLines; + } + + + /** + * The OH-Visualization header element, a single bar with hours + * @param availableArea + * @param changeHours + * @param changeHourText + * @param earliestOpen + * @constructor + * @private + */ + private static ConstructHeaderElement(availableArea: number, changeHours: number[], changeHourText: string[], earliestOpen: number) + : [BaseUIElement, string] { + let header: BaseUIElement[] = []; + + header.push(...OpeningHoursVisualization.CreateLinesAtChangeHours(changeHours, availableArea, earliestOpen)) + + let showHigher = false; + let showHigherUsed = false; + for (let i = 0; i < changeHours.length; i++) { + let changeMoment = changeHours[i]; + const offset = 100 * (changeMoment - earliestOpen) / availableArea; + if (offset < 0 || offset > 100) { + continue; + } + + if (i > 0 && ((changeMoment - changeHours[i - 1]) / (60*60)) < 2) { + // Quite close to the previous value + // We alternate the heights + showHigherUsed = true; + showHigher = !showHigher; + } else { + showHigher = false; + } + + const el = new Combine([ + + new FixedUiElement(changeHourText[i]) + .SetClass("relative bg-white pl-1 pr-1 h-3 font-sm rounded-xl border-2 border-black border-opacity-50") + .SetStyle("left: -50%; word-break:initial") + + ]) + .SetStyle(`left:${offset}%;margin-top: ${showHigher ? '1.4rem;' : "0.1rem"}`) + .SetClass("block absolute top-0 m-0 h-full box-border ohviz-time-indication"); + header.push(el); + } + const headerElem = new Combine(header).SetClass(`w-full absolute block ${showHigherUsed ? "h-16" : "h-8"}`) + .SetStyle("margin-top: -1rem") + const headerHeight = showHigherUsed ? "4rem" : "2rem"; + return [headerElem, headerHeight] + + } + + /* + * Visualizes any special case: e.g. not open for a long time, 24/7 open, ... + * */ + private static ShowSpecialCase(oh: any) { + const opensAtDate = oh.getNextChange(); + if (opensAtDate === undefined) { + const comm = oh.getComment() ?? oh.getUnknown(); + if (!!comm) { + return new FixedUiElement(comm) + } + + if (oh.getState()) { + return Translations.t.general.opening_hours.open_24_7.Clone() + } + return Translations.t.general.opening_hours.closed_permanently.Clone() + } + const willOpenAt = `${opensAtDate.getDate()}/${opensAtDate.getMonth() + 1} ${OH.hhmm(opensAtDate.getHours(), opensAtDate.getMinutes())}` + return Translations.t.general.opening_hours.closed_until.Subs({date: willOpenAt}) + } + + private static getMonday(d) { + d = new Date(d); + const day = d.getDay(); + const diff = d.getDate() - day + (day == 0 ? -6 : 1); // adjust when day is sunday + return new Date(d.setDate(diff)); + } + +} \ No newline at end of file diff --git a/UI/OpeningHours/PublicHolidayInput.ts b/UI/OpeningHours/PublicHolidayInput.ts index bcdc3cef5..dd3c7b9f5 100644 --- a/UI/OpeningHours/PublicHolidayInput.ts +++ b/UI/OpeningHours/PublicHolidayInput.ts @@ -1,164 +1,23 @@ - import {OH} from "./OpeningHours"; import {UIEventSource} from "../../Logic/UIEventSource"; -import {UIElement} from "../UIElement"; import Combine from "../Base/Combine"; import {TextField} from "../Input/TextField"; import {DropDown} from "../Input/DropDown"; import {InputElement} from "../Input/InputElement"; import Translations from "../i18n/Translations"; +import Toggle from "../Input/Toggle"; export default class PublicHolidayInput extends InputElement { IsSelected: UIEventSource = new UIEventSource(false); private readonly _value: UIEventSource; - private readonly _dropdown: UIElement; - private readonly _mode: UIEventSource; - private readonly _startHour: UIElement; - private readonly _endHour: UIElement; constructor(value: UIEventSource = new UIEventSource("")) { super(); this._value = value; - - const dropdown = new DropDown( - Translations.t.general.opening_hours.open_during_ph, - [ - {shown: Translations.t.general.opening_hours.ph_not_known, value: ""}, - {shown: Translations.t.general.opening_hours.ph_closed, value: "off"}, - {shown:Translations.t.general.opening_hours.ph_open, value: " "} - ] - ); - this._dropdown = dropdown.SetStyle("display:inline-block;"); - this._mode = dropdown.GetValue(); - this.ListenTo(this._mode); - - const start = new TextField({ - placeholder: "starthour", - htmlType: "time" - }); - const end = new TextField({ - placeholder: "starthour", - htmlType: "time" - }); - this._startHour = start.SetStyle("display:inline-block;"); - this._endHour = end.SetStyle("display:inline-block;"); - const self = this; - - this._value.addCallbackAndRun(ph => { - if (ph === undefined) { - return; - } - const parsed = PublicHolidayInput.LoadValue(ph); - if (parsed === null) { - return; - } - - dropdown.GetValue().setData(parsed.mode); - if (parsed.start) { - start.GetValue().setData(parsed.start); - } - if (parsed.end) { - end.GetValue().setData(parsed.end); - } - - }) - - - function updateValue() { - const phStart = dropdown.GetValue().data; - if (phStart === undefined || phStart === "") { - // Unknown - self._value.setData(""); - return; - } - - if (phStart === " ") { - // THey are open, we need to include the start- and enddate - const startV = start.GetValue().data; - const endV = end.GetValue().data; - if (startV === undefined || endV === undefined) { - self._value.setData(`PH open`); - return; - } - - self._value.setData(`PH ${startV}-${endV}`); - return; - } - self._value.setData(`PH ${phStart}`); - } - - dropdown.GetValue().addCallbackAndRun(() => { - updateValue(); - }); - start.GetValue().addCallbackAndRun(() => { - updateValue(); - }); - end.GetValue().addCallbackAndRun(() => { - updateValue(); - }); - } - - public static LoadValue(str: string): { - mode: string, - start?: string, - end?: string - } { - str = str.trim(); - if (!str.startsWith("PH")) { - return null; - } - - str = str.trim(); - if (str === "PH off") { - return { - mode: "off" - } - } - - if(str === "PH open"){ - return { - mode: " " - } - } - - if (!str.startsWith("PH ")) { - return null; - } - try { - - const timerange = OH.parseHHMMRange(str.substring(2)); - if (timerange === null) { - return null; - } - - return { - mode: " ", - start: OH.hhmm(timerange.startHour, timerange.startMinutes), - end: OH.hhmm(timerange.endHour, timerange.endMinutes), - - } - } catch (e) { - return null; - } - } - - InnerRender(): string { - const mode = this._mode.data; - if (mode === " ") { - return new Combine([this._dropdown, - " ", - Translations.t.general.opening_hours.opensAt, - " ", - this._startHour, - " ", - Translations.t.general.opening_hours.openTill, - " ", - this._endHour]).Render(); - } - return this._dropdown.Render(); } + GetValue(): UIEventSource { return this._value; } @@ -166,5 +25,97 @@ export default class PublicHolidayInput extends InputElement { IsValid(t: string): boolean { return true; } + + private SetupDataSync(mode: UIEventSource, startTime: UIEventSource, endTime: UIEventSource) { + + const value = this._value; + value.addCallbackAndRun(ph => { + if (ph === undefined) { + return; + } + const parsed = OH.ParsePHRule(ph); + if (parsed === null) { + return; + } + mode.setData(parsed.mode) + startTime.setData(parsed.start) + endTime.setData(parsed.end) + }) + + // We use this as a 'addCallbackAndRun' + mode.map(mode => { + if (mode === undefined || mode === "") { + // not known + value.setData(undefined) + return + } + if (mode === "off") { + value.setData("PH off"); + return; + } + if (mode === "open") { + value.setData("PH open"); + return; + } + + + // Open during PH with special hours + if (startTime.data === undefined || endTime.data === undefined) { + // hours not filled in - not saveable + value.setData(undefined) + return + } + const oh = `PH ${startTime.data}-${endTime.data}` + value.setData(oh) + + + }, [startTime, endTime] + ) + } + + + protected InnerConstructElement(): HTMLElement { + const dropdown = new DropDown( + Translations.t.general.opening_hours.open_during_ph.Clone(), + [ + {shown: Translations.t.general.opening_hours.ph_not_known.Clone(), value: ""}, + {shown: Translations.t.general.opening_hours.ph_closed.Clone(), value: "off"}, + {shown:Translations.t.general.opening_hours.ph_open_as_usual.Clone(), value: "open"}, + {shown: Translations.t.general.opening_hours.ph_open.Clone(), value: " "}, + ] + ).SetClass("inline-block"); + /* + * Either "" (unknown), " " (opened) or "off" (closed) + * */ + const mode = dropdown.GetValue(); + + + const start = new TextField({ + placeholder: "starthour", + htmlType: "time" + }).SetClass("inline-block"); + const end = new TextField({ + placeholder: "starthour", + htmlType: "time" + }).SetClass("inline-block"); + + const askHours = new Toggle( + new Combine([ + Translations.t.general.opening_hours.opensAt.Clone(), + start, + Translations.t.general.opening_hours.openTill.Clone(), + end + ]), + undefined, + mode.map(mode => mode === " ") + ) + + this.SetupDataSync(mode, start.GetValue(), end.GetValue()) + + return new Combine([ + dropdown, + askHours + ]).ConstructElement() + } } \ No newline at end of file diff --git a/UI/Popup/EditableTagRendering.ts b/UI/Popup/EditableTagRendering.ts index 0019910a5..85952d9a6 100644 --- a/UI/Popup/EditableTagRendering.ts +++ b/UI/Popup/EditableTagRendering.ts @@ -1,4 +1,3 @@ -import {UIElement} from "../UIElement"; import {UIEventSource} from "../../Logic/UIEventSource"; import TagRenderingConfig from "../../Customizations/JSON/TagRenderingConfig"; import TagRenderingQuestion from "./TagRenderingQuestion"; @@ -7,80 +6,66 @@ import Combine from "../Base/Combine"; import TagRenderingAnswer from "./TagRenderingAnswer"; import State from "../../State"; import Svg from "../../Svg"; +import Toggle from "../Input/Toggle"; +import BaseUIElement from "../BaseUIElement"; -export default class EditableTagRendering extends UIElement { - private readonly _tags: UIEventSource; - private readonly _configuration: TagRenderingConfig; - - private _editMode: UIEventSource = new UIEventSource(false); - private _editButton: UIElement; - - private _question: UIElement; - private _answer: UIElement; +export default class EditableTagRendering extends Toggle { constructor(tags: UIEventSource, - configuration: TagRenderingConfig) { - super(tags); - this._tags = tags; - this._configuration = configuration; + configuration: TagRenderingConfig, + editMode = new UIEventSource(false) + ) { + const answer: BaseUIElement = new TagRenderingAnswer(tags, configuration) + answer.SetClass("w-full") + let rendering = answer; - this.ListenTo(this._editMode); - this.ListenTo(State.state?.osmConnection?.userDetails) + if (configuration.question !== undefined && State.state?.featureSwitchUserbadge?.data) { + // We have a question and editing is enabled + const editButton = + new Combine([Svg.pencil_ui()]).SetClass("block relative h-10 w-10 p-2 float-right").SetStyle("border: 1px solid black; border-radius: 0.7em") + .onClick(() => { + editMode.setData(true); + }); - this._answer = new TagRenderingAnswer(tags, configuration); - this._answer.SetClass("w-full") - this._question = this.GenerateQuestion(); - this.dumbMode = false; - if (this._configuration.question !== undefined) { - if (State.state?.featureSwitchUserbadge?.data) { - // 2.3em total width - const self = this; - this._editButton = - Svg.pencil_svg().SetClass("edit-button") - .onClick(() => { - self._editMode.setData(true); - }); - } - } - } + + const answerWithEditButton = new Combine([answer, + new Toggle(editButton, undefined, State.state.osmConnection.isLoggedIn)]) + .SetClass("flex justify-between w-full") - InnerRender(): string { - if (!this._configuration?.condition?.matchesProperties(this._tags.data)) { - return ""; - } - if (this._editMode.data) { - return this._question.Render(); - } - if(!this._configuration.IsKnown(this._tags.data)){ - // Even though it is not known, we hide the question here - // It is the questionbox's task to show the question in edit mode - return ""; - } - return new Combine([this._answer, - (State.state?.osmConnection?.userDetails?.data?.loggedIn ?? true) ? this._editButton : undefined - ]).SetClass("flex w-full break-word justify-between text-default landscape:w-1/2 landscape:p-2 pb-2 border-b border-gray-300 mb-2") - .Render(); - } - - private GenerateQuestion() { - const self = this; - if (this._configuration.question !== undefined) { - // And at last, set up the skip button const cancelbutton = Translations.t.general.cancel.Clone() .SetClass("btn btn-secondary mr-3") .onClick(() => { - self._editMode.setData(false) + editMode.setData(false) }); - return new TagRenderingQuestion(this._tags, this._configuration, + const question = new TagRenderingQuestion(tags, configuration, () => { - self._editMode.setData(false) + editMode.setData(false) }, cancelbutton) + + + rendering = new Toggle( + question, + answerWithEditButton, + editMode + ) } + rendering.SetClass("block w-full break-word text-default m-1 p-1 border-b border-gray-200 mb-2 pb-2") + // The tagrendering is hidden if: + // The answer is unknown. The questionbox will then show the question + // There is a condition hiding the answer + const renderingIsShown = tags.map(tags => + configuration.IsKnown(tags) && + (configuration?.condition?.matchesProperties(tags) ?? true)) + super( + rendering, + undefined, + renderingIsShown + ) } } \ No newline at end of file diff --git a/UI/Popup/FeatureInfoBox.ts b/UI/Popup/FeatureInfoBox.ts index c3f249c0b..7f375d298 100644 --- a/UI/Popup/FeatureInfoBox.ts +++ b/UI/Popup/FeatureInfoBox.ts @@ -11,10 +11,11 @@ import ScrollableFullScreen from "../Base/ScrollableFullScreen"; import {Tag} from "../../Logic/Tags/Tag"; import Constants from "../../Models/Constants"; import SharedTagRenderings from "../../Customizations/SharedTagRenderings"; +import BaseUIElement from "../BaseUIElement"; export default class FeatureInfoBox extends ScrollableFullScreen { - private constructor( + public constructor( tags: UIEventSource, layerConfig: LayerConfig ) { @@ -28,18 +29,13 @@ export default class FeatureInfoBox extends ScrollableFullScreen { } - static construct(tags: UIEventSource, layer: LayerConfig): FeatureInfoBox { - return new FeatureInfoBox(tags, layer) - } - private static GenerateTitleBar(tags: UIEventSource, - layerConfig: LayerConfig): UIElement { + layerConfig: LayerConfig): BaseUIElement { const title = new TagRenderingAnswer(tags, layerConfig.title ?? new TagRenderingConfig("POI", undefined)) .SetClass("break-words font-bold sm:p-0.5 md:p-1 sm:p-1.5 md:p-2"); const titleIcons = new Combine( layerConfig.titleIcons.map(icon => new TagRenderingAnswer(tags, icon, "block w-8 h-8 align-baseline box-content sm:p-0.5", "width: 2rem !important;") - .HideOnEmpty(true) )) .SetClass("flex flex-row flex-wrap pt-0.5 sm:pt-1 items-center mr-2") @@ -49,7 +45,7 @@ export default class FeatureInfoBox extends ScrollableFullScreen { } private static GenerateContent(tags: UIEventSource, - layerConfig: LayerConfig): UIElement { + layerConfig: LayerConfig): BaseUIElement { let questionBox: UIElement = undefined; if (State.state.featureSwitchUserbadge.data) { @@ -57,7 +53,7 @@ export default class FeatureInfoBox extends ScrollableFullScreen { } let questionBoxIsUsed = false; - const renderings = layerConfig.tagRenderings.map(tr => { + const renderings : BaseUIElement[] = layerConfig.tagRenderings.map(tr => { if (tr.question === null) { // This is the question box! questionBoxIsUsed = true; diff --git a/UI/Popup/QuestionBox.ts b/UI/Popup/QuestionBox.ts index 97d2d0fb9..76e8f8ed3 100644 --- a/UI/Popup/QuestionBox.ts +++ b/UI/Popup/QuestionBox.ts @@ -48,7 +48,7 @@ export default class QuestionBox extends UIElement { this.SetClass("block mb-8") } - InnerRender(): string { + InnerRender() { const allQuestions : UIElement[] = [] for (let i = 0; i < this._tagRenderingQuestions.length; i++) { let tagRendering = this._tagRenderings[i]; @@ -72,7 +72,7 @@ export default class QuestionBox extends UIElement { } - return new Combine(allQuestions).Render(); + return new Combine(allQuestions); } } \ No newline at end of file diff --git a/UI/Popup/SaveButton.ts b/UI/Popup/SaveButton.ts index 2c277c7fc..85c7b3076 100644 --- a/UI/Popup/SaveButton.ts +++ b/UI/Popup/SaveButton.ts @@ -1,35 +1,36 @@ import {UIEventSource} from "../../Logic/UIEventSource"; -import {UIElement} from "../UIElement"; import Translations from "../i18n/Translations"; -import UserDetails, {OsmConnection} from "../../Logic/Osm/OsmConnection"; +import {OsmConnection} from "../../Logic/Osm/OsmConnection"; +import Toggle from "../Input/Toggle"; -export class SaveButton extends UIElement { - - private readonly _value: UIEventSource; - private readonly _friendlyLogin: UIElement; - private readonly _userDetails: UIEventSource; +export class SaveButton extends Toggle { constructor(value: UIEventSource, osmConnection: OsmConnection) { - super(value); - this._userDetails = osmConnection?.userDetails; - if(value === undefined){ + if (value === undefined) { throw "No event source for savebutton, something is wrong" } - this._value = value; - this._friendlyLogin = Translations.t.general.loginToStart.Clone() + + const pleaseLogin = Translations.t.general.loginToStart.Clone() .SetClass("login-button-friendly") .onClick(() => osmConnection?.AttemptLogin()) - } - InnerRender(): string { - if(this._userDetails != undefined && !this._userDetails.data.loggedIn){ - return this._friendlyLogin.Render(); - } - let inactive_class = '' - if (this._value.data === false || (this._value.data ?? "") === "") { - inactive_class = "btn-disabled"; - } - return Translations.t.general.save.Clone().SetClass(`btn ${inactive_class}`).Render(); + + const isSaveable = value.map(v => v !== false && (v ?? "") !== "") + + + const saveEnabled = Translations.t.general.save.Clone().SetClass(`btn`); + const saveDisabled = Translations.t.general.save.Clone().SetClass(`btn btn-disabled`); + const save = new Toggle( + saveEnabled, + saveDisabled, + isSaveable + ) + super( + save, + pleaseLogin, + osmConnection?.userDetails?.map(userDetails => userDetails.loggedIn) ?? new UIEventSource(false) + ) + } } \ No newline at end of file diff --git a/UI/Popup/TagRenderingAnswer.ts b/UI/Popup/TagRenderingAnswer.ts index 43966d4a4..6c8fd257e 100644 --- a/UI/Popup/TagRenderingAnswer.ts +++ b/UI/Popup/TagRenderingAnswer.ts @@ -1,97 +1,47 @@ import {UIEventSource} from "../../Logic/UIEventSource"; import TagRenderingConfig from "../../Customizations/JSON/TagRenderingConfig"; -import {UIElement} from "../UIElement"; import {Utils} from "../../Utils"; -import Combine from "../Base/Combine"; +import BaseUIElement from "../BaseUIElement"; +import {VariableUiElement} from "../Base/VariableUIElement"; +import List from "../Base/List"; import {SubstitutedTranslation} from "../SubstitutedTranslation"; -import {Translation} from "../i18n/Translation"; -import {TagUtils} from "../../Logic/Tags/TagUtils"; /*** * Displays the correct value for a known tagrendering */ -export default class TagRenderingAnswer extends UIElement { - private readonly _tags: UIEventSource; - private _configuration: TagRenderingConfig; - private _content: UIElement; - private readonly _contentClass: string; - private _contentStyle: string; +export default class TagRenderingAnswer extends VariableUiElement { - constructor(tags: UIEventSource, configuration: TagRenderingConfig, contentClasses: string = "", contentStyle: string = "") { - super(tags); - this._tags = tags; - this._configuration = configuration; - this._contentClass = contentClasses; - this._contentStyle = contentStyle; + constructor(tagsSource: UIEventSource, configuration: TagRenderingConfig, contentClasses: string = "", contentStyle: string = "") { if (configuration === undefined) { throw "Trying to generate a tagRenderingAnswer without configuration..." } - this.SetClass("flex items-center flex-row text-lg link-underline") + super(tagsSource.map(tags => { + if(tags === undefined){ + return undefined; + } + + if(configuration.condition){ + if(!configuration.condition.matchesProperties(tags)){ + return undefined; + } + } + + const trs = Utils.NoNull(configuration.GetRenderValues(tags)); + if(trs.length === 0){ + return undefined; + } + + const valuesToRender: BaseUIElement[] = trs.map(tr => new SubstitutedTranslation(tr, tagsSource)) + if(valuesToRender.length === 1){ + return valuesToRender[0]; + }else if(valuesToRender.length > 1){ + return new List(valuesToRender) + } + return undefined; + }).map((element : BaseUIElement) => element?.SetClass(contentClasses)?.SetStyle(contentStyle))) + + this.SetClass("flex items-center flex-row text-lg link-underline tag-renering-answer") this.SetStyle("word-wrap: anywhere;"); } - InnerRender(): string { - if (this._configuration.condition !== undefined) { - if (!this._configuration.condition.matchesProperties(this._tags.data)) { - return ""; - } - } - - const tags = this._tags.data; - if (tags === undefined) { - return ""; - } - - // The render value doesn't work well with multi-answers (checkboxes), so we have to check for them manually - if (this._configuration.multiAnswer) { - - let freeformKeyUsed = this._configuration.freeform?.key === undefined; // If it is undefined, it is "used" already, or at least we don't have to check for it anymore - const applicableThens: Translation[] = Utils.NoNull(this._configuration.mappings?.map(mapping => { - if (mapping.if === undefined) { - return mapping.then; - } - if (TagUtils.MatchesMultiAnswer(mapping.if, tags)) { - if(!freeformKeyUsed){ - if(mapping.if.usedKeys().indexOf(this._configuration.freeform.key) >= 0){ - freeformKeyUsed = true; - } - } - return mapping.then; - } - return undefined; - }) ?? []) - - if (!freeformKeyUsed - && tags[this._configuration.freeform.key] !== undefined) { - applicableThens.push(this._configuration.render) - } - - const self = this - const valuesToRender: UIElement[] = applicableThens.map(tr => SubstitutedTranslation.construct(tr, self._tags)) - - if (valuesToRender.length >= 0) { - if (valuesToRender.length === 1) { - this._content = valuesToRender[0]; - } else { - this._content = new Combine(["
      ", - ...valuesToRender.map(tr => new Combine(["
    • ", tr, "
    • "])) - , - "
    " - ]) - - } - return this._content.SetClass(this._contentClass).SetStyle(this._contentStyle).Render(); - } - } - - const tr = this._configuration.GetRenderValue(tags); - if (tr !== undefined) { - this._content = SubstitutedTranslation.construct(tr, this._tags); - return this._content.SetClass(this._contentClass).SetStyle(this._contentStyle).Render(); - } - - return ""; - - } - } \ No newline at end of file diff --git a/UI/Popup/TagRenderingQuestion.ts b/UI/Popup/TagRenderingQuestion.ts index d4c1ee104..2b6dd7bc4 100644 --- a/UI/Popup/TagRenderingQuestion.ts +++ b/UI/Popup/TagRenderingQuestion.ts @@ -22,6 +22,8 @@ import {TagsFilter} from "../../Logic/Tags/TagsFilter"; import {Tag} from "../../Logic/Tags/Tag"; import {And} from "../../Logic/Tags/And"; import {TagUtils} from "../../Logic/Tags/TagUtils"; +import BaseUIElement from "../BaseUIElement"; +import {DropDown} from "../Input/DropDown"; /** * Shows the question element. @@ -31,23 +33,23 @@ export default class TagRenderingQuestion extends UIElement { private readonly _tags: UIEventSource; private _configuration: TagRenderingConfig; - private _saveButton: UIElement; + private _saveButton: BaseUIElement; private _inputElement: InputElement; - private _cancelButton: UIElement; - private _appliedTags: UIElement; - private _question: UIElement; + private _cancelButton: BaseUIElement; + private _appliedTags: BaseUIElement; + private _question: BaseUIElement; constructor(tags: UIEventSource, configuration: TagRenderingConfig, afterSave?: () => void, - cancelButton?: UIElement + cancelButton?: BaseUIElement ) { super(tags); this._tags = tags; this._configuration = configuration; this._cancelButton = cancelButton; - this._question = SubstitutedTranslation.construct(this._configuration.question, tags) + this._question = new SubstitutedTranslation(this._configuration.question, tags) .SetClass("question-text"); if (configuration === undefined) { throw "A question is needed for a question visualization" @@ -70,7 +72,8 @@ export default class TagRenderingQuestion extends UIElement { } - this._saveButton = new SaveButton(this._inputElement.GetValue(), State.state?.osmConnection) + this._saveButton = new SaveButton(this._inputElement.GetValue(), + State.state?.osmConnection) .onClick(save) @@ -82,19 +85,21 @@ export default class TagRenderingQuestion extends UIElement { return ""; } if (tags === undefined) { - return Translations.t.general.noTagsSelected.SetClass("subtle").Render(); + return Translations.t.general.noTagsSelected.SetClass("subtle"); } if (csCount < Constants.userJourney.tagsVisibleAndWikiLinked) { const tagsStr = tags.asHumanString(false, true, self._tags.data); - return new FixedUiElement(tagsStr).SetClass("subtle").Render(); + return new FixedUiElement(tagsStr).SetClass("subtle"); } return tags.asHumanString(true, true, self._tags.data); } ) - ).SetClass("block") + ).SetClass("block break-all") + + } - InnerRender(): string { + InnerRender() { return new Combine([ this._question, this._inputElement, @@ -103,28 +108,61 @@ export default class TagRenderingQuestion extends UIElement { this._appliedTags] ) .SetClass("question") - .Render() } private GenerateInputElement(): InputElement { - const ff = this.GenerateFreeform(); const self = this; - let mappings = - (this._configuration.mappings ?? []).map(mapping => self.GenerateMappingElement(mapping)); - mappings = Utils.NoNull(mappings); + let inputEls: InputElement[]; - if (mappings.length == 0) { + const mappings = (this._configuration.mappings??[]) + .filter( mapping => { + if(mapping.hideInAnswer === true){ + return false; + } + if (typeof (mapping.hideInAnswer) !== "boolean" && mapping.hideInAnswer.matchesProperties(this._tags.data)) { + return false; + } + return true; + }) + + + let allIfNots: TagsFilter[] = Utils.NoNull(this._configuration.mappings?.map(m => m.ifnot) ?? [] ); + const ff = this.GenerateFreeform(); + const hasImages = mappings.filter(mapping => mapping.then.ExtractImages().length > 0).length > 0 + + if (mappings.length < 8 || this._configuration.multiAnswer || hasImages) { + inputEls = (mappings ?? []).map(mapping => self.GenerateMappingElement(mapping, allIfNots)); + inputEls = Utils.NoNull(inputEls); + } else { + const dropdown: InputElement = new DropDown("", + mappings.map(mapping => { + return { + value: new And([mapping.if, ...allIfNots]), + shown: Translations.WT(mapping.then).Clone() + } + }) + ) + + if (ff == undefined) { + return dropdown; + } else { + inputEls = [dropdown] + } + } + + + if (inputEls.length == 0) { return ff; } if (ff) { - mappings.push(ff); + inputEls.push(ff); } if (this._configuration.multiAnswer) { - return this.GenerateMultiAnswer(mappings, ff, this._configuration.mappings.map(mp => mp.ifnot)) + return this.GenerateMultiAnswer(inputEls, ff, this._configuration.mappings.map(mp => mp.ifnot)) } else { - return new RadioButton(mappings, false) + return new RadioButton(inputEls, false) } } @@ -153,7 +191,9 @@ export default class TagRenderingQuestion extends UIElement { oppositeTags.push(notSelected); } tags.push(TagUtils.FlattenMultiAnswer(oppositeTags)); - return TagUtils.FlattenMultiAnswer(tags); + const actualTags = TagUtils.FlattenMultiAnswer(tags); + console.log("Converted ", indices.join(","), "into", actualTags.asHumanString(false, false, {}), "with elems", elements) + return actualTags; }, (tags: TagsFilter) => { // {key --> values[]} @@ -231,16 +271,16 @@ export default class TagRenderingQuestion extends UIElement { if: TagsFilter, then: Translation, hideInAnswer: boolean | TagsFilter - }): InputElement { - if (mapping.hideInAnswer === true) { - return undefined; - } - if (typeof (mapping.hideInAnswer) !== "boolean" && mapping.hideInAnswer.matchesProperties(this._tags.data)) { - return undefined; + }, ifNot?: TagsFilter[]): InputElement { + + let tagging = mapping.if; + if (ifNot.length > 0) { + tagging = new And([tagging, ...ifNot]) } + return new FixedInputElement( - SubstitutedTranslation.construct(mapping.then, this._tags), - mapping.if, + new SubstitutedTranslation(mapping.then, this._tags), + tagging, (t0, t1) => t1.isEquivalent(t0)); } diff --git a/UI/Reviews/ReviewElement.ts b/UI/Reviews/ReviewElement.ts index 9f123a22b..38b9f226a 100644 --- a/UI/Reviews/ReviewElement.ts +++ b/UI/Reviews/ReviewElement.ts @@ -8,13 +8,14 @@ import {UIElement} from "../UIElement"; import Combine from "../Base/Combine"; import Translations from "../i18n/Translations"; import SingleReview from "./SingleReview"; +import BaseUIElement from "../BaseUIElement"; export default class ReviewElement extends UIElement { private readonly _reviews: UIEventSource; private readonly _subject: string; - private readonly _middleElement: UIElement; + private readonly _middleElement: BaseUIElement; - constructor(subject: string, reviews: UIEventSource, middleElement: UIElement) { + constructor(subject: string, reviews: UIEventSource, middleElement: BaseUIElement) { super(reviews); this._middleElement = middleElement; if (reviews === undefined) { @@ -26,7 +27,7 @@ export default class ReviewElement extends UIElement { - InnerRender(): string { + InnerRender(): BaseUIElement { const elements = []; const revs = this._reviews.data; @@ -56,7 +57,7 @@ export default class ReviewElement extends UIElement { .SetClass("review-attribution")) - return new Combine(elements).SetClass("block").Render(); + return new Combine(elements).SetClass("block"); } } \ No newline at end of file diff --git a/UI/Reviews/ReviewForm.ts b/UI/Reviews/ReviewForm.ts index 29cc9680e..1f8f743d5 100644 --- a/UI/Reviews/ReviewForm.ts +++ b/UI/Reviews/ReviewForm.ts @@ -1,4 +1,3 @@ -import {UIElement} from "../UIElement"; import {InputElement} from "../Input/InputElement"; import {Review} from "../../Logic/Web/Review"; import {UIEventSource} from "../../Logic/UIEventSource"; @@ -9,28 +8,31 @@ import Svg from "../../Svg"; import {VariableUiElement} from "../Base/VariableUIElement"; import {SaveButton} from "../Popup/SaveButton"; import CheckBoxes from "../Input/Checkboxes"; -import UserDetails from "../../Logic/Osm/OsmConnection"; +import UserDetails, {OsmConnection} from "../../Logic/Osm/OsmConnection"; +import BaseUIElement from "../BaseUIElement"; +import Toggle from "../Input/Toggle"; +import State from "../../State"; export default class ReviewForm extends InputElement { private readonly _value: UIEventSource; - private readonly _comment: UIElement; - private readonly _stars: UIElement; - private _saveButton: UIElement; - private readonly _isAffiliated: UIElement; - private userDetails: UIEventSource; - private readonly _postingAs: UIElement; + private readonly _comment: BaseUIElement; + private readonly _stars: BaseUIElement; + private _saveButton: BaseUIElement; + private readonly _isAffiliated: BaseUIElement; + private readonly _postingAs: BaseUIElement; + private readonly _osmConnection: OsmConnection; - constructor(onSave: ((r: Review, doneSaving: (() => void)) => void), userDetails: UIEventSource) { + constructor(onSave: ((r: Review, doneSaving: (() => void)) => void), osmConnection: OsmConnection) { super(); - this.userDetails = userDetails; + this._osmConnection = osmConnection; const t = Translations.t.reviews; this._value = new UIEventSource({ made_by_user: new UIEventSource(true), rating: undefined, comment: undefined, - author: userDetails.data.name, + author: osmConnection.userDetails.data.name, affiliated: false, date: new Date() }); @@ -47,7 +49,7 @@ export default class ReviewForm extends InputElement { const self = this; this._postingAs = - new Combine([t.posting_as, new VariableUiElement(userDetails.map((ud: UserDetails) => ud.name)).SetClass("review-author")]) + new Combine([t.posting_as, new VariableUiElement(osmConnection.userDetails.map((ud: UserDetails) => ud.name)).SetClass("review-author")]) .SetStyle("display:flex;flex-direction: column;align-items: flex-end;margin-left: auto;") this._saveButton = new SaveButton(this._value.map(r => self.IsValid(r)), undefined) @@ -86,13 +88,9 @@ export default class ReviewForm extends InputElement { return this._value; } - InnerRender(): string { + InnerConstructElement(): HTMLElement { - if(!this.userDetails.data.loggedIn){ - return Translations.t.reviews.plz_login.Render(); - } - - return new Combine([ + const form = new Combine([ new Combine([this._stars, this._postingAs]).SetClass("review-form-top"), this._comment, new Combine([ @@ -103,7 +101,13 @@ export default class ReviewForm extends InputElement { Translations.t.reviews.tos.SetClass("subtle") ]) .SetClass("review-form") - .Render(); + + const connection = this._osmConnection; + const login = Translations.t.reviews.plz_login.Clone().onClick(() => connection.AttemptLogin()) + + return new Toggle(form,login , + connection.isLoggedIn) + .ConstructElement() } IsSelected: UIEventSource = new UIEventSource(false); diff --git a/UI/Reviews/SingleReview.ts b/UI/Reviews/SingleReview.ts index 9a9edd819..a595ba7f9 100644 --- a/UI/Reviews/SingleReview.ts +++ b/UI/Reviews/SingleReview.ts @@ -5,6 +5,7 @@ import {FixedUiElement} from "../Base/FixedUiElement"; import Translations from "../i18n/Translations"; import {Utils} from "../../Utils"; import ReviewElement from "./ReviewElement"; +import BaseUIElement from "../BaseUIElement"; export default class SingleReview extends UIElement{ private _review: Review; @@ -13,7 +14,7 @@ export default class SingleReview extends UIElement{ this._review = review; } - public static GenStars(rating: number): UIElement { + public static GenStars(rating: number): BaseUIElement { if (rating === undefined) { return Translations.t.reviews.no_rating; } @@ -26,7 +27,7 @@ export default class SingleReview extends UIElement{ scoreTen % 2 == 1 ? "" : "" ]).SetClass("flex w-max") } - InnerRender(): string { + InnerRender(): BaseUIElement { const d = this._review.date; let review = this._review; const el= new Combine( @@ -51,7 +52,7 @@ export default class SingleReview extends UIElement{ if(review.made_by_user.data){ el.SetClass("border-attention-catch") } - return el.Render(); + return el; } } \ No newline at end of file diff --git a/UI/ShowDataLayer.ts b/UI/ShowDataLayer.ts index 2eb7437f4..509ca2f5e 100644 --- a/UI/ShowDataLayer.ts +++ b/UI/ShowDataLayer.ts @@ -14,8 +14,7 @@ export default class ShowDataLayer { private _layerDict; private readonly _leafletMap: UIEventSource; - - private readonly _popups = new Map(); + private _cleanCount = 0; constructor(features: UIEventSource<{ feature: any, freshness: Date }[]>, leafletMap: UIEventSource, @@ -44,6 +43,7 @@ export default class ShowDataLayer { return; } + self._cleanCount++ // clean all the old stuff away, if any if (geoLayer !== undefined) { mp.removeLayer(geoLayer); @@ -74,35 +74,6 @@ export default class ShowDataLayer { features.addCallback(() => update()); leafletMap.addCallback(() => update()); update(); - - - State.state.selectedElement.addCallbackAndRun(selected => { - if (selected === undefined) { - mp.closePopup(); - return; - } - const marker = self._popups.get(selected); - if (marker === undefined) { - return; - } - marker.openPopup(); - - const popup = marker.getPopup(); - const tags = State.state.allElements.getEventSourceById(selected.properties.id); - const layer: LayerConfig = this._layerDict[selected._matching_layer_id]; - const infoBox = FeatureInfoBox.construct(tags, layer); - - infoBox.isShown.addCallback(isShown => { - if (!isShown) { - State.state.selectedElement.setData(undefined); - } - }); - - popup.setContent(infoBox.Render()); - infoBox.Activate(); - infoBox.Update(); - }) - } @@ -128,7 +99,7 @@ export default class ShowDataLayer { const style = layer.GenerateLeafletStyle(tagSource, !(layer.title === undefined && (layer.tagRenderings ?? []).length === 0)); return L.marker(latLng, { icon: L.divIcon({ - html: style.icon.html.Render(), + html: style.icon.html.ConstructElement(), className: style.icon.className, iconAnchor: style.icon.iconAnchor, iconUrl: style.icon.iconUrl, @@ -138,7 +109,6 @@ export default class ShowDataLayer { }); } - private postProcessFeature(feature, leafletLayer: L.Layer) { const layer: LayerConfig = this._layerDict[feature._matching_layer_id]; if (layer === undefined) { @@ -155,15 +125,49 @@ export default class ShowDataLayer { closeButton: false }, leafletLayer); - // By setting 50vh, leaflet will attempt to fit the entire screen and move the feature down - popup.setContent("
    Rendering
    "); - leafletLayer.bindPopup(popup); + + let infobox: FeatureInfoBox = undefined; + + const id = `popup-${feature.properties.id}-${this._cleanCount}` + popup.setContent(`
    Rendering
    `) + leafletLayer.on("popupopen", () => { State.state.selectedElement.setData(feature) + if (infobox === undefined) { + const tags = State.state.allElements.getEventSourceById(feature.properties.id); + infobox = new FeatureInfoBox(tags, layer); + + infobox.isShown.addCallback(isShown => { + if (!isShown) { + State.state.selectedElement.setData(undefined); + leafletLayer.closePopup() + } + }); + } + + + infobox.AttachTo(id) + infobox.Activate(); }); - - this._popups.set(feature, leafletLayer); + const self = this; + State.state.selectedElement.addCallbackAndRun(selected => { + if (selected === undefined || self._leafletMap.data === undefined) { + return; + } + if (leafletLayer.getPopup().isOpen()) { + return; + } + if (selected.properties.id === feature.properties.id) { + // A small sanity check to prevent infinite loops: + // If a feature is rendered both as way and as point, opening one popup might trigger the other to open, which might trigger the one to open again + if(selected.geometry.type === feature.geometry.type){ + leafletLayer.openPopup() + } + + } + }) + } private CreateGeojsonLayer(): L.Layer { diff --git a/UI/SpecialVisualizations.ts b/UI/SpecialVisualizations.ts index 0307fa7d1..86766c5b4 100644 --- a/UI/SpecialVisualizations.ts +++ b/UI/SpecialVisualizations.ts @@ -1,4 +1,3 @@ -import {UIElement} from "./UIElement"; import {UIEventSource} from "../Logic/UIEventSource"; import {VariableUiElement} from "./Base/VariableUIElement"; import LiveQueryHandler from "../Logic/Web/LiveQueryHandler"; @@ -13,16 +12,19 @@ import ReviewElement from "./Reviews/ReviewElement"; import MangroveReviews from "../Logic/Web/MangroveReviews"; import Translations from "./i18n/Translations"; import ReviewForm from "./Reviews/ReviewForm"; -import OpeningHoursVisualization from "./OpeningHours/OhVisualization"; +import OpeningHoursVisualization from "./OpeningHours/OpeningHoursVisualization"; import State from "../State"; import {ImageSearcher} from "../Logic/Actors/ImageSearcher"; +import BaseUIElement from "./BaseUIElement"; +import LayerConfig from "../Customizations/JSON/LayerConfig"; export default class SpecialVisualizations { + public static specialVisualizations: { funcName: string, - constr: ((state: State, tagSource: UIEventSource, argument: string[]) => UIElement), + constr: ((state: State, tagSource: UIEventSource, argument: string[]) => BaseUIElement), docs: string, example?: string, args: { name: string, defaultValue?: string, doc: string }[] @@ -36,6 +38,9 @@ export default class SpecialVisualizations { return new VariableUiElement(tags.map(tags => { const parts = []; for (const key in tags) { + if (!tags.hasOwnProperty(key)) { + continue; + } parts.push(key + "=" + tags[key]); } return parts.join("
    ") @@ -102,7 +107,7 @@ export default class SpecialVisualizations { state.mangroveIdentity, state.osmConnection._dryRun ); - const form = new ReviewForm((r, whenDone) => mangrove.AddReview(r, whenDone), state.osmConnection.userDetails); + const form = new ReviewForm((r, whenDone) => mangrove.AddReview(r, whenDone), state.osmConnection); return new ReviewElement(mangrove.GetSubjectUri(), mangrove.GetReviews(), form); } }, @@ -115,11 +120,7 @@ export default class SpecialVisualizations { doc: "The tagkey from which the table is constructed." }], constr: (state: State, tagSource: UIEventSource, args) => { - let keyname = args[0]; - if (keyname === undefined || keyname === "") { - keyname = keyname ?? "opening_hours" - } - return new OpeningHoursVisualization(tagSource, keyname) + return new OpeningHoursVisualization(tagSource, args[0]) } }, @@ -155,22 +156,36 @@ export default class SpecialVisualizations { ], constr: (state: State, tagSource: UIEventSource, args) => { if (window.navigator.share) { - const title = state.layoutToUse.data.title.txt; - let name = tagSource.data.name; - if (name) { - name = `${name} (${title})` - } else { - name = title; + + const generateShareData = () => { + + + const title = state?.layoutToUse?.data?.title?.txt ?? "MapComplete"; + + let matchingLayer: LayerConfig = undefined; + for (const layer of (state?.layoutToUse?.data?.layers ?? [])) { + if (layer.source.osmTags.matchesProperties(tagSource?.data)) { + matchingLayer = layer + } + } + let name = matchingLayer?.title?.GetRenderValue(tagSource.data)?.txt ?? tagSource.data?.name ?? "POI"; + if (name) { + name = `${name} (${title})` + } else { + name = title; + } + let url = args[0] ?? "" + if (url === "") { + url = window.location.href + } + return { + title: name, + url: url, + text: state?.layoutToUse?.data?.shortDescription?.txt ?? "MapComplete" + } } - let url = args[0] ?? "" - if (url === "") { - url = window.location.href - } - return new ShareButton(Svg.share_ui(), { - title: name, - url: url, - text: state.layoutToUse.data.shortDescription.txt - }) + + return new ShareButton(Svg.share_ui(), generateShareData) } else { return new FixedUiElement("") } @@ -179,7 +194,7 @@ export default class SpecialVisualizations { } ] - static HelpMessage: UIElement = SpecialVisualizations.GenHelpMessage(); + static HelpMessage: BaseUIElement = SpecialVisualizations.GenHelpMessage(); private static GenHelpMessage() { @@ -211,7 +226,6 @@ export default class SpecialVisualizations { "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", ...helpTexts - ] ); } diff --git a/UI/SubstitutedTranslation.ts b/UI/SubstitutedTranslation.ts index 7b39e9ace..2a51d15e4 100644 --- a/UI/SubstitutedTranslation.ts +++ b/UI/SubstitutedTranslation.ts @@ -1,88 +1,35 @@ -import {UIElement} from "./UIElement"; import {UIEventSource} from "../Logic/UIEventSource"; import {Translation} from "./i18n/Translation"; import Locale from "./i18n/Locale"; -import Combine from "./Base/Combine"; import State from "../State"; import {FixedUiElement} from "./Base/FixedUiElement"; import SpecialVisualizations from "./SpecialVisualizations"; +import BaseUIElement from "./BaseUIElement"; import {Utils} from "../Utils"; +import {VariableUiElement} from "./Base/VariableUIElement"; +import Combine from "./Base/Combine"; -export class SubstitutedTranslation extends UIElement { - private static cachedTranslations: - Map, SubstitutedTranslation>>> = new Map, SubstitutedTranslation>>>(); - private readonly tags: UIEventSource; - private readonly translation: Translation; - private content: UIElement[]; +export class SubstitutedTranslation extends VariableUiElement { - private constructor( + public constructor( translation: Translation, - tags: UIEventSource) { - super(tags); - this.translation = translation; - this.tags = tags; - const self = this; - tags.addCallbackAndRun(() => { - self.content = self.CreateContent(); - self.Update(); - }); - - Locale.language.addCallback(() => { - self.content = self.CreateContent(); - self.Update(); - }); + tagsSource: UIEventSource) { + super( + tagsSource.map(tags => { + const txt = Utils.SubstituteKeys(translation.txt, tags) + if (txt === undefined) { + return undefined + } + return new Combine(SubstitutedTranslation.EvaluateSpecialComponents(txt, tagsSource)) + }, [Locale.language]) + ) + + this.SetClass("w-full") } - public static construct( - translation: Translation, - tags: UIEventSource): SubstitutedTranslation { - /* let cachedTranslations = Utils.getOrSetDefault(SubstitutedTranslation.cachedTranslations, SubstitutedTranslation.GenerateSubCache); - const innerMap = Utils.getOrSetDefault(cachedTranslations, translation, SubstitutedTranslation.GenerateMap); - - const cachedTranslation = innerMap.get(tags); - if (cachedTranslation !== undefined) { - return cachedTranslation; - }*/ - const st = new SubstitutedTranslation(translation, tags); - // innerMap.set(tags, st); - return st; - } - - public static SubstituteKeys(txt: string, tags: any) { - for (const key in tags) { - txt = txt.replace(new RegExp("{" + key + "}", "g"), tags[key]) - } - return txt; - } - - private static GenerateMap() { - return new Map, SubstitutedTranslation>() - } - - private static GenerateSubCache() { - return new Map, SubstitutedTranslation>>(); - } - - InnerRender(): string { - if (this.content.length == 1) { - return this.content[0].Render(); - } - return new Combine(this.content).Render(); - } - - private CreateContent(): UIElement[] { - let txt = this.translation?.txt; - if (txt === undefined) { - return [] - } - const tags = this.tags.data; - txt = SubstitutedTranslation.SubstituteKeys(txt, tags); - return this.EvaluateSpecialComponents(txt); - } - - private EvaluateSpecialComponents(template: string): UIElement[] { + private static EvaluateSpecialComponents(template: string, tags: UIEventSource): BaseUIElement[] { for (const knownSpecial of SpecialVisualizations.specialVisualizations) { @@ -91,9 +38,9 @@ export class SubstitutedTranslation extends UIElement { if (matched != null) { // We found a special component that should be brought to live - const partBefore = this.EvaluateSpecialComponents(matched[1]); + const partBefore = SubstitutedTranslation.EvaluateSpecialComponents(matched[1], tags); const argument = matched[2].trim(); - const partAfter = this.EvaluateSpecialComponents(matched[3]); + const partAfter = SubstitutedTranslation.EvaluateSpecialComponents(matched[3], tags); try { const args = knownSpecial.args.map(arg => arg.defaultValue ?? ""); if (argument.length > 0) { @@ -108,7 +55,14 @@ export class SubstitutedTranslation extends UIElement { } - const element = knownSpecial.constr(State.state, this.tags, args); + let element: BaseUIElement = new FixedUiElement(`Constructing ${knownSpecial}(${args.join(", ")})`) + try{ + element = knownSpecial.constr(State.state, tags, args); + }catch(e){ + console.error("SPECIALRENDERING FAILED for", tags.data.id, e) + element = new FixedUiElement(`Could not generate special renering for ${knownSpecial}(${args.join(", ")}) ${e}`).SetClass("alert") + } + return [...partBefore, element, ...partAfter] } catch (e) { console.error(e); @@ -118,11 +72,11 @@ export class SubstitutedTranslation extends UIElement { } // Let's to a small sanity check to help the theme designers: - if(template.search(/{[^}]+\([^}]*\)}/) >= 0){ + if (template.search(/{[^}]+\([^}]*\)}/) >= 0) { // Hmm, we might have found an invalid rendering name - console.warn("Found a suspicious special rendering value in: ", template, " did you mean one of: ", SpecialVisualizations.specialVisualizations.map(sp => sp.funcName+"()").join(", ")) + console.warn("Found a suspicious special rendering value in: ", template, " did you mean one of: ", SpecialVisualizations.specialVisualizations.map(sp => sp.funcName + "()").join(", ")) } - + // IF we end up here, no changes have to be made - except to remove any resting {} return [new FixedUiElement(template.replace(/{.*}/g, ""))]; } diff --git a/UI/UIElement.ts b/UI/UIElement.ts index b4f658da4..d1802bcda 100644 --- a/UI/UIElement.ts +++ b/UI/UIElement.ts @@ -1,25 +1,19 @@ import {UIEventSource} from "../Logic/UIEventSource"; -import {Utils} from "../Utils"; +import BaseUIElement from "./BaseUIElement"; -export abstract class UIElement extends UIEventSource { +export abstract class UIElement extends BaseUIElement{ private static nextId: number = 0; public readonly id: string; public readonly _source: UIEventSource; - public dumbMode = false; - private clss: Set = new Set(); - private style: string; - private _hideIfEmpty = false; + private lastInnerRender: string; - private _onClick: () => void; - private _onHover: UIEventSource; protected constructor(source: UIEventSource = undefined) { - super(""); - this.id = "ui-element-" + UIElement.nextId; + super() + this.id = `ui-${this.constructor.name}-${UIElement.nextId}`; this._source = source; UIElement.nextId++; - this.dumbMode = true; this.ListenTo(source); } @@ -27,183 +21,70 @@ export abstract class UIElement extends UIEventSource { if (source === undefined) { return this; } - this.dumbMode = false; + //console.trace("Got a listenTo in ", this.constructor.name) const self = this; source.addCallback(() => { self.lastInnerRender = undefined; - self.Update(); + if(self._constructedHtmlElement !== undefined){ + self.UpdateElement(self._constructedHtmlElement); + } + }) return this; } - public onClick(f: (() => void)) { - this.dumbMode = false; - this._onClick = f; - this.SetClass("clickable") - this.Update(); - return this; - } - - public IsHovered(): UIEventSource { - this.dumbMode = false; - if (this._onHover !== undefined) { - return this._onHover; + /** + * Should be overridden for specific HTML functionality + */ + protected InnerConstructElement(): HTMLElement { + // Uses the old fashioned way to construct an element using 'InnerRender' + const innerRender = this.InnerRender(); + if (innerRender === undefined || innerRender === "") { + return undefined; } - // Note: we just save it. 'Update' will register that an eventsource exist and install the necessary hooks - this._onHover = new UIEventSource(false); - return this._onHover; - } - - Update(): void { - if (Utils.runningFromConsole) { - return; - } - - let element = document.getElementById(this.id); - if (element === undefined || element === null) { - // The element is not painted or, in the case of 'dumbmode' this UI-element is not explicitely present - if (this.dumbMode) { - // We update all the children anyway - this.UpdateAllChildren(); + const el = document.createElement("span") + if (typeof innerRender === "string") { + el.innerHTML = innerRender + } else { + const subElement = innerRender.ConstructElement(); + if (subElement === undefined) { + return undefined; } - return; - } - const newRender = this.InnerRender(); - if (newRender !== this.lastInnerRender) { - this.lastInnerRender = newRender; - this.setData(this.InnerRender()); - element.innerHTML = this.data; + el.appendChild(subElement) } + return el; + } - if (this._hideIfEmpty) { - if (element.innerHTML === "") { - element.parentElement.style.display = "none"; - } else { - element.parentElement.style.display = ""; + protected UpdateElement(el: HTMLElement) : void{ + const innerRender = this.InnerRender(); + + if (typeof innerRender === "string") { + if(el.innerHTML !== innerRender){ + el.innerHTML = innerRender } - } - - if (this._onClick !== undefined) { - const self = this; - element.onclick = (e) => { - // @ts-ignore - if (e.consumed) { - return; - } - self._onClick(); - // @ts-ignore - e.consumed = true; + } else { + const subElement = innerRender.ConstructElement(); + if(el.children.length === 1 && el.children[0] === subElement){ + return; // Nothing changed } - element.style.pointerEvents = "all"; - element.style.cursor = "pointer"; + + while (el.firstChild) { + el.removeChild(el.firstChild); + } + + if (subElement === undefined) { + return; + } + el.appendChild(subElement) } - - if (this._onHover !== undefined) { - const self = this; - element.addEventListener('mouseover', () => self._onHover.setData(true)); - element.addEventListener('mouseout', () => self._onHover.setData(false)); - } - - this.InnerUpdate(element); - this.UpdateAllChildren(); - - } - - HideOnEmpty(hide: boolean): UIElement { - this._hideIfEmpty = hide; - this.Update(); - return this; - } - - Render(): string { - this.lastInnerRender = this.InnerRender(); - if (this.dumbMode) { - return this.lastInnerRender; - } - - let style = ""; - if (this.style !== undefined && this.style !== "") { - style = `style="${this.style}" `; - } - let clss = ""; - if (this.clss.size > 0) { - clss = `class='${Array.from(this.clss).join(" ")}' `; - } - return `${this.lastInnerRender}` - } - - AttachTo(divId: string) { - this.dumbMode = false; - let element = document.getElementById(divId); - if (element === null) { - throw "SEVERE: could not attach UIElement to " + divId; - } - element.innerHTML = this.Render(); - this.Update(); - return this; - } - - public abstract InnerRender(): string; - - public IsEmpty(): boolean { - return this.InnerRender() === ""; + } /** - * Adds all the relevant classes, space seperated - * @param clss - * @constructor + * @deprecated The method should not be used */ - public SetClass(clss: string) { - this.dumbMode = false; - const all = clss.split(" "); - let recordedChange = false; - for (const c of all) { - if (this.clss.has(clss)) { - continue; - } - this.clss.add(c); - recordedChange = true; - } - if (recordedChange) { - this.Update(); - } - return this; - } + protected abstract InnerRender(): string | BaseUIElement; - public RemoveClass(clss: string): UIElement { - if (this.clss.has(clss)) { - this.clss.delete(clss); - this.Update(); - } - return this; - } - - public SetStyle(style: string): UIElement { - this.dumbMode = false; - this.style = style; - this.Update(); - return this; - } - - // Called after the HTML has been replaced. Can be used for css tricks - protected InnerUpdate(htmlElement: HTMLElement) { - } - - private UpdateAllChildren() { - for (const i in this) { - const child = this[i]; - if (child instanceof UIElement) { - child.Update(); - } else if (child instanceof Array) { - for (const ch of child) { - if (ch instanceof UIElement) { - ch.Update(); - } - } - } - } - } } diff --git a/UI/i18n/Translation.ts b/UI/i18n/Translation.ts index 673a5efbd..508d9c026 100644 --- a/UI/i18n/Translation.ts +++ b/UI/i18n/Translation.ts @@ -1,24 +1,22 @@ import {UIElement} from "../UIElement"; -import Combine from "../Base/Combine"; import Locale from "./Locale"; import {Utils} from "../../Utils"; +import BaseUIElement from "../BaseUIElement"; -export class Translation extends UIElement { +export class Translation extends BaseUIElement { public static forcedLanguage = undefined; public readonly translations: object - return - allIcons; constructor(translations: object, context?: string) { - super(Locale.language) + super() if (translations === undefined) { throw `Translation without content (${context})` } let count = 0; for (const translationsKey in translations) { - if(!translations.hasOwnProperty(translationsKey)){ + if (!translations.hasOwnProperty(translationsKey)) { continue } count++; @@ -34,10 +32,14 @@ export class Translation extends UIElement { } get txt(): string { + return this.textFor(Translation.forcedLanguage ?? Locale.language.data) + } + + public textFor(language: string): string{ if (this.translations["*"]) { return this.translations["*"]; } - const txt = this.translations[Translation.forcedLanguage ?? Locale.language.data]; + const txt = this.translations[language]; if (txt !== undefined) { return txt; } @@ -46,15 +48,29 @@ export class Translation extends UIElement { return en; } for (const i in this.translations) { + if (!this.translations.hasOwnProperty(i)) { + continue; + } return this.translations[i]; // Return a random language } console.error("Missing language ", Locale.language.data, "for", this.translations) return ""; } + + InnerConstructElement(): HTMLElement { + const el = document.createElement("span") + Locale.language.addCallbackAndRun(_ => { + el.innerHTML = this.txt + }) + return el; + } public SupportedLanguages(): string[] { const langs = [] for (const translationsKey in this.translations) { + if (!this.translations.hasOwnProperty(translationsKey)) { + continue; + } if (translationsKey === "#") { continue; } @@ -69,9 +85,15 @@ export class Translation extends UIElement { public Subs(text: any): Translation { const newTranslations = {}; for (const lang in this.translations) { + if (!this.translations.hasOwnProperty(lang)) { + continue; + } let template: string = this.translations[lang]; for (const k in text) { - const combined = []; + if (!text.hasOwnProperty(k)) { + continue + } + const combined: (string)[] = []; const parts = template.split("{" + k + "}"); const el: string | UIElement = text[k]; if (el === undefined) { @@ -88,12 +110,12 @@ export class Translation extends UIElement { // @ts-ignore const date: Date = el; rtext = date.toLocaleString(); - } else if (el.InnerRender === undefined) { + } else if (el.ConstructElement() === undefined) { console.error("InnerREnder is not defined", el); throw "Hmmm, el.InnerRender is not defined?" } else { Translation.forcedLanguage = lang; // This is a very dirty hack - it'll bite me one day - rtext = el.InnerRender(); + rtext = el.ConstructElement().innerHTML; } for (let i = 0; i < parts.length - 1; i++) { @@ -101,7 +123,7 @@ export class Translation extends UIElement { combined.push(rtext) } combined.push(parts[parts.length - 1]); - template = new Combine(combined).InnerRender(); + template = combined.join("") } newTranslations[lang] = template; } @@ -110,16 +132,11 @@ export class Translation extends UIElement { } - InnerRender(): string { - return this.txt - } - public replace(a: string, b: string) { if (a.startsWith("{") && a.endsWith("}")) { a = a.substr(1, a.length - 2); } - const result = this.Subs({[a]: b}); - return result; + return this.Subs({[a]: b}); } public Clone() { @@ -130,6 +147,9 @@ export class Translation extends UIElement { const tr = {}; for (const lng in this.translations) { + if (!this.translations.hasOwnProperty(lng)) { + continue + } let txt = this.translations[lng]; txt = txt.replace(/\..*/, ""); txt = Utils.EllipsesAfter(txt, 255); @@ -142,6 +162,9 @@ export class Translation extends UIElement { public ExtractImages(isIcon = false): string[] { const allIcons: string[] = [] for (const key in this.translations) { + if (!this.translations.hasOwnProperty(key)) { + continue; + } const render = this.translations[key] if (isIcon) { diff --git a/UI/i18n/Translations.ts b/UI/i18n/Translations.ts index a31aa4c70..d530c8261 100644 --- a/UI/i18n/Translations.ts +++ b/UI/i18n/Translations.ts @@ -2,6 +2,7 @@ import {UIElement} from "../UIElement"; import {FixedUiElement} from "../Base/FixedUiElement"; import AllTranslationAssets from "../../AllTranslationAssets"; import {Translation} from "./Translation"; +import BaseUIElement from "../BaseUIElement"; export default class Translations { @@ -10,7 +11,7 @@ export default class Translations { } static t = AllTranslationAssets.t; - public static W(s: string | UIElement): UIElement { + public static W(s: string | BaseUIElement): BaseUIElement { if (typeof (s) === "string") { return new FixedUiElement(s); } diff --git a/Utils.ts b/Utils.ts index 9b41a260d..a7cbcb3e6 100644 --- a/Utils.ts +++ b/Utils.ts @@ -73,6 +73,14 @@ export class Utils { return res; } + public static TimesT(count : number, f: ((i: number) => T)): T[] { + let res : T[] = []; + for (let i = 0; i < count; i++) { + res .push(f(i)); + } + return res; + } + static DoEvery(millis: number, f: (() => void)) { if (Utils.runningFromConsole) { return; @@ -108,7 +116,7 @@ export class Utils { } public static EllipsesAfter(str: string, l: number = 100) { - if (str === undefined) { + if (str === undefined || str === null) { return undefined; } if (str.length <= l) { @@ -149,7 +157,16 @@ export class Utils { return [a.substr(0, index), a.substr(index + sep.length)]; } - // Date will be undefined on failure + public static SubstituteKeys(txt: string, tags: any) { + for (const key in tags) { + if(!tags.hasOwnProperty(key)) { + continue + } + txt = txt.replace(new RegExp("{" + key + "}", "g"), tags[key]) + } + return txt; + } + public static LoadCustomCss(location: string) { const head = document.getElementsByTagName('head')[0]; const link = document.createElement('link'); @@ -251,6 +268,10 @@ export class Utils { public static UnMinify(minified: string): string { + if(minified === undefined || minified === null){ + return undefined; + } + const parts = minified.split("|"); let result = parts.shift(); const keys = Utils.knownKeys.concat(Utils.extraKeys); diff --git a/assets/contributors.json b/assets/contributors.json index eda3444b7..02c30c2ce 100644 --- a/assets/contributors.json +++ b/assets/contributors.json @@ -1 +1 @@ -{"contributors":[{"contributor":"Pieter Vander Vennet", "commits":714},{"contributor":"pietervdvn", "commits":650},{"contributor":"Tobias", "commits":35},{"contributor":"Christian Neumann", "commits":33},{"contributor":"Win Olario", "commits":31},{"contributor":"Pieter Fiers", "commits":31},{"contributor":"Sebastian Kürten", "commits":16},{"contributor":"ToastHawaii", "commits":15},{"contributor":"Weblate", "commits":14},{"contributor":"Marco", "commits":14},{"contributor":"Bavo Vanderghote", "commits":12},{"contributor":"Joost", "commits":11},{"contributor":"Midgard", "commits":8},{"contributor":"Jacque Fresco", "commits":8},{"contributor":"Artem", "commits":8},{"contributor":"yopaseopor", "commits":7},{"contributor":"Flo Edelmann", "commits":7},{"contributor":"Binnette", "commits":7},{"contributor":"pelderson", "commits":6},{"contributor":"Mateusz Konieczny", "commits":6},{"contributor":"lvgx", "commits":6},{"contributor":"dependabot[bot]", "commits":6},{"contributor":"Alexey Shabanov", "commits":6},{"contributor":"SiegbjornSitumeang", "commits":4},{"contributor":"Polgár Sándor", "commits":4},{"contributor":"Léo Villeveygoux", "commits":3},{"contributor":"Hosted Weblate", "commits":3},{"contributor":"David Haberthür", "commits":3},{"contributor":"Wiktor Przybylski", "commits":2},{"contributor":"Stanislas Gueniffey", "commits":2},{"contributor":"Robin van der Linde", "commits":2},{"contributor":"riiga", "commits":2},{"contributor":"pbarban", "commits":2},{"contributor":"Leo Alcaraz", "commits":2},{"contributor":"Jan Zabel", "commits":2},{"contributor":"graveelius", "commits":2},{"contributor":"Vinicius", "commits":1},{"contributor":"Tomas Fiers", "commits":1},{"contributor":"Thibault Molleman", "commits":1},{"contributor":"tbowdecl97", "commits":1},{"contributor":"Schouppe Joost", "commits":1},{"contributor":"Noémie", "commits":1},{"contributor":"mozita", "commits":1},{"contributor":"Carlos Ramos Carreño", "commits":1},{"contributor":"Beardhatcode", "commits":1}]} \ No newline at end of file +{"contributors":[{"contributor":"Pieter Vander Vennet", "commits":738},{"contributor":"pietervdvn", "commits":718},{"contributor":"Weblate", "commits":35},{"contributor":"Tobias", "commits":35},{"contributor":"Christian Neumann", "commits":33},{"contributor":"Win Olario", "commits":31},{"contributor":"Pieter Fiers", "commits":31},{"contributor":"Sebastian Kürten", "commits":16},{"contributor":"Marco", "commits":16},{"contributor":"Joost", "commits":16},{"contributor":"ToastHawaii", "commits":15},{"contributor":"J. Lavoie", "commits":14},{"contributor":"Bavo Vanderghote", "commits":12},{"contributor":"Artem", "commits":12},{"contributor":"Supaplex", "commits":9},{"contributor":"Jacque Fresco", "commits":9},{"contributor":"Midgard", "commits":8},{"contributor":"Mateusz Konieczny", "commits":8},{"contributor":"yopaseopor", "commits":7},{"contributor":"Flo Edelmann", "commits":7},{"contributor":"Binnette", "commits":7},{"contributor":"Allan Nordhøy", "commits":7},{"contributor":"pelderson", "commits":6},{"contributor":"lvgx", "commits":6},{"contributor":"dependabot[bot]", "commits":6},{"contributor":"Alexey Shabanov", "commits":6},{"contributor":"SiegbjornSitumeang", "commits":4},{"contributor":"Polgár Sándor", "commits":4},{"contributor":"Hiroshi Miura", "commits":4},{"contributor":"vankos", "commits":3},{"contributor":"Léo Villeveygoux", "commits":3},{"contributor":"JCGF-OSM", "commits":3},{"contributor":"Jan Zabel", "commits":3},{"contributor":"Hosted Weblate", "commits":3},{"contributor":"David Haberthür", "commits":3},{"contributor":"快乐的老鼠宝宝", "commits":2},{"contributor":"Wiktor Przybylski", "commits":2},{"contributor":"Vinicius", "commits":2},{"contributor":"Stanislas Gueniffey", "commits":2},{"contributor":"Robin van der Linde", "commits":2},{"contributor":"riiga", "commits":2},{"contributor":"pbarban", "commits":2},{"contributor":"mic140", "commits":2},{"contributor":"Leo Alcaraz", "commits":2},{"contributor":"Jose Luis Infante", "commits":2},{"contributor":"Heiko", "commits":2},{"contributor":"graveelius", "commits":2},{"contributor":"Tomas Fiers", "commits":1},{"contributor":"Thibault Molleman", "commits":1},{"contributor":"tbowdecl97", "commits":1},{"contributor":"Sebastian", "commits":1},{"contributor":"Sean Young", "commits":1},{"contributor":"Schouppe Joost", "commits":1},{"contributor":"Noémie", "commits":1},{"contributor":"mozita", "commits":1},{"contributor":"Michał Targoński", "commits":1},{"contributor":"Iváns", "commits":1},{"contributor":"Eric Armijo", "commits":1},{"contributor":"Damian Pułka", "commits":1},{"contributor":"Carlos Ramos Carreño", "commits":1},{"contributor":"Beardhatcode", "commits":1}]} \ No newline at end of file diff --git a/css/imageUploadFlow.css b/css/imageUploadFlow.css deleted file mode 100644 index c36b33b64..000000000 --- a/css/imageUploadFlow.css +++ /dev/null @@ -1,23 +0,0 @@ -.image-upload-flow-button span { - width: max-content; - font-size: 28px; - font-weight: bold; - margin-top: 4px; - padding-top: 4px; - padding-bottom: 4px; - padding-left: 13px; -} - -.image-upload-flow-button { - display: flex; - cursor: pointer; - padding: 0.5em; - border-radius: 1em; - border: 3px solid var(--foreground-color); - box-sizing: border-box; -} - -.image-upload-flow svg { - fill: var(--foreground-color); - stroke: var(--foreground-color); -} diff --git a/css/mobile.css b/css/mobile.css index 1d0b523eb..7165f0cd7 100644 --- a/css/mobile.css +++ b/css/mobile.css @@ -2,19 +2,14 @@ Contains tweaks for small screens */ -.only-on-mobile { - display: none !important; - background-color: var(--background-color); - color: var(--foreground-color); -} - -@media only screen and (max-width: 600px), only screen and (max-height: 600px) { +@media only screen and (min-width: 769px) { .only-on-mobile { - display: unset !important; - background-color: var(--background-color); - color: var(--foreground-color); + display: none !important; } +} +@media only screen and (max-width: 768px), only screen and (max-height: 768px) { + .hidden-on-mobile { display: none !important; @@ -55,6 +50,10 @@ Contains tweaks for small screens .leaflet-control-attribution{ display: none; } + + .leaflet-popup { + display: none; + } } diff --git a/css/openinghourstable.css b/css/openinghourstable.css index 20d76cf29..fddff2496 100644 --- a/css/openinghourstable.css +++ b/css/openinghourstable.css @@ -64,6 +64,7 @@ font-size: large; padding: 0; padding-right: 0.2em; + box-sizing: border-box; } .oh-timecell-0 { @@ -103,70 +104,22 @@ border-right: 10px solid var(--catch-detail-color) !important; } - -.oh-draggable-header { - background-color: blue; - height: 0.5em; -} - .oh-timerange { + color: var(--catch-detail-color-contrast); border-radius: 0.5em; - margin-left: 2px; display: block; position: absolute; top: 0; left: 0; - width: calc(100% - 4px); + margin-left: calc(5% - 1px); + width: 90%; background: var(--catch-detail-color); z-index: 1; box-sizing: border-box; + border: 2px solid var(--catch-detail-color); + overflow: unset; } -.oh-timerange-inner { - display: flex; - flex-direction: column; - overflow-x: hidden; - justify-content: space-between; - align-content: center; - height: 100%; - overflow-y: hidden; -} - -.oh-timerange-inner input { - width: 100%; - box-sizing: border-box; - } - -.oh-timerange-inner-small { - display: flex; - flex-direction: row; - justify-content: space-between; - height: 100%; - width:100%; -} - -.oh-timerange-inner-small input { - width: min-content; - box-sizing: border-box; -} - -.oh-delete-range{ - width: 1.5em; - height: 1.5em; - background:black; - border-radius:0.75em; -} - -.oh-delete-range img { - height: 100%; - max-width: 2em; -} - -.oh-timerange-label { - color: white; -} - - /**** Opening hours visualization table ****/ .ohviz-table { @@ -190,7 +143,6 @@ .ohviz-today .ohviz-range { border: 1.5px solid black; - } .ohviz-day-off { @@ -235,70 +187,12 @@ border-radius: 1em; } -.ohviz-now { - position: absolute; - top: 0; - margin: 0; - height: 100%; - border: 1px solid black; - box-sizing: border-box -} .ohviz-line { position: absolute; top: 0; margin: 0; height: 100%; - border-left: 1px solid #ccc; + border-left: 1px solid #999; box-sizing: border-box } - - -.ohviz-time-indication > div { - position: relative; - background-color: white; - left: -50%; - padding-left: 0.3em; - padding-right: 0.3em; - font-size: smaller; - border-radius: 0.3em; - border: 1px solid #ccc; - word-break: initial; - -} - -.ohviz-time-indication { - position: absolute; - top: 0; - margin: 0; - height: 100%; - box-sizing: border-box; -} - - -.ohviz-today { - background-color: var(--subtle-detail-color); -} - -.ohviz-weekday { - padding-left: 0.5em; - word-break: normal; -} - - -.ohviz { - border-collapse: collapse; -} - -.ohviz-container { - border: 0.5em solid var(--subtle-detail-color); - border-radius: 1em; - display: block; -} - -.ohviz-closed { - padding: 1em; - background-color: #eee; - border-radius: 1em; - display: block; -} \ No newline at end of file diff --git a/css/slideshow.css b/css/slideshow.css deleted file mode 100644 index 7fe59c113..000000000 --- a/css/slideshow.css +++ /dev/null @@ -1,22 +0,0 @@ - -.slick-next { - top: unset; - bottom: -25px; - right: 15px; - z-index: 10000; -} - -.slick-prev { - top: unset; - bottom: -25px; - left: 0; - z-index: 10000; -} - -.slick-next::before { - font-size: 35px; -} - -.slick-prev::before { - font-size: 35px; -} \ No newline at end of file diff --git a/css/tabbedComponent.css b/css/tabbedComponent.css index d895fc9ae..a4b7bc182 100644 --- a/css/tabbedComponent.css +++ b/css/tabbedComponent.css @@ -30,17 +30,6 @@ } -.tab-content { - z-index: 5002; - background-color: var(--background-color); - color: var(--foreground-color); - position: relative; - padding: 1em; - display: inline-block; - width: 100%; - box-sizing: border-box; -} - .tab-single-header { border-top-left-radius: 1em; border-top-right-radius: 1em; diff --git a/css/tagrendering.css b/css/tagrendering.css index 5c73f84ed..a87f67a47 100644 --- a/css/tagrendering.css +++ b/css/tagrendering.css @@ -68,57 +68,4 @@ input:checked + label .question-option-with-border { width: 100%; } -.edit-button img { - width: 1.3em; - height: 1.3em; - padding: 0.5em; - border-radius: 0.65em; - border: solid var(--popup-border) 1px; - font-size: medium; - float: right; -} -.edit-button svg { - width: 1.3em; - height: 1.3em; - padding: 0.5em; - border-radius: 0.65em; - border: solid var(--foreground-color) 1px; - stroke: var(--foreground-color) !important; - fill: var(--foreground-color) !important; - font-size: medium; - float: right; -} - -.edit-button svg path { - stroke: var(--foreground-color) !important; - fill: var(--foreground-color) !important; -} - - - -.to-the-map span { - font-size: xx-large; -} - -.to-the-map { - background: var(--catch-detail-color); - height: var(--return-to-the-map-height); - color: var(--catch-detail-color-contrast); - font-weight: bold; - pointer-events: all; - cursor: pointer; - padding-top: 0.4em; - text-align: center; - box-sizing: border-box; - display: block; - max-height: var(--return-to-the-map-height); - position: fixed; - width: 100vw; - bottom: 0; - z-index: 100000; -} - -.to-the-map-inner{ - font-size: xx-large; -} diff --git a/css/userbadge.css b/css/userbadge.css index b515cf501..2fe2efec1 100644 --- a/css/userbadge.css +++ b/css/userbadge.css @@ -12,16 +12,6 @@ overflow-x: hidden; } -#userbadge a { - text-decoration: none; - color: var(--foreground-color); -} - - -#userbadge form { - width: unset !important; -} - .userstats { display: flex; align-items: center; @@ -55,17 +45,6 @@ display: block; } -#profile-pic { - float: left; - width: 4em; - height: 4em; - padding: 0; - margin: 0; - opacity: 0; - transition: opacity 500ms linear; - border-radius: 999em; -} - .usertext { display: block; width: max-content; diff --git a/customGenerator.html b/customGenerator.html deleted file mode 100644 index 8f124a4e9..000000000 --- a/customGenerator.html +++ /dev/null @@ -1,109 +0,0 @@ - - - - - - - Custom Theme Generator for Mapcomplete - - - - - - -
    - Loading the MapComplete custom theme builder...
    - If this message persists, make sure javascript is enabled and no script blocker is blocking this. -
    - - - \ No newline at end of file diff --git a/customGenerator.ts b/customGenerator.ts deleted file mode 100644 index c0179c8d7..000000000 --- a/customGenerator.ts +++ /dev/null @@ -1,32 +0,0 @@ -import {UIEventSource} from "./Logic/UIEventSource"; -import {GenerateEmpty} from "./UI/CustomGenerator/GenerateEmpty"; -import {LayoutConfigJson} from "./Customizations/JSON/LayoutConfigJson"; -import {OsmConnection} from "./Logic/Osm/OsmConnection"; -import CustomGeneratorPanel from "./UI/CustomGenerator/CustomGeneratorPanel"; -import {LocalStorageSource} from "./Logic/Web/LocalStorageSource"; -import {Utils} from "./Utils"; -import LZString from "lz-string"; - -let layout = GenerateEmpty.createEmptyLayout(); -if (window.location.hash.length > 10) { - const hash = window.location.hash.substr(1) - try{ - layout = JSON.parse(atob(hash)) as LayoutConfigJson; - }catch(e){ - console.log("Initial load of theme failed, attempt nr 2 with decompression", e) - layout = JSON.parse( Utils.UnMinify(LZString.decompressFromBase64(hash))) - } - -} else { - const hash = LocalStorageSource.Get("last-custom-theme").data - if (hash !== undefined) { - console.log("Using theme from local storage") - layout = JSON.parse(atob(hash)) as LayoutConfigJson; - } -} - -const connection = new OsmConnection(false, new UIEventSource(undefined), "customGenerator", false); - -new CustomGeneratorPanel(connection, layout) - .AttachTo("maindiv"); - diff --git a/index.css b/index.css index cdc550874..ebf83ca4b 100644 --- a/index.css +++ b/index.css @@ -62,22 +62,7 @@ --variable-title-height: 0px; /* Set by javascript */ --return-to-the-map-height: 2em; - --image-carousel-height: 400px; -} - -.slick-carousel-content { - width: 300px; - max-height: var(--image-carousel-height); - display: block; - margin-left: 10px; -} - -.slick-carousel-content img { - /** -Workaround to patch images within a slick carousel - */ - height: var(--image-carousel-height); - width: auto; + --image-carousel-height: 350px; } html, body { @@ -104,7 +89,6 @@ svg, img { display: unset; } - a { color: var(--foreground-color); } @@ -164,10 +148,6 @@ li::marker { .border-attention-catch{ border: 5px solid var(--catch-detail-color);} -.slick-prev:before, .slick-next:before { - /*Slideshow workaround*/ - color:black !important; -} #topleft-tools svg { fill: var(--foreground-color) !important; @@ -222,23 +202,6 @@ li::marker { max-width: 2em !important; } -.simple-add-ui-icon { - position: relative; - display: block; - width: 4em; - height: 3.5em; -} - -.simple-add-ui-icon img { - max-height: 3.5em !important; - max-width: 3.5em !important; -} - -.simple-add-ui-icon svg { - max-height: 3.5em !important; - max-width: 3.5em !important; -} - /**************** GENERIC ****************/ @@ -258,10 +221,6 @@ li::marker { width: 100%; } -.question form input[type="radio"] { - margin-right: 0.5em; -} - .invalid { box-shadow: 0 0 10px #ff5353; height: min-content; @@ -292,14 +251,10 @@ li::marker { } .link-underline .subtle a { - color: var(--foreground-color); text-decoration: underline 1px #7193bb88; color: #7193bb; } -.bold { - font-weight: bold; -} .thanks { background-color: #43d904; @@ -318,59 +273,8 @@ li::marker { pointer-events: none !important; } -.page-split { - display: flex; - height: 100%; -} -.activate-osm-authentication { - cursor: pointer; - color: blue; - text-decoration: underline; -} - - -#searchbox { - display: inline-block; - text-align: left; - background-color: var(--background-color); - color: var(--foreground-color); - - transition: all 500ms linear; - pointer-events: all; - margin: 0 0 0.5em; - width: 100%; -} - -.search { - position: relative; - float: left; - height: 2em; - margin-right: 0.5em; -} - -#searchbox { - width: 100% -} - -#searchbox .form-text-field { - position: relative; - float: left; - margin-top: 0.2em; - margin-left: 1em; - width: calc(100% - 4em) -} - -#searchbox input[type="text"] { - background: transparent; - border: none; - font-size: large; - width: 100%; - box-sizing: border-box; - color: var(--foreground-color); -} - /**************************************/ @@ -409,25 +313,9 @@ li::marker { } -#centermessage { - z-index: 4000; - pointer-events: none; - transition: opacity 500ms linear; -} - - - /***************** Info box (box containing features and questions ******************/ -.map-attribution img { - width: 1em; - height: 1em; - fill: black; - border-radius: 0; - display: inline; -} - .leaflet-popup-content { width: 45em !important; } @@ -461,3 +349,13 @@ li::marker { max-width: 1em; } +.small-image { + height: 1em; + max-width: 1em; +} + + +.slideshow-item img{ + height: var(--image-carousel-height); + width: unset; +} \ No newline at end of file diff --git a/index.html b/index.html index bb3bbb633..eb45c805a 100644 --- a/index.html +++ b/index.html @@ -12,10 +12,6 @@ - - - - @@ -74,7 +70,7 @@
    + class="clutter absolute h-24 left-24 right-24 top-56 text-xl text-center" style="z-index: 4000"> Loading MapComplete, hang on...
    diff --git a/index.ts b/index.ts index 47cf7ebb0..472fb943f 100644 --- a/index.ts +++ b/index.ts @@ -5,7 +5,6 @@ import {QueryParameters} from "./Logic/Web/QueryParameters"; import {UIEventSource} from "./Logic/UIEventSource"; import * as $ from "jquery"; import LayoutConfig from "./Customizations/JSON/LayoutConfig"; -import {Utils} from "./Utils"; import MoreScreen from "./UI/BigComponents/MoreScreen"; import State from "./State"; import Combine from "./UI/Base/Combine"; @@ -33,11 +32,6 @@ if (location.href.indexOf("buurtnatuur.be") >= 0) { defaultLayout = "buurtnatuur" } -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); -} - let testing: UIEventSource; if (location.hostname === "localhost" || location.hostname === "127.0.0.1") { @@ -87,18 +81,16 @@ if (layoutToUse?.id === "cyclofix") { const layoutFromBase64 = decodeURIComponent(userLayoutParam.data); -document.getElementById('centermessage').innerText = 'Initilai'; new Combine(["Initializing...
    ", new FixedUiElement("If this message persist, something went wrong - click here to try again") .SetClass("link-underline small") .onClick(() => { - localStorage.clear(); + localStorage.clear(); window.location.reload(true); })]) .AttachTo("centermessage"); // Add an initialization and reset button if something goes wrong - document.getElementById("decoration-desktop").remove(); @@ -142,8 +134,8 @@ if (layoutFromBase64.startsWith("http")) { }); } else if (layoutFromBase64 !== "false") { - layoutToUse = InitUiElements.LoadLayoutFromHash(userLayoutParam); - InitUiElements.InitAll(layoutToUse, layoutFromBase64, testing, defaultLayout, location.hash.substr(1)); + let [layoutToUse, encoded] = InitUiElements.LoadLayoutFromHash(userLayoutParam); + InitUiElements.InitAll(layoutToUse, layoutFromBase64, testing, defaultLayout, encoded); } else if (layoutToUse !== undefined) { // This is the default case: a builtin theme InitUiElements.InitAll(layoutToUse, layoutFromBase64, testing, defaultLayout); diff --git a/langs/en.json b/langs/en.json index 1c602017f..b3ae627a3 100644 --- a/langs/en.json +++ b/langs/en.json @@ -29,8 +29,10 @@ }, "general": { "loginWithOpenStreetMap": "Login with OpenStreetMap", + "loginOnlyNeededToEdit": "to make changes to the map", "welcomeBack": "You are logged in, welcome back!", "loginToStart": "Login to answer this question", + "testing":"Testing - changes won't be saved", "search": { "search": "Search a location", "searching": "Searching…", @@ -38,6 +40,7 @@ "error": "Something went wrong…" }, "returnToTheMap": "Return to the map", + "openTheMap": "Open the map", "save": "Save", "cancel": "Cancel", "skip": "Skip this question", @@ -53,7 +56,7 @@ "zoomInFurther": "Zoom in further to add a point.", "stillLoading": "The data is still loading. Please wait a bit before you add a new point.", "confirmIntro": "

    Add a {title} here?

    The point you create here will be visible for everyone. Please, only add things on to the map if they truly exist. A lot of applications use this data.", - "confirmButton": "Add a {category} here.
    Your addition is visible for everyone
    ", + "warnVisibleForEveryone": "Your addition will be visible for everyone", "openLayerControl": "Open the layer control box", "layerNotEnabled": "The layer {layer} is not enabled. Enable this layer to add a point" }, @@ -107,6 +110,7 @@ "createYourOwnTheme": "Create your own MapComplete theme from scratch" }, "readYourMessages": "Please, read all your OpenStreetMap-messages before adding a new point.", + "presetInfo": "The new POI will have {tags}", "fewChangesBefore": "Please, answer a few questions of existing points before adding a new point.", "goToInbox": "Open inbox", "getStartedLogin": "Login with OpenStreetMap to get started", @@ -119,6 +123,7 @@ "zoomInToSeeThisLayer": "Zoom in to see this layer", "title": "Select layers" }, + "loadingCountry": "Determining country...", "weekdays": { "abbreviations": { "monday": "Mon", @@ -148,7 +153,8 @@ "open_24_7": "Opened around the clock", "ph_not_known": " ", "ph_closed": "closed", - "ph_open": "opened" + "ph_open": "opened with different hours", + "ph_open_as_usual": "opened as usual" } }, "favourite": { diff --git a/package.json b/package.json index 1f7dccad8..75dcc75ac 100644 --- a/package.json +++ b/package.json @@ -80,14 +80,12 @@ "postcss": "^7.0.35", "prompt-sync": "^4.2.0", "sharp": "^0.27.0", - "slick-carousel": "^1.8.1", "tailwindcss": "npm:@tailwindcss/postcss7-compat@^2.0.2", "tslint": "^6.1.3" }, "devDependencies": { "@babel/polyfill": "^7.10.4", "@types/node": "^7.0.5", - "@types/slick-carousel": "^1.6.34", "assert": "^2.0.0", "fs": "0.0.1-security", "marked": "^2.0.0", @@ -95,7 +93,6 @@ "ts-node": "^9.0.0", "ts-node-dev": "^1.0.0-pre.63", "tslint-no-circular-imports": "^0.7.0", - "turndown": "^7.0.0", "typescript": "^3.9.7", "write-file": "^1.0.0" } diff --git a/preferences.ts b/preferences.ts index 7f390c797..4b1dce30a 100644 --- a/preferences.ts +++ b/preferences.ts @@ -9,6 +9,8 @@ import {Utils} from "./Utils"; import {SubtleButton} from "./UI/Base/SubtleButton"; import LZString from "lz-string"; import {LayoutConfigJson} from "./Customizations/JSON/LayoutConfigJson"; +import BaseUIElement from "./UI/BaseUIElement"; +import Table from "./UI/Base/Table"; const connection = new OsmConnection(false, new UIEventSource(undefined), ""); @@ -99,11 +101,14 @@ function createTable(preferences: any) { return; } rendered = true; - const prefs = []; + const prefs: (BaseUIElement|string)[][] = []; for (const key in preferences) { + if(!preferences.hasOwnProperty(key)){ + continue; + } const pref = connection.GetPreference(key, ""); - let value: UIElement = new FixedUiElement(pref.data); + let value: BaseUIElement = new FixedUiElement(pref.data); if (connection.userDetails.data.csCount > 500 && (key.startsWith("mapcomplete") || connection.userDetails.data.csCount > 2500)) { value = new TextField({ @@ -111,24 +116,22 @@ function createTable(preferences: any) { }); } - const c = [ - "", + const row = [ key, - "", - new Button("delete", () => pref.setData("")), - "", - value, - "" + new Button("delete", () => pref.setData(null)), + value ]; - prefs.push(...c); + prefs.push(row); } new Combine( [ ...salvageThemes(preferences).map(theme => SalvageButton(theme)), - "", - ...prefs, - "
    ", + new Table( + ["Key","","Value"], + prefs + + ), new SubtleButton("./assets/svg/delete_icon.svg", "Delete all mapcomplete preferences (mangrove identies are preserved)").onClick(() => clearAll(preferences))] ).AttachTo("maindiv"); } diff --git a/scripts/generateDocs.ts b/scripts/generateDocs.ts index 09e96f0e4..1cfe1418a 100644 --- a/scripts/generateDocs.ts +++ b/scripts/generateDocs.ts @@ -1,25 +1,56 @@ import {Utils} from "../Utils"; Utils.runningFromConsole = true; import SpecialVisualizations from "../UI/SpecialVisualizations"; -import {writeFileSync} from "fs"; -import {UIElement} from "../UI/UIElement"; import SimpleMetaTagger from "../Logic/SimpleMetaTagger"; import Combine from "../UI/Base/Combine"; import {ExtraFunction} from "../Logic/ExtraFunction"; import ValidatedTextField from "../UI/Input/ValidatedTextField"; +import BaseUIElement from "../UI/BaseUIElement"; +import Translations from "../UI/i18n/Translations"; +import {writeFileSync} from "fs"; +import LayoutConfig from "../Customizations/JSON/LayoutConfig"; +import State from "../State"; +import {QueryParameters} from "../Logic/Web/QueryParameters"; - -const TurndownService = require('turndown') - -function WriteFile(filename, html: UIElement) : void { - const md = new TurndownService().turndown(html.InnerRender()); - writeFileSync(filename, md); +function WriteFile(filename, html: string | BaseUIElement, autogenSource: string[]): void { + writeFileSync(filename, new Combine([Translations.W(html), + "Generated from "+autogenSource.join(", ") + ]).AsMarkdown()); } -WriteFile("./Docs/SpecialRenderings.md", SpecialVisualizations.HelpMessage) -WriteFile("./Docs/CalculatedTags.md", new Combine([SimpleMetaTagger.HelpText(), ExtraFunction.HelpText()])) -writeFileSync("./Docs/SpecialInputElements.md", ValidatedTextField.HelpText()); +WriteFile("./Docs/SpecialRenderings.md", SpecialVisualizations.HelpMessage, ["UI/SpecialVisualisations.ts"]) +WriteFile("./Docs/CalculatedTags.md", new Combine([SimpleMetaTagger.HelpText(), ExtraFunction.HelpText()]).SetClass("flex-col"), + ["SimpleMetaTagger","ExtraFunction"]) +WriteFile("./Docs/SpecialInputElements.md", ValidatedTextField.HelpText(),["ValidatedTextField.ts"]); + + +new State(new LayoutConfig({ + language: ["en"], + id: "", + maintainer: "pietervdvn", + version: "0", + title: "", + description: "A theme to generate docs with", + startLat: 0, + startLon: 0, + startZoom: 0, + icon: undefined, + layers: [ + { + name: "", + id: "", + source: { + osmTags: "id~*" + } + } + ] + +})) +QueryParameters.GetQueryParameter("layer-", "true", "Wether or not the layer with id is shown") + +WriteFile("./Docs/URL_Parameters.md", QueryParameters.GenerateQueryParameterDocs(), ["QueryParameters"]) + console.log("Generated docs") diff --git a/scripts/generateIncludedImages.ts b/scripts/generateIncludedImages.ts index f240be7ef..f812bb2c4 100644 --- a/scripts/generateIncludedImages.ts +++ b/scripts/generateIncludedImages.ts @@ -28,7 +28,7 @@ function genImages() { .replace(/[ -]/g, "_"); module += ` public static ${name} = "${svg}"\n` module += ` public static ${name}_img = Img.AsImageElement(Svg.${name})\n` - module += ` public static ${name}_svg() { return new FixedUiElement(Svg.${name});}\n` + module += ` public static ${name}_svg() { return new Img(Svg.${name}, true);}\n` module += ` public static ${name}_ui() { return new FixedUiElement(Svg.${name}_img);}\n\n` allNames.push(`"${path}": Svg.${name}`) } diff --git a/scripts/generateLayouts.ts b/scripts/generateLayouts.ts index 17024c930..747de7ead 100644 --- a/scripts/generateLayouts.ts +++ b/scripts/generateLayouts.ts @@ -88,8 +88,8 @@ async function createManifest(layout: LayoutConfig) { console.log(icon) throw "Icon is not an svg for " + layout.id } - const ogTitle = Translations.W(layout.title).InnerRender(); - const ogDescr = Translations.W(layout.description ?? "").InnerRender(); + const ogTitle = Translations.WT(layout.title).txt; + const ogDescr = Translations.WT(layout.description ?? "").txt; return { name: name, @@ -109,8 +109,8 @@ async function createLandingPage(layout: LayoutConfig, manifest) { Locale.language.setData(layout.language[0]); - const ogTitle = Translations.W(layout.title)?.InnerRender(); - const ogDescr = Translations.W(layout.shortDescription ?? "Easily add and edit geodata with OpenStreetMap")?.InnerRender(); + const ogTitle = Translations.WT(layout.title).txt; + const ogDescr = Translations.WT(layout.shortDescription ?? "Easily add and edit geodata with OpenStreetMap").txt; const ogImage = layout.socialImage; let customCss = ""; diff --git a/scripts/generateWikiPage.ts b/scripts/generateWikiPage.ts index dbbf1ca34..0ff0bf3aa 100644 --- a/scripts/generateWikiPage.ts +++ b/scripts/generateWikiPage.ts @@ -20,7 +20,7 @@ function generateWikiEntry(layout: LayoutConfig) { |region= Worldwide |lang= ${languages} |descr= A MapComplete theme: ${Translations.W(layout.description) - .InnerRender() + .InnerRenderAsString() .replace(".*<\/a>/, "]]") } diff --git a/test.html b/test.html index 437e113aa..839114efe 100644 --- a/test.html +++ b/test.html @@ -3,14 +3,11 @@ Small tests - - - diff --git a/test.ts b/test.ts index 0b0b2f062..4bf602cf7 100644 --- a/test.ts +++ b/test.ts @@ -1,4 +1,66 @@ import ValidatedTextField from "./UI/Input/ValidatedTextField"; -import TestAll from "./test/TestAll"; +import Combine from "./UI/Base/Combine"; +import {VariableUiElement} from "./UI/Base/VariableUIElement"; +import {UIEventSource} from "./Logic/UIEventSource"; +import TagRenderingConfig from "./Customizations/JSON/TagRenderingConfig"; +import State from "./State"; +import TagRenderingQuestion from "./UI/Popup/TagRenderingQuestion"; +import {SlideShow} from "./UI/Image/SlideShow"; +import {FixedUiElement} from "./UI/Base/FixedUiElement"; +import Img from "./UI/Base/Img"; +import {AttributedImage} from "./UI/Image/AttributedImage"; +import {Imgur} from "./Logic/Web/Imgur"; -new TestAll().testAll(); \ No newline at end of file + +function TestSlideshow(){ + const elems = new UIEventSource([ + new FixedUiElement("A"), + new FixedUiElement("qmsldkfjqmlsdkjfmqlskdjfmqlksdf").SetClass("text-xl"), + new Img("https://i.imgur.com/8lIQ5Hv.jpg"), + new AttributedImage("https://i.imgur.com/y5XudzW.jpg", new Imgur()), + new Img("https://www.grunge.com/img/gallery/the-real-reason-your-cat-sleeps-so-much/intro-1601496900.webp") + ]) + new SlideShow(elems).AttachTo("maindiv") +} + +function TestTagRendering(){ + State.state = new State(undefined) + const tagsSource = new UIEventSource({ + id:"node/1" + }) + new TagRenderingQuestion( + tagsSource, + new TagRenderingConfig({ + multiAnswer: false, + freeform: { + key:"valve" + }, + question: "What valves are supported?", + render: "This pump supports {valve}", + mappings: [ + { + if: "valve=dunlop", + then: "This pump supports dunlop" + }, + { + if:"valve=shrader", + then:"shrader is supported", + } + ], + + }, undefined, "test") + ).AttachTo("maindiv") + new VariableUiElement(tagsSource.map(tags => tags["valves"])).SetClass("alert").AttachTo("extradiv") +} + +function TestAllInputMethods(){ + + new Combine(ValidatedTextField.tpList.map(tp => { + const tf = ValidatedTextField.InputForType(tp.name); + + return new Combine([tf, new VariableUiElement(tf.GetValue()).SetClass("alert")]); + })).AttachTo("maindiv") +} + + +TestSlideshow() \ No newline at end of file diff --git a/test/Tag.spec.ts b/test/Tag.spec.ts index 457e68075..70f3b1905 100644 --- a/test/Tag.spec.ts +++ b/test/Tag.spec.ts @@ -144,8 +144,6 @@ export default class TagSpec extends T{ equal(undefined, tr.GetRenderValue({"foo": "bar"})); equal("Has no name", tr.GetRenderValue({"noname": "yes"})?.txt); equal("Ook een {name}", tr.GetRenderValue({"name": "xyz"})?.txt); - equal("Ook een xyz", SubstitutedTranslation.construct(tr.GetRenderValue({"name": "xyz"}), - new UIEventSource({"name": "xyz"})).InnerRender()); equal(undefined, tr.GetRenderValue({"foo": "bar"})); })], @@ -192,12 +190,6 @@ export default class TagSpec extends T{ ] }; - const constr = new TagRenderingConfig(def, undefined, "test"); - const uiEl = new EditableTagRendering(new UIEventSource( - {leisure: "park", "access": "no"}), constr - ); - const rendered = uiEl.InnerRender(); - equal(true, rendered.indexOf("Niet toegankelijk") > 0) } ], [ @@ -360,6 +352,47 @@ export default class TagSpec extends T{ ]); equal(rules, "Tu 10:00-12:00; Su 13:00-17:00"); }], + ["JOIN OH with end hours", () =>{ + const rules = OH.ToString( + OH.MergeTimes([ + + { + weekday: 1, + endHour: 23, + endMinutes: 30, + startHour: 23, + startMinutes: 0 + }, { + weekday: 1, + endHour: 24, + endMinutes: 0, + startHour: 23, + startMinutes: 30 + }, + + ])); + equal(rules, "Tu 23:00-00:00"); + }], ["JOIN OH with overflowed hours", () =>{ + const rules = OH.ToString( + OH.MergeTimes([ + + { + weekday: 1, + endHour: 23, + endMinutes: 30, + startHour: 23, + startMinutes: 0 + }, { + weekday: 1, + endHour: 0, + endMinutes: 0, + startHour: 23, + startMinutes: 30 + }, + + ])); + equal(rules, "Tu 23:00-00:00"); + }], ["OH 24/7", () => { const rules = OH.Parse("24/7"); equal(rules.length, 7); @@ -376,8 +409,10 @@ export default class TagSpec extends T{ equal(rules, null); }], ["OH Parse PH 12:00-17:00", () => { - const rules = PublicHolidayInput.LoadValue("PH 12:00-17:00"); + const rules = OH.ParsePHRule("PH 12:00-17:00"); equal(rules.mode, " "); + equal(rules.start, "12:00") + equal(rules.end, "17:00") }], ["Round", () => { equal(Utils.Round(15), "15.0") diff --git a/test/TagQuestion.spec.ts b/test/TagQuestion.spec.ts deleted file mode 100644 index 0e0dad1e7..000000000 --- a/test/TagQuestion.spec.ts +++ /dev/null @@ -1,63 +0,0 @@ -import T from "./TestHelper"; -import {Utils} from "../Utils"; - -Utils.runningFromConsole = true; -import TagRenderingQuestion from "../UI/Popup/TagRenderingQuestion"; -import {UIEventSource} from "../Logic/UIEventSource"; -import TagRenderingConfig from "../Customizations/JSON/TagRenderingConfig"; -import EditableTagRendering from "../UI/Popup/EditableTagRendering"; - -export default class TagQuestionSpec extends T { - constructor() { - super("TagQuestionElement", - [ - ["Freeform has textfield", () => { - const tags = new UIEventSource({ - id: "way/123", - amenity: 'public_bookcases' - }); - const config = new TagRenderingConfig( - { - render: "The name is {name}", - question: "What is the name of this bookcase?", - freeform: { - key: "name", - type: "string" - } - }, undefined, "Testing tag" - ); - const questionElement = new TagRenderingQuestion(tags, config); - const html = questionElement.InnerRender(); - T.assertContains("What is the name of this bookcase?", html); - T.assertContains(" { - const tags = new UIEventSource({ - id: "way/123", - amenity: 'public_bookcases' - }); - const config = new TagRenderingConfig( - { - render: "The name is {name}", - question: "What is the name of this bookcase?", - freeform: { - key: "name", - type: "string" - }, - mappings: [ - { - "if": "noname=yes", - "then": "This bookcase has no name" - } - ] - }, undefined, "Testing tag" - ); - const questionElement = new TagRenderingQuestion(tags, config); - const html = questionElement.InnerRender(); - T.assertContains("What is the name of this bookcase?", html); - T.assertContains("This bookcase has no name", html); - T.assertContains(" 1)) { - this._cleanUpAccuratePositioning(); - this._handleAccuratePositionResponse(pos); - } else { - this._handleAccuratePositionProgress(pos); - } - }, - - _prepareAccuratePositionData: function (pos) { - var lat = pos.coords.latitude, - lng = pos.coords.longitude, - latlng = new L.LatLng(lat, lng), - - latAccuracy = 180 * pos.coords.accuracy / 40075017, - lngAccuracy = latAccuracy / Math.cos(Math.PI / 180 * lat), - - bounds = L.latLngBounds( - [lat - latAccuracy, lng - lngAccuracy], - [lat + latAccuracy, lng + lngAccuracy]); - - var data = { - latlng: latlng, - bounds: bounds, - timestamp: pos.timestamp - }; - - for (var i in pos.coords) { - if (typeof pos.coords[i] === 'number') { - data[i] = pos.coords[i]; - } - } - - return data; - }, - - _handleAccuratePositionProgress: function (pos) { - var data = this._prepareAccuratePositionData(pos); - this.fire('accuratepositionprogress', data); - }, - - _handleAccuratePositionResponse: function (pos) { - var data = this._prepareAccuratePositionData(pos); - this.fire('accuratepositionfound', data); - }, - - _handleAccuratePositionError: function (error) { - var c = error.code, - message = error.message || - (c === 1 ? 'permission denied' : - (c === 2 ? 'position unavailable' : 'timeout')); - - this._cleanUpAccuratePositioning(); - - this.fire('accuratepositionerror', { - code: c, - message: 'Geolocation error: ' + message + '.' - }); - } -}); -console.log("Find accurate position script loaded"); \ No newline at end of file