forked from MapComplete/MapComplete
		
	Merge branch 'refactoring/new-ui' into develop
This commit is contained in:
		
						commit
						8e22ae9aee
					
				
					 163 changed files with 4624 additions and 6819 deletions
				
			
		|  | @ -12,12 +12,11 @@ import Combine from "../../UI/Base/Combine"; | |||
| import {VariableUiElement} from "../../UI/Base/VariableUIElement"; | ||||
| import {UIEventSource} from "../../Logic/UIEventSource"; | ||||
| import {FixedUiElement} from "../../UI/Base/FixedUiElement"; | ||||
| import {UIElement} from "../../UI/UIElement"; | ||||
| import {SubstitutedTranslation} from "../../UI/SubstitutedTranslation"; | ||||
| import SourceConfig from "./SourceConfig"; | ||||
| import {TagsFilter} from "../../Logic/Tags/TagsFilter"; | ||||
| import {Tag} from "../../Logic/Tags/Tag"; | ||||
| import SubstitutingTag from "../../Logic/Tags/SubstitutingTag"; | ||||
| import BaseUIElement from "../../UI/BaseUIElement"; | ||||
| 
 | ||||
| export default class LayerConfig { | ||||
| 
 | ||||
|  | @ -290,11 +289,11 @@ export default class LayerConfig { | |||
| 
 | ||||
|     } | ||||
| 
 | ||||
|     public GenerateLeafletStyle(tags: UIEventSource<any>, clickable: boolean): | ||||
|     public GenerateLeafletStyle(tags: UIEventSource<any>, clickable: boolean, widthHeight= "100%"): | ||||
|         { | ||||
|             icon: | ||||
|                 { | ||||
|                     html: UIElement, | ||||
|                     html: BaseUIElement, | ||||
|                     iconSize: [number, number], | ||||
|                     iconAnchor: [number, number], | ||||
|                     popupAnchor: [number, number], | ||||
|  | @ -325,7 +324,7 @@ export default class LayerConfig { | |||
| 
 | ||||
|         function render(tr: TagRenderingConfig, deflt?: string) { | ||||
|             const str = (tr?.GetRenderValue(tags.data)?.txt ?? deflt); | ||||
|             return SubstitutedTranslation.SubstituteKeys(str, tags.data).replace(/{.*}/g, ""); | ||||
|             return Utils.SubstituteKeys(str, tags.data).replace(/{.*}/g, ""); | ||||
|         } | ||||
| 
 | ||||
|         const iconSize = render(this.iconSize, "40,40,center").split(","); | ||||
|  | @ -361,7 +360,7 @@ export default class LayerConfig { | |||
|         const iconUrlStatic = render(this.icon); | ||||
|         const self = this; | ||||
|         const mappedHtml = tags.map(tgs => { | ||||
|             function genHtmlFromString(sourcePart: string): UIElement { | ||||
|             function genHtmlFromString(sourcePart: string): BaseUIElement { | ||||
|                 if (sourcePart.indexOf("html:") == 0) { | ||||
|                     // We use § as a replacement for ;
 | ||||
|                     const html = sourcePart.substring("html:".length) | ||||
|  | @ -370,7 +369,7 @@ export default class LayerConfig { | |||
|                 } | ||||
| 
 | ||||
|                 const style = `width:100%;height:100%;transform: rotate( ${rotation} );display:block;position: absolute; top: 0; left: 0`; | ||||
|                 let html: UIElement = new FixedUiElement(`<img src="${sourcePart}" style="${style}" />`); | ||||
|                 let html: BaseUIElement = new FixedUiElement(`<img src="${sourcePart}" style="${style}" />`); | ||||
|                 const match = sourcePart.match(/([a-zA-Z0-9_]*):([^;]*)/) | ||||
|                 if (match !== null && Svg.All[match[1] + ".svg"] !== undefined) { | ||||
|                     html = new Combine([ | ||||
|  | @ -387,7 +386,7 @@ export default class LayerConfig { | |||
|             const iconUrl = render(self.icon); | ||||
|             const rotation = render(self.rotation, "0deg"); | ||||
| 
 | ||||
|             let htmlParts: UIElement[] = []; | ||||
|             let htmlParts: BaseUIElement[] = []; | ||||
|             let sourceParts = Utils.NoNull(iconUrl.split(";").filter(prt => prt != "")); | ||||
|             for (const sourcePart of sourceParts) { | ||||
|                 htmlParts.push(genHtmlFromString(sourcePart)) | ||||
|  | @ -399,7 +398,7 @@ export default class LayerConfig { | |||
|                     continue; | ||||
|                 } | ||||
|                 if (iconOverlay.badge) { | ||||
|                     const badgeParts: UIElement[] = []; | ||||
|                     const badgeParts: BaseUIElement[] = []; | ||||
|                     const partDefs = iconOverlay.then.GetRenderValue(tgs).txt.split(";").filter(prt => prt != ""); | ||||
| 
 | ||||
|                     for (const badgePartStr of partDefs) { | ||||
|  | @ -437,7 +436,7 @@ export default class LayerConfig { | |||
|             } catch (e) { | ||||
|                 console.error(e, tgs) | ||||
|             } | ||||
|             return new Combine(htmlParts).Render(); | ||||
|             return new Combine(htmlParts); | ||||
|         }) | ||||
| 
 | ||||
| 
 | ||||
|  |  | |||
|  | @ -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); | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|  |  | |||
|  | @ -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 | ||||
|  |  | |||
|  | @ -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 | ||||
| 
 | ||||
|  |  | |||
|  | @ -1,5 +1,8 @@ | |||
| Metatags | ||||
| -------- | ||||
| 
 | ||||
|  Metatags  | ||||
| ========== | ||||
| 
 | ||||
| 
 | ||||
| 
 | ||||
| Metatags are extra tags available, in order to display more data or to give better questions. | ||||
| 
 | ||||
|  | @ -7,85 +10,155 @@ The are calculated automatically on every feature when the data arrives in the w | |||
| 
 | ||||
| **Hint:** when using metatags, add the [query parameter](URL_Parameters.md) `debug=true` to the URL. This will include a box in the popup for features which shows all the properties of the object | ||||
| 
 | ||||
| ### \_lat, \_lon | ||||
| 
 | ||||
|  Metatags calculated by MapComplete  | ||||
| ------------------------------------ | ||||
| 
 | ||||
| 
 | ||||
| 
 | ||||
| The following values are always calculated, by default, by MapComplete and are available automatically on all elements in every theme | ||||
| 
 | ||||
| 
 | ||||
| ### _lat, _lon  | ||||
| 
 | ||||
| 
 | ||||
| 
 | ||||
| The latitude and longitude of the point (or centerpoint in the case of a way/area) | ||||
| 
 | ||||
| ### \_surface, \_surface:ha | ||||
| 
 | ||||
| ### _surface, _surface:ha  | ||||
| 
 | ||||
| 
 | ||||
| 
 | ||||
| The surface area of the feature, in square meters and in hectare. Not set on points and ways | ||||
| 
 | ||||
| ### \_length, \_length:km | ||||
| 
 | ||||
| The total length of a feature in meters (and in kilometers, rounded to one decimal for '\_length:km'). For a surface, the length of the perimeter | ||||
| ### _length, _length:km  | ||||
| 
 | ||||
| 
 | ||||
| 
 | ||||
| The total length of a feature in meters (and in kilometers, rounded to one decimal for '_length:km'). For a surface, the length of the perimeter | ||||
| 
 | ||||
| 
 | ||||
| ### _country  | ||||
| 
 | ||||
| 
 | ||||
| ### \_country | ||||
| 
 | ||||
| The country code of the property (with latlon2country) | ||||
| 
 | ||||
| ### \_isOpen, \_isOpen:description | ||||
| 
 | ||||
| If 'opening\_hours' is present, it will add the current state of the feature (being 'yes' or 'no') | ||||
| ### _isOpen, _isOpen:description  | ||||
| 
 | ||||
| 
 | ||||
| 
 | ||||
| If 'opening_hours' is present, it will add the current state of the feature (being 'yes' or 'no') | ||||
| 
 | ||||
| 
 | ||||
| ### _width:needed, _width:needed:no_pedestrians, _width:difference  | ||||
| 
 | ||||
| 
 | ||||
| ### \_width:needed, \_width:needed:no\_pedestrians, \_width:difference | ||||
| 
 | ||||
| Legacy for a specific project calculating the needed width for safe traffic on a road. Only activated if 'width:carriageway' is present | ||||
| 
 | ||||
| ### \_direction:numerical, \_direction:leftright | ||||
| 
 | ||||
| \_direction:numerical is a normalized, numerical direction based on 'camera:direction' or on 'direction'; it is only present if a valid direction is found (e.g. 38.5 or NE). \_direction:leftright is either 'left' or 'right', which is left-looking on the map or 'right-looking' on the map | ||||
| ### _direction:numerical, _direction:leftright  | ||||
| 
 | ||||
| 
 | ||||
| 
 | ||||
| _direction:numerical is a normalized, numerical direction based on 'camera:direction' or on 'direction'; it is only present if a valid direction is found (e.g. 38.5 or NE). _direction:leftright is either 'left' or 'right', which is left-looking on the map or 'right-looking' on the map | ||||
| 
 | ||||
| 
 | ||||
| ### _now:date, _now:datetime, _loaded:date, _loaded:_datetime  | ||||
| 
 | ||||
| 
 | ||||
| ### \_now:date, \_now:datetime, \_loaded:date, \_loaded:\_datetime | ||||
| 
 | ||||
| Adds the time that the data got loaded - pretty much the time of downloading from overpass. The format is YYYY-MM-DD hh:mm, aka 'sortable' aka ISO-8601-but-not-entirely | ||||
| 
 | ||||
| ### \_last\_edit:contributor, \_last\_edit:contributor:uid, \_last\_edit:changeset, \_last\_edit:timestamp, \_version\_number | ||||
| 
 | ||||
| ### _last_edit:contributor, _last_edit:contributor:uid, _last_edit:changeset, _last_edit:timestamp, _version_number  | ||||
| 
 | ||||
| 
 | ||||
| 
 | ||||
| Information about the last edit of this object. | ||||
| 
 | ||||
| Calculating tags with Javascript | ||||
| -------------------------------- | ||||
| 
 | ||||
| In some cases, it is useful to have some tags calculated based on other properties. Some useful tags are available by default (e.g. **lat**, **lon**, **\_country**), as detailed above. | ||||
|  Calculating tags with Javascript  | ||||
| ---------------------------------- | ||||
| 
 | ||||
| 
 | ||||
| 
 | ||||
| In some cases, it is useful to have some tags calculated based on other properties. Some useful tags are available by default (e.g. `lat`, `lon`, `_country`), as detailed above. | ||||
| 
 | ||||
| It is also possible to calculate your own tags - but this requires some javascript knowledge. | ||||
| 
 | ||||
| 
 | ||||
| 
 | ||||
| Before proceeding, some warnings: | ||||
| 
 | ||||
| *   DO NOT DO THIS AS BEGINNER | ||||
| *   **Only do this if all other techniques fail**. This should _not_ be done to create a rendering effect, only to calculate a specific value | ||||
| *   **THIS MIGHT BE DISABLED WITHOUT ANY NOTICE ON UNOFFICIAL THEMES**. As unofficial themes might be loaded from the internet, this is the equivalent of injecting arbitrary code into the client. It'll be disabled if abuse occurs. | ||||
| 
 | ||||
| In the layer object, add a field **calculatedTags**, e.g.: | ||||
| 
 | ||||
| "calculatedTags": \[ "\_someKey=javascript-expression", "name=feat.properties.name ?? feat.properties.ref ?? feat.properties.operator", "\_distanceCloserThen3Km=feat.distanceTo( some\_lon, some\_lat) < 3 ? 'yes' : 'no'" \] | ||||
|   - DO NOT DO THIS AS BEGINNER | ||||
|   - **Only do this if all other techniques fail**  This should _not_ be done to create a rendering effect, only to calculate a specific value | ||||
|   - **THIS MIGHT BE DISABLED WITHOUT ANY NOTICE ON UNOFFICIAL THEMES** As unofficial themes might be loaded from the internet, this is the equivalent of injecting arbitrary code into the client. It'll be disabled if abuse occurs. | ||||
| 
 | ||||
| The above code will be executed for every feature in the layer. The feature is accessible as **feat** and is an amended geojson object: - **area** contains the surface area (in square meters) of the object - **lat** and **lon** contain the latitude and longitude Some advanced functions are available on **feat** as well: | ||||
| 
 | ||||
| *   distanceTo | ||||
| *   overlapWith | ||||
| *   closest | ||||
| *   memberships | ||||
| To enable this feature,  add a field `calculatedTags` in the layer object, e.g.: | ||||
| 
 | ||||
| ### distanceTo | ||||
| ```` | ||||
| 
 | ||||
| Calculates the distance between the feature and a specified point in kilometer. The input should either be a pair of coordinates, a geojson feature or the ID of an object | ||||
| "calculatedTags": [ | ||||
| 
 | ||||
| *   longitude | ||||
| *   latitude | ||||
|     "_someKey=javascript-expression", | ||||
| 
 | ||||
| ### overlapWith | ||||
|     "name=feat.properties.name ?? feat.properties.ref ?? feat.properties.operator", | ||||
| 
 | ||||
| Gives a list of features from the specified layer which this feature (partly) overlaps with. If the current feature is a point, all features that embed the point are given. The returned value is `{ feat: GeoJSONFeature, overlap: number}[]` where `overlap` is the overlapping surface are (in m²) for areas, the overlapping length (in meter) if the current feature is a line or `undefined` if the current feature is a point | ||||
|     "_distanceCloserThen3Km=feat.distanceTo( some_lon, some_lat) < 3 ? 'yes' : 'no'"  | ||||
| 
 | ||||
| *   ...layerIds - one or more layer ids of the layer from which every feature is checked for overlap) | ||||
|   ] | ||||
| 
 | ||||
| ### closest | ||||
| ```` | ||||
| 
 | ||||
| Given either a list of geojson features or a single layer name, gives the single object which is nearest to the feature. In the case of ways/polygons, only the centerpoint is considered. | ||||
| 
 | ||||
| *   list of features | ||||
| 
 | ||||
| ### memberships | ||||
| The above code will be executed for every feature in the layer. The feature is accessible as `feat` and is an amended geojson object: | ||||
| 
 | ||||
| Gives a list of `{role: string, relation: Relation}`\-objects, containing all the relations that this feature is part of. For example: `_part_of_walking_routes=feat.memberships().map(r => r.relation.tags.name).join(';')` | ||||
| 
 | ||||
| 
 | ||||
|   - `area` contains the surface area (in square meters) of the object | ||||
|   - `lat` and `lon` contain the latitude and longitude | ||||
| 
 | ||||
| 
 | ||||
| Some advanced functions are available on **feat** as well:  | ||||
| 
 | ||||
|   - distanceTo | ||||
|   - overlapWith | ||||
|   - closest | ||||
|   - memberships | ||||
|   | ||||
| ### distanceTo  | ||||
| 
 | ||||
|  Calculates the distance between the feature and a specified point in kilometer. The input should either be a pair of coordinates, a geojson feature or the ID of an object  | ||||
| 
 | ||||
|   0. longitude | ||||
|   1. latitude | ||||
|   | ||||
| ### overlapWith  | ||||
| 
 | ||||
|  Gives a list of features from the specified layer which this feature (partly) overlaps with. If the current feature is a point, all features that embed the point are given. The returned value is `{ feat: GeoJSONFeature, overlap: number}[]` where `overlap` is the overlapping surface are (in m²) for areas, the overlapping length (in meter) if the current feature is a line or `undefined` if the current feature is a point  | ||||
| 
 | ||||
|   0. ...layerIds - one or more layer ids  of the layer from which every feature is checked for overlap) | ||||
|   | ||||
| ### closest  | ||||
| 
 | ||||
|  Given either a list of geojson features or a single layer name, gives the single object which is nearest to the feature. In the case of ways/polygons, only the centerpoint is considered.  | ||||
| 
 | ||||
|   0. list of features | ||||
|   | ||||
| ### memberships  | ||||
| 
 | ||||
|  Gives a list of `{role: string, relation: Relation}`-objects, containing all the relations that this feature is part of.  | ||||
| 
 | ||||
| For example: `_part_of_walking_routes=feat.memberships().map(r => r.relation.tags.name).join(';')`  | ||||
| 
 | ||||
| 
 | ||||
|  Generated from SimpleMetaTagger, ExtraFunction | ||||
|  | @ -60,4 +60,4 @@ Has extra elements to easily input when a POI is opened | |||
| 
 | ||||
| ## color | ||||
| 
 | ||||
| Shows a color picker | ||||
| Shows a color picker Generated from ValidatedTextField.ts | ||||
|  | @ -1,61 +1 @@ | |||
| ### Special tag renderings | ||||
| 
 | ||||
| In a tagrendering, some special values are substituted by an advanced UI-element. This allows advanced features and visualizations to be reused by custom themes or even to query third-party API's.General usage is **{func\_name()}** or **{func\_name(arg, someotherarg)}**. Note that you _do not_ need to use quotes around your arguments, the comma is enough to seperate them. This also implies you cannot use a comma in your args | ||||
| 
 | ||||
| ### all\_tags | ||||
| 
 | ||||
| Prints all key-value pairs of the object - used for debugging | ||||
| 
 | ||||
| **Example usage:** {all\_tags()} | ||||
| 
 | ||||
| ### image\_carousel | ||||
| 
 | ||||
| Creates an image carousel for the given sources. An attempt will be made to guess what source is used. Supported: Wikidata identifiers, Wikipedia pages, Wikimedia categories, IMGUR (with attribution, direct links) | ||||
| 
 | ||||
| 1.  **image key/prefix**: The keys given to the images, e.g. if image is given, the first picture URL will be added as image, the second as image:0, the third as image:1, etc... Default: image | ||||
| 2.  **smart search**: Also include images given via 'Wikidata', 'wikimedia\_commons' and 'mapillary Default: true | ||||
| 
 | ||||
| **Example usage:** {image\_carousel(image,true)} | ||||
| 
 | ||||
| ### image\_upload | ||||
| 
 | ||||
| Creates a button where a user can upload an image to IMGUR | ||||
| 
 | ||||
| 1.  **image-key**: Image tag to add the URL to (or image-tag:0, image-tag:1 when multiple images are added) Default: image | ||||
| 
 | ||||
| **Example usage:** {image\_upload(image)} | ||||
| 
 | ||||
| ### reviews | ||||
| 
 | ||||
| Adds an overview of the mangrove-reviews of this object. Mangrove.Reviews needs - in order to identify the reviewed object - a coordinate and a name. By default, the name of the object is given, but this can be overwritten | ||||
| 
 | ||||
| 1.  **subjectKey**: The key to use to determine the subject. If specified, the subject will be **tags\[subjectKey\]** Default: name | ||||
| 2.  **fallback**: The identifier to use, if _tags\[subjectKey\]_ as specified above is not available. This is effectively a fallback value | ||||
| 
 | ||||
| **Example usage:** **{reviews()} **for a vanilla review, **{reviews(name, play\_forest)}** to review a play forest. If a name is known, the name will be used as identifier, otherwise 'play\_forest' is used**** | ||||
| 
 | ||||
| ### ****opening\_hours\_table**** | ||||
| 
 | ||||
| ****Creates an opening-hours table. Usage: {opening\_hours\_table(opening\_hours)} to create a table of the tag 'opening\_hours'. | ||||
| 
 | ||||
| 1.  **key**: The tagkey from which the table is constructed. Default: opening\_hours | ||||
| 
 | ||||
| **Example usage:** {opening\_hours\_table(opening\_hours)} | ||||
| 
 | ||||
| ### live | ||||
| 
 | ||||
| Downloads a JSON from the given URL, e.g. '{live(example.org/data.json, shorthand:x.y.z, other:a.b.c, shorthand)}' will download the given file, will create an object {shorthand: json\[x\]\[y\]\[z\], other: json\[a\]\[b\]\[c\] out of it and will return 'other' or 'json\[a\]\[b\]\[c\]. This is made to use in combination with tags, e.g. {live({url}, {url:format}, needed\_value)} | ||||
| 
 | ||||
| 1.  **Url**: The URL to load | ||||
| 2.  **Shorthands**: A list of shorthands, of the format 'shorthandname:path.path.path'. Seperated by ; | ||||
| 3.  **path**: The path (or shorthand) that should be returned | ||||
| 
 | ||||
| **Example usage:** {live({url},{url:format},hour)} {live(https://data.mobility.brussels/bike/api/counts/?request=live&featureID=CB2105,hour:data.hour\_cnt;day:data.day\_cnt;year:data.year\_cnt,hour)} | ||||
| 
 | ||||
| ### share\_link | ||||
| 
 | ||||
| Creates a link that (attempts to) open the native 'share'-screen | ||||
| 
 | ||||
| 1.  **url**: The url to share (default: current URL) | ||||
| 
 | ||||
| **Example usage:** {share\_link()} to share the current page, {share\_link()} to share the given url**** | ||||
| <h3>Special tag renderings</h3> In a tagrendering, some special values are substituted by an advanced UI-element. This allows advanced features and visualizations to be reused by custom themes or even to query third-party API's. General usage is <b>{func_name()}</b> or <b>{func_name(arg, someotherarg)}</b>. Note that you <i>do not</i> need to use quotes around your arguments, the comma is enough to seperate them. This also implies you cannot use a comma in your args <h3>all_tags</h3> Prints all key-value pairs of the object - used for debugging <ol> </ol> <b>Example usage: </b> {all_tags()} <h3>image_carousel</h3> Creates an image carousel for the given sources. An attempt will be made to guess what source is used. Supported: Wikidata identifiers, Wikipedia pages, Wikimedia categories, IMGUR (with attribution, direct links) <ol> <li> <b>image key/prefix</b>:  The keys given to the images, e.g. if <span class='literal-code'>image</span> is given, the first picture URL will be added as <span class='literal-code'>image</span>, the second as <span class='literal-code'>image:0</span>, the third as <span class='literal-code'>image:1</span>, etc...   Default: <span class='literal-code'>image</span> </li> <li> <b>smart search</b>:  Also include images given via 'Wikidata', 'wikimedia_commons' and 'mapillary  Default: <span class='literal-code'>true</span> </li> </ol> <b>Example usage: </b> {image_carousel(image,true)} <h3>image_upload</h3> Creates a button where a user can upload an image to IMGUR <ol> <li> <b>image-key</b>:  Image tag to add the URL to (or image-tag:0, image-tag:1 when multiple images are added)  Default: <span class='literal-code'>image</span> </li> </ol> <b>Example usage: </b> {image_upload(image)} <h3>reviews</h3> Adds an overview of the mangrove-reviews of this object. Mangrove.Reviews needs - in order to identify the reviewed object - a coordinate and a name. By default, the name of the object is given, but this can be overwritten <ol> <li> <b>subjectKey</b>:  The key to use to determine the subject. If specified, the subject will be <b>tags[subjectKey]</b>  Default: <span class='literal-code'>name</span> </li> <li> <b>fallback</b>:  The identifier to use, if <i>tags[subjectKey]</i> as specified above is not available. This is effectively a fallback value  </li> </ol> <b>Example usage: </b> <b>{reviews()}<b> for a vanilla review, <b>{reviews(name, play_forest)}</b> to review a play forest. If a name is known, the name will be used as identifier, otherwise 'play_forest' is used <h3>opening_hours_table</h3> Creates an opening-hours table. Usage: {opening_hours_table(opening_hours)} to create a table of the tag 'opening_hours'. <ol> <li> <b>key</b>:  The tagkey from which the table is constructed.  Default: <span class='literal-code'>opening_hours</span> </li> </ol> <b>Example usage: </b> {opening_hours_table(opening_hours)} <h3>live</h3> Downloads a JSON from the given URL, e.g. '{live(example.org/data.json, shorthand:x.y.z, other:a.b.c, shorthand)}' will download the given file, will create an object {shorthand: json[x][y][z], other: json[a][b][c] out of it and will return 'other' or 'json[a][b][c]. This is made to use in combination with tags, e.g. {live({url}, {url:format}, needed_value)} <ol> <li> <b>Url</b>:  The URL to load  </li> <li> <b>Shorthands</b>:  A list of shorthands, of the format 'shorthandname:path.path.path'. Seperated by ;  </li> <li> <b>path</b>:  The path (or shorthand) that should be returned  </li> </ol> <b>Example usage: </b> {live({url},{url:format},hour)} {live(https://data.mobility.brussels/bike/api/counts/?request=live&featureID=CB2105,hour:data.hour_cnt;day:data.day_cnt;year:data.year_cnt,hour)} <h3>share_link</h3> Creates a link that (attempts to) open the native 'share'-screen <ol> <li> <b>url</b>:  The url to share (default: current URL)  </li> </ol> <b>Example usage: </b> {share_link()} to share the current page, {share_link(<some_url>)} to share the given url Generated from UI/SpecialVisualisations.ts | ||||
|  | @ -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": "" | ||||
|   }, | ||||
|   { | ||||
|  |  | |||
|  | @ -1,3 +1,4 @@ | |||
| 
 | ||||
| URL-parameters and URL-hash | ||||
| ============================ | ||||
| 
 | ||||
|  | @ -18,125 +19,128 @@ the URL-parameters are stated in the part between the `?` and the `#`. There are | |||
| 
 | ||||
| Finally, the URL-hash is the part after the `#`. It is `node/1234` in this case. | ||||
| 
 | ||||
| custom-css (broken) | ||||
| ------------ | ||||
| If specified, the custom css from the given link will be loaded additionaly | ||||
| 
 | ||||
| 
 | ||||
| test | ||||
| ------ | ||||
| If true, 'dryrun' mode is activated. The app will behave as normal, except that changes to OSM will be printed onto the console instead of actually uploaded to osm.org | ||||
| The default value is _false_ | ||||
| 
 | ||||
| layout | ||||
| -------- | ||||
| The layout to load into MapComplete | ||||
| 
 | ||||
| userlayout | ||||
| ------------ | ||||
| If not 'false', a custom (non-official) theme is loaded. This custom layout can be done in multiple ways: | ||||
| 
 | ||||
| - The hash of the URL contains a base64-encoded .json-file containing the theme definition | ||||
| - The hash of the URL contains a lz-compressed .json-file, as generated by the custom theme generator | ||||
| - The parameter itself is an URL, in which case that URL will be downloaded. It should point to a .json of a theme | ||||
|   The default value is _false_ | ||||
| 
 | ||||
| layer-control-toggle | ||||
|  layer-control-toggle  | ||||
| ---------------------- | ||||
| Whether or not the layer control is shown | ||||
| The default value is _false_ | ||||
| 
 | ||||
| tab | ||||
|  Whether or not the layer control is shown The default value is _false_ | ||||
| 
 | ||||
| 
 | ||||
|  tab  | ||||
| ----- | ||||
| The tab that is shown in the welcome-message. 0 = the explanation of the theme,1 = OSM-credits, 2 = sharescreen, 3 = more themes, 4 = about mapcomplete (user must be logged in and have >50 changesets) | ||||
| The default value is _0_ | ||||
| 
 | ||||
| z | ||||
|  The tab that is shown in the welcome-message. 0 = the explanation of the theme,1 = OSM-credits, 2 = sharescreen, 3 = more themes, 4 = about mapcomplete (user must be logged in and have >50 changesets) The default value is _0_ | ||||
| 
 | ||||
| 
 | ||||
|  z  | ||||
| --- | ||||
| The initial/current zoom level | ||||
| The default value is set by the theme | ||||
| 
 | ||||
| lat | ||||
|  The initial/current zoom level The default value is _0_ | ||||
| 
 | ||||
| 
 | ||||
|  lat  | ||||
| ----- | ||||
| The initial/current latitude | ||||
| The default value is set by the theme | ||||
| 
 | ||||
| lon | ||||
|  The initial/current latitude The default value is _0_ | ||||
| 
 | ||||
| 
 | ||||
|  lon  | ||||
| ----- | ||||
| The initial/current longitude of the app | ||||
| The default value is set by the theme | ||||
| 
 | ||||
| fs-userbadge | ||||
|  The initial/current longitude of the app The default value is _0_ | ||||
| 
 | ||||
| 
 | ||||
|  fs-userbadge  | ||||
| -------------- | ||||
| Disables/Enables the user information pill (userbadge) at the top left. Disabling this disables logging in and thus disables editing all together, effectively putting MapComplete into read-only mode. | ||||
| The default value is _true_ | ||||
| 
 | ||||
| fs-search | ||||
|  Disables/Enables the user information pill (userbadge) at the top left. Disabling this disables logging in and thus disables editing all together, effectively putting MapComplete into read-only mode. The default value is _true_ | ||||
| 
 | ||||
| 
 | ||||
|  fs-search  | ||||
| ----------- | ||||
| Disables/Enables the search bar | ||||
| The default value is _true_ | ||||
| 
 | ||||
| fs-layers | ||||
|  Disables/Enables the search bar The default value is _true_ | ||||
| 
 | ||||
| 
 | ||||
|  fs-layers  | ||||
| ----------- | ||||
| Disables/Enables the layer control | ||||
| The default value is _true_ | ||||
| 
 | ||||
| fs-add-new | ||||
|  Disables/Enables the layer control The default value is _true_ | ||||
| 
 | ||||
| 
 | ||||
|  fs-add-new  | ||||
| ------------ | ||||
| Disables/Enables the 'add new feature'-popup. (A theme without presets might not have it in the first place) | ||||
| The default value is _true_ | ||||
| 
 | ||||
| fs-welcome-message | ||||
|  Disables/Enables the 'add new feature'-popup. (A theme without presets might not have it in the first place) The default value is _true_ | ||||
| 
 | ||||
| 
 | ||||
|  fs-welcome-message  | ||||
| -------------------- | ||||
| Disables/enables the help menu or welcome message | ||||
| The default value is _true_ | ||||
| 
 | ||||
| fs-iframe | ||||
|  Disables/enables the help menu or welcome message The default value is _true_ | ||||
| 
 | ||||
| 
 | ||||
|  fs-iframe  | ||||
| ----------- | ||||
| Disables/Enables the iframe-popup | ||||
| The default value is _false_ | ||||
| 
 | ||||
| fs-more-quests | ||||
|  Disables/Enables the iframe-popup The default value is _false_ | ||||
| 
 | ||||
| 
 | ||||
|  fs-more-quests  | ||||
| ---------------- | ||||
| Disables/Enables the 'More Quests'-tab in the welcome message | ||||
| The default value is _true_ | ||||
| 
 | ||||
| fs-share-screen | ||||
|  Disables/Enables the 'More Quests'-tab in the welcome message The default value is _true_ | ||||
| 
 | ||||
| 
 | ||||
|  fs-share-screen  | ||||
| ----------------- | ||||
| Disables/Enables the 'Share-screen'-tab in the welcome message | ||||
| The default value is _true_ | ||||
| 
 | ||||
| fs-geolocation | ||||
|  Disables/Enables the 'Share-screen'-tab in the welcome message The default value is _true_ | ||||
| 
 | ||||
| 
 | ||||
|  fs-geolocation  | ||||
| ---------------- | ||||
| Disables/Enables the geolocation button | ||||
| The default value is _true_ | ||||
| 
 | ||||
| fs-all-questions | ||||
|  Disables/Enables the geolocation button The default value is _true_ | ||||
| 
 | ||||
| 
 | ||||
|  fs-all-questions  | ||||
| ------------------ | ||||
| Always show all questions | ||||
| The default value is _false_ | ||||
| 
 | ||||
| debug | ||||
|  Always show all questions The default value is _false_ | ||||
| 
 | ||||
| 
 | ||||
|  test  | ||||
| ------ | ||||
| 
 | ||||
|  If true, 'dryrun' mode is activated. The app will behave as normal, except that changes to OSM will be printed onto the console instead of actually uploaded to osm.org The default value is _false_ | ||||
| 
 | ||||
| 
 | ||||
|  debug  | ||||
| ------- | ||||
| If true, shows some extra debugging help such as all the available tags on every object | ||||
| The default value is _false_ | ||||
| 
 | ||||
| backend | ||||
|  If true, shows some extra debugging help such as all the available tags on every object The default value is _false_ | ||||
| 
 | ||||
| 
 | ||||
|  backend  | ||||
| --------- | ||||
| The OSM backend to use - can be used to redirect mapcomplete to the testing backend when using osm-test | ||||
| The default value is _osm_ | ||||
| 
 | ||||
| oauth_token | ||||
| ------------- | ||||
| Used to complete the login | ||||
| No default value set | ||||
|  The OSM backend to use - can be used to redirect mapcomplete to the testing backend when using 'osm-test' The default value is _osm_ | ||||
| 
 | ||||
| background | ||||
| 
 | ||||
|  custom-css  | ||||
| ------------ | ||||
| The id of the background layer to start with | ||||
| The default value is _OSM_ (overridden by the theme)  | ||||
| 
 | ||||
| layer-<layerid> | ||||
| -------------- | ||||
| Wether or not layer with layer-id is shown | ||||
| The default value is _true_ | ||||
|  If specified, the custom css from the given link will be loaded additionaly The default value is __ | ||||
| 
 | ||||
| 
 | ||||
|  background  | ||||
| ------------ | ||||
| 
 | ||||
|  The id of the background layer to start with The default value is _osm_ | ||||
| 
 | ||||
| 
 | ||||
|  layer-<layer-id>  | ||||
| ------------------ | ||||
| 
 | ||||
|  Wether or not the layer with id <layer-id> is shown The default value is _true_ Generated from QueryParameters | ||||
|  | @ -1,5 +1,5 @@ | |||
| import {FixedUiElement} from "./UI/Base/FixedUiElement"; | ||||
| import CheckBox from "./UI/Input/CheckBox"; | ||||
| import Toggle from "./UI/Input/Toggle"; | ||||
| import {Basemap} from "./UI/BigComponents/Basemap"; | ||||
| import State from "./State"; | ||||
| import LoadFromOverpass from "./Logic/Actors/OverpassFeatureSource"; | ||||
|  | @ -21,11 +21,9 @@ import * as L from "leaflet"; | |||
| import Img from "./UI/Base/Img"; | ||||
| import UserDetails from "./Logic/Osm/OsmConnection"; | ||||
| import Attribution from "./UI/BigComponents/Attribution"; | ||||
| import AvailableBaseLayers from "./Logic/Actors/AvailableBaseLayers"; | ||||
| import LayerResetter from "./Logic/Actors/LayerResetter"; | ||||
| import FullWelcomePaneWithTabs from "./UI/BigComponents/FullWelcomePaneWithTabs"; | ||||
| import LayerControlPanel from "./UI/BigComponents/LayerControlPanel"; | ||||
| import FeatureSwitched from "./UI/Base/FeatureSwitched"; | ||||
| import ShowDataLayer from "./UI/ShowDataLayer"; | ||||
| import Hash from "./Logic/Web/Hash"; | ||||
| import FeaturePipeline from "./Logic/FeatureSource/FeaturePipeline"; | ||||
|  | @ -39,9 +37,9 @@ import {LayoutConfigJson} from "./Customizations/JSON/LayoutConfigJson"; | |||
| import AttributionPanel from "./UI/BigComponents/AttributionPanel"; | ||||
| import ContributorCount from "./Logic/ContributorCount"; | ||||
| import FeatureSource from "./Logic/FeatureSource/FeatureSource"; | ||||
| import {AllKnownLayouts} from "./Customizations/AllKnownLayouts"; | ||||
| import AllKnownLayers from "./Customizations/AllKnownLayers"; | ||||
| import LayerConfig from "./Customizations/JSON/LayerConfig"; | ||||
| import AvailableBaseLayers from "./Logic/Actors/AvailableBaseLayers"; | ||||
| 
 | ||||
| export class InitUiElements { | ||||
| 
 | ||||
|  | @ -170,13 +168,14 @@ export class InitUiElements { | |||
|                 marker.addTo(State.state.leafletMap.data) | ||||
|             }); | ||||
| 
 | ||||
|         const geolocationButton = new FeatureSwitched( | ||||
|         const geolocationButton = new Toggle( | ||||
|             new MapControlButton( | ||||
|                 new GeoLocationHandler( | ||||
|                     State.state.currentGPSLocation, | ||||
|                     State.state.leafletMap, | ||||
|                     State.state.layoutToUse | ||||
|                 )), | ||||
|             undefined, | ||||
|             State.state.featureSwitchGeolocation); | ||||
| 
 | ||||
|         const plus = new MapControlButton( | ||||
|  | @ -193,7 +192,7 @@ export class InitUiElements { | |||
|             State.state.locationControl.ping(); | ||||
|         }) | ||||
| 
 | ||||
|         new Combine([plus, min, geolocationButton].map(el => el.SetClass("m-1"))) | ||||
|         new Combine([plus, min, geolocationButton].map(el => el.SetClass("m-0.5 md:m-1"))) | ||||
|             .SetClass("flex flex-col") | ||||
|             .AttachTo("bottom-right"); | ||||
| 
 | ||||
|  | @ -211,13 +210,12 @@ export class InitUiElements { | |||
| 
 | ||||
|         // Reset the loading message once things are loaded
 | ||||
|         new CenterMessageBox().AttachTo("centermessage"); | ||||
| 
 | ||||
|         // At last, zoom to the needed location if the focus is on an element
 | ||||
|         document.getElementById("centermessage").classList.add("pointer-events-none") | ||||
| 
 | ||||
| 
 | ||||
|     } | ||||
| 
 | ||||
|     static LoadLayoutFromHash(userLayoutParam: UIEventSource<string>) { | ||||
|     static LoadLayoutFromHash(userLayoutParam: UIEventSource<string>): [LayoutConfig, string]{ | ||||
|         try { | ||||
|             let hash = location.hash.substr(1); | ||||
|             const layoutFromBase64 = userLayoutParam.data; | ||||
|  | @ -249,7 +247,7 @@ export class InitUiElements { | |||
|             // @ts-ignore
 | ||||
|             const layoutToUse = new LayoutConfig(json, false); | ||||
|             userLayoutParam.setData(layoutToUse.id); | ||||
|             return layoutToUse; | ||||
|             return [layoutToUse, btoa(Utils.MinifyJSON(JSON.stringify(json)))]; | ||||
|         } catch (e) { | ||||
| 
 | ||||
|             new FixedUiElement("Error: could not parse the custom layout:<br/> " + e).AttachTo("centermessage"); | ||||
|  | @ -272,11 +270,10 @@ export class InitUiElements { | |||
| 
 | ||||
|         // ?-Button on Desktop, opens panel with close-X.
 | ||||
|         const help = new MapControlButton(Svg.help_svg()); | ||||
|         new CheckBox( | ||||
|         help.onClick(() => isOpened.setData(true)) | ||||
|         new Toggle( | ||||
|             fullOptions | ||||
|                 .SetClass("welcomeMessage") | ||||
|                 .onClick(() => {/*Catch the click*/ | ||||
|                 }), | ||||
|                 .SetClass("welcomeMessage"), | ||||
|             help | ||||
|             , isOpened | ||||
|         ).AttachTo("messagesbox"); | ||||
|  | @ -307,22 +304,23 @@ export class InitUiElements { | |||
|             ) | ||||
| 
 | ||||
|         ; | ||||
|         const copyrightButton = new CheckBox( | ||||
|         const copyrightButton = new Toggle( | ||||
|             copyrightNotice, | ||||
|             new MapControlButton(Svg.osm_copyright_svg()), | ||||
|             copyrightNotice.isShown | ||||
|         ).SetClass("p-0.5") | ||||
|         ).ToggleOnClick() | ||||
|             .SetClass("p-0.5") | ||||
| 
 | ||||
|         const layerControlPanel = new LayerControlPanel( | ||||
|             State.state.layerControlIsOpened) | ||||
|             .SetClass("block p-1 rounded-full"); | ||||
|         const layerControlButton = new CheckBox( | ||||
|         const layerControlButton = new Toggle( | ||||
|             layerControlPanel, | ||||
|             new MapControlButton(Svg.layers_svg()), | ||||
|             State.state.layerControlIsOpened | ||||
|         ) | ||||
|         ).ToggleOnClick() | ||||
| 
 | ||||
|         const layerControl = new CheckBox( | ||||
|         const layerControl = new Toggle( | ||||
|             layerControlButton, | ||||
|             "", | ||||
|             State.state.featureSwitchLayers | ||||
|  | @ -351,9 +349,8 @@ export class InitUiElements { | |||
|     private static InitBaseMap() { | ||||
| 
 | ||||
|         State.state.availableBackgroundLayers = new AvailableBaseLayers(State.state.locationControl).availableEditorLayers; | ||||
|         State.state.backgroundLayer = QueryParameters.GetQueryParameter("background", | ||||
|             State.state.layoutToUse.data.defaultBackgroundId ?? AvailableBaseLayers.osmCarto.id, | ||||
|             "The id of the background layer to start with") | ||||
| 
 | ||||
|         State.state.backgroundLayer = State.state.backgroundLayerId | ||||
|             .map((selectedId: string) => { | ||||
|                 const available = State.state.availableBackgroundLayers.data; | ||||
|                 for (const layer of available) { | ||||
|  | @ -362,9 +359,8 @@ export class InitUiElements { | |||
|                     } | ||||
|                 } | ||||
|                 return AvailableBaseLayers.osmCarto; | ||||
|             }, [], layer => layer.id); | ||||
| 
 | ||||
| 
 | ||||
|             }, [State.state.availableBackgroundLayers], layer => layer.id); | ||||
|          | ||||
|         new LayerResetter( | ||||
|             State.state.backgroundLayer, State.state.locationControl, | ||||
|             State.state.availableBackgroundLayers, State.state.layoutToUse.map((layout: LayoutConfig) => layout.defaultBackgroundId)); | ||||
|  |  | |||
|  | @ -6,6 +6,8 @@ import Svg from "../../Svg"; | |||
| import Img from "../../UI/Base/Img"; | ||||
| import {LocalStorageSource} from "../Web/LocalStorageSource"; | ||||
| import LayoutConfig from "../../Customizations/JSON/LayoutConfig"; | ||||
| import BaseUIElement from "../../UI/BaseUIElement"; | ||||
| import {VariableUiElement} from "../../UI/Base/VariableUIElement"; | ||||
| 
 | ||||
| export default class GeoLocationHandler extends UIElement { | ||||
| 
 | ||||
|  | @ -52,19 +54,19 @@ export default class GeoLocationHandler extends UIElement { | |||
|     private readonly _previousLocationGrant: UIEventSource<string> = LocalStorageSource.Get("geolocation-permissions"); | ||||
|     private readonly _layoutToUse: UIEventSource<LayoutConfig>; | ||||
| 
 | ||||
| 
 | ||||
|     private readonly _element: BaseUIElement; | ||||
| 
 | ||||
|     constructor(currentGPSLocation: UIEventSource<{ latlng: any; accuracy: number }>, | ||||
|                 leafletMap: UIEventSource<L.Map>, | ||||
|                 layoutToUse: UIEventSource<LayoutConfig>) { | ||||
|         super(undefined); | ||||
|         super(); | ||||
|         this._currentGPSLocation = currentGPSLocation; | ||||
|         this._leafletMap = leafletMap; | ||||
|         this._layoutToUse = layoutToUse; | ||||
|         this._hasLocation = currentGPSLocation.map((location) => location !== undefined); | ||||
|         this.dumbMode = false; | ||||
| 
 | ||||
|         const self = this; | ||||
|         import("../../vendor/Leaflet.AccuratePosition.js").then(() => { | ||||
|             self.init(); | ||||
|         }) | ||||
| 
 | ||||
|         const currentPointer = this._isActive.map(isActive => { | ||||
|             if (isActive && !self._hasLocation.data) { | ||||
|  | @ -74,62 +76,35 @@ export default class GeoLocationHandler extends UIElement { | |||
|         }, [this._hasLocation]) | ||||
|         currentPointer.addCallbackAndRun(pointerClass => { | ||||
|             self.SetClass(pointerClass); | ||||
|             self.Update() | ||||
|         }) | ||||
|         this._element = new VariableUiElement( | ||||
|             this._hasLocation.map(hasLocation => { | ||||
| 
 | ||||
|                 if (hasLocation) { | ||||
|                     return Svg.crosshair_blue_ui() | ||||
|                 } | ||||
|                 if (self._isActive.data) { | ||||
|                     return Svg.crosshair_blue_center_ui(); | ||||
|                 } | ||||
|                 return Svg.crosshair_ui(); | ||||
|             }, [this._isActive]) | ||||
|         ); | ||||
| 
 | ||||
|         this.onClick(() => self.init(true)) | ||||
| 
 | ||||
|         self.init(false) | ||||
| 
 | ||||
|     } | ||||
| 
 | ||||
|     InnerRender(): string { | ||||
|         if (this._hasLocation.data) { | ||||
|             return Svg.crosshair_blue_img; | ||||
|         } | ||||
|         if (this._isActive.data) { | ||||
|             return Svg.crosshair_blue_center_img; | ||||
|         } | ||||
| 
 | ||||
|         return Svg.crosshair_img; | ||||
|     protected InnerRender(): string | BaseUIElement { | ||||
|         return this._element | ||||
|     } | ||||
| 
 | ||||
|     InnerUpdate(htmlElement: HTMLElement) { | ||||
|         super.InnerUpdate(htmlElement); | ||||
|     private init(askPermission: boolean) { | ||||
| 
 | ||||
|         const self = this; | ||||
|         htmlElement.onclick = function () { | ||||
|             self.StartGeolocating(); | ||||
|         } | ||||
| 
 | ||||
|         htmlElement.oncontextmenu = function (e) { | ||||
|             self.StartGeolocating(); | ||||
|             e.preventDefault(); | ||||
|             return false; | ||||
|         } | ||||
| 
 | ||||
|     } | ||||
| 
 | ||||
|     private init() { | ||||
|         this.ListenTo(this._hasLocation); | ||||
|         this.ListenTo(this._isActive); | ||||
|         this.ListenTo(this._permission); | ||||
| 
 | ||||
|         const self = this; | ||||
| 
 | ||||
|         function onAccuratePositionProgress(e) { | ||||
|             self._currentGPSLocation.setData({latlng: e.latlng, accuracy: e.accuracy}); | ||||
|         } | ||||
| 
 | ||||
|         function onAccuratePositionFound(e) { | ||||
|             self._currentGPSLocation.setData({latlng: e.latlng, accuracy: e.accuracy}); | ||||
|         } | ||||
| 
 | ||||
|         function onAccuratePositionError(e) { | ||||
|             console.log("onerror", e.message); | ||||
| 
 | ||||
|         } | ||||
| 
 | ||||
|         const map = this._leafletMap.data; | ||||
|         map.on('accuratepositionprogress', onAccuratePositionProgress); | ||||
|         map.on('accuratepositionfound', onAccuratePositionFound); | ||||
|         map.on('accuratepositionerror', onAccuratePositionError); | ||||
| 
 | ||||
| 
 | ||||
|         this._currentGPSLocation.addCallback((location) => { | ||||
|             self._previousLocationGrant.setData("granted"); | ||||
|  | @ -178,12 +153,13 @@ export default class GeoLocationHandler extends UIElement { | |||
|         } catch (e) { | ||||
|             console.error(e) | ||||
|         } | ||||
|         if (this._previousLocationGrant.data === "granted") { | ||||
|         if (askPermission) { | ||||
|             self.StartGeolocating(true); | ||||
|         } else if (this._previousLocationGrant.data === "granted") { | ||||
|             this._previousLocationGrant.setData(""); | ||||
|             self.StartGeolocating(false); | ||||
|         } | ||||
| 
 | ||||
|         this.HideOnEmpty(true); | ||||
|     } | ||||
| 
 | ||||
|     private locate() { | ||||
|  | @ -211,7 +187,7 @@ export default class GeoLocationHandler extends UIElement { | |||
|     private MoveToCurrentLoction(targetZoom = 16) { | ||||
|         const location = this._currentGPSLocation.data; | ||||
|         this._lastUserRequest = undefined; | ||||
|          | ||||
| 
 | ||||
| 
 | ||||
|         if (this._currentGPSLocation.data.latlng[0] === 0 && this._currentGPSLocation.data.latlng[1] === 0) { | ||||
|             console.debug("Not moving to GPS-location: it is null island") | ||||
|  |  | |||
|  | @ -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] | ||||
|         ); | ||||
|  |  | |||
|  | @ -47,13 +47,13 @@ export default class StrayClickHandler { | |||
|                     popupAnchor: [0, -45] | ||||
|                 }) | ||||
|             }); | ||||
|             const popup = L.popup().setContent(uiToShow.Render()); | ||||
|             const popup = L.popup().setContent("<div id='strayclick'></div>"); | ||||
|             self._lastMarker.addTo(leafletMap.data); | ||||
|             self._lastMarker.bindPopup(popup); | ||||
| 
 | ||||
|             self._lastMarker.on("click", () => { | ||||
|                 uiToShow.AttachTo("strayclick") | ||||
|                 uiToShow.Activate(); | ||||
|                 uiToShow.Update(); | ||||
|             }); | ||||
|         }); | ||||
| 
 | ||||
|  |  | |||
|  | @ -2,12 +2,12 @@ import {UIEventSource} from "../UIEventSource"; | |||
| import LayoutConfig from "../../Customizations/JSON/LayoutConfig"; | ||||
| import Translations from "../../UI/i18n/Translations"; | ||||
| import Locale from "../../UI/i18n/Locale"; | ||||
| import {UIElement} from "../../UI/UIElement"; | ||||
| import TagRenderingAnswer from "../../UI/Popup/TagRenderingAnswer"; | ||||
| import {ElementStorage} from "../ElementStorage"; | ||||
| import Combine from "../../UI/Base/Combine"; | ||||
| 
 | ||||
| class TitleElement extends UIElement { | ||||
| class TitleElement extends UIEventSource<string> { | ||||
|      | ||||
|     private readonly _layoutToUse: UIEventSource<LayoutConfig>; | ||||
|     private readonly _selectedFeature: UIEventSource<any>; | ||||
|     private readonly _allElementsStorage: ElementStorage; | ||||
|  | @ -15,42 +15,44 @@ class TitleElement extends UIElement { | |||
|     constructor(layoutToUse: UIEventSource<LayoutConfig>, | ||||
|                 selectedFeature: UIEventSource<any>, | ||||
|                 allElementsStorage: ElementStorage) { | ||||
|         super(layoutToUse); | ||||
|         super("MapComplete"); | ||||
|          | ||||
|         this._layoutToUse = layoutToUse; | ||||
|         this._selectedFeature = selectedFeature; | ||||
|         this._allElementsStorage = allElementsStorage; | ||||
|         this.ListenTo(Locale.language); | ||||
|         this.ListenTo(this._selectedFeature) | ||||
|         this.dumbMode = false; | ||||
|     } | ||||
|          | ||||
|         this.syncWith( | ||||
|             this._selectedFeature.map( | ||||
|                 selected => { | ||||
|                     const defaultTitle = Translations.WT(this._layoutToUse.data?.title)?.txt ??"MapComplete" | ||||
| 
 | ||||
|     InnerRender(): string { | ||||
|                     if(selected === undefined){ | ||||
|                         return defaultTitle | ||||
|                     } | ||||
| 
 | ||||
|         const defaultTitle = Translations.WT(this._layoutToUse.data?.title)?.txt ?? "MapComplete" | ||||
|         const feature = this._selectedFeature.data; | ||||
| 
 | ||||
|         if (feature === undefined) { | ||||
|             return defaultTitle; | ||||
|         } | ||||
|                     const layout = layoutToUse.data; | ||||
|                     const tags = selected.properties; | ||||
| 
 | ||||
| 
 | ||||
|         const layout = this._layoutToUse.data; | ||||
|         const properties = this._selectedFeature.data.properties; | ||||
|                     for (const layer of layout.layers) { | ||||
|                         if (layer.title === undefined) { | ||||
|                             continue; | ||||
|                         } | ||||
|                         if (layer.source.osmTags.matchesProperties(tags)) { | ||||
|                             const tagsSource = allElementsStorage.getEventSourceById(tags.id) | ||||
|                             const title = new TagRenderingAnswer(tagsSource, layer.title) | ||||
|                             return new Combine([defaultTitle, " | ", title]).ConstructElement().innerText; | ||||
|                         } | ||||
|                     } | ||||
| 
 | ||||
|         for (const layer of layout.layers) { | ||||
|             if (layer.title === undefined) { | ||||
|                 continue; | ||||
|             } | ||||
|             if (layer.source.osmTags.matchesProperties(properties)) { | ||||
|                 const tags = this._allElementsStorage.getEventSourceById(feature.properties.id); | ||||
|                 if (tags == undefined) { | ||||
|                     return defaultTitle; | ||||
|                     return defaultTitle | ||||
|                 } | ||||
|                 const title = new TagRenderingAnswer(tags, layer.title) | ||||
|                 return new Combine([defaultTitle, " | ", title]).Render(); | ||||
|             } | ||||
|         } | ||||
|         return defaultTitle; | ||||
|                 , [Locale.language, layoutToUse] | ||||
|             ) | ||||
|              | ||||
|         ) | ||||
|          | ||||
|          | ||||
|     } | ||||
| 
 | ||||
| } | ||||
|  | @ -59,14 +61,8 @@ export default class TitleHandler { | |||
|     constructor(layoutToUse: UIEventSource<LayoutConfig>, | ||||
|                 selectedFeature: UIEventSource<any>, | ||||
|                 allElementsStorage: ElementStorage) { | ||||
| 
 | ||||
|         selectedFeature.addCallbackAndRun(_ => { | ||||
|             const title = new TitleElement(layoutToUse, selectedFeature, allElementsStorage) | ||||
|             const d = document.createElement('div'); | ||||
|             d.innerHTML = title.InnerRender(); | ||||
|             // We pass everything into a div to strip out images etc...
 | ||||
|             document.title = (d.textContent || d.innerText); | ||||
|         new TitleElement(layoutToUse, selectedFeature, allElementsStorage).addCallbackAndRun(title => { | ||||
|             document.title = title | ||||
|         }) | ||||
| 
 | ||||
|     } | ||||
| } | ||||
|  | @ -1,46 +1,48 @@ | |||
| import {GeoOperations} from "./GeoOperations"; | ||||
| import {UIElement} from "../UI/UIElement"; | ||||
| import Combine from "../UI/Base/Combine"; | ||||
| import {Relation} from "./Osm/ExtractRelations"; | ||||
| import State from "../State"; | ||||
| import {Utils} from "../Utils"; | ||||
| import BaseUIElement from "../UI/BaseUIElement"; | ||||
| import List from "../UI/Base/List"; | ||||
| import Title from "../UI/Base/Title"; | ||||
| 
 | ||||
| export class ExtraFunction { | ||||
| 
 | ||||
| 
 | ||||
|     static readonly intro = `<h2>Calculating tags with Javascript</h2>
 | ||||
|     static readonly intro = new Combine([ | ||||
|         new Title("Calculating tags with Javascript", 2), | ||||
|         "In some cases, it is useful to have some tags calculated based on other properties. Some useful tags are available by default (e.g. `lat`, `lon`, `_country`), as detailed above.", | ||||
|         "It is also possible to calculate your own tags - but this requires some javascript knowledge.", | ||||
|         "", | ||||
|         "Before proceeding, some warnings:", | ||||
|         new List([ | ||||
|             "DO NOT DO THIS AS BEGINNER", | ||||
|             "**Only do this if all other techniques fail**  This should _not_ be done to create a rendering effect, only to calculate a specific value", | ||||
|             "**THIS MIGHT BE DISABLED WITHOUT ANY NOTICE ON UNOFFICIAL THEMES** As unofficial themes might be loaded from the internet, this is the equivalent of injecting arbitrary code into the client. It'll be disabled if abuse occurs." | ||||
|         ]), | ||||
|         "To enable this feature,  add a field `calculatedTags` in the layer object, e.g.:", | ||||
|         "````", | ||||
|         "\"calculatedTags\": [", | ||||
|         "    \"_someKey=javascript-expression\",", | ||||
|         "    \"name=feat.properties.name ?? feat.properties.ref ?? feat.properties.operator\",", | ||||
|         "    \"_distanceCloserThen3Km=feat.distanceTo( some_lon, some_lat) < 3 ? 'yes' : 'no'\" ", | ||||
|         "  ]", | ||||
|         "````", | ||||
|         "", | ||||
|         "The above code will be executed for every feature in the layer. The feature is accessible as `feat` and is an amended geojson object:", | ||||
| 
 | ||||
| <p>In some cases, it is useful to have some tags calculated based on other properties. Some useful tags are available by default (e.g. <b>lat</b>, <b>lon</b>, <b>_country</b>), as detailed above.</p> | ||||
|         new List([ | ||||
|             "`area` contains the surface area (in square meters) of the object", | ||||
|             "`lat` and `lon` contain the latitude and longitude" | ||||
|         ]), | ||||
|         "Some advanced functions are available on **feat** as well:" | ||||
|     ]).SetClass("flex-col").AsMarkdown(); | ||||
| 
 | ||||
| <p>It is also possible to calculate your own tags - but this requires some javascript knowledge. </p> | ||||
| 
 | ||||
| Before proceeding, some warnings: | ||||
| 
 | ||||
| <ul> | ||||
| <li> DO NOT DO THIS AS BEGINNER</li> | ||||
| <li> <b>Only do this if all other techniques fail</b>. This should <i>not</i> be done to create a rendering effect, only to calculate a specific value</li> | ||||
| <li> <b>THIS MIGHT BE DISABLED WITHOUT ANY NOTICE ON UNOFFICIAL THEMES</b>. As unofficial themes might be loaded from the internet, this is the equivalent of injecting arbitrary code into the client. It'll be disabled if abuse occurs.</li> | ||||
| </ul> | ||||
| In the layer object, add a field <b>calculatedTags</b>, e.g.: | ||||
| 
 | ||||
| <div class="code"> | ||||
|   "calculatedTags": [ | ||||
|     "_someKey=javascript-expression", | ||||
|     "name=feat.properties.name ?? feat.properties.ref ?? feat.properties.operator", | ||||
|     "_distanceCloserThen3Km=feat.distanceTo( some_lon, some_lat) < 3 ? 'yes' : 'no'"  | ||||
|   ] | ||||
| </div> | ||||
| 
 | ||||
| The above code will be executed for every feature in the layer. The feature is accessible as <b>feat</b> and is an amended geojson object: | ||||
| - <b>area</b> contains the surface area (in square meters) of the object | ||||
| - <b>lat</b> and <b>lon</b> contain the latitude and longitude | ||||
| 
 | ||||
| Some advanced functions are available on <b>feat</b> as well: | ||||
| 
 | ||||
| ` | ||||
|     private static readonly OverlapFunc = new ExtraFunction( | ||||
|         "overlapWith", | ||||
|         "Gives a list of features from the specified layer which this feature (partly) overlaps with. If the current feature is a point, all features that embed the point are given. The returned value is <code>{ feat: GeoJSONFeature, overlap: number}[]</code> where <code>overlap</code> is the overlapping surface are (in m²) for areas, the overlapping length (in meter) if the current feature is a line or <code>undefined</code> if the current feature is a point", | ||||
|         "Gives a list of features from the specified layer which this feature (partly) overlaps with. If the current feature is a point, all features that embed the point are given. The returned value is `{ feat: GeoJSONFeature, overlap: number}[]` where `overlap` is the overlapping surface are (in m²) for areas, the overlapping length (in meter) if the current feature is a line or `undefined` if the current feature is a point", | ||||
|         ["...layerIds - one or more layer ids  of the layer from which every feature is checked for overlap)"], | ||||
|         (params, feat) => { | ||||
|             return (...layerIds: string[]) => { | ||||
|  | @ -72,7 +74,7 @@ Some advanced functions are available on <b>feat</b> as well: | |||
|                 if (typeof arg0 === "string") { | ||||
|                     // This is an identifier
 | ||||
|                     const feature = State.state.allElements.ContainingFeatures.get(arg0); | ||||
|                     if(feature === undefined){ | ||||
|                     if (feature === undefined) { | ||||
|                         return undefined; | ||||
|                     } | ||||
|                     arg0 = feature; | ||||
|  | @ -138,9 +140,9 @@ Some advanced functions are available on <b>feat</b> as well: | |||
| 
 | ||||
|     private static readonly Memberships = new ExtraFunction( | ||||
|         "memberships", | ||||
|         "Gives a list of <code>{role: string, relation: Relation}</code>-objects, containing all the relations that this feature is part of. " + | ||||
|         "Gives a list of `{role: string, relation: Relation}`-objects, containing all the relations that this feature is part of. " + | ||||
|         "\n\n" + | ||||
|         "For example: <code>_part_of_walking_routes=feat.memberships().map(r => r.relation.tags.name).join(';')</code>", | ||||
|         "For example: `_part_of_walking_routes=feat.memberships().map(r => r.relation.tags.name).join(';')`", | ||||
|         [], | ||||
|         (params, _) => { | ||||
|             return () => params.relations ?? []; | ||||
|  | @ -167,25 +169,19 @@ Some advanced functions are available on <b>feat</b> as well: | |||
|         } | ||||
|     } | ||||
| 
 | ||||
|     public static HelpText(): UIElement { | ||||
|     public static HelpText(): BaseUIElement { | ||||
| 
 | ||||
|         const elems = [] | ||||
|         for (const func of ExtraFunction.allFuncs) { | ||||
|             elems.push(new Title(func._name, 3), | ||||
|                 func._doc, | ||||
|                 new List(func._args, true)) | ||||
|         } | ||||
| 
 | ||||
|         return new Combine([ | ||||
|             ExtraFunction.intro, | ||||
|             "<ul>", | ||||
|             ...ExtraFunction.allFuncs.map(func => | ||||
|                 new Combine([ | ||||
|                     "<li>", func._name, "</li>" | ||||
|                 ]) | ||||
|             ), | ||||
|             "</ul>", | ||||
|             ...ExtraFunction.allFuncs.map(func => | ||||
|                 new Combine([ | ||||
|                     "<h3>" + func._name + "</h3>", | ||||
|                     func._doc, | ||||
|                     "<ul>", | ||||
|                     ...func._args.map(arg => "<li>" + arg + "</li>"), | ||||
|                     "</ul>" | ||||
|                 ]) | ||||
|             ) | ||||
|             new List(ExtraFunction.allFuncs.map(func => func._name)), | ||||
|             ...elems | ||||
|         ]); | ||||
|     } | ||||
| 
 | ||||
|  |  | |||
|  | @ -10,12 +10,11 @@ import Constants from "../../Models/Constants"; | |||
| 
 | ||||
| export class ChangesetHandler { | ||||
| 
 | ||||
|     public readonly currentChangeset: UIEventSource<string>; | ||||
|     private readonly _dryRun: boolean; | ||||
|     private readonly userDetails: UIEventSource<UserDetails>; | ||||
|     private readonly auth: any; | ||||
| 
 | ||||
|     public readonly currentChangeset: UIEventSource<string>; | ||||
| 
 | ||||
|     constructor(layoutName: string, dryRun: boolean, osmConnection: OsmConnection, auth) { | ||||
|         this._dryRun = dryRun; | ||||
|         this.userDetails = osmConnection.userDetails; | ||||
|  | @ -27,14 +26,34 @@ export class ChangesetHandler { | |||
|         } | ||||
|     } | ||||
| 
 | ||||
|     private static parseUploadChangesetResponse(response: XMLDocument, allElements: ElementStorage) { | ||||
|         const nodes = response.getElementsByTagName("node"); | ||||
|         // @ts-ignore
 | ||||
|         for (const node of nodes) { | ||||
|             const oldId = parseInt(node.attributes.old_id.value); | ||||
|             const newId = parseInt(node.attributes.new_id.value); | ||||
|             if (oldId !== undefined && newId !== undefined && | ||||
|                 !isNaN(oldId) && !isNaN(newId)) { | ||||
|                 if (oldId == newId) { | ||||
|                     continue; | ||||
|                 } | ||||
|                 console.log("Rewriting id: ", oldId, "-->", newId); | ||||
|                 const element = allElements.getEventSourceById("node/" + oldId); | ||||
|                 element.data.id = "node/" + newId; | ||||
|                 allElements.addElementById("node/" + newId, element); | ||||
|                 element.ping(); | ||||
| 
 | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     public UploadChangeset( | ||||
|         layout: LayoutConfig, | ||||
|         allElements: ElementStorage, | ||||
|         generateChangeXML: (csid: string) => string, | ||||
|          continuation: () => void) { | ||||
|          | ||||
|         if(this.userDetails.data.csCount == 0){ | ||||
|         continuation: () => void) { | ||||
| 
 | ||||
|         if (this.userDetails.data.csCount == 0) { | ||||
|             // The user became a contributor!
 | ||||
|             this.userDetails.data.csCount = 1; | ||||
|             this.userDetails.ping(); | ||||
|  | @ -51,7 +70,7 @@ export class ChangesetHandler { | |||
| 
 | ||||
|         if (this.currentChangeset.data === undefined || this.currentChangeset.data === "") { | ||||
|             // We have to open a new changeset
 | ||||
|             this.OpenChangeset(layout,(csId) => { | ||||
|             this.OpenChangeset(layout, (csId) => { | ||||
|                 this.currentChangeset.setData(csId); | ||||
|                 const changeset = generateChangeXML(csId); | ||||
|                 console.log(changeset); | ||||
|  | @ -86,31 +105,61 @@ export class ChangesetHandler { | |||
|         } | ||||
|     } | ||||
| 
 | ||||
|     public CloseChangeset(changesetId: string = undefined, continuation: (() => void) = () => { | ||||
|     }) { | ||||
|         if (changesetId === undefined) { | ||||
|             changesetId = this.currentChangeset.data; | ||||
|         } | ||||
|         if (changesetId === undefined) { | ||||
|             return; | ||||
|         } | ||||
|         console.log("closing changeset", changesetId); | ||||
|         this.currentChangeset.setData(""); | ||||
|         this.auth.xhr({ | ||||
|             method: 'PUT', | ||||
|             path: '/api/0.6/changeset/' + changesetId + '/close', | ||||
|         }, function (err, response) { | ||||
|             if (response == null) { | ||||
| 
 | ||||
|                 console.log("err", err); | ||||
|             } | ||||
|             console.log("Closed changeset ", changesetId) | ||||
| 
 | ||||
|             if (continuation !== undefined) { | ||||
|                 continuation(); | ||||
|             } | ||||
|         }); | ||||
|     } | ||||
| 
 | ||||
|     private OpenChangeset( | ||||
|         layout : LayoutConfig, | ||||
|         layout: LayoutConfig, | ||||
|         continuation: (changesetId: string) => void) { | ||||
| 
 | ||||
|         const commentExtra = layout.changesetmessage !== undefined ? " - " + layout.changesetmessage : ""; | ||||
| 
 | ||||
|         let surveySource = ""; | ||||
|         if (State.state.currentGPSLocation.data !== undefined) { | ||||
|             surveySource = '<tag k="source" v="survey"/>' | ||||
|         } | ||||
|         let path = window.location.pathname; | ||||
|         path = path.substr(1, path.lastIndexOf("/")); | ||||
|         const metadata = [ | ||||
|             ["created_by", `MapComplete ${Constants.vNumber}`], | ||||
|             ["comment", `Adding data with #MapComplete for theme #${layout.id}${commentExtra}`], | ||||
|             ["theme", layout.id], | ||||
|             ["language", Locale.language.data], | ||||
|             ["host", window.location.host], | ||||
|             ["path", path], | ||||
|             ["source", State.state.currentGPSLocation.data !== undefined ? "survey" : undefined], | ||||
|             ["imagery", State.state.backgroundLayer.data.id], | ||||
|             ["theme-creator", layout.maintainer] | ||||
|         ] | ||||
|             .filter(kv => (kv[1] ?? "") !== "") | ||||
|             .map(kv => `<tag k="${kv[0]}" v="${escapeHtml(kv[1])}"/>`) | ||||
|             .join("\n") | ||||
| 
 | ||||
|         this.auth.xhr({ | ||||
|             method: 'PUT', | ||||
|             path: '/api/0.6/changeset/create', | ||||
|             options: {header: {'Content-Type': 'text/xml'}}, | ||||
|             content: [`<osm><changeset>`, | ||||
|                 `<tag k="created_by" v="MapComplete ${Constants.vNumber}" />`, | ||||
|                 `<tag k="comment" v="Adding data with #MapComplete for theme #${layout.id}${commentExtra}"/>`, | ||||
|                 `<tag k="theme" v="${layout.id}"/>`, | ||||
|                 `<tag k="language" v="${Locale.language.data}"/>`, | ||||
|                 `<tag k="host" v="${escapeHtml(window.location.host)}"/>`, | ||||
|                 `<tag k="imagery" v="${State.state.backgroundLayer.data.id}"/>`, | ||||
|                 surveySource, | ||||
|                 (layout.maintainer ?? "") !== "" ? `<tag k="theme-creator" v="${escapeHtml(layout.maintainer)}"/>` : "", | ||||
|                 metadata, | ||||
|                 `</changeset></osm>`].join("") | ||||
|         }, function (err, response) { | ||||
|             if (response === undefined) { | ||||
|  | @ -147,52 +196,5 @@ export class ChangesetHandler { | |||
|         }); | ||||
|     } | ||||
| 
 | ||||
|     public CloseChangeset(changesetId: string = undefined, continuation: (() => void) = () => { | ||||
|     }) { | ||||
|         if (changesetId === undefined) { | ||||
|             changesetId = this.currentChangeset.data; | ||||
|         } | ||||
|         if (changesetId === undefined) { | ||||
|             return; | ||||
|         } | ||||
|         console.log("closing changeset", changesetId); | ||||
|         this.currentChangeset.setData(""); | ||||
|         this.auth.xhr({ | ||||
|             method: 'PUT', | ||||
|             path: '/api/0.6/changeset/' + changesetId + '/close', | ||||
|         }, function (err, response) { | ||||
|             if (response == null) { | ||||
| 
 | ||||
|                 console.log("err", err); | ||||
|             } | ||||
|             console.log("Closed changeset ", changesetId) | ||||
| 
 | ||||
|             if (continuation !== undefined) { | ||||
|                 continuation(); | ||||
|             } | ||||
|         }); | ||||
|     } | ||||
| 
 | ||||
|     private static parseUploadChangesetResponse(response: XMLDocument, allElements: ElementStorage) { | ||||
|         const nodes = response.getElementsByTagName("node"); | ||||
|         // @ts-ignore
 | ||||
|         for (const node of nodes) { | ||||
|             const oldId = parseInt(node.attributes.old_id.value); | ||||
|             const newId = parseInt(node.attributes.new_id.value); | ||||
|             if (oldId !== undefined && newId !== undefined && | ||||
|                 !isNaN(oldId) && !isNaN(newId)) { | ||||
|                 if(oldId == newId){ | ||||
|                     continue; | ||||
|                 } | ||||
|                 console.log("Rewriting id: ", oldId, "-->", newId); | ||||
|                 const element = allElements.getEventSourceById("node/" + oldId); | ||||
|                 element.data.id = "node/" + newId; | ||||
|                 allElements.addElementById("node/" + newId, element); | ||||
|                 element.ping(); | ||||
| 
 | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
| 
 | ||||
| } | ||||
|  | @ -39,6 +39,7 @@ export class OsmConnection { | |||
|     } | ||||
|     public auth; | ||||
|     public userDetails: UIEventSource<UserDetails>; | ||||
|     public isLoggedIn: UIEventSource<boolean> | ||||
|     _dryRun: boolean; | ||||
|     public preferencesHandler: OsmPreferences; | ||||
|     public changesetHandler: ChangesetHandler; | ||||
|  | @ -64,6 +65,14 @@ export class OsmConnection { | |||
| 
 | ||||
|         this.userDetails = new UIEventSource<UserDetails>(new UserDetails(), "userDetails"); | ||||
|         this.userDetails.data.dryRun = dryRun; | ||||
|         const self =this; | ||||
|         this.isLoggedIn = this.userDetails.map(user => user.loggedIn).addCallback(isLoggedIn => { | ||||
|             if(self.userDetails.data.loggedIn == false && isLoggedIn == true){ | ||||
|                 // We have an inconsistency: the userdetails say we _didn't_ log in, but this actor says we do
 | ||||
|                 // This means someone attempted to toggle this; so we attempt to login!
 | ||||
|                 self.AttemptLogin() | ||||
|             } | ||||
|         }); | ||||
|         this._dryRun = dryRun; | ||||
| 
 | ||||
|         this.updateAuthObject(); | ||||
|  | @ -215,14 +224,15 @@ export class OsmConnection { | |||
|         }); | ||||
|     } | ||||
| 
 | ||||
|     private CheckForMessagesContinuously() { | ||||
|         const self = this; | ||||
|         window.setTimeout(() => { | ||||
|             if (self.userDetails.data.loggedIn) { | ||||
|                 console.log("Checking for messages") | ||||
|                 this.AttemptLogin(); | ||||
|             } | ||||
|         }, 5 * 60 * 1000); | ||||
|     private CheckForMessagesContinuously(){ | ||||
|         const self =this; | ||||
|         UIEventSource.Chronic(5 * 60 * 1000).addCallback(_ => { | ||||
|             if (self.isLoggedIn .data) { | ||||
|             console.log("Checking for messages") | ||||
|             self.AttemptLogin(); | ||||
|         } | ||||
|         }); | ||||
|         | ||||
|     } | ||||
| 
 | ||||
| 
 | ||||
|  |  | |||
|  | @ -5,13 +5,15 @@ import {Tag} from "./Tags/Tag"; | |||
| import {Or} from "./Tags/Or"; | ||||
| import {Utils} from "../Utils"; | ||||
| import opening_hours from "opening_hours"; | ||||
| import {UIElement} from "../UI/UIElement"; | ||||
| import Combine from "../UI/Base/Combine"; | ||||
| import BaseUIElement from "../UI/BaseUIElement"; | ||||
| import Title from "../UI/Base/Title"; | ||||
| import {FixedUiElement} from "../UI/Base/FixedUiElement"; | ||||
| 
 | ||||
| 
 | ||||
| const cardinalDirections = { | ||||
|     N:   0, NNE:  22.5, NE:  45, ENE:  67.5, | ||||
|     E:  90, ESE: 112.5, SE: 135, SSE: 157.5, | ||||
|     N: 0, NNE: 22.5, NE: 45, ENE: 67.5, | ||||
|     E: 90, ESE: 112.5, SE: 135, SSE: 157.5, | ||||
|     S: 180, SSW: 202.5, SW: 225, WSW: 247.5, | ||||
|     W: 270, WNW: 292.5, NW: 315, NNW: 337.5 | ||||
| } | ||||
|  | @ -31,20 +33,20 @@ export default class SimpleMetaTagger { | |||
|         (feature) => {/*Note: also handled by 'UpdateTagsFromOsmAPI'*/ | ||||
| 
 | ||||
|             const tgs = feature.properties; | ||||
|              | ||||
|             function move(src: string, target: string){ | ||||
|                 if(tgs[src] === undefined){ | ||||
| 
 | ||||
|             function move(src: string, target: string) { | ||||
|                 if (tgs[src] === undefined) { | ||||
|                     return; | ||||
|                 } | ||||
|                 tgs[target] = tgs[src] | ||||
|                 delete tgs[src] | ||||
|             } | ||||
|              | ||||
|             move("user","_last_edit:contributor") | ||||
|             move("uid","_last_edit:contributor:uid") | ||||
|             move("changeset","_last_edit:changeset") | ||||
|             move("timestamp","_last_edit:timestamp") | ||||
|             move("version","_version_number") | ||||
| 
 | ||||
|             move("user", "_last_edit:contributor") | ||||
|             move("uid", "_last_edit:contributor:uid") | ||||
|             move("changeset", "_last_edit:changeset") | ||||
|             move("timestamp", "_last_edit:timestamp") | ||||
|             move("version", "_version_number") | ||||
|         } | ||||
|     ) | ||||
|     private static latlon = new SimpleMetaTagger({ | ||||
|  | @ -100,9 +102,13 @@ export default class SimpleMetaTagger { | |||
| 
 | ||||
|             SimpleMetaTagger.GetCountryCodeFor(lon, lat, (countries) => { | ||||
|                 try { | ||||
|                     const oldCountry = feature.properties["_country"]; | ||||
|                     feature.properties["_country"] = countries[0].trim().toLowerCase(); | ||||
|                     const tagsSource = State.state.allElements.getEventSourceById(feature.properties.id); | ||||
|                     tagsSource.ping(); | ||||
|                     if (oldCountry !== feature.properties["_country"]) { | ||||
|                         const tagsSource = State.state.allElements.getEventSourceById(feature.properties.id); | ||||
|                         tagsSource.ping(); | ||||
|                     } | ||||
| 
 | ||||
|                 } catch (e) { | ||||
|                     console.warn(e) | ||||
|                 } | ||||
|  | @ -375,28 +381,27 @@ export default class SimpleMetaTagger { | |||
|         SimpleMetaTagger.coder?.GetCountryCodeFor(lon, lat, callback) | ||||
|     } | ||||
| 
 | ||||
|     static HelpText(): UIElement { | ||||
|         const subElements: UIElement[] = [ | ||||
|     static HelpText(): BaseUIElement { | ||||
|         const subElements: (string | BaseUIElement)[] = [ | ||||
|             new Combine([ | ||||
|                 "<h2>Metatags</h2>", | ||||
|                 "<p>Metatags are extra tags available, in order to display more data or to give better questions.</p>", | ||||
|                 "<p>The are calculated automatically on every feature when the data arrives in the webbrowser. This document gives an overview of the available metatags.</p>", | ||||
|                 "<p><b>Hint:</b> when using metatags, add the <a href='URL_Parameters.md'>query parameter</a> <code>debug=true</code> to the URL. This will include a box in the popup for features which shows all the properties of the object</p>" | ||||
|             ]) | ||||
| 
 | ||||
|                 new Title("Metatags", 1), | ||||
|                 "Metatags are extra tags available, in order to display more data or to give better questions.", | ||||
|                 "The are calculated automatically on every feature when the data arrives in the webbrowser. This document gives an overview of the available metatags.", | ||||
|                 "**Hint:** when using metatags, add the [query parameter](URL_Parameters.md) `debug=true` to the URL. This will include a box in the popup for features which shows all the properties of the object" | ||||
|             ]).SetClass("flex-col") | ||||
| 
 | ||||
|         ]; | ||||
| 
 | ||||
|         subElements.push(new Title("Metatags calculated by MapComplete", 2)) | ||||
|         subElements.push(new FixedUiElement("The following values are always calculated, by default, by MapComplete and are available automatically on all elements in every theme")) | ||||
|         for (const metatag of SimpleMetaTagger.metatags) { | ||||
|             subElements.push( | ||||
|                 new Combine([ | ||||
|                     "<h3>", metatag.keys.join(", "), "</h3>", | ||||
|                     metatag.doc] | ||||
|                 ) | ||||
|                 new Title(metatag.keys.join(", "), 3), | ||||
|                 metatag.doc | ||||
|             ) | ||||
|         } | ||||
| 
 | ||||
|         return new Combine(subElements) | ||||
|         return new Combine(subElements).SetClass("flex-col") | ||||
|     } | ||||
| 
 | ||||
|     addMetaTags(features: { feature: any, freshness: Date }[]) { | ||||
|  |  | |||
|  | @ -92,9 +92,16 @@ export class UIEventSource<T> { | |||
|         } | ||||
|     } | ||||
| 
 | ||||
|     public map<J>(f: ((T) => J), | ||||
|     /** | ||||
|      * Monoidal map: | ||||
|      * Given a function 'f', will construct a new UIEventSource where the contents will always be "f(this.data)' | ||||
|      * @param f: The transforming function | ||||
|      * @param extraSources: also trigger the update if one of these sources change | ||||
|      * @param g: a 'backfunction to let the sync run in two directions. (data of the new UIEVEntSource, currentData) => newData | ||||
|      */ | ||||
|     public map<J>(f: ((t: T) => J), | ||||
|                   extraSources: UIEventSource<any>[] = [], | ||||
|                   g: ((J) => T) = undefined): UIEventSource<J> { | ||||
|                   g: ((j:J, t:T) => T) = undefined): UIEventSource<J> { | ||||
|         const self = this; | ||||
| 
 | ||||
|         const newSource = new UIEventSource<J>( | ||||
|  | @ -113,7 +120,7 @@ export class UIEventSource<T> { | |||
| 
 | ||||
|         if (g !== undefined) { | ||||
|             newSource.addCallback((latest) => { | ||||
|                 self.setData(g(latest)); | ||||
|                 self.setData(g(latest, self.data)); | ||||
|             }) | ||||
|         } | ||||
| 
 | ||||
|  |  | |||
							
								
								
									
										29
									
								
								Logic/Web/ImageAttributionSource.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										29
									
								
								Logic/Web/ImageAttributionSource.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,29 @@ | |||
| import {UIEventSource} from "../UIEventSource"; | ||||
| import {LicenseInfo} from "./Wikimedia"; | ||||
| import BaseUIElement from "../../UI/BaseUIElement"; | ||||
| 
 | ||||
| 
 | ||||
| export default abstract class ImageAttributionSource { | ||||
| 
 | ||||
| 
 | ||||
|     private _cache = new Map<string, UIEventSource<LicenseInfo>>() | ||||
| 
 | ||||
|     GetAttributionFor(url: string): UIEventSource<LicenseInfo> { | ||||
|         const cached = this._cache.get(url); | ||||
|         if (cached !== undefined) { | ||||
|             return cached; | ||||
|         } | ||||
|         const src = this.DownloadAttribution(url) | ||||
|         this._cache.set(url, src) | ||||
|         return src; | ||||
|     } | ||||
| 
 | ||||
| 
 | ||||
|      | ||||
|     public abstract SourceIcon(backlinkSource?: string) : BaseUIElement; | ||||
|     protected abstract DownloadAttribution(url: string): UIEventSource<LicenseInfo>; | ||||
|     public PrepareUrl(value: string): string{ | ||||
|         return value; | ||||
|     } | ||||
| 
 | ||||
| } | ||||
|  | @ -1,20 +1,25 @@ | |||
| // @ts-ignore
 | ||||
| import $ from "jquery" | ||||
| import {LicenseInfo} from "./Wikimedia"; | ||||
| import ImageAttributionSource from "./ImageAttributionSource"; | ||||
| import {UIEventSource} from "../UIEventSource"; | ||||
| import BaseUIElement from "../../UI/BaseUIElement"; | ||||
| 
 | ||||
| export class Imgur { | ||||
| export class Imgur extends ImageAttributionSource { | ||||
|      | ||||
|     public static readonly singleton = new Imgur();  | ||||
| 
 | ||||
|     private constructor() { | ||||
|         super(); | ||||
|     } | ||||
| 
 | ||||
|     static uploadMultiple( | ||||
|         title: string, description: string, blobs: FileList, | ||||
|         handleSuccessfullUpload: ((imageURL: string) => void), | ||||
|         allDone: (() => void), | ||||
|         onFail: ((reason: string) => void), | ||||
|         offset:number) { | ||||
|         offset: number = 0) { | ||||
| 
 | ||||
|         if(offset === undefined){ | ||||
|             throw "Offset undefined - not uploading to prevent to much uploads!" | ||||
|         } | ||||
|         if (blobs.length == offset) { | ||||
|             allDone(); | ||||
|             return; | ||||
|  | @ -35,55 +40,11 @@ export class Imgur { | |||
|         ); | ||||
| 
 | ||||
| 
 | ||||
|     } | ||||
|     static getDescriptionOfImage(url: string, | ||||
|                        handleDescription: ((license: LicenseInfo) => void)) { | ||||
| 
 | ||||
|         const hash = url.substr("https://i.imgur.com/".length).split(".jpg")[0]; | ||||
|          | ||||
|         const apiUrl = 'https://api.imgur.com/3/image/'+hash; | ||||
|         const apiKey = '7070e7167f0a25a'; | ||||
| 
 | ||||
|         const settings = { | ||||
|             async: true, | ||||
|             crossDomain: true, | ||||
|             processData: false, | ||||
|             contentType: false, | ||||
|             type: 'GET', | ||||
|             url: apiUrl, | ||||
|             headers: { | ||||
|                 Authorization: 'Client-ID ' + apiKey, | ||||
|                 Accept: 'application/json', | ||||
|             }, | ||||
|         }; | ||||
|         // @ts-ignore
 | ||||
|         $.ajax(settings).done(function (response) { | ||||
|             const descr: string = response.data.description ?? ""; | ||||
|             const data: any = {}; | ||||
|             for (const tag of descr.split("\n")) { | ||||
|                 const kv = tag.split(":"); | ||||
|                 const k = kv[0]; | ||||
|                 const v = kv[1].replace("\r", ""); | ||||
|                 data[k] = v; | ||||
|             } | ||||
| 
 | ||||
|              | ||||
|             const licenseInfo = new LicenseInfo(); | ||||
|              | ||||
|             licenseInfo.licenseShortName = data.license; | ||||
|             licenseInfo.artist = data.author; | ||||
|              | ||||
|             handleDescription(licenseInfo); | ||||
|              | ||||
|         }).fail((reason) => { | ||||
|             console.log("Getting metadata from to IMGUR failed", reason) | ||||
|         }); | ||||
|      | ||||
|     } | ||||
| 
 | ||||
|     static uploadImage(title: string, description: string, blob, | ||||
|                        handleSuccessfullUpload: ((imageURL: string) => void), | ||||
|                        onFail: (reason:string) => void) { | ||||
|                        onFail: (reason: string) => void) { | ||||
| 
 | ||||
|         const apiUrl = 'https://api.imgur.com/3/image'; | ||||
|         const apiKey = '7070e7167f0a25a'; | ||||
|  | @ -121,4 +82,55 @@ export class Imgur { | |||
|         }); | ||||
|     } | ||||
| 
 | ||||
|     SourceIcon(): BaseUIElement { | ||||
|         return undefined; | ||||
|     } | ||||
| 
 | ||||
|     protected DownloadAttribution(url: string): UIEventSource<LicenseInfo> { | ||||
|         const src = new UIEventSource<LicenseInfo>(undefined) | ||||
| 
 | ||||
| 
 | ||||
|         const hash = url.substr("https://i.imgur.com/".length).split(".jpg")[0]; | ||||
| 
 | ||||
|         const apiUrl = 'https://api.imgur.com/3/image/' + hash; | ||||
|         const apiKey = '7070e7167f0a25a'; | ||||
| 
 | ||||
|         const settings = { | ||||
|             async: true, | ||||
|             crossDomain: true, | ||||
|             processData: false, | ||||
|             contentType: false, | ||||
|             type: 'GET', | ||||
|             url: apiUrl, | ||||
|             headers: { | ||||
|                 Authorization: 'Client-ID ' + apiKey, | ||||
|                 Accept: 'application/json', | ||||
|             }, | ||||
|         }; | ||||
|         // @ts-ignore
 | ||||
|         $.ajax(settings).done(function (response) { | ||||
|             const descr: string = response.data.description ?? ""; | ||||
|             const data: any = {}; | ||||
|             for (const tag of descr.split("\n")) { | ||||
|                 const kv = tag.split(":"); | ||||
|                 const k = kv[0]; | ||||
|                 data[k] = kv[1].replace("\r", ""); | ||||
|             } | ||||
| 
 | ||||
| 
 | ||||
|             const licenseInfo = new LicenseInfo(); | ||||
| 
 | ||||
|             licenseInfo.licenseShortName = data.license; | ||||
|             licenseInfo.artist = data.author; | ||||
| 
 | ||||
|             src.setData(licenseInfo) | ||||
| 
 | ||||
|         }).fail((reason) => { | ||||
|             console.log("Getting metadata from to IMGUR failed", reason) | ||||
|         }); | ||||
| 
 | ||||
|         return src; | ||||
|     } | ||||
| 
 | ||||
| 
 | ||||
| } | ||||
							
								
								
									
										41
									
								
								Logic/Web/ImgurUploader.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										41
									
								
								Logic/Web/ImgurUploader.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,41 @@ | |||
| import {UIEventSource} from "../UIEventSource"; | ||||
| import {Imgur} from "./Imgur"; | ||||
| 
 | ||||
| export default class ImgurUploader { | ||||
| 
 | ||||
|     public readonly queue: UIEventSource<string[]> = new UIEventSource<string[]>([]); | ||||
|     public readonly failed: UIEventSource<string[]> = new UIEventSource<string[]>([]); | ||||
|     public readonly success: UIEventSource<string[]> = new UIEventSource<string[]>([]); | ||||
|     private readonly _handleSuccessUrl: (string) => void; | ||||
| 
 | ||||
|     constructor(handleSuccessUrl: (string) => void) { | ||||
|         this._handleSuccessUrl = handleSuccessUrl; | ||||
|     } | ||||
| 
 | ||||
|     public uploadMany(title: string, description: string, files: FileList) { | ||||
|         for (let i = 0; i < files.length; i++) { | ||||
|             this.queue.data.push(files.item(i).name) | ||||
|         } | ||||
|         this.queue.ping() | ||||
| 
 | ||||
|         const self = this; | ||||
|         this.queue.setData([...self.queue.data]) | ||||
|         Imgur.uploadMultiple(title, | ||||
|             description, | ||||
|             files, | ||||
|             function (url) { | ||||
|                 console.log("File saved at", url); | ||||
|                 self.success.setData([...self.success.data, url]); | ||||
|                 self._handleSuccessUrl(url); | ||||
|             }, | ||||
|             function () { | ||||
|                 console.log("All uploads completed"); | ||||
|             }, | ||||
| 
 | ||||
|             function (failReason) { | ||||
|                 console.log("Upload failed due to ", failReason) | ||||
|                 self.failed.setData([...self.failed.data, failReason]) | ||||
|             } | ||||
|         ); | ||||
|     } | ||||
| } | ||||
|  | @ -1,26 +1,57 @@ | |||
| import $ from "jquery" | ||||
| import {LicenseInfo} from "./Wikimedia"; | ||||
| import ImageAttributionSource from "./ImageAttributionSource"; | ||||
| import BaseUIElement from "../../UI/BaseUIElement"; | ||||
| import {UIEventSource} from "../UIEventSource"; | ||||
| import Svg from "../../Svg"; | ||||
| 
 | ||||
| export class Mapillary { | ||||
| export class Mapillary extends ImageAttributionSource { | ||||
| 
 | ||||
|     public static readonly singleton = new Mapillary(); | ||||
| 
 | ||||
|     static getDescriptionOfImage(key: string, | ||||
|                                  handleDescription: ((license: LicenseInfo) => void)) { | ||||
|         const url = `https://a.mapillary.com/v3/images/${key}?client_id=TXhLaWthQ1d4RUg0czVxaTVoRjFJZzowNDczNjUzNmIyNTQyYzI2` | ||||
|     private constructor() { | ||||
|         super(); | ||||
|     } | ||||
| 
 | ||||
|         const settings = { | ||||
|             async: true, | ||||
|             type: 'GET', | ||||
|             url: url | ||||
|         }; | ||||
|         $.getJSON(url, function(data) { | ||||
|     private static ExtractKeyFromURL(value: string) { | ||||
|         if (value.startsWith("https://a.mapillary.com")) { | ||||
|             return value.substring(0, value.lastIndexOf("?")).substring(value.lastIndexOf("/") + 1); | ||||
|         } | ||||
|         const matchApi = value.match(/https?:\/\/images.mapillary.com\/([^/]*)/) | ||||
|         if (matchApi !== null) { | ||||
|             return matchApi[1]; | ||||
|         } | ||||
| 
 | ||||
|         if (value.toLowerCase().startsWith("https://www.mapillary.com/map/im/")) { | ||||
|             // Extract the key of the image
 | ||||
|             value = value.substring("https://www.mapillary.com/map/im/".length); | ||||
|         } | ||||
|         return value; | ||||
|     } | ||||
| 
 | ||||
|     SourceIcon(backlinkSource?: string): BaseUIElement { | ||||
|         return Svg.mapillary_svg(); | ||||
|     } | ||||
| 
 | ||||
|     PrepareUrl(value: string): string { | ||||
|         const key = Mapillary.ExtractKeyFromURL(value) | ||||
|         return `https://images.mapillary.com/${key}/thumb-640.jpg?client_id=TXhLaWthQ1d4RUg0czVxaTVoRjFJZzowNDczNjUzNmIyNTQyYzI2` | ||||
|     } | ||||
| 
 | ||||
|     protected DownloadAttribution(url: string): UIEventSource<LicenseInfo> { | ||||
| 
 | ||||
|         const key = Mapillary.ExtractKeyFromURL(url) | ||||
|         const metadataURL = `https://a.mapillary.com/v3/images/${key}?client_id=TXhLaWthQ1d4RUg0czVxaTVoRjFJZzowNDczNjUzNmIyNTQyYzI2` | ||||
|         const source = new UIEventSource<LicenseInfo>(undefined) | ||||
|         $.getJSON(metadataURL, function (data) { | ||||
|             const license = new LicenseInfo(); | ||||
|             license.artist = data.properties?.username; | ||||
|             license.licenseShortName = "CC BY-SA 4.0"; | ||||
|             license.license = "Creative Commons Attribution-ShareAlike 4.0 International License"; | ||||
|             license.attributionRequired = true; | ||||
|             handleDescription(license); | ||||
|             source.setData(license); | ||||
|         }) | ||||
| 
 | ||||
|         return source | ||||
|     } | ||||
| } | ||||
|  | @ -3,6 +3,9 @@ | |||
|  */ | ||||
| import {UIEventSource} from "../UIEventSource"; | ||||
| import Hash from "./Hash"; | ||||
| import {Utils} from "../../Utils"; | ||||
| import Title from "../../UI/Base/Title"; | ||||
| import Combine from "../../UI/Base/Combine"; | ||||
| 
 | ||||
| export class QueryParameters { | ||||
| 
 | ||||
|  | @ -12,6 +15,58 @@ export class QueryParameters { | |||
|     private static defaults = {} | ||||
| 
 | ||||
|     private static documentation = {} | ||||
|     private static QueryParamDocsIntro = "\n" + | ||||
|         "URL-parameters and URL-hash\n" + | ||||
|         "============================\n" + | ||||
|         "\n" + | ||||
|         "This document gives an overview of which URL-parameters can be used to influence MapComplete.\n" + | ||||
|         "\n" + | ||||
|         "What is a URL parameter?\n" + | ||||
|         "------------------------\n" + | ||||
|         "\n" + | ||||
|         "URL-parameters are extra parts of the URL used to set the state.\n" + | ||||
|         "\n" + | ||||
|         "For example, if the url is `https://mapcomplete.osm.be/cyclofix?lat=51.0&lon=4.3&z=5&test=true#node/1234`,\n" + | ||||
|         "the URL-parameters are stated in the part between the `?` and the `#`. There are multiple, all seperated by `&`, namely:\n" + | ||||
|         "\n" + | ||||
|         "- The url-parameter `lat` is `51.0` in this instance\n" + | ||||
|         "- The url-parameter `lon` is `4.3` in this instance\n" + | ||||
|         "- The url-parameter `z` is `5` in this instance\n" + | ||||
|         "- The url-parameter `test` is `true` in this instance\n" + | ||||
|         "\n" + | ||||
|         "Finally, the URL-hash is the part after the `#`. It is `node/1234` in this case." | ||||
| 
 | ||||
|     public static GetQueryParameter(key: string, deflt: string, documentation?: string): UIEventSource<string> { | ||||
|         if (!this.initialized) { | ||||
|             this.init(); | ||||
|         } | ||||
|         QueryParameters.documentation[key] = documentation; | ||||
|         if (deflt !== undefined) { | ||||
|             QueryParameters.defaults[key] = deflt; | ||||
|         } | ||||
|         if (QueryParameters.knownSources[key] !== undefined) { | ||||
|             return QueryParameters.knownSources[key]; | ||||
|         } | ||||
|         QueryParameters.addOrder(key); | ||||
|         const source = new UIEventSource<string>(deflt, "&" + key); | ||||
|         QueryParameters.knownSources[key] = source; | ||||
|         source.addCallback(() => QueryParameters.Serialize()) | ||||
|         return source; | ||||
|     } | ||||
| 
 | ||||
|     public static GenerateQueryParameterDocs(): string { | ||||
|         const docs = [QueryParameters.QueryParamDocsIntro]; | ||||
|         for (const key in QueryParameters.documentation) { | ||||
|             const c = new Combine([ | ||||
|                 new Title(key, 2), | ||||
|                 QueryParameters.documentation[key], | ||||
|                 QueryParameters.defaults[key] === undefined ? "No default value set" : `The default value is _${QueryParameters.defaults[key]}_` | ||||
| 
 | ||||
|             ]) | ||||
|             docs.push(c.AsMarkdown()) | ||||
|         } | ||||
|         return docs.join("\n\n"); | ||||
|     } | ||||
| 
 | ||||
|     private static addOrder(key) { | ||||
|         if (this.order.indexOf(key) < 0) { | ||||
|  | @ -25,7 +80,11 @@ export class QueryParameters { | |||
|             return; | ||||
|         } | ||||
|         this.initialized = true; | ||||
|         | ||||
| 
 | ||||
|         if (Utils.runningFromConsole) { | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         if (window?.location?.search) { | ||||
|             const params = window.location.search.substr(1).split("&"); | ||||
|             for (const param of params) { | ||||
|  | @ -38,7 +97,7 @@ export class QueryParameters { | |||
|                 QueryParameters.knownSources[key] = source; | ||||
|             } | ||||
|         } | ||||
|          | ||||
| 
 | ||||
|         window["mapcomplete_query_parameter_overview"] = () => { | ||||
|             console.log(QueryParameters.GenerateQueryParameterDocs()) | ||||
|         } | ||||
|  | @ -50,7 +109,7 @@ export class QueryParameters { | |||
|             if (QueryParameters.knownSources[key]?.data === undefined) { | ||||
|                 continue; | ||||
|             } | ||||
|              | ||||
| 
 | ||||
|             if (QueryParameters.knownSources[key].data === "undefined") { | ||||
|                 continue; | ||||
|             } | ||||
|  | @ -62,41 +121,8 @@ export class QueryParameters { | |||
|             parts.push(encodeURIComponent(key) + "=" + encodeURIComponent(QueryParameters.knownSources[key].data)) | ||||
|         } | ||||
|         // Don't pollute the history every time a parameter changes
 | ||||
|          | ||||
| 
 | ||||
|         history.replaceState(null, "", "?" + parts.join("&") + Hash.Current()); | ||||
| 
 | ||||
|     } | ||||
| 
 | ||||
|     public static GetQueryParameter(key: string, deflt: string, documentation?: string): UIEventSource<string> { | ||||
|         if(!this.initialized){ | ||||
|             this.init(); | ||||
|         } | ||||
|         QueryParameters.documentation[key] = documentation; | ||||
|         if (deflt !== undefined) { | ||||
|             QueryParameters.defaults[key] = deflt; | ||||
|         } | ||||
|         if (QueryParameters.knownSources[key] !== undefined) { | ||||
|             return QueryParameters.knownSources[key]; | ||||
|         } | ||||
|         QueryParameters.addOrder(key); | ||||
|         const source = new UIEventSource<string>(deflt, "&"+key); | ||||
|         QueryParameters.knownSources[key] = source; | ||||
|         source.addCallback(() => QueryParameters.Serialize()) | ||||
|         return source; | ||||
|     } | ||||
| 
 | ||||
|     public static GenerateQueryParameterDocs(): string { | ||||
|         const docs = []; | ||||
|         for (const key in QueryParameters.documentation) { | ||||
|             docs.push([ | ||||
|                 " "+key+" ", | ||||
|                 "-".repeat(key.length + 2), | ||||
|                 QueryParameters.documentation[key], | ||||
|                 QueryParameters.defaults[key] === undefined ? "No default value set" : `The default value is _${QueryParameters.defaults[key]}_` | ||||
|                  | ||||
|             ].join("\n")) | ||||
|         } | ||||
|         return docs.join("\n\n"); | ||||
|     } | ||||
| 
 | ||||
| } | ||||
|  | @ -1,47 +1,28 @@ | |||
| import * as $ from "jquery" | ||||
| import ImageAttributionSource from "./ImageAttributionSource"; | ||||
| import BaseUIElement from "../../UI/BaseUIElement"; | ||||
| import Svg from "../../Svg"; | ||||
| import {UIEventSource} from "../UIEventSource"; | ||||
| import Link from "../../UI/Base/Link"; | ||||
| 
 | ||||
| /** | ||||
|  * This module provides endpoints for wikipedia/wikimedia and others | ||||
|  */ | ||||
| export class Wikimedia { | ||||
| export class Wikimedia extends ImageAttributionSource { | ||||
| 
 | ||||
| 
 | ||||
|     public static readonly singleton = new Wikimedia(); | ||||
| 
 | ||||
|     private constructor() { | ||||
|         super(); | ||||
|     } | ||||
| 
 | ||||
|     private static knownLicenses = {}; | ||||
| 
 | ||||
|     static ImageNameToUrl(filename: string, width: number = 500, height: number = 200): string { | ||||
|         filename = encodeURIComponent(filename); | ||||
|         return "https://commons.wikimedia.org/wiki/Special:FilePath/" + filename + "?width=" + width + "&height=" + height; | ||||
|     } | ||||
| 
 | ||||
|     static LicenseData(filename: string, handle: ((LicenseInfo) => void)): void { | ||||
|         if (filename in this.knownLicenses) { | ||||
|             return this.knownLicenses[filename]; | ||||
|         } | ||||
|         if (filename === "") { | ||||
|             return; | ||||
|         } | ||||
|         const url = "https://en.wikipedia.org/w/" + | ||||
|             "api.php?action=query&prop=imageinfo&iiprop=extmetadata&" + | ||||
|             "titles=" + filename + | ||||
|             "&format=json&origin=*"; | ||||
|         $.getJSON(url, function (data) { | ||||
|             const licenseInfo = new LicenseInfo(); | ||||
|             const license = data.query.pages[-1].imageinfo[0].extmetadata; | ||||
| 
 | ||||
|             licenseInfo.artist = license.Artist?.value; | ||||
|             licenseInfo.license = license.License?.value; | ||||
|             licenseInfo.copyrighted = license.Copyrighted?.value; | ||||
|             licenseInfo.attributionRequired = license.AttributionRequired?.value; | ||||
|             licenseInfo.usageTerms = license.UsageTerms?.value; | ||||
|             licenseInfo.licenseShortName = license.LicenseShortName?.value; | ||||
|             licenseInfo.credit = license.Credit?.value; | ||||
|             licenseInfo.description = license.ImageDescription?.value; | ||||
| 
 | ||||
|             Wikimedia.knownLicenses[filename] = licenseInfo; | ||||
|             handle(licenseInfo); | ||||
|         }); | ||||
| 
 | ||||
|     } | ||||
| 
 | ||||
|     static GetCategoryFiles(categoryName: string, handleCategory: ((ImagesInCategory: ImagesInCategory) => void), | ||||
|                             alreadyLoaded = 0, | ||||
|                             continueParameter: { k: string, param: string } = undefined) { | ||||
|  | @ -111,6 +92,71 @@ export class Wikimedia { | |||
|         }); | ||||
|     } | ||||
| 
 | ||||
|     private static ExtractFileName(url: string) { | ||||
|         if (!url.startsWith("http")) { | ||||
|             return url; | ||||
|         } | ||||
|         const path = new URL(url).pathname | ||||
|         return path.substring(path.lastIndexOf("/") + 1); | ||||
| 
 | ||||
|     } | ||||
| 
 | ||||
|     SourceIcon(backlink: string): BaseUIElement { | ||||
|         const img = Svg.wikimedia_commons_white_svg() | ||||
|             .SetStyle("width:2em;height: 2em"); | ||||
|         if (backlink === undefined) { | ||||
|             return img | ||||
|         } | ||||
| 
 | ||||
| 
 | ||||
|         return new Link(Svg.wikimedia_commons_white_img, | ||||
|             `https://commons.wikimedia.org/wiki/${backlink}`, true) | ||||
| 
 | ||||
| 
 | ||||
|     } | ||||
| 
 | ||||
|     PrepareUrl(value: string): string { | ||||
| 
 | ||||
|         if (value.toLowerCase().startsWith("https://commons.wikimedia.org/wiki/")) { | ||||
|             return value; | ||||
|         } | ||||
|         return Wikimedia.ImageNameToUrl(value, 500, 400) | ||||
|             .replace(/'/g, '%27'); | ||||
|     } | ||||
| 
 | ||||
|     protected DownloadAttribution(filename: string): UIEventSource<LicenseInfo> { | ||||
| 
 | ||||
|         const source = new UIEventSource<LicenseInfo>(undefined); | ||||
| 
 | ||||
|         filename = Wikimedia.ExtractFileName(filename) | ||||
| 
 | ||||
|         if (filename === "") { | ||||
|             return source; | ||||
|         } | ||||
| 
 | ||||
|         const url = "https://en.wikipedia.org/w/" + | ||||
|             "api.php?action=query&prop=imageinfo&iiprop=extmetadata&" + | ||||
|             "titles=" + filename + | ||||
|             "&format=json&origin=*"; | ||||
|         console.log("Getting attribution at ", url) | ||||
|         $.getJSON(url, function (data) { | ||||
|             const licenseInfo = new LicenseInfo(); | ||||
|             const license = data.query.pages[-1].imageinfo[0].extmetadata; | ||||
| 
 | ||||
|             licenseInfo.artist = license.Artist?.value; | ||||
|             licenseInfo.license = license.License?.value; | ||||
|             licenseInfo.copyrighted = license.Copyrighted?.value; | ||||
|             licenseInfo.attributionRequired = license.AttributionRequired?.value; | ||||
|             licenseInfo.usageTerms = license.UsageTerms?.value; | ||||
|             licenseInfo.licenseShortName = license.LicenseShortName?.value; | ||||
|             licenseInfo.credit = license.Credit?.value; | ||||
|             licenseInfo.description = license.ImageDescription?.value; | ||||
|             source.setData(licenseInfo); | ||||
|         }); | ||||
|         return source; | ||||
| 
 | ||||
|     } | ||||
| 
 | ||||
| 
 | ||||
| } | ||||
| 
 | ||||
|  |  | |||
|  | @ -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, | ||||
|  |  | |||
							
								
								
									
										25
									
								
								State.ts
									
										
									
									
									
								
							
							
						
						
									
										25
									
								
								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<string>(""); | ||||
| 
 | ||||
|     /** | ||||
|      The latest element that was selected | ||||
|  | @ -106,6 +102,8 @@ export default class State { | |||
|      */ | ||||
|     public readonly locationControl = new UIEventSource<Loc>(undefined); | ||||
|     public backgroundLayer; | ||||
|     public readonly backgroundLayerId: UIEventSource<string>; | ||||
| 
 | ||||
|     /* Last location where a click was registered | ||||
|      */ | ||||
|     public readonly LastClickLocation: UIEventSource<{ lat: number, lon: number }> = new UIEventSource<{ lat: number, lon: number }>(undefined) | ||||
|  | @ -127,7 +125,7 @@ export default class State { | |||
|     public welcomeMessageOpenedTab = QueryParameters.GetQueryParameter("tab", "0", `The tab that is shown in the welcome-message. 0 = the explanation of the theme,1 = OSM-credits, 2 = sharescreen, 3 = more themes, 4 = about mapcomplete (user must be logged in and have >${Constants.userJourney.mapCompleteHelpUnlock} changesets)`).map<number>( | ||||
|         str => isNaN(Number(str)) ? 0 : Number(str), [], n => "" + n | ||||
|     ); | ||||
| 
 | ||||
|   | ||||
|     constructor(layoutToUse: LayoutConfig) { | ||||
|         const self = this; | ||||
| 
 | ||||
|  | @ -214,8 +212,25 @@ export default class State { | |||
|                 "The OSM backend to use - can be used to redirect mapcomplete to the testing backend when using 'osm-test'") | ||||
| 
 | ||||
|         } | ||||
|         { | ||||
|             // Some other feature switches
 | ||||
|             const customCssQP = QueryParameters.GetQueryParameter("custom-css", "", "If specified, the custom css from the given link will be loaded additionaly"); | ||||
|             if (customCssQP.data !== undefined && customCssQP.data !== "") { | ||||
|                 Utils.LoadCustomCss(customCssQP.data); | ||||
|             } | ||||
| 
 | ||||
| 
 | ||||
|             this.backgroundLayerId = QueryParameters.GetQueryParameter("background", | ||||
|             layoutToUse?.defaultBackgroundId ?? "osm", | ||||
|             "The id of the background layer to start with") | ||||
| 
 | ||||
|         } | ||||
|          | ||||
|          | ||||
|         if(Utils.runningFromConsole){ | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         this.osmConnection = new OsmConnection( | ||||
|             this.featureSwitchIsTesting.data, | ||||
|             QueryParameters.GetQueryParameter("oauth_token", undefined, | ||||
|  |  | |||
							
								
								
									
										126
									
								
								Svg.ts
									
										
									
									
									
								
							
							
						
						
									
										126
									
								
								Svg.ts
									
										
									
									
									
								
							
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							|  | @ -1,38 +1,29 @@ | |||
| import {UIElement} from "../UIElement"; | ||||
| import Locale from "../i18n/Locale"; | ||||
| import Translations from "../i18n/Translations"; | ||||
| import BaseUIElement from "../BaseUIElement"; | ||||
| 
 | ||||
| export class Button extends UIElement { | ||||
|     private _text: UIElement; | ||||
| export class Button extends BaseUIElement { | ||||
|     private _text: BaseUIElement; | ||||
|     private _onclick: () => void; | ||||
|     private _clss: string; | ||||
| 
 | ||||
|     constructor(text: string | UIElement, onclick: (() => void), clss: string = "") { | ||||
|         super(Locale.language); | ||||
|     constructor(text: string | UIElement, onclick: (() => void)) { | ||||
|         super(); | ||||
|         this._text = Translations.W(text); | ||||
|         this._onclick = onclick; | ||||
|         if (clss !== "") { | ||||
| 
 | ||||
|             this._clss = "class='" + clss + "'"; | ||||
|         }else{ | ||||
|             this._clss = ""; | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
| 
 | ||||
|     InnerRender(): string { | ||||
| 
 | ||||
|         return "<form>" + | ||||
|             "<button id='button-"+this.id+"' type='button' "+this._clss+">" + this._text.Render() +  "</button>" + | ||||
|             "</form>"; | ||||
|     } | ||||
| 
 | ||||
|     InnerUpdate(htmlElement: HTMLElement) { | ||||
|         super.InnerUpdate(htmlElement); | ||||
|         const self = this; | ||||
|         document.getElementById("button-"+this.id).onclick = function(){ | ||||
|             self._onclick(); | ||||
|     protected InnerConstructElement(): HTMLElement { | ||||
|         const el = this._text.ConstructElement(); | ||||
|         if(el === undefined){ | ||||
|             return undefined; | ||||
|         } | ||||
|         const form = document.createElement("form") | ||||
|         const button = document.createElement("button") | ||||
|         button.type = "button" | ||||
|         button.appendChild(el) | ||||
|         button.onclick = this._onclick | ||||
|         form.appendChild(button) | ||||
|         return form; | ||||
|     } | ||||
| 
 | ||||
| } | ||||
|  | @ -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" : " "); | ||||
|     } | ||||
| 
 | ||||
| } | ||||
|  | @ -1,22 +0,0 @@ | |||
| import {UIElement} from "../UIElement"; | ||||
| import {UIEventSource} from "../../Logic/UIEventSource"; | ||||
| 
 | ||||
| export default class FeatureSwitched extends UIElement{ | ||||
|     private readonly _upstream: UIElement; | ||||
|     private readonly _swtch: UIEventSource<boolean>; | ||||
|      | ||||
|     constructor(upstream :UIElement, | ||||
|                 swtch: UIEventSource<boolean>) { | ||||
|         super(swtch); | ||||
|         this._upstream = upstream; | ||||
|         this._swtch = swtch; | ||||
|     } | ||||
|      | ||||
|     InnerRender(): string { | ||||
|         if(this._swtch.data){ | ||||
|             return this._upstream.Render(); | ||||
|         } | ||||
|         return ""; | ||||
|     } | ||||
|      | ||||
| } | ||||
|  | @ -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; | ||||
|     } | ||||
| 
 | ||||
| } | ||||
|  | @ -1,19 +1,41 @@ | |||
| import Constants from "../../Models/Constants"; | ||||
| import {Utils} from "../../Utils"; | ||||
| import BaseUIElement from "../BaseUIElement"; | ||||
| 
 | ||||
| export default class Img { | ||||
| export default class Img extends BaseUIElement { | ||||
|     private _src: string; | ||||
|     private readonly _rawSvg: boolean; | ||||
| 
 | ||||
|     public static runningFromConsole = false; | ||||
|     constructor(src: string, rawSvg = false) { | ||||
|         super(); | ||||
|         this._src = src; | ||||
|         this._rawSvg = rawSvg; | ||||
|     } | ||||
| 
 | ||||
|    static AsData(source:string){ | ||||
|        if(Utils.runningFromConsole){ | ||||
|            return source; | ||||
|        } | ||||
|        return `data:image/svg+xml;base64,${(btoa(source))}`; | ||||
|    } | ||||
|     static AsData(source: string) { | ||||
|         if (Utils.runningFromConsole) { | ||||
|             return source; | ||||
|         } | ||||
|         return `data:image/svg+xml;base64,${(btoa(source))}`; | ||||
|     } | ||||
| 
 | ||||
|     static AsImageElement(source: string, css_class: string = "", style=""): string{ | ||||
|     static AsImageElement(source: string, css_class: string = "", style = ""): string { | ||||
|         return `<img class="${css_class}" style="${style}" alt="" src="${Img.AsData(source)}">`; | ||||
|     } | ||||
| 
 | ||||
|     protected InnerConstructElement(): HTMLElement { | ||||
| 
 | ||||
|         if (this._rawSvg) { | ||||
|             const e = document.createElement("div") | ||||
|             e.innerHTML = this._src | ||||
|             return e; | ||||
|         } | ||||
| 
 | ||||
|         const el = document.createElement("img") | ||||
|         el.src = this._src; | ||||
|         el.onload = () => { | ||||
|             el.style.opacity = "1" | ||||
|         } | ||||
|         return el; | ||||
|     } | ||||
| } | ||||
| 
 | ||||
|  |  | |||
|  | @ -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(); | ||||
|     } | ||||
| 
 | ||||
| } | ||||
|  | @ -1,24 +1,44 @@ | |||
| import {UIElement} from "../UIElement"; | ||||
| import Translations from "../i18n/Translations"; | ||||
| import BaseUIElement from "../BaseUIElement"; | ||||
| import {UIEventSource} from "../../Logic/UIEventSource"; | ||||
| 
 | ||||
| 
 | ||||
| export default class Link extends UIElement { | ||||
|     private readonly _embeddedShow: UIElement; | ||||
|     private readonly _target: string; | ||||
|     private readonly _newTab: string; | ||||
| export default class Link extends BaseUIElement { | ||||
|     private readonly _href: string | UIEventSource<string>; | ||||
|     private readonly _embeddedShow: BaseUIElement; | ||||
|     private readonly _newTab: boolean; | ||||
| 
 | ||||
|     constructor(embeddedShow: UIElement | string, target: string, newTab: boolean = false) { | ||||
|     constructor(embeddedShow: BaseUIElement | string, href: string | UIEventSource<string>, newTab: boolean = false) { | ||||
|         super(); | ||||
|         this._embeddedShow = Translations.W(embeddedShow); | ||||
|         this._target = target; | ||||
|         this._newTab = ""; | ||||
|         if (newTab) { | ||||
|             this._newTab = "target='_blank'" | ||||
|         } | ||||
|         this._embeddedShow =Translations.W(embeddedShow); | ||||
|         this._href = href; | ||||
|         this._newTab = newTab; | ||||
| 
 | ||||
|     } | ||||
| 
 | ||||
|     InnerRender(): string { | ||||
|         return `<a href="${this._target}" ${this._newTab}>${this._embeddedShow.Render()}</a>`; | ||||
|     protected InnerConstructElement(): HTMLElement { | ||||
|         const embeddedShow = this._embeddedShow?.ConstructElement(); | ||||
|         if(embeddedShow === undefined){ | ||||
|             return undefined; | ||||
|         } | ||||
|         const el = document.createElement("a") | ||||
|         if(typeof this._href === "string"){ | ||||
|             el.href = this._href | ||||
|         }else{ | ||||
|            this._href.addCallbackAndRun(href => { | ||||
|                 el.href = href; | ||||
|             }) | ||||
|         } | ||||
|         if (this._newTab) { | ||||
|             el.target = "_blank" | ||||
|         } | ||||
|         el.appendChild(embeddedShow) | ||||
|         return el; | ||||
|     } | ||||
| 
 | ||||
|     AsMarkdown(): string { | ||||
|         // @ts-ignore
 | ||||
|         return `[${this._embeddedShow.AsMarkdown()}](${this._href.data ?? this._href})`; | ||||
|     } | ||||
| 
 | ||||
| } | ||||
							
								
								
									
										43
									
								
								UI/Base/List.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										43
									
								
								UI/Base/List.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,43 @@ | |||
| import {Utils} from "../../Utils"; | ||||
| import BaseUIElement from "../BaseUIElement"; | ||||
| import Translations from "../i18n/Translations"; | ||||
| 
 | ||||
| export default class List extends BaseUIElement { | ||||
|     private readonly uiElements: BaseUIElement[]; | ||||
|     private readonly _ordered: boolean; | ||||
| 
 | ||||
|     constructor(uiElements: (string | BaseUIElement)[], ordered = false) { | ||||
|         super(); | ||||
|         this._ordered = ordered; | ||||
|         this.uiElements = Utils.NoNull(uiElements) | ||||
|             .map(Translations.W); | ||||
|     } | ||||
| 
 | ||||
|     protected InnerConstructElement(): HTMLElement { | ||||
|         const el = document.createElement(this._ordered ? "ol" : "ul") | ||||
| 
 | ||||
|         for (const subEl of this.uiElements) { | ||||
|             if(subEl === undefined || subEl === null){ | ||||
|                 continue; | ||||
|             } | ||||
|             const subHtml = subEl.ConstructElement() | ||||
|             if(subHtml !== undefined){ | ||||
|                 const item = document.createElement("li") | ||||
|                 item.appendChild(subHtml) | ||||
|                 el.appendChild(item) | ||||
|             } | ||||
|         } | ||||
| 
 | ||||
|         return el; | ||||
|     } | ||||
|      | ||||
|     AsMarkdown(): string { | ||||
|         if(this._ordered){ | ||||
|             return "\n\n"+this.uiElements.map((el, i) => "  "+i+". "+el.AsMarkdown().replace(/\n/g, '  \n') ).join("\n") + "\n" | ||||
|         }else{ | ||||
|             return "\n\n"+this.uiElements.map(el => "  - "+el.AsMarkdown().replace(/\n/g, '  \n') ).join("\n")+"\n" | ||||
| 
 | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
| } | ||||
|  | @ -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 { | ||||
| 
 | ||||
|  |  | |||
|  | @ -1,20 +0,0 @@ | |||
| import {UIElement} from "../UIElement"; | ||||
| 
 | ||||
| export default class PageSplit extends UIElement{ | ||||
|     private _left: UIElement; | ||||
|     private _right: UIElement; | ||||
|     private _leftPercentage: number; | ||||
|      | ||||
|     constructor(left: UIElement, right:UIElement, | ||||
|                 leftPercentage: number = 50) { | ||||
|         super(); | ||||
|         this._left = left; | ||||
|         this._right = right; | ||||
|         this._leftPercentage = leftPercentage; | ||||
|     } | ||||
|      | ||||
|     InnerRender(): string { | ||||
|         return `<span class="page-split" style="height: min-content"><span style="flex:0 0 ${this._leftPercentage}%">${this._left.Render()}</span><span style="flex: 0 0 ${100-this._leftPercentage}%">${this._right.Render()}</span></span>`; | ||||
|     } | ||||
|      | ||||
| } | ||||
|  | @ -5,20 +5,27 @@ import Ornament from "./Ornament"; | |||
| import {FixedUiElement} from "./FixedUiElement"; | ||||
| import {UIEventSource} from "../../Logic/UIEventSource"; | ||||
| import Hash from "../../Logic/Web/Hash"; | ||||
| import BaseUIElement from "../BaseUIElement"; | ||||
| 
 | ||||
| /** | ||||
|  * Wraps some contents into a panel that scrolls the content _under_ the title | ||||
|  *  | ||||
|  * The scrollableFullScreen is a bit of a peculiar component: | ||||
|  * - It shows a title and some contents, constructed from the respective functions passed into the constructor | ||||
|  * - When the element is 'activated', one clone of title+contents is attached to the fullscreen | ||||
|  * - The element itself will - upon rendering - also show the title and contents (allthough it'll be a different clone) | ||||
|  *  | ||||
|  *  | ||||
|  */ | ||||
| export default class ScrollableFullScreen extends UIElement { | ||||
|     private static readonly empty = new FixedUiElement(""); | ||||
|     public isShown: UIEventSource<boolean>; | ||||
|     private _component: UIElement; | ||||
|     private _fullscreencomponent: UIElement; | ||||
|     private _component: BaseUIElement; | ||||
|     private _fullscreencomponent: BaseUIElement; | ||||
|     private static readonly _actor = ScrollableFullScreen.InitActor(); | ||||
|     private _hashToSet: string;   | ||||
|     private static _currentlyOpen : ScrollableFullScreen; | ||||
| 
 | ||||
|     constructor(title: ((mode: string) => UIElement), content: ((mode: string) => UIElement), | ||||
|     constructor(title: ((mode: string) => BaseUIElement), content: ((mode: string) => BaseUIElement), | ||||
|                 hashToSet: string, | ||||
|                 isShown: UIEventSource<boolean> = new UIEventSource<boolean>(false) | ||||
|               ) { | ||||
|  | @ -29,7 +36,6 @@ export default class ScrollableFullScreen extends UIElement { | |||
|         this._component = this.BuildComponent(title("desktop"), content("desktop"), isShown) | ||||
|             .SetClass("hidden md:block"); | ||||
|         this._fullscreencomponent = this.BuildComponent(title("mobile"), content("mobile"), isShown); | ||||
|         this.dumbMode = false; | ||||
|         const self = this; | ||||
|         isShown.addCallback(isShown => { | ||||
|             if (isShown) { | ||||
|  | @ -40,8 +46,8 @@ export default class ScrollableFullScreen extends UIElement { | |||
|         }) | ||||
|     } | ||||
| 
 | ||||
|     InnerRender(): string { | ||||
|         return this._component.Render(); | ||||
|     InnerRender(): BaseUIElement { | ||||
|         return this._component; | ||||
|     } | ||||
| 
 | ||||
|     Activate(): void { | ||||
|  | @ -55,7 +61,7 @@ export default class ScrollableFullScreen extends UIElement { | |||
|         fs.classList.remove("hidden") | ||||
|     } | ||||
| 
 | ||||
|     private BuildComponent(title: UIElement, content: UIElement, isShown: UIEventSource<boolean>) { | ||||
|     private BuildComponent(title: BaseUIElement, content:BaseUIElement, isShown: UIEventSource<boolean>) { | ||||
|         const returnToTheMap = | ||||
|             new Combine([ | ||||
|                 Svg.back_svg().SetClass("block md:hidden"), | ||||
|  |  | |||
|  | @ -1,57 +1,58 @@ | |||
| import {UIElement} from "../UIElement"; | ||||
| import Translations from "../i18n/Translations"; | ||||
| import Combine from "./Combine"; | ||||
| import {FixedUiElement} from "./FixedUiElement"; | ||||
| import BaseUIElement from "../BaseUIElement"; | ||||
| import Link from "./Link"; | ||||
| import Img from "./Img"; | ||||
| import {UIEventSource} from "../../Logic/UIEventSource"; | ||||
| import {UIElement} from "../UIElement"; | ||||
| 
 | ||||
| 
 | ||||
| export class SubtleButton extends Combine { | ||||
| export class SubtleButton extends UIElement { | ||||
| 
 | ||||
|     constructor(imageUrl: string | UIElement, message: string | UIElement, linkTo: { url: string, newTab?: boolean } = undefined) { | ||||
|         super(SubtleButton.generateContent(imageUrl, message, linkTo)); | ||||
|     private readonly _element: BaseUIElement | ||||
| 
 | ||||
|     constructor(imageUrl: string | BaseUIElement, message: string | BaseUIElement, linkTo: { url: string | UIEventSource<string>, newTab?: boolean } = undefined) { | ||||
|         super(); | ||||
|         this._element = SubtleButton.generateContent(imageUrl, message, linkTo) | ||||
|         this.SetClass("block flex p-3 my-2 bg-blue-100 rounded-lg hover:shadow-xl hover:bg-blue-200 link-no-underline") | ||||
| 
 | ||||
|     } | ||||
| 
 | ||||
|     private static generateContent(imageUrl: string | UIElement, messageT: string | UIElement, linkTo: { url: string, newTab?: boolean } = undefined): (UIElement | string)[] { | ||||
|     private static generateContent(imageUrl: string | BaseUIElement, messageT: string | BaseUIElement, linkTo: { url: string | UIEventSource<string>, newTab?: boolean } = undefined): BaseUIElement { | ||||
|         const message = Translations.W(messageT); | ||||
|         if (message !== null) { | ||||
|             message.dumbMode = false; | ||||
|         } | ||||
|         message | ||||
|         let img; | ||||
|         if ((imageUrl ?? "") === "") { | ||||
|             img = new FixedUiElement(""); | ||||
|             img = undefined; | ||||
|         } else if (typeof (imageUrl) === "string") { | ||||
|             img = new FixedUiElement(`<img style="width: 100%;" src="${imageUrl}" alt="">`); | ||||
|             img = new Img(imageUrl) | ||||
|         } else { | ||||
|             img = imageUrl; | ||||
|         } | ||||
|         img.SetClass("block flex items-center justify-center h-11 w-11 flex-shrink0") | ||||
|         img?.SetClass("block flex items-center justify-center h-11 w-11 flex-shrink0 mr-4") | ||||
|         const image = new Combine([img]) | ||||
|             .SetClass("flex-shrink-0"); | ||||
| 
 | ||||
| 
 | ||||
|         if (message !== null && message.IsEmpty()) { | ||||
|             // Message == null: special case to force empty text
 | ||||
|             return []; | ||||
|         } | ||||
| 
 | ||||
|         if (linkTo != undefined) { | ||||
|             return [ | ||||
|                 `<a class='flex group' href="${linkTo.url}" ${linkTo.newTab ? 'target="_blank"' : ""}>`, | ||||
|         if (linkTo == undefined) { | ||||
|             return new Combine([ | ||||
|                 image, | ||||
|                 `<div class='ml-4 overflow-ellipsis'>`, | ||||
|                 message, | ||||
|                 `</div>`, | ||||
|                 `</a>` | ||||
|             ]; | ||||
|                 message?.SetClass("block overflow-ellipsis"), | ||||
|             ]).SetClass("flex group w-full"); | ||||
|         } | ||||
| 
 | ||||
|         return [ | ||||
|             image, | ||||
|             message, | ||||
|         ]; | ||||
| 
 | ||||
|         return new Link( | ||||
|             new Combine([ | ||||
|                 image, | ||||
|                 message?.SetClass("block overflow-ellipsis") | ||||
|             ]).SetClass("flex group w-full"), | ||||
|             linkTo.url, | ||||
|             linkTo.newTab ?? false | ||||
|         ) | ||||
|     } | ||||
| 
 | ||||
|     protected InnerRender(): string | BaseUIElement { | ||||
|         return this._element; | ||||
|     } | ||||
| 
 | ||||
| 
 | ||||
|  |  | |||
|  | @ -1,39 +1,42 @@ | |||
| import {UIElement} from "../UIElement"; | ||||
| import Translations from "../i18n/Translations"; | ||||
| import {UIEventSource} from "../../Logic/UIEventSource"; | ||||
| import Combine from "./Combine"; | ||||
| import BaseUIElement from "../BaseUIElement"; | ||||
| import {VariableUiElement} from "./VariableUIElement"; | ||||
| 
 | ||||
| export class TabbedComponent extends UIElement { | ||||
| export class TabbedComponent extends Combine { | ||||
| 
 | ||||
|     private headers: UIElement[] = []; | ||||
|     private content: UIElement[] = []; | ||||
|     constructor(elements: { header: BaseUIElement | string, content: BaseUIElement | string }[], openedTab: (UIEventSource<number> | number) = 0) { | ||||
| 
 | ||||
|     constructor(elements: { header: UIElement | string, content: UIElement | string }[], openedTab: (UIEventSource<number> | number) = 0) { | ||||
|         super(typeof (openedTab) === "number" ? new UIEventSource(openedTab) : (openedTab ?? new UIEventSource<number>(0))); | ||||
|         const self = this; | ||||
|         const openedTabSrc = typeof (openedTab) === "number" ? new UIEventSource(openedTab) : (openedTab ?? new UIEventSource<number>(0)) | ||||
|              | ||||
|         const tabs: BaseUIElement[] = [] | ||||
|         const contentElements: BaseUIElement[] = []; | ||||
|         for (let i = 0; i < elements.length; i++) { | ||||
|             let element = elements[i]; | ||||
|             this.headers.push(Translations.W(element.header).onClick(() => self._source.setData(i))); | ||||
|             const header = Translations.W(element.header).onClick(() => openedTabSrc.setData(i)) | ||||
|             openedTabSrc.addCallbackAndRun(selected => { | ||||
|                 if(selected === i){ | ||||
|                     header.SetClass("tab-active") | ||||
|                     header.RemoveClass("tab-non-active") | ||||
|                 }else{ | ||||
|                     header.SetClass("tab-non-active") | ||||
|                     header.RemoveClass("tab-active") | ||||
|                 } | ||||
|             }) | ||||
|             const content = Translations.W(element.content) | ||||
|             this.content.push(content); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     InnerRender(): string { | ||||
|         let headerBar = ""; | ||||
|         for (let i = 0; i < this.headers.length; i++) { | ||||
|             let header = this.headers[i]; | ||||
| 
 | ||||
|             if (!this.content[i].IsEmpty()) { | ||||
|                 headerBar += `<div class=\'tab-single-header ${i == this._source.data ? 'tab-active' : 'tab-non-active'}\'>` + | ||||
|                     header.Render() + "</div>" | ||||
|             } | ||||
|             content.SetClass("relative p-4 w-full inline-block") | ||||
|             contentElements.push(content); | ||||
|             const tab = header.SetClass("block tab-single-header") | ||||
|             tabs.push(tab) | ||||
|         } | ||||
| 
 | ||||
|         const header = new Combine(tabs).SetClass("block tabs-header-bar") | ||||
|         const actualContent = new VariableUiElement( | ||||
|             openedTabSrc.map(i => contentElements[i]) | ||||
|         ) | ||||
|         super([header, actualContent]) | ||||
| 
 | ||||
|         headerBar = "<div class='tabs-header-bar'>" + headerBar + "</div>" | ||||
| 
 | ||||
|         const content = this.content[this._source.data]; | ||||
|         return headerBar + "<div class='tab-content'>" + (content?.Render() ?? "") + "</div>"; | ||||
|     } | ||||
| 
 | ||||
| } | ||||
							
								
								
									
										71
									
								
								UI/Base/Table.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										71
									
								
								UI/Base/Table.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,71 @@ | |||
| import BaseUIElement from "../BaseUIElement"; | ||||
| import {Utils} from "../../Utils"; | ||||
| import Translations from "../i18n/Translations"; | ||||
| 
 | ||||
| export default class Table extends BaseUIElement { | ||||
| 
 | ||||
|     private readonly _header: BaseUIElement[]; | ||||
|     private readonly _contents: BaseUIElement[][]; | ||||
|     private readonly _contentStyle: string[][]; | ||||
| 
 | ||||
|     constructor(header: (BaseUIElement | string)[],  | ||||
|                 contents: (BaseUIElement | string)[][], | ||||
|                 contentStyle?: string[][]) { | ||||
|         super(); | ||||
|         this._contentStyle = contentStyle ?? []; | ||||
|         this._header = header?.map(Translations.W); | ||||
|         this._contents = contents.map(row => row.map(Translations.W)); | ||||
|     } | ||||
| 
 | ||||
|     protected InnerConstructElement(): HTMLElement { | ||||
|         const table = document.createElement("table") | ||||
| 
 | ||||
|         const headerElems = Utils.NoNull((this._header ?? []).map(elems => elems.ConstructElement())) | ||||
|         if (headerElems.length > 0) { | ||||
| 
 | ||||
|             const tr = document.createElement("tr"); | ||||
|             headerElems.forEach(headerElem => { | ||||
|                 const td = document.createElement("th") | ||||
|                 td.appendChild(headerElem) | ||||
|                 tr.appendChild(td) | ||||
|             }) | ||||
|             table.appendChild(tr) | ||||
|         } | ||||
| 
 | ||||
|         for (let i = 0; i < this._contents.length; i++){ | ||||
|             let row = this._contents[i]; | ||||
|             const tr = document.createElement("tr") | ||||
|             for (let j = 0; j < row.length; j++){ | ||||
|                 let elem = row[j]; | ||||
|                 const htmlElem = elem?.ConstructElement() | ||||
|                 if (htmlElem === undefined) { | ||||
|                     continue; | ||||
|                 } | ||||
| 
 | ||||
|                 let style = undefined; | ||||
|                 if(this._contentStyle !== undefined && this._contentStyle[i] !== undefined && this._contentStyle[j]!== undefined){ | ||||
|                     style = this._contentStyle[i][j] | ||||
|                 } | ||||
|          | ||||
|                 const td = document.createElement("td") | ||||
|                 td.style.cssText = style; | ||||
|                 td.appendChild(htmlElem) | ||||
|                 tr.appendChild(td) | ||||
|             } | ||||
|             table.appendChild(tr) | ||||
|         } | ||||
| 
 | ||||
|         return table; | ||||
|     } | ||||
|      | ||||
|     AsMarkdown(): string { | ||||
|          | ||||
|         const headerMarkdownParts =  this._header.map(hel => hel?.AsMarkdown() ?? " ") | ||||
|         const header =headerMarkdownParts.join(" | "); | ||||
|         const headerSep = headerMarkdownParts.map(part => '-'.repeat(part.length + 2)).join("|") | ||||
|         const table = this._contents.map(row => row.map(el => el.AsMarkdown()?? " ").join("|")).join("\n") | ||||
|          | ||||
|         return [header, headerSep, table, ""].join("\n") | ||||
|     } | ||||
| 
 | ||||
| } | ||||
							
								
								
									
										37
									
								
								UI/Base/Title.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										37
									
								
								UI/Base/Title.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,37 @@ | |||
| import {UIElement} from "../UIElement"; | ||||
| import BaseUIElement from "../BaseUIElement"; | ||||
| import Translations from "../i18n/Translations"; | ||||
| 
 | ||||
| export default class Title extends BaseUIElement{ | ||||
|     private readonly _embedded: BaseUIElement; | ||||
|     private readonly  _level: number; | ||||
|     constructor(embedded: string | BaseUIElement, level: number =3 ) { | ||||
|         super() | ||||
|         this._embedded = Translations.W(embedded); | ||||
|         this._level = level; | ||||
|     } | ||||
| 
 | ||||
|     protected InnerConstructElement(): HTMLElement { | ||||
|         const el = this._embedded.ConstructElement() | ||||
|         if(el === undefined){ | ||||
|             return undefined; | ||||
|         } | ||||
|         const h =  document.createElement("h"+this._level) | ||||
|         h.appendChild(el) | ||||
|         return h; | ||||
|     } | ||||
|      | ||||
|     AsMarkdown(): string { | ||||
|         const embedded = " " +this._embedded.AsMarkdown()+" "; | ||||
| 
 | ||||
|         if(this._level == 1){ | ||||
|             return "\n"+embedded+"\n"+"=".repeat(embedded.length)+"\n\n" | ||||
|         } | ||||
| 
 | ||||
|         if(this._level == 2){ | ||||
|             return "\n"+embedded+"\n"+"-".repeat(embedded.length)+"\n\n" | ||||
|         } | ||||
|          | ||||
|         return "\n"+"#".repeat( this._level)+embedded +"\n\n"; | ||||
|     } | ||||
| } | ||||
|  | @ -1,16 +1,46 @@ | |||
| import {UIElement} from "../UIElement"; | ||||
| import {UIEventSource} from "../../Logic/UIEventSource"; | ||||
| import BaseUIElement from "../BaseUIElement"; | ||||
| 
 | ||||
| export class VariableUiElement extends UIElement { | ||||
|     private _html: UIEventSource<string>; | ||||
| export class VariableUiElement extends BaseUIElement { | ||||
| 
 | ||||
|     constructor(html: UIEventSource<string>) { | ||||
|         super(html); | ||||
|         this._html = html; | ||||
|     private _element : HTMLElement; | ||||
|      | ||||
|     constructor(contents: UIEventSource<string | BaseUIElement | BaseUIElement[]>) { | ||||
|         super(); | ||||
|          | ||||
|         this._element = document.createElement("span") | ||||
|         const el = this._element | ||||
|         contents.addCallbackAndRun(contents => { | ||||
|             while (el.firstChild) { | ||||
|                 el.removeChild( | ||||
|                     el.lastChild | ||||
|                 ) | ||||
|             } | ||||
| 
 | ||||
|             if (contents === undefined) { | ||||
|                 return el; | ||||
|             } | ||||
|             if (typeof contents === "string") { | ||||
|                 el.innerHTML = contents | ||||
|             } else if (contents instanceof Array) { | ||||
|                 for (const content of contents) { | ||||
|                     const c = content.ConstructElement(); | ||||
|                     if (c !== undefined && c !== null) { | ||||
|                         el.appendChild(c)   | ||||
|                     } | ||||
| 
 | ||||
|                 } | ||||
|             } else { | ||||
|                 const c = contents.ConstructElement(); | ||||
|                 if (c !== undefined && c !== null) { | ||||
|                     el.appendChild(c) | ||||
|                 } | ||||
|             } | ||||
|         }) | ||||
|     } | ||||
| 
 | ||||
|     InnerRender(): string { | ||||
|         return this._html.data; | ||||
|     protected InnerConstructElement(): HTMLElement { | ||||
|         return this._element; | ||||
|     } | ||||
| 
 | ||||
| } | ||||
|  | @ -1,20 +0,0 @@ | |||
| import {UIElement} from "../UIElement"; | ||||
| 
 | ||||
| export class VerticalCombine extends UIElement { | ||||
|     private readonly _elements: UIElement[]; | ||||
| 
 | ||||
|     constructor(elements: UIElement[]) { | ||||
|         super(undefined); | ||||
|         this._elements = elements; | ||||
|     } | ||||
| 
 | ||||
|     InnerRender(): string { | ||||
|         let html = ""; | ||||
|         for (const element of this._elements) { | ||||
|             if (element !== undefined && !element.IsEmpty()) { | ||||
|                 html += "<div>" + element.Render() + "</div>"; | ||||
|             } | ||||
|         } | ||||
|         return html; | ||||
|     } | ||||
| } | ||||
							
								
								
									
										166
									
								
								UI/BaseUIElement.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										166
									
								
								UI/BaseUIElement.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,166 @@ | |||
| import {Utils} from "../Utils"; | ||||
| import {UIEventSource} from "../Logic/UIEventSource"; | ||||
| 
 | ||||
| /** | ||||
|  * A thin wrapper around a html element, which allows to generate a HTML-element. | ||||
|  *  | ||||
|  * Assumes a read-only configuration, so it has no 'ListenTo' | ||||
|  */ | ||||
| export default abstract class BaseUIElement { | ||||
| 
 | ||||
|     private clss: Set<string> = new Set<string>(); | ||||
|     private style: string; | ||||
|     private _onClick: () => void; | ||||
|     private _onHover: UIEventSource<boolean>; | ||||
| 
 | ||||
|     protected _constructedHtmlElement: HTMLElement; | ||||
|      | ||||
| 
 | ||||
|     protected abstract InnerConstructElement(): HTMLElement; | ||||
| 
 | ||||
|     public onClick(f: (() => void)) { | ||||
|         this._onClick = f; | ||||
|         this.SetClass("clickable") | ||||
|         if(this._constructedHtmlElement !== undefined){ | ||||
|             this._constructedHtmlElement.onclick = f; | ||||
|         } | ||||
|         return this; | ||||
|     } | ||||
|      | ||||
|     AttachTo(divId: string) { | ||||
|         let element = document.getElementById(divId); | ||||
|         if (element === null) { | ||||
|             throw "SEVERE: could not attach UIElement to " + divId; | ||||
|         } | ||||
| 
 | ||||
|         while (element.firstChild) { | ||||
|             //The list is LIVE so it will re-index each call
 | ||||
|             element.removeChild(element.firstChild); | ||||
|         } | ||||
|         const el = this.ConstructElement(); | ||||
|         if(el !== undefined){ | ||||
|             element.appendChild(el) | ||||
|         } | ||||
| 
 | ||||
|         return this; | ||||
|     } | ||||
|     /** | ||||
|      * Adds all the relevant classes, space seperated | ||||
|      */ | ||||
|     public SetClass(clss: string) { | ||||
|         const all = clss.split(" ").map(clsName => clsName.trim()); | ||||
|         let recordedChange = false; | ||||
|         for (let c of all) { | ||||
|             c = c.trim(); | ||||
|             if (this.clss.has(clss)) { | ||||
|                 continue; | ||||
|             } | ||||
|             if(c === undefined || c === ""){ | ||||
|                 continue; | ||||
|             } | ||||
|             this.clss.add(c); | ||||
|             recordedChange = true; | ||||
|         } | ||||
|         if (recordedChange) { | ||||
|             this._constructedHtmlElement?.classList.add(...Array.from(this.clss)); | ||||
|         } | ||||
|         return this; | ||||
|     } | ||||
| 
 | ||||
|     public RemoveClass(clss: string): BaseUIElement { | ||||
|         if (this.clss.has(clss)) { | ||||
|             this.clss.delete(clss); | ||||
|             this._constructedHtmlElement?.classList.remove(clss) | ||||
|         } | ||||
|         return this; | ||||
|     } | ||||
|      | ||||
|     public HasClass(clss: string): boolean{ | ||||
|         return this.clss.has(clss) | ||||
|     } | ||||
| 
 | ||||
|     public SetStyle(style: string): BaseUIElement { | ||||
|         this.style = style; | ||||
|         if(this._constructedHtmlElement !== undefined){ | ||||
|             this._constructedHtmlElement.style.cssText = style; | ||||
|         } | ||||
|         return this; | ||||
|     } | ||||
|      | ||||
|     /** | ||||
|      * The same as 'Render', but creates a HTML element instead of the HTML representation | ||||
|      */ | ||||
|     public ConstructElement(): HTMLElement { | ||||
|         if (Utils.runningFromConsole) { | ||||
|             return undefined; | ||||
|         } | ||||
| 
 | ||||
|         if (this._constructedHtmlElement !== undefined) { | ||||
|             return this._constructedHtmlElement | ||||
|         } | ||||
| 
 | ||||
|         if(this.InnerConstructElement === undefined){ | ||||
|             throw "ERROR! This is not a correct baseUIElement: "+this.constructor.name | ||||
|         } | ||||
| try{ | ||||
|              | ||||
| 
 | ||||
|         const el = this.InnerConstructElement(); | ||||
| 
 | ||||
|         if(el === undefined){ | ||||
|             return undefined; | ||||
|         } | ||||
| 
 | ||||
|         this._constructedHtmlElement = el; | ||||
|         const style = this.style | ||||
|         if (style !== undefined && style !== "") { | ||||
|             el.style.cssText = style | ||||
|         } | ||||
|         if (this.clss.size > 0) { | ||||
|             try{ | ||||
|                 el.classList.add(...Array.from(this.clss)) | ||||
|             }catch(e){ | ||||
|                 console.error("Invalid class name detected in:", Array.from(this.clss).join(" "),"\nErr msg is ",e) | ||||
|             } | ||||
|         } | ||||
| 
 | ||||
|         if (this._onClick !== undefined) { | ||||
|             const self = this; | ||||
|             el.onclick = (e) => { | ||||
|                 // @ts-ignore
 | ||||
|                 if (e.consumed) { | ||||
|                     return; | ||||
|                 } | ||||
|                 self._onClick(); | ||||
|                 // @ts-ignore
 | ||||
|                 e.consumed = true; | ||||
|             } | ||||
|             el.style.pointerEvents = "all"; | ||||
|             el.style.cursor = "pointer"; | ||||
|         } | ||||
| 
 | ||||
|         if (this._onHover !== undefined) { | ||||
|             const self = this; | ||||
|             el.addEventListener('mouseover', () => self._onHover.setData(true)); | ||||
|             el.addEventListener('mouseout', () => self._onHover.setData(false)); | ||||
|         } | ||||
| 
 | ||||
|         if (this._onHover !== undefined) { | ||||
|             const self = this; | ||||
|             el.addEventListener('mouseover', () => self._onHover.setData(true)); | ||||
|             el.addEventListener('mouseout', () => self._onHover.setData(false)); | ||||
|         } | ||||
| 
 | ||||
|         return el}catch(e){ | ||||
|             const domExc = e as DOMException; | ||||
|             if(domExc){ | ||||
|                 console.log("An exception occured", domExc.code, domExc.message, domExc.name ) | ||||
|             } | ||||
|             console.error(e) | ||||
| } | ||||
|     } | ||||
|      | ||||
|     public AsMarkdown(): string{ | ||||
|         throw "AsMarkdown is not implemented by "+this.constructor.name | ||||
|     } | ||||
| } | ||||
|  | @ -1,4 +1,3 @@ | |||
| import {UIElement} from "../UIElement"; | ||||
| import Link from "../Base/Link"; | ||||
| import Svg from "../../Svg"; | ||||
| import Combine from "../Base/Combine"; | ||||
|  | @ -8,67 +7,57 @@ import Constants from "../../Models/Constants"; | |||
| import LayoutConfig from "../../Customizations/JSON/LayoutConfig"; | ||||
| import Loc from "../../Models/Loc"; | ||||
| import * as L from "leaflet" | ||||
| import {VariableUiElement} from "../Base/VariableUIElement"; | ||||
| 
 | ||||
| /** | ||||
|  * The bottom right attribution panel in the leaflet map | ||||
|  */ | ||||
| export default class Attribution extends UIElement { | ||||
| 
 | ||||
|     private readonly _location: UIEventSource<Loc>; | ||||
|     private readonly _layoutToUse: UIEventSource<LayoutConfig>; | ||||
|     private readonly _userDetails: UIEventSource<UserDetails>; | ||||
|     private readonly _leafletMap: UIEventSource<L.Map>; | ||||
| export default class Attribution extends Combine { | ||||
| 
 | ||||
|     constructor(location: UIEventSource<Loc>, | ||||
|                 userDetails: UIEventSource<UserDetails>, | ||||
|                 layoutToUse: UIEventSource<LayoutConfig>, | ||||
|                 leafletMap: UIEventSource<L.Map>) { | ||||
|         super(location); | ||||
|         this._layoutToUse = layoutToUse; | ||||
|         this.ListenTo(layoutToUse); | ||||
|         this._userDetails = userDetails; | ||||
|         this._leafletMap = leafletMap; | ||||
|         this.ListenTo(userDetails); | ||||
|         this._location = location; | ||||
|         this.SetClass("map-attribution"); | ||||
|     } | ||||
| 
 | ||||
|     InnerRender(): string { | ||||
|         const location: Loc = this._location?.data; | ||||
|         const userDetails = this._userDetails?.data; | ||||
| 
 | ||||
|         | ||||
|         const mapComplete = new Link(`Mapcomplete ${Constants.vNumber}`, 'https://github.com/pietervdvn/MapComplete', true); | ||||
|         const reportBug = new Link(Svg.bug_img, "https://github.com/pietervdvn/MapComplete/issues", true); | ||||
|         const reportBug = new Link(Svg.bug_ui().SetClass("small-image"), "https://github.com/pietervdvn/MapComplete/issues", true); | ||||
| 
 | ||||
|         const layoutId = this._layoutToUse?.data?.id; | ||||
|         const layoutId = layoutToUse?.data?.id; | ||||
|         const osmChaLink = `https://osmcha.org/?filters=%7B%22comment%22%3A%5B%7B%22label%22%3A%22%23${layoutId}%22%2C%22value%22%3A%22%23${layoutId}%22%7D%5D%2C%22date__gte%22%3A%5B%7B%22label%22%3A%222020-07-05%22%2C%22value%22%3A%222020-07-05%22%7D%5D%2C%22editor%22%3A%5B%7B%22label%22%3A%22MapComplete%22%2C%22value%22%3A%22MapComplete%22%7D%5D%7D` | ||||
|         const stats = new Link(Svg.statistics_img, osmChaLink, true) | ||||
|         let editHere: (UIElement | string) = ""; | ||||
|         let mapillary: UIElement = undefined; | ||||
|         if (location !== undefined) { | ||||
|             const idLink = `https://www.openstreetmap.org/edit?editor=id#map=${location.zoom}/${location.lat}/${location.lon}` | ||||
|             editHere = new Link(Svg.pencil_img, idLink, true); | ||||
| 
 | ||||
|             const mapillaryLink: string = `https://www.mapillary.com/app/?focus=map&lat=${location.lat}&lng=${location.lon}&z=${Math.max(location.zoom - 1, 1)}`; | ||||
|             mapillary = new Link(Svg.mapillary_black_img, mapillaryLink, true); | ||||
| 
 | ||||
|         } | ||||
|         const stats = new Link(Svg.statistics_ui().SetClass("small-image"), osmChaLink, true) | ||||
| 
 | ||||
| 
 | ||||
|         let editWithJosm: (UIElement | string) = "" | ||||
|         if (location !== undefined && | ||||
|             this._leafletMap?.data !== undefined && | ||||
|             userDetails.csCount >= Constants.userJourney.tagsVisibleAndWikiLinked) { | ||||
|             const bounds: any = this._leafletMap.data.getBounds(); | ||||
|             const top = bounds.getNorth(); | ||||
|             const bottom = bounds.getSouth(); | ||||
|             const right = bounds.getEast(); | ||||
|             const left = bounds.getWest(); | ||||
|         const idLink = location.map(location =>  `https://www.openstreetmap.org/edit?editor=id#map=${location?.zoom ?? 0}/${location?.lat ?? 0}/${location?.lon ?? 0}`) | ||||
|         const editHere = new Link(Svg.pencil_ui().SetClass("small-image"), idLink, true) | ||||
| 
 | ||||
|         const mapillaryLink = location.map(location => `https://www.mapillary.com/app/?focus=map&lat=${location?.lat ?? 0}&lng=${location?.lon ?? 0}&z=${Math.max((location?.zoom ?? 2) - 1, 1)}`) | ||||
|         const mapillary = new Link(Svg.mapillary_black_ui().SetClass("small-image"), mapillaryLink, true); | ||||
| 
 | ||||
| 
 | ||||
| 
 | ||||
|         let editWithJosm = new VariableUiElement( | ||||
|             userDetails.map(userDetails => { | ||||
| 
 | ||||
|                     if (userDetails.csCount >= Constants.userJourney.tagsVisibleAndWikiLinked) { | ||||
|                         return undefined; | ||||
|                     } | ||||
|                     const bounds: any = leafletMap?.data?.getBounds(); | ||||
|                     if(bounds === undefined){ | ||||
|                         return undefined | ||||
|                     } | ||||
|                     const top = bounds.getNorth(); | ||||
|                     const bottom = bounds.getSouth(); | ||||
|                     const right = bounds.getEast(); | ||||
|                     const left = bounds.getWest(); | ||||
| 
 | ||||
|                     const josmLink = `http://127.0.0.1:8111/load_and_zoom?left=${left}&right=${right}&top=${top}&bottom=${bottom}` | ||||
|                     return new Link(Svg.josm_logo_ui().SetClass("small-image"), josmLink, true); | ||||
|                 }, | ||||
|                 [location] | ||||
|             ) | ||||
|         ) | ||||
|         super([mapComplete, reportBug, stats, editHere, editWithJosm, mapillary]); | ||||
| 
 | ||||
|             const josmLink = `http://127.0.0.1:8111/load_and_zoom?left=${left}&right=${right}&top=${top}&bottom=${bottom}` | ||||
|             editWithJosm = new Link(Svg.josm_logo_img, josmLink, true); | ||||
|         } | ||||
|         return new Combine([mapComplete, reportBug, stats, editHere, editWithJosm, mapillary]).Render(); | ||||
|     } | ||||
| 
 | ||||
| 
 | ||||
|  |  | |||
|  | @ -10,8 +10,8 @@ import SmallLicense from "../../Models/smallLicense"; | |||
| import {Utils} from "../../Utils"; | ||||
| import Link from "../Base/Link"; | ||||
| import {VariableUiElement} from "../Base/VariableUIElement"; | ||||
| import {UIElement} from "../UIElement"; | ||||
| import * as contributors from "../../assets/contributors.json" | ||||
| import BaseUIElement from "../BaseUIElement"; | ||||
| 
 | ||||
| /** | ||||
|  * The attribution panel shown on mobile | ||||
|  | @ -26,7 +26,7 @@ export default class AttributionPanel extends Combine { | |||
|             ((layoutToUse.data.maintainer ?? "") == "") ? "" : Translations.t.general.attribution.themeBy.Subs({author: layoutToUse.data.maintainer}), | ||||
|             layoutToUse.data.credits, | ||||
|             "<br/>", | ||||
|             new Attribution(undefined, undefined, State.state.layoutToUse, undefined), | ||||
|             new Attribution(State.state.locationControl, State.state.osmConnection.userDetails, State.state.layoutToUse, State.state.leafletMap), | ||||
|             "<br/>", | ||||
| 
 | ||||
|             new VariableUiElement(contributions.map(contributions => { | ||||
|  | @ -44,16 +44,14 @@ export default class AttributionPanel extends Combine { | |||
|                 const contribs = links.join(", ") | ||||
| 
 | ||||
|                 if (hiddenCount == 0) { | ||||
| 
 | ||||
| 
 | ||||
|                     return Translations.t.general.attribution.mapContributionsBy.Subs({ | ||||
|                         contributors: contribs | ||||
|                     }).InnerRender() | ||||
|                     }) | ||||
|                 } else { | ||||
|                     return Translations.t.general.attribution.mapContributionsByAndHidden.Subs({ | ||||
|                         contributors: contribs, | ||||
|                         hiddenCount: hiddenCount | ||||
|                     }).InnerRender(); | ||||
|                     }); | ||||
|                 } | ||||
| 
 | ||||
| 
 | ||||
|  | @ -68,7 +66,7 @@ export default class AttributionPanel extends Combine { | |||
|         this.SetStyle("max-width: calc(100vw - 5em); width: 40em;") | ||||
|     } | ||||
| 
 | ||||
|     private static CodeContributors(): UIElement { | ||||
|     private static CodeContributors(): BaseUIElement { | ||||
| 
 | ||||
|         const total = contributors.contributors.length; | ||||
|         let filtered = contributors.contributors | ||||
|  | @ -89,7 +87,7 @@ export default class AttributionPanel extends Combine { | |||
|         }); | ||||
|     } | ||||
| 
 | ||||
|     private static IconAttribution(iconPath: string): UIElement { | ||||
|     private static IconAttribution(iconPath: string): BaseUIElement { | ||||
|         if (iconPath.startsWith("http")) { | ||||
|             iconPath = "." + new URL(iconPath).pathname; | ||||
|         } | ||||
|  |  | |||
|  | @ -1,38 +1,35 @@ | |||
| import {UIElement} from "../UIElement"; | ||||
| import {DropDown} from "../Input/DropDown"; | ||||
| import Translations from "../i18n/Translations"; | ||||
| import State from "../../State"; | ||||
| import {UIEventSource} from "../../Logic/UIEventSource"; | ||||
| import BaseLayer from "../../Models/BaseLayer"; | ||||
| import {VariableUiElement} from "../Base/VariableUIElement"; | ||||
| 
 | ||||
| export default class BackgroundSelector extends UIElement { | ||||
| 
 | ||||
|     private _dropdown: UIElement; | ||||
|     private readonly _availableLayers: UIEventSource<BaseLayer[]>; | ||||
| export default class BackgroundSelector extends VariableUiElement { | ||||
| 
 | ||||
|     constructor() { | ||||
|         super(); | ||||
|         const self = this; | ||||
|         this._availableLayers = State.state.availableBackgroundLayers; | ||||
|         this._availableLayers.addCallbackAndRun(available => self.CreateDropDown(available)); | ||||
|     } | ||||
|         const available = State.state.availableBackgroundLayers.map(available => { | ||||
|                 const baseLayers: { value: BaseLayer, shown: string }[] = []; | ||||
|                 for (const i in available) { | ||||
|                     if(!available.hasOwnProperty(i)){ | ||||
|                         continue; | ||||
|                     } | ||||
|                     const layer: BaseLayer = available[i]; | ||||
|                     baseLayers.push({value: layer, shown: layer.name ?? "id:" + layer.id}); | ||||
|                 } | ||||
|                 return baseLayers | ||||
|             } | ||||
|         ) | ||||
| 
 | ||||
|     private CreateDropDown(available) { | ||||
|         if(available.length === 0){ | ||||
|             return; | ||||
|         } | ||||
|          | ||||
|         const baseLayers: { value: BaseLayer, shown: string }[] = []; | ||||
|         for (const i in available) { | ||||
|             const layer: BaseLayer = available[i]; | ||||
|             baseLayers.push({value: layer, shown: layer.name ?? "id:" + layer.id}); | ||||
|         } | ||||
|         super( | ||||
|             available.map(baseLayers => { | ||||
|                     if (baseLayers.length <= 1) { | ||||
|                         return undefined; | ||||
|                     } | ||||
|                     return new DropDown(Translations.t.general.backgroundMap.Clone(), baseLayers, State.state.backgroundLayer) | ||||
|                 } | ||||
|             ) | ||||
|         ) | ||||
| 
 | ||||
|         this._dropdown = new DropDown(Translations.t.general.backgroundMap, baseLayers, State.state.backgroundLayer); | ||||
|     } | ||||
| 
 | ||||
|     InnerRender(): string { | ||||
|         return this._dropdown.Render(); | ||||
|     } | ||||
| 
 | ||||
| } | ||||
|  | @ -1,8 +1,8 @@ | |||
| import * as L from "leaflet" | ||||
| import {UIEventSource} from "../../Logic/UIEventSource"; | ||||
| import Loc from "../../Models/Loc"; | ||||
| import {UIElement} from "../UIElement"; | ||||
| import BaseLayer from "../../Models/BaseLayer"; | ||||
| import BaseUIElement from "../BaseUIElement"; | ||||
| 
 | ||||
| export class Basemap { | ||||
| 
 | ||||
|  | @ -12,14 +12,14 @@ export class Basemap { | |||
|     constructor(leafletElementId: string, | ||||
|                 location: UIEventSource<Loc>, | ||||
|                 currentLayer: UIEventSource<BaseLayer>, | ||||
|                 lastClickLocation: UIEventSource<{ lat: number, lon: number }>, | ||||
|                 extraAttribution: UIElement) { | ||||
|                 lastClickLocation?: UIEventSource<{ lat: number, lon: number }>, | ||||
|                 extraAttribution?: BaseUIElement) { | ||||
|         this.map = L.map(leafletElementId, { | ||||
|             center: [location.data.lat ?? 0, location.data.lon ?? 0], | ||||
|             zoom: location.data.zoom ?? 2, | ||||
|             layers: [currentLayer.data.layer], | ||||
|             zoomControl: false | ||||
|              | ||||
|             zoomControl: false, | ||||
|             attributionControl: extraAttribution !== undefined | ||||
|         }); | ||||
| 
 | ||||
|         L.control.scale( | ||||
|  | @ -35,9 +35,11 @@ export class Basemap { | |||
|         this.map.setMaxBounds( | ||||
|             [[-100, -200], [100, 200]] | ||||
|         ); | ||||
|         this.map.attributionControl.setPrefix( | ||||
|             extraAttribution.Render() + " | <a href='https://osm.org'>OpenStreetMap</a>"); | ||||
| 
 | ||||
|         this.map.attributionControl.setPrefix( | ||||
|             "<span id='leaflet-attribution'></span> | <a href='https://osm.org'>OpenStreetMap</a>"); | ||||
| 
 | ||||
|         extraAttribution.AttachTo('leaflet-attribution') | ||||
|         const self = this; | ||||
| 
 | ||||
|         let previousLayer = currentLayer.data; | ||||
|  | @ -69,12 +71,12 @@ export class Basemap { | |||
| 
 | ||||
|         this.map.on("click", function (e) { | ||||
|             // @ts-ignore
 | ||||
|             lastClickLocation.setData({lat: e.latlng.lat, lon: e.latlng.lng}) | ||||
|             lastClickLocation?.setData({lat: e.latlng.lat, lon: e.latlng.lng}) | ||||
|         }); | ||||
| 
 | ||||
|         this.map.on("contextmenu", function (e) { | ||||
|             // @ts-ignore
 | ||||
|             lastClickLocation.setData({lat: e.latlng.lat, lon: e.latlng.lng}); | ||||
|             lastClickLocation?.setData({lat: e.latlng.lat, lon: e.latlng.lng}); | ||||
|         }); | ||||
| 
 | ||||
| 
 | ||||
|  |  | |||
|  | @ -1,4 +1,3 @@ | |||
| import {UIElement} from "../UIElement"; | ||||
| import State from "../../State"; | ||||
| import ThemeIntroductionPanel from "./ThemeIntroductionPanel"; | ||||
| import * as personal from "../../assets/themes/personalLayout/personalLayout.json"; | ||||
|  | @ -7,47 +6,39 @@ import Svg from "../../Svg"; | |||
| import Translations from "../i18n/Translations"; | ||||
| import ShareScreen from "./ShareScreen"; | ||||
| import MoreScreen from "./MoreScreen"; | ||||
| import {VariableUiElement} from "../Base/VariableUIElement"; | ||||
| import Constants from "../../Models/Constants"; | ||||
| import Combine from "../Base/Combine"; | ||||
| import Locale from "../i18n/Locale"; | ||||
| import {TabbedComponent} from "../Base/TabbedComponent"; | ||||
| import {UIEventSource} from "../../Logic/UIEventSource"; | ||||
| import LayoutConfig from "../../Customizations/JSON/LayoutConfig"; | ||||
| import UserDetails from "../../Logic/Osm/OsmConnection"; | ||||
| import ScrollableFullScreen from "../Base/ScrollableFullScreen"; | ||||
| import BaseUIElement from "../BaseUIElement"; | ||||
| import Toggle from "../Input/Toggle"; | ||||
| 
 | ||||
| export default class FullWelcomePaneWithTabs extends UIElement { | ||||
|     private readonly _layoutToUse: UIEventSource<LayoutConfig>; | ||||
|     private readonly _userDetails: UIEventSource<UserDetails>; | ||||
| export default class FullWelcomePaneWithTabs extends ScrollableFullScreen { | ||||
| 
 | ||||
|     private readonly _component: UIElement; | ||||
| 
 | ||||
|     constructor(isShown: UIEventSource<boolean>) { | ||||
|         super(State.state.layoutToUse); | ||||
|         this._layoutToUse = State.state.layoutToUse; | ||||
|         this._userDetails = State.state.osmConnection.userDetails; | ||||
|         const layoutToUse = this._layoutToUse.data; | ||||
|        | ||||
| 
 | ||||
|         this._component = new ScrollableFullScreen( | ||||
|         const layoutToUse = State.state.layoutToUse.data; | ||||
|         super ( | ||||
|             () => layoutToUse.title.Clone(), | ||||
|             () => FullWelcomePaneWithTabs.GenerateContents(layoutToUse, State.state.osmConnection.userDetails), | ||||
|             () => FullWelcomePaneWithTabs.GenerateContents(layoutToUse, State.state.osmConnection.userDetails, isShown), | ||||
|             "welcome" ,isShown | ||||
|         ) | ||||
|     } | ||||
|      | ||||
|     private static ConstructBaseTabs(layoutToUse: LayoutConfig, isShown: UIEventSource<boolean>): { header: string | BaseUIElement; content: BaseUIElement }[]{ | ||||
| 
 | ||||
|     private static GenerateContents(layoutToUse: LayoutConfig, userDetails: UIEventSource<UserDetails>) { | ||||
| 
 | ||||
|         let welcome: UIElement = new ThemeIntroductionPanel(); | ||||
|         let welcome: BaseUIElement = new ThemeIntroductionPanel(isShown); | ||||
|         if (layoutToUse.id === personal.id) { | ||||
|             welcome = new PersonalLayersPanel(); | ||||
|         } | ||||
|         const tabs = [ | ||||
|         const tabs: { header: string | BaseUIElement, content: BaseUIElement }[] = [ | ||||
|             {header: `<img src='${layoutToUse.icon}'>`, content: welcome}, | ||||
|             { | ||||
|                 header: Svg.osm_logo_img, | ||||
|                 content: Translations.t.general.openStreetMapIntro.Clone().SetClass("link-underline") as UIElement | ||||
|                 content: Translations.t.general.openStreetMapIntro.Clone().SetClass("link-underline") | ||||
|             }, | ||||
| 
 | ||||
|         ] | ||||
|  | @ -64,25 +55,27 @@ export default class FullWelcomePaneWithTabs extends UIElement { | |||
|             }); | ||||
|         } | ||||
| 
 | ||||
|         return tabs; | ||||
|     } | ||||
| 
 | ||||
|         tabs.push({ | ||||
|     private static GenerateContents(layoutToUse: LayoutConfig, userDetails: UIEventSource<UserDetails>, isShown: UIEventSource<boolean>) { | ||||
| 
 | ||||
|         const tabs = FullWelcomePaneWithTabs.ConstructBaseTabs(layoutToUse, isShown) | ||||
|         const tabsWithAboutMc = [...FullWelcomePaneWithTabs.ConstructBaseTabs(layoutToUse, isShown)] | ||||
|         tabsWithAboutMc.push({ | ||||
|                 header: Svg.help, | ||||
|                 content: new VariableUiElement(userDetails.map(userdetails => { | ||||
|                     if (userdetails.csCount < Constants.userJourney.mapCompleteHelpUnlock) { | ||||
|                         return "" | ||||
|                     } | ||||
|                     return new Combine([Translations.t.general.aboutMapcomplete, "<br/>Version " + Constants.vNumber]).SetClass("link-underline").Render(); | ||||
|                 }, [Locale.language])) | ||||
|                 content: new Combine([Translations.t.general.aboutMapcomplete.Clone(), "<br/>Version " + Constants.vNumber]) | ||||
|                     .SetClass("link-underline") | ||||
|             } | ||||
|         ); | ||||
| 
 | ||||
|         return new TabbedComponent(tabs, State.state.welcomeMessageOpenedTab) | ||||
|             .ListenTo(userDetails); | ||||
|     } | ||||
| 
 | ||||
|     InnerRender(): string { | ||||
|         return this._component.Render(); | ||||
| 
 | ||||
|         return new Toggle( | ||||
|           new TabbedComponent(tabsWithAboutMc, State.state.welcomeMessageOpenedTab), | ||||
|            new TabbedComponent(tabs, State.state.welcomeMessageOpenedTab), | ||||
|             userDetails.map((userdetails: UserDetails) => | ||||
|                 userdetails.loggedIn && | ||||
|                 userdetails.csCount >= Constants.userJourney.mapCompleteHelpUnlock) | ||||
|         ) | ||||
|     } | ||||
| 
 | ||||
| } | ||||
|  | @ -1,4 +1,3 @@ | |||
| import {UIElement} from "../UIElement"; | ||||
| import Combine from "../Base/Combine"; | ||||
| import Translations from "../i18n/Translations"; | ||||
| import {FixedUiElement} from "../Base/FixedUiElement"; | ||||
|  |  | |||
|  | @ -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"); | ||||
|  |  | |||
|  | @ -1,84 +1,69 @@ | |||
| import {UIEventSource} from "../../Logic/UIEventSource"; | ||||
| import {UIElement} from "../UIElement"; | ||||
| import {VariableUiElement} from "../Base/VariableUIElement"; | ||||
| import State from "../../State"; | ||||
| import CheckBox from "../Input/CheckBox"; | ||||
| import Toggle from "../Input/Toggle"; | ||||
| import Combine from "../Base/Combine"; | ||||
| import {FixedUiElement} from "../Base/FixedUiElement"; | ||||
| import Translations from "../i18n/Translations"; | ||||
| import LayerConfig from "../../Customizations/JSON/LayerConfig"; | ||||
| import BaseUIElement from "../BaseUIElement"; | ||||
| 
 | ||||
| /** | ||||
|  * Shows the panel with all layers and a toggle for each of them | ||||
|  */ | ||||
| export default class LayerSelection extends UIElement { | ||||
| export default class LayerSelection extends Combine { | ||||
| 
 | ||||
|     private _checkboxes: UIElement[]; | ||||
|     private activeLayers: UIEventSource<{ | ||||
|         readonly isDisplayed: UIEventSource<boolean>, | ||||
|         readonly layerDef: LayerConfig; | ||||
|     }[]>; | ||||
| 
 | ||||
|     constructor(activeLayers: UIEventSource<{ | ||||
|         readonly isDisplayed: UIEventSource<boolean>, | ||||
|         readonly layerDef: LayerConfig; | ||||
|     }[]>) { | ||||
|         super(activeLayers); | ||||
|         if(activeLayers === undefined){ | ||||
| 
 | ||||
|         if (activeLayers === undefined) { | ||||
|             throw "ActiveLayers should be defined..." | ||||
|         } | ||||
|         this.activeLayers = activeLayers; | ||||
| 
 | ||||
|     } | ||||
| 
 | ||||
|     InnerRender(): string { | ||||
|         const checkboxes: BaseUIElement[] = []; | ||||
| 
 | ||||
|         this._checkboxes = []; | ||||
| 
 | ||||
|         for (const layer of this.activeLayers.data) { | ||||
|         for (const layer of activeLayers.data) { | ||||
|             const leafletStyle = layer.layerDef.GenerateLeafletStyle( | ||||
|                 new UIEventSource<any>({id: "node/-1"}), | ||||
|                 false) | ||||
|             const leafletHtml = leafletStyle.icon.html; | ||||
|             const icon = | ||||
|                 new FixedUiElement(leafletHtml.Render()) | ||||
|                     .SetClass("single-layer-selection-toggle") | ||||
|             let iconUnselected: UIElement = new FixedUiElement(leafletHtml.Render()) | ||||
|             const icon = new Combine([leafletStyle.icon.html]).SetClass("single-layer-selection-toggle") | ||||
|             let iconUnselected: BaseUIElement = new Combine([leafletStyle.icon.html]) | ||||
|                 .SetClass("single-layer-selection-toggle") | ||||
|                 .SetStyle("opacity:0.2;"); | ||||
| 
 | ||||
|             const name = Translations.WT(layer.layerDef.name)?.Clone() | ||||
|                 ?.SetStyle("font-size:large;margin-left: 0.5em;"); | ||||
| 
 | ||||
|             if((name ?? "") === ""){ | ||||
|             if ((name ?? "") === "") { | ||||
|                 continue | ||||
|             } | ||||
| 
 | ||||
|             const zoomStatus = new VariableUiElement(State.state.locationControl.map(location => { | ||||
|                 if (location.zoom < layer.layerDef.minzoom) { | ||||
|                     return Translations.t.general.layerSelection.zoomInToSeeThisLayer | ||||
|                     return Translations.t.general.layerSelection.zoomInToSeeThisLayer.Clone() | ||||
|                         .SetClass("alert") | ||||
|                         .SetStyle("display: block ruby;width:min-content;") | ||||
|                         .Render(); | ||||
|                 } | ||||
|                 return "" | ||||
|             })) | ||||
|             const style = "display:flex;align-items:center;" | ||||
|             const styleWhole = "display:flex; flex-wrap: wrap" | ||||
|             this._checkboxes.push(new CheckBox( | ||||
|             checkboxes.push(new Toggle( | ||||
|                 new Combine([new Combine([icon, name]).SetStyle(style), zoomStatus]) | ||||
|                     .SetStyle(styleWhole), | ||||
|                 new Combine([new Combine([iconUnselected, "<del>", name, "</del>"]).SetStyle(style), zoomStatus]) | ||||
|                     .SetStyle(styleWhole), | ||||
|                 layer.isDisplayed) | ||||
|                 layer.isDisplayed).ToggleOnClick() | ||||
|                 .SetStyle("margin:0.3em;") | ||||
|             ); | ||||
|         } | ||||
| 
 | ||||
| 
 | ||||
|         return new Combine(this._checkboxes) | ||||
|             .SetStyle("display:flex;flex-direction:column;") | ||||
|             .Render(); | ||||
|     } | ||||
|         super(checkboxes) | ||||
|         this.SetStyle("display:flex;flex-direction:column;") | ||||
| 
 | ||||
|     } | ||||
| } | ||||
							
								
								
									
										20
									
								
								UI/BigComponents/LicensePicker.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								UI/BigComponents/LicensePicker.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,20 @@ | |||
| import {DropDown} from "../Input/DropDown"; | ||||
| import Translations from "../i18n/Translations"; | ||||
| import State from "../../State"; | ||||
| import {UIEventSource} from "../../Logic/UIEventSource"; | ||||
| 
 | ||||
| export default class LicensePicker extends DropDown<string>{ | ||||
|      | ||||
|     constructor() { | ||||
|         super(Translations.t.image.willBePublished.Clone(), | ||||
|             [ | ||||
|                 {value: "CC0", shown: Translations.t.image.cco.Clone()}, | ||||
|                 {value: "CC-BY-SA 4.0", shown: Translations.t.image.ccbs.Clone()}, | ||||
|                 {value: "CC-BY 4.0", shown: Translations.t.image.ccb.Clone()} | ||||
|             ], | ||||
|             State.state?.osmConnection?.GetPreference("pictures-license") ?? new UIEventSource<string>("CC0") | ||||
|         ) | ||||
|             this.SetClass("flex flex-col sm:flex-row").SetStyle("float:left"); | ||||
|     } | ||||
|      | ||||
| } | ||||
|  | @ -1,4 +1,3 @@ | |||
| import {UIElement} from "../UIElement"; | ||||
| import {VariableUiElement} from "../Base/VariableUIElement"; | ||||
| import LayoutConfig from "../../Customizations/JSON/LayoutConfig"; | ||||
| import {AllKnownLayouts} from "../../Customizations/AllKnownLayouts"; | ||||
|  | @ -11,87 +10,94 @@ import * as personal from "../../assets/themes/personalLayout/personalLayout.jso | |||
| import Constants from "../../Models/Constants"; | ||||
| import LanguagePicker from "../LanguagePicker"; | ||||
| import IndexText from "./IndexText"; | ||||
| import BaseUIElement from "../BaseUIElement"; | ||||
| 
 | ||||
| export default class MoreScreen extends UIElement { | ||||
|     private readonly _onMainScreen: boolean; | ||||
| 
 | ||||
|     private _component: UIElement; | ||||
| export default class MoreScreen extends Combine { | ||||
| 
 | ||||
| 
 | ||||
|     constructor(onMainScreen: boolean = false) { | ||||
|         super(State.state.locationControl); | ||||
|         this._onMainScreen = onMainScreen; | ||||
|         this.ListenTo(State.state.osmConnection.userDetails); | ||||
|         this.ListenTo(State.state.installedThemes); | ||||
|         super(MoreScreen.Init(onMainScreen, State.state)); | ||||
|     } | ||||
| 
 | ||||
|     InnerRender(): string { | ||||
| 
 | ||||
|     private static Init(onMainScreen: boolean, state: State): BaseUIElement [] { | ||||
|         const tr = Translations.t.general.morescreen; | ||||
| 
 | ||||
|         const els: UIElement[] = [] | ||||
| 
 | ||||
|         const themeButtons: UIElement[] = [] | ||||
| 
 | ||||
|         for (const layout of AllKnownLayouts.layoutsList) { | ||||
|             if (layout.id === personal.id) { | ||||
|                 if (State.state.osmConnection.userDetails.data.csCount < Constants.userJourney.personalLayoutUnlock) { | ||||
|                     continue; | ||||
|                 } | ||||
|             } | ||||
|             themeButtons.push(this.createLinkButton(layout)); | ||||
|         } | ||||
| 
 | ||||
| 
 | ||||
|         els.push(new VariableUiElement( | ||||
|             State.state.osmConnection.userDetails.map(userDetails => { | ||||
|                 if (userDetails.csCount < Constants.userJourney.themeGeneratorReadOnlyUnlock) { | ||||
|                     return new SubtleButton(null, tr.requestATheme, {url:"https://github.com/pietervdvn/MapComplete/issues", newTab: true}).Render(); | ||||
|                 } | ||||
|                 return new SubtleButton(Svg.pencil_ui(), tr.createYourOwnTheme, { | ||||
|                     url: "./customGenerator.html", | ||||
|                     newTab: false | ||||
|                 }).Render(); | ||||
|             }) | ||||
|         )); | ||||
| 
 | ||||
|         els.push(new Combine(themeButtons)) | ||||
| 
 | ||||
| 
 | ||||
|         const customThemesNames = State.state.installedThemes.data ?? []; | ||||
| 
 | ||||
|         if (customThemesNames.length > 0) { | ||||
|             els.push(Translations.t.general.customThemeIntro) | ||||
| 
 | ||||
|             for (const installed of State.state.installedThemes.data) { | ||||
|                 els.push(this.createLinkButton(installed.layout, installed.definition)); | ||||
|             } | ||||
|         } | ||||
| 
 | ||||
|         let intro: UIElement = tr.intro; | ||||
|         const themeButtonsElement = new Combine(els) | ||||
| 
 | ||||
|         if (this._onMainScreen) { | ||||
|         let intro: BaseUIElement = tr.intro.Clone(); | ||||
|         let themeButtonStyle = "" | ||||
|         let themeListStyle = "" | ||||
|         if (onMainScreen) { | ||||
|             intro = new Combine([ | ||||
|                 LanguagePicker.CreateLanguagePicker(Translations.t.index.title.SupportedLanguages()) | ||||
|                     .SetClass("absolute top-2 right-3"), | ||||
|                 new IndexText() | ||||
|             ]) | ||||
|             themeButtons.map(e => e?.SetClass("h-32 min-h-32 max-h-32 overflow-ellipsis overflow-hidden")) | ||||
|             themeButtonsElement.SetClass("md:grid md:grid-flow-row md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-g4 gap-4") | ||||
|             themeButtonStyle = "h-32 min-h-32 max-h-32 overflow-ellipsis overflow-hidden" | ||||
|             themeListStyle = "md:grid md:grid-flow-row md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-g4 gap-4" | ||||
|         } | ||||
| 
 | ||||
|          | ||||
| 
 | ||||
|         this._component = new Combine([ | ||||
|         return[ | ||||
|             intro, | ||||
|             themeButtonsElement, | ||||
|             tr.streetcomplete.SetClass("block text-base mx-10 my-3 mb-10") | ||||
|         ]); | ||||
|         return this._component.Render(); | ||||
|             MoreScreen.createOfficialThemesList(state, themeButtonStyle).SetClass(themeListStyle), | ||||
|             MoreScreen.createUnofficialThemeList(themeButtonStyle)?.SetClass(themeListStyle), | ||||
|             tr.streetcomplete.Clone().SetClass("block text-base mx-10 my-3 mb-10") | ||||
|         ]; | ||||
|     } | ||||
|      | ||||
|     private static createUnofficialThemeList(buttonClass: string): BaseUIElement{ | ||||
|         return new VariableUiElement(State.state.installedThemes.map(customThemes => { | ||||
|             const els : BaseUIElement[] = [] | ||||
|             if (customThemes.length > 0) { | ||||
|                 els.push(Translations.t.general.customThemeIntro.Clone()) | ||||
| 
 | ||||
|                 const customThemesElement = new Combine( | ||||
|                     customThemes.map(theme => MoreScreen.createLinkButton(theme.layout, theme.definition)?.SetClass(buttonClass)) | ||||
|                 ) | ||||
|                 els.push(customThemesElement) | ||||
|             } | ||||
|             return els; | ||||
|         })); | ||||
|     } | ||||
| 
 | ||||
|     private createLinkButton(layout: LayoutConfig, customThemeDefinition: string = undefined) { | ||||
|     private static createOfficialThemesList(state: State, buttonClass: string): BaseUIElement { | ||||
|         let officialThemes = AllKnownLayouts.layoutsList | ||||
|         if (State.state.osmConnection.userDetails.data.csCount < Constants.userJourney.personalLayoutUnlock) { | ||||
|             officialThemes = officialThemes.filter(theme => theme.id !== personal.id) | ||||
|         } | ||||
|         let buttons = officialThemes.map((layout) => MoreScreen.createLinkButton(layout)?.SetClass(buttonClass)) | ||||
| 
 | ||||
|         let customGeneratorLink = MoreScreen.createCustomGeneratorButton(state) | ||||
|         buttons.splice(0, 0, customGeneratorLink); | ||||
| 
 | ||||
|         return new Combine(buttons) | ||||
|     } | ||||
| 
 | ||||
|     /* | ||||
|     * Returns either a link to the issue tracker or a link to the custom generator, depending on the achieved number of changesets | ||||
|     * */ | ||||
|     private static createCustomGeneratorButton(state: State): VariableUiElement { | ||||
|         const tr = Translations.t.general.morescreen; | ||||
|         return new VariableUiElement( | ||||
|             state.osmConnection.userDetails.map(userDetails => { | ||||
|                 if (userDetails.csCount < Constants.userJourney.themeGeneratorReadOnlyUnlock) { | ||||
|                     return new SubtleButton(null, tr.requestATheme.Clone(), { | ||||
|                         url: "https://github.com/pietervdvn/MapComplete/issues", | ||||
|                         newTab: true | ||||
|                     }); | ||||
|                 } | ||||
|                 return new SubtleButton(Svg.pencil_ui(), tr.createYourOwnTheme.Clone(), { | ||||
|                     url: "https://pietervdvn.github.io/mc/legacy/070/customGenerator.html", | ||||
|                     newTab: false | ||||
|                 }); | ||||
|             }) | ||||
|         ) | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Creates a button linking to the given theme | ||||
|      * @param layout | ||||
|      * @param customThemeDefinition | ||||
|      * @private | ||||
|      */ | ||||
|     private static createLinkButton(layout: LayoutConfig, customThemeDefinition: string = undefined): BaseUIElement { | ||||
|         if (layout === undefined) { | ||||
|             return undefined; | ||||
|         } | ||||
|  | @ -100,17 +106,14 @@ export default class MoreScreen extends UIElement { | |||
|             return undefined; | ||||
|         } | ||||
|         if (layout.hideFromOverview) { | ||||
|             const pref = State.state.osmConnection.GetPreference("hidden-theme-" + layout.id + "-enabled"); | ||||
|             this.ListenTo(pref); | ||||
|             if (pref.data !== "true") { | ||||
|                 return undefined; | ||||
|             } | ||||
|             return undefined; | ||||
|         } | ||||
|         if (layout.id === State.state.layoutToUse.data?.id) { | ||||
|             return undefined; | ||||
|         } | ||||
| 
 | ||||
|         const currentLocation = State.state.locationControl.data; | ||||
|         const currentLocation = State.state.locationControl; | ||||
|          | ||||
|         let path = window.location.pathname; | ||||
|         // Path starts with a '/' and contains everything, e.g. '/dir/dir/page.html'
 | ||||
|         path = path.substr(0, path.lastIndexOf("/")); | ||||
|  | @ -119,29 +122,42 @@ export default class MoreScreen extends UIElement { | |||
|             path = "." | ||||
|         } | ||||
| 
 | ||||
|         const params = `z=${currentLocation.zoom ?? 1}&lat=${currentLocation.lat ?? 0}&lon=${currentLocation.lon ?? 0}` | ||||
|         let linkText = | ||||
|             `${path}/${layout.id.toLowerCase()}.html?${params}` | ||||
| 
 | ||||
|         let linkPrefix = `${path}/${layout.id.toLowerCase()}.html?` | ||||
|         let linkSuffix = "" | ||||
|         if (location.hostname === "localhost" || location.hostname === "127.0.0.1") { | ||||
|             linkText = `${path}/index.html?layout=${layout.id}&${params}` | ||||
|             linkPrefix = `${path}/index.html?layout=${layout.id}&` | ||||
|         } | ||||
| 
 | ||||
|         if (customThemeDefinition) { | ||||
|             linkText = `${path}/index.html?userlayout=${layout.id}&${params}#${customThemeDefinition}` | ||||
| 
 | ||||
|             linkPrefix = `${path}/index.html?userlayout=${layout.id}&` | ||||
|             linkSuffix = `#${customThemeDefinition}` | ||||
|         } | ||||
| 
 | ||||
|         let description = Translations.W(layout.shortDescription); | ||||
|         const linkText = currentLocation.map(currentLocation => { | ||||
|             const params = [ | ||||
|                 ["z", currentLocation?.zoom], | ||||
|                 ["lat", currentLocation?.lat], | ||||
|                 ["lon",currentLocation?.lon] | ||||
|             ].filter(part => part[1] !== undefined) | ||||
|                 .map(part => part[0]+"="+part[1]) | ||||
|                 .join("&") | ||||
|             return `${linkPrefix}${params}${linkSuffix}`; | ||||
|         }) | ||||
| 
 | ||||
|    | ||||
|         | ||||
| 
 | ||||
|         let description = Translations.WT(layout.shortDescription).Clone(); | ||||
|         return new SubtleButton(layout.icon, | ||||
|             new Combine([ | ||||
|                 `<dt class='text-lg leading-6 font-medium text-gray-900 group-hover:text-blue-800'>`, | ||||
|                 Translations.W(layout.title), | ||||
|                 Translations.WT(layout.title).Clone(), | ||||
|                 `</dt>`, | ||||
|                 `<dd class='mt-1 text-base text-gray-500 group-hover:text-blue-900 overflow-ellipsis'>`, | ||||
|                 description ?? "", | ||||
|                 description.Clone().SetClass("subtle") ?? "", | ||||
|                 `</dd>`, | ||||
|             ]), {url: linkText, newTab: false}); | ||||
|     } | ||||
| 
 | ||||
| 
 | ||||
| } | ||||
|  | @ -1,137 +1,123 @@ | |||
| import {UIEventSource} from "../../Logic/UIEventSource"; | ||||
| import {UIElement} from "../UIElement"; | ||||
| import LayoutConfig from "../../Customizations/JSON/LayoutConfig"; | ||||
| import {AllKnownLayouts} from "../../Customizations/AllKnownLayouts"; | ||||
| import Svg from "../../Svg"; | ||||
| import State from "../../State"; | ||||
| import Combine from "../Base/Combine"; | ||||
| import CheckBox from "../Input/CheckBox"; | ||||
| import Toggle from "../Input/Toggle"; | ||||
| import {SubtleButton} from "../Base/SubtleButton"; | ||||
| import {FixedUiElement} from "../Base/FixedUiElement"; | ||||
| import Translations from "../i18n/Translations"; | ||||
| import * as personal from "../../assets/themes/personalLayout/personalLayout.json" | ||||
| import Locale from "../i18n/Locale"; | ||||
| export default class PersonalLayersPanel extends UIElement { | ||||
|     private checkboxes: UIElement[] = []; | ||||
| import BaseUIElement from "../BaseUIElement"; | ||||
| import {VariableUiElement} from "../Base/VariableUIElement"; | ||||
| import LayerConfig from "../../Customizations/JSON/LayerConfig"; | ||||
| import Img from "../Base/Img"; | ||||
| import {UIEventSource} from "../../Logic/UIEventSource"; | ||||
| 
 | ||||
| export default class PersonalLayersPanel extends VariableUiElement { | ||||
| 
 | ||||
|     constructor() { | ||||
|         super(State.state.favouriteLayers); | ||||
|         this.ListenTo(State.state.osmConnection.userDetails); | ||||
|         this.ListenTo(Locale.language); | ||||
|         this.UpdateView([]); | ||||
|         const self = this; | ||||
|         State.state.installedThemes.addCallback(extraThemes => { | ||||
|             self.UpdateView(extraThemes.map(layout => layout.layout)); | ||||
|             self.Update(); | ||||
|         }) | ||||
|     } | ||||
|         super( | ||||
|             State.state.installedThemes.map(installedThemes => { | ||||
|                 const t = Translations.t.favourite; | ||||
| 
 | ||||
|                 // Lets get all the layers
 | ||||
|                 const allThemes = AllKnownLayouts.layoutsList.concat(installedThemes.map(layout => layout.layout)) | ||||
|                     .filter(theme => !theme.hideFromOverview) | ||||
| 
 | ||||
|     private UpdateView(extraThemes: LayoutConfig[]) { | ||||
|         this.checkboxes = []; | ||||
|         const favs = State.state.favouriteLayers.data ?? []; | ||||
|         const controls = new Map<string, UIEventSource<boolean>>(); | ||||
|         const allLayouts = AllKnownLayouts.layoutsList.concat(extraThemes); | ||||
|         for (const layout of allLayouts) { | ||||
|             if (layout.id === personal.id) { | ||||
|                 continue; | ||||
|             } | ||||
|              | ||||
|             if(layout.hideFromOverview){ | ||||
|                 continue; | ||||
|             } | ||||
| 
 | ||||
|             const header = | ||||
|                 new Combine([ | ||||
|                     `<img style="max-width: 3em;max-height: 3em; float: left; padding: 0.1em; margin-right: 0.3em;" src='${layout.icon}'>`, | ||||
|                     "<b>", | ||||
|                     layout.title, | ||||
|                     "</b><br/>", | ||||
|                     layout.shortDescription ?? "" | ||||
|                 ]).SetClass("block p1 overflow-auto rounded") | ||||
|                     .SetStyle("background: #eee;") | ||||
|             this.checkboxes.push(header); | ||||
| 
 | ||||
|             for (const layer of layout.layers) { | ||||
|                 if(layer === undefined){ | ||||
|                     console.warn("Undefined layer for ",layout.id) | ||||
|                     continue; | ||||
|                 } | ||||
|                 if (typeof layer === "string") { | ||||
|                     continue; | ||||
|                 } | ||||
|                 let icon :UIElement = layer.GenerateLeafletStyle(new UIEventSource<any>({id:"node/-1"}), false).icon.html | ||||
|                     ?? Svg.checkmark_svg(); | ||||
|                 let iconUnset =new FixedUiElement(icon.Render()); | ||||
|                 icon.SetClass("single-layer-selection-toggle") | ||||
|                 iconUnset.SetClass("single-layer-selection-toggle") | ||||
| 
 | ||||
| 
 | ||||
|                 let name = layer.name ?? layer.id; | ||||
|                 if (name === undefined) { | ||||
|                     continue; | ||||
|                 } | ||||
|                 const content = new Combine([ | ||||
|                     "<b>",  | ||||
|                     name,  | ||||
|                     "</b> ", | ||||
|                     layer.description !== undefined ? new Combine(["<br/>", layer.description]) : "", | ||||
|                 ]) | ||||
|                  | ||||
|                  | ||||
|                 const cb = new CheckBox( | ||||
|                     new SubtleButton( | ||||
|                         icon,  | ||||
|                         content), | ||||
|                     new SubtleButton( | ||||
|                         iconUnset.SetStyle("opacity:0.1"), | ||||
|                         new Combine(["<del>", | ||||
|                             content, | ||||
|                             "</del>" | ||||
|                         ])), | ||||
|                     controls[layer.id] ?? (favs.indexOf(layer.id) >= 0) | ||||
|                 ); | ||||
|                 cb.SetClass("custom-layer-checkbox"); | ||||
|                 controls[layer.id] = cb.isEnabled; | ||||
| 
 | ||||
|                 cb.isEnabled.addCallback((isEnabled) => { | ||||
|                     const favs = State.state.favouriteLayers; | ||||
|                     if (isEnabled) { | ||||
|                         if(favs.data.indexOf(layer.id)>= 0){ | ||||
|                             return; // Already added
 | ||||
|                 const allLayers = [] | ||||
|                 { | ||||
|                     const seenLayers = new Set<string>() | ||||
|                     for (const layers of allThemes.map(theme => theme.layers)) { | ||||
|                         for (const layer of layers) { | ||||
|                             if (seenLayers.has(layer.id)) { | ||||
|                                 continue | ||||
|                             } | ||||
|                             seenLayers.add(layer.id) | ||||
|                             allLayers.push(layer) | ||||
|                         } | ||||
|                         favs.data.push(layer.id); | ||||
|                     } else { | ||||
|                         favs.data.splice(favs.data.indexOf(layer.id), 1); | ||||
|                     } | ||||
|                     favs.ping(); | ||||
|                 }) | ||||
|                 } | ||||
| 
 | ||||
|                 this.checkboxes.push(cb); | ||||
|                 // Time to create a panel based on them!
 | ||||
|                 const panel: BaseUIElement = new Combine(allLayers.map(PersonalLayersPanel.CreateLayerToggle)); | ||||
| 
 | ||||
|             } | ||||
| 
 | ||||
|         } | ||||
| 
 | ||||
|         State.state.favouriteLayers.addCallback((layers) => { | ||||
|             for (const layerId of layers) { | ||||
|                 controls[layerId]?.setData(true); | ||||
|             } | ||||
|         }); | ||||
| 
 | ||||
|                 return new Toggle( | ||||
|                     new Combine([ | ||||
|                         t.panelIntro.Clone(), | ||||
|                         panel | ||||
|                     ]).SetClass("flex flex-col"), | ||||
|                     new SubtleButton( | ||||
|                         Svg.osm_logo_ui(), | ||||
|                         t.loginNeeded.Clone().SetClass("text-center") | ||||
|                     ).onClick(() => State.state.osmConnection.AttemptLogin()), | ||||
|                     State.state.osmConnection.isLoggedIn | ||||
|                 ) | ||||
|             }) | ||||
|         ) | ||||
|     } | ||||
| 
 | ||||
|     InnerRender(): string { | ||||
|         const t = Translations.t.favourite; | ||||
|         const userDetails = State.state.osmConnection.userDetails.data; | ||||
|         if(!userDetails.loggedIn){ | ||||
|             return t.loginNeeded.Render(); | ||||
|         } | ||||
|     /*** | ||||
|      * Creates a toggle for the given layer, which'll update State.state.favouriteLayers right away | ||||
|      * @param layer | ||||
|      * @constructor | ||||
|      * @private | ||||
|      */ | ||||
|     private static CreateLayerToggle(layer: LayerConfig): Toggle { | ||||
|         const iconUrl = layer.icon.GetRenderValue({id: "node/-1"}).txt | ||||
|         let icon :BaseUIElement =new Combine([ layer.GenerateLeafletStyle( | ||||
|             new UIEventSource<any>({id: "node/-1"}), | ||||
|             false, | ||||
|             "2em" | ||||
|         ).icon.html]).SetClass("relative") | ||||
|         let iconUnset =new Combine([ layer.GenerateLeafletStyle( | ||||
|             new UIEventSource<any>({id: "node/-1"}), | ||||
|             false, | ||||
|             "2em" | ||||
|         ).icon.html]).SetClass("relative") | ||||
| 
 | ||||
|         return new Combine([ | ||||
|             t.panelIntro, | ||||
|             ...this.checkboxes | ||||
|         ]).Render(); | ||||
|         iconUnset.SetStyle("opacity:0.1") | ||||
| 
 | ||||
|         let name = layer.name ; | ||||
|         if (name === undefined) { | ||||
|             return undefined; | ||||
|         } | ||||
|         const content = new Combine([ | ||||
|             Translations.WT(name).Clone().SetClass("font-bold"), | ||||
|             Translations.WT(layer.description)?.Clone() | ||||
|         ]).SetClass("flex flex-col") | ||||
| 
 | ||||
|         const contentUnselected = new Combine([ | ||||
|             Translations.WT(name).Clone().SetClass("font-bold"), | ||||
|             Translations.WT(layer.description)?.Clone() | ||||
|         ]).SetClass("flex flex-col line-through") | ||||
| 
 | ||||
|         return new Toggle( | ||||
|             new SubtleButton( | ||||
|                 icon, | ||||
|                 content ), | ||||
|             new SubtleButton( | ||||
|                 iconUnset, | ||||
|                 contentUnselected | ||||
|             ), | ||||
|             State.state.favouriteLayers.map(favLayers => { | ||||
|                 return favLayers.indexOf(layer.id) >= 0 | ||||
|             }, [], (selected, current) => { | ||||
|                 if (!selected && current.indexOf(layer.id) <= 0) { | ||||
|                     // Not selected and not contained: nothing to change: we return current as is
 | ||||
|                     return current; | ||||
|                 } | ||||
|                 if (selected && current.indexOf(layer.id) >= 0) { | ||||
|                     // Selected and contained: this is fine!
 | ||||
|                     return current; | ||||
|                 } | ||||
|                 const clone = [...current] | ||||
|                 if (selected) { | ||||
|                     clone.push(layer.id) | ||||
|                 } else { | ||||
|                     clone.splice(clone.indexOf(layer.id), 1) | ||||
|                 } | ||||
|                 return clone | ||||
|             }) | ||||
|         ).ToggleOnClick(); | ||||
|     } | ||||
| 
 | ||||
| 
 | ||||
|  |  | |||
|  | @ -1,6 +1,5 @@ | |||
| import Locale from "../i18n/Locale"; | ||||
| import {UIEventSource} from "../../Logic/UIEventSource"; | ||||
| import {UIElement} from "../UIElement"; | ||||
| import {Translation} from "../i18n/Translation"; | ||||
| import {VariableUiElement} from "../Base/VariableUIElement"; | ||||
| import Svg from "../../Svg"; | ||||
|  | @ -9,75 +8,76 @@ import {TextField} from "../Input/TextField"; | |||
| import {Geocoding} from "../../Logic/Osm/Geocoding"; | ||||
| import Translations from "../i18n/Translations"; | ||||
| import Hash from "../../Logic/Web/Hash"; | ||||
| import Combine from "../Base/Combine"; | ||||
| 
 | ||||
| export default class SearchAndGo extends UIElement { | ||||
| 
 | ||||
|     private _placeholder = new UIEventSource<Translation>(Translations.t.general.search.search) | ||||
|     private _searchField = new TextField({ | ||||
|             placeholder: new VariableUiElement( | ||||
|                 this._placeholder.map(uiElement => uiElement.InnerRender(), [Locale.language]) | ||||
|             ), | ||||
|             value: new UIEventSource<string>("") | ||||
|         } | ||||
|     ); | ||||
| 
 | ||||
|     private _foundEntries = new UIEventSource([]); | ||||
|     private _goButton = Svg.search_ui().SetClass('w-8 h-8 full-rounded border-black float-right'); | ||||
| export default class SearchAndGo extends Combine { | ||||
| 
 | ||||
|     constructor() { | ||||
|         super(undefined); | ||||
|         this.ListenTo(this._foundEntries); | ||||
|         const goButton = Svg.search_ui().SetClass('w-8 h-8 full-rounded border-black float-right'); | ||||
| 
 | ||||
|         const self = this; | ||||
|         this._searchField.enterPressed.addCallback(() => { | ||||
|             self.RunSearch(); | ||||
|         }); | ||||
|         const placeholder = new UIEventSource<Translation>(Translations.t.general.search.search) | ||||
|         const searchField = new TextField({ | ||||
|                 placeholder: new VariableUiElement( | ||||
|                     placeholder.map(uiElement => uiElement, [Locale.language]) | ||||
|                 ), | ||||
|                 value: new UIEventSource<string>(""), | ||||
|              | ||||
|             inputStyle: " background: transparent;\n" + | ||||
|                 "    border: none;\n" + | ||||
|                 "    font-size: large;\n" + | ||||
|                 "    width: 100%;\n" + | ||||
|                 "    box-sizing: border-box;\n" + | ||||
|                 "    color: var(--foreground-color);" | ||||
|              | ||||
|             } | ||||
|         ); | ||||
|          | ||||
|         searchField.SetClass("relative float-left mt-0 ml-2") | ||||
|         searchField.SetStyle("width: calc(100% - 3em)") | ||||
| 
 | ||||
|         this._goButton.onClick(function () { | ||||
|             self.RunSearch(); | ||||
|         }); | ||||
|         super([searchField, goButton]) | ||||
| 
 | ||||
|     } | ||||
|         this.SetClass("block h-8") | ||||
|         this.SetStyle("background: var(--background-color); color: var(--foreground-color); pointer-evetns:all;") | ||||
| 
 | ||||
|     InnerRender(): string { | ||||
|         return this._searchField.Render() + | ||||
|             this._goButton.Render(); | ||||
| 
 | ||||
|     } | ||||
|         // Triggered by 'enter' or onclick
 | ||||
|         function runSearch() { | ||||
|             const searchString = searchField.GetValue().data; | ||||
|             if (searchString === undefined || searchString === "") { | ||||
|                 return; | ||||
|             } | ||||
|             searchField.GetValue().setData(""); | ||||
|             placeholder.setData(Translations.t.general.search.searching); | ||||
|             Geocoding.Search(searchString, (result) => { | ||||
| 
 | ||||
|                     console.log("Search result", result) | ||||
|                     if (result.length == 0) { | ||||
|                         placeholder.setData(Translations.t.general.search.nothing); | ||||
|                         return; | ||||
|                     } | ||||
| 
 | ||||
|                     const poi = result[0]; | ||||
|                     const bb = poi.boundingbox; | ||||
|                     const bounds: [[number, number], [number, number]] = [ | ||||
|                         [bb[0], bb[2]], | ||||
|                         [bb[1], bb[3]] | ||||
|                     ] | ||||
|                     State.state.selectedElement.setData(undefined); | ||||
|                     Hash.hash.setData(poi.osm_type + "/" + poi.osm_id); | ||||
|                     State.state.leafletMap.data.fitBounds(bounds); | ||||
|                     placeholder.setData(Translations.t.general.search.search); | ||||
|                 }, | ||||
|                 () => { | ||||
|                     searchField.GetValue().setData(""); | ||||
|                     placeholder.setData(Translations.t.general.search.error); | ||||
|                 }); | ||||
| 
 | ||||
|     // Triggered by 'enter' or onclick
 | ||||
|     private RunSearch() { | ||||
|         const searchString = this._searchField.GetValue().data; | ||||
|         if (searchString === undefined || searchString === "") { | ||||
|             return; | ||||
|         } | ||||
|         this._searchField.GetValue().setData(""); | ||||
|         this._placeholder.setData(Translations.t.general.search.searching); | ||||
|         const self = this; | ||||
|         Geocoding.Search(searchString, (result) => { | ||||
| 
 | ||||
|                 console.log("Search result", result) | ||||
|                 if (result.length == 0) { | ||||
|                     self._placeholder.setData(Translations.t.general.search.nothing); | ||||
|                     return; | ||||
|                 } | ||||
| 
 | ||||
|                 const poi = result[0]; | ||||
|                 const bb = poi.boundingbox; | ||||
|                 const bounds: [[number, number], [number, number]] = [ | ||||
|                     [bb[0], bb[2]], | ||||
|                     [bb[1], bb[3]] | ||||
|                 ] | ||||
|             State.state.selectedElement. setData(undefined); | ||||
|                 Hash.hash.setData(poi.osm_type+"/"+poi.osm_id); | ||||
|                 State.state.leafletMap.data.fitBounds(bounds); | ||||
|                 self._placeholder.setData(Translations.t.general.search.search); | ||||
|             }, | ||||
|             () => { | ||||
|                 self._searchField.GetValue().setData(""); | ||||
|                 self._placeholder.setData(Translations.t.general.search.error); | ||||
|             }); | ||||
| 
 | ||||
|         searchField.enterPressed.addCallback(runSearch); | ||||
|         goButton.onClick(runSearch); | ||||
|     } | ||||
| 
 | ||||
| 
 | ||||
|  |  | |||
|  | @ -1,29 +1,28 @@ | |||
| import {UIElement} from "../UIElement"; | ||||
| import BaseUIElement from "../BaseUIElement"; | ||||
| 
 | ||||
| export default class ShareButton extends UIElement{ | ||||
|     private _embedded: UIElement; | ||||
|     private _shareData: { text: string; title: string; url: string }; | ||||
| export default class ShareButton extends BaseUIElement{ | ||||
|     private _embedded: BaseUIElement; | ||||
|     private _shareData: () => { text: string; title: string; url: string }; | ||||
|      | ||||
|     constructor(embedded: UIElement, shareData: { | ||||
|     constructor(embedded: BaseUIElement, generateShareData: () => { | ||||
|         text: string, | ||||
|         title: string, | ||||
|         url: string | ||||
|     }) { | ||||
|         super(); | ||||
|         this._embedded = embedded; | ||||
|         this._shareData = shareData; | ||||
|     } | ||||
|      | ||||
|     InnerRender(): string { | ||||
|         return `<button type="button" class="share-button" id="${this.id}">${this._embedded.Render()}</button>` | ||||
|         this._shareData = generateShareData; | ||||
|         this.SetClass("share-button") | ||||
|     } | ||||
| 
 | ||||
|     protected InnerUpdate(htmlElement: HTMLElement) { | ||||
|         super.InnerUpdate(htmlElement); | ||||
|         const self= this; | ||||
|         htmlElement.addEventListener('click', () => { | ||||
|     protected InnerConstructElement(): HTMLElement { | ||||
|         const e = document.createElement("button") | ||||
|         e.type = "button" | ||||
|         e.appendChild(this._embedded.ConstructElement()) | ||||
|          | ||||
|         e.addEventListener('click', () => { | ||||
|             if (navigator.share) { | ||||
|                 navigator.share(self._shareData).then(() => { | ||||
|                 navigator.share(this._shareData()).then(() => { | ||||
|                     console.log('Thanks for sharing!'); | ||||
|                 }) | ||||
|                     .catch(err => { | ||||
|  | @ -33,6 +32,9 @@ export default class ShareButton extends UIElement{ | |||
|                 console.log('web share not supported'); | ||||
|             } | ||||
|         }); | ||||
|          | ||||
|         return e; | ||||
|     } | ||||
| 
 | ||||
| 
 | ||||
| } | ||||
|  | @ -1,5 +1,3 @@ | |||
| import {VerticalCombine} from "../Base/VerticalCombine"; | ||||
| import {UIElement} from "../UIElement"; | ||||
| import {VariableUiElement} from "../Base/VariableUIElement"; | ||||
| import {Translation} from "../i18n/Translation"; | ||||
| import LayoutConfig from "../../Customizations/JSON/LayoutConfig"; | ||||
|  | @ -9,29 +7,23 @@ import {SubtleButton} from "../Base/SubtleButton"; | |||
| import {UIEventSource} from "../../Logic/UIEventSource"; | ||||
| import {Utils} from "../../Utils"; | ||||
| import State from "../../State"; | ||||
| import CheckBox from "../Input/CheckBox"; | ||||
| import Toggle from "../Input/Toggle"; | ||||
| import {FixedUiElement} from "../Base/FixedUiElement"; | ||||
| import Translations from "../i18n/Translations"; | ||||
| import Constants from "../../Models/Constants"; | ||||
| import LayerConfig from "../../Customizations/JSON/LayerConfig"; | ||||
| import BaseUIElement from "../BaseUIElement"; | ||||
| 
 | ||||
| export default class ShareScreen extends UIElement { | ||||
|     private readonly _options: UIElement; | ||||
|     private readonly _iframeCode: UIElement; | ||||
|     public iframe: UIEventSource<string>; | ||||
|     private readonly _link: UIElement; | ||||
|     private readonly _linkStatus: UIEventSource<string | UIElement>; | ||||
|     private readonly _editLayout: UIElement; | ||||
| export default class ShareScreen extends Combine { | ||||
| 
 | ||||
|     constructor(layout: LayoutConfig = undefined, layoutDefinition: string = undefined) { | ||||
|         super(undefined) | ||||
|         layout = layout ?? State.state?.layoutToUse?.data; | ||||
|         layoutDefinition = layoutDefinition ?? State.state?.layoutDefinition; | ||||
|         const tr = Translations.t.general.sharescreen; | ||||
| 
 | ||||
|         const optionCheckboxes: UIElement[] = [] | ||||
|         const optionCheckboxes: BaseUIElement[] = [] | ||||
|         const optionParts: (UIEventSource<string>)[] = []; | ||||
|         this.SetClass("link-underline") | ||||
|          | ||||
|         function check() { | ||||
|             return Svg.checkmark_svg().SetStyle("width: 1.5em; display:inline-block;"); | ||||
|         } | ||||
|  | @ -40,11 +32,11 @@ export default class ShareScreen extends UIElement { | |||
|             return Svg.no_checkmark_svg().SetStyle("width: 1.5em; display: inline-block;"); | ||||
|         } | ||||
| 
 | ||||
|         const includeLocation = new CheckBox( | ||||
|             new Combine([check(), tr.fsIncludeCurrentLocation]), | ||||
|             new Combine([nocheck(), tr.fsIncludeCurrentLocation]), | ||||
|             true | ||||
|         ) | ||||
|         const includeLocation = new Toggle( | ||||
|             new Combine([check(), tr.fsIncludeCurrentLocation.Clone()]), | ||||
|             new Combine([nocheck(), tr.fsIncludeCurrentLocation.Clone()]), | ||||
|             new UIEventSource<boolean>(true) | ||||
|         ).ToggleOnClick() | ||||
|         optionCheckboxes.push(includeLocation); | ||||
| 
 | ||||
|         const currentLocation = State.state?.locationControl; | ||||
|  | @ -54,7 +46,10 @@ export default class ShareScreen extends UIElement { | |||
|                 return null; | ||||
|             } | ||||
|             if (includeL) { | ||||
|                 return `z=${currentLocation.data.zoom}&lat=${currentLocation.data.lat}&lon=${currentLocation.data.lon}` | ||||
|                 return [["z", currentLocation.data?.zoom], ["lat", currentLocation.data?.lat], ["lon", currentLocation.data?.lon]] | ||||
|                     .filter(p => p[1] !== undefined) | ||||
|                     .map(p => p[0]+"="+p[1]) | ||||
|                     .join("&") | ||||
|             } else { | ||||
|                 return null; | ||||
|             } | ||||
|  | @ -73,13 +68,13 @@ export default class ShareScreen extends UIElement { | |||
| 
 | ||||
|             const currentLayer: UIEventSource<{ id: string, name: string, layer: any }> = State.state.backgroundLayer; | ||||
|             const currentBackground = new VariableUiElement(currentLayer.map(layer => { | ||||
|                 return tr.fsIncludeCurrentBackgroundMap.Subs({name: layer?.name ?? ""}).Render(); | ||||
|                 return tr.fsIncludeCurrentBackgroundMap.Subs({name: layer?.name ?? ""}); | ||||
|             })); | ||||
|             const includeCurrentBackground = new CheckBox( | ||||
|             const includeCurrentBackground = new Toggle( | ||||
|                 new Combine([check(), currentBackground]), | ||||
|                 new Combine([nocheck(), currentBackground]), | ||||
|                 true | ||||
|             ) | ||||
|                 new UIEventSource<boolean>(true) | ||||
|             ).ToggleOnClick() | ||||
|             optionCheckboxes.push(includeCurrentBackground); | ||||
|             optionParts.push(includeCurrentBackground.isEnabled.map((includeBG) => { | ||||
|                 if (includeBG) { | ||||
|  | @ -90,11 +85,11 @@ export default class ShareScreen extends UIElement { | |||
|             }, [currentLayer])); | ||||
| 
 | ||||
| 
 | ||||
|             const includeLayerChoices = new CheckBox( | ||||
|                 new Combine([check(), tr.fsIncludeCurrentLayers]), | ||||
|                 new Combine([nocheck(), tr.fsIncludeCurrentLayers]), | ||||
|                 true | ||||
|             ) | ||||
|             const includeLayerChoices = new Toggle( | ||||
|                 new Combine([check(), tr.fsIncludeCurrentLayers.Clone()]), | ||||
|                 new Combine([nocheck(), tr.fsIncludeCurrentLayers.Clone()]), | ||||
|                 new UIEventSource<boolean>(true) | ||||
|             ).ToggleOnClick() | ||||
|             optionCheckboxes.push(includeLayerChoices); | ||||
| 
 | ||||
|             optionParts.push(includeLayerChoices.isEnabled.map((includeLayerSelection) => { | ||||
|  | @ -120,10 +115,11 @@ export default class ShareScreen extends UIElement { | |||
| 
 | ||||
|         for (const swtch of switches) { | ||||
| 
 | ||||
|             const checkbox = new CheckBox( | ||||
|                 new Combine([check(), Translations.W(swtch.human)]), | ||||
|                 new Combine([nocheck(), Translations.W(swtch.human)]), !swtch.reverse | ||||
|             ); | ||||
|             const checkbox = new Toggle( | ||||
|                 new Combine([check(), Translations.W(swtch.human.Clone())]), | ||||
|                 new Combine([nocheck(), Translations.W(swtch.human.Clone())]), | ||||
|                 new UIEventSource<boolean>(!swtch.reverse) | ||||
|             ).ToggleOnClick(); | ||||
|             optionCheckboxes.push(checkbox); | ||||
|             optionParts.push(checkbox.isEnabled.map((isEn) => { | ||||
|                 if (isEn) { | ||||
|  | @ -143,7 +139,7 @@ export default class ShareScreen extends UIElement { | |||
|         } | ||||
| 
 | ||||
| 
 | ||||
|         this._options = new VerticalCombine(optionCheckboxes) | ||||
|         const options = new Combine(optionCheckboxes).SetClass("flex flex-col") | ||||
|         const url = (currentLocation ?? new UIEventSource(undefined)).map(() => { | ||||
| 
 | ||||
|             const host = window.location.host; | ||||
|  | @ -173,12 +169,10 @@ export default class ShareScreen extends UIElement { | |||
|         }, optionParts); | ||||
| 
 | ||||
| 
 | ||||
|         this.iframe = url.map(url => `<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 `<span class='literal-code iframe-code-block'>
 | ||||
|                          <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>  | ||||
|                     </span>` | ||||
|             }) | ||||
|         ); | ||||
|  | @ -186,9 +180,9 @@ export default class ShareScreen extends UIElement { | |||
| 
 | ||||
|       | ||||
| 
 | ||||
|         this._editLayout = new FixedUiElement(""); | ||||
|         let editLayout : BaseUIElement= new FixedUiElement(""); | ||||
|         if ((layoutDefinition !== undefined && State.state?.osmConnection !== undefined)) { | ||||
|             this._editLayout = | ||||
|             editLayout = | ||||
|                 new VariableUiElement( | ||||
|                     State.state.osmConnection.userDetails.map( | ||||
|                         userDetails => { | ||||
|  | @ -197,28 +191,24 @@ export default class ShareScreen extends UIElement { | |||
|                             } | ||||
| 
 | ||||
|                             return new SubtleButton(Svg.pencil_ui(), | ||||
|                                 new Combine([tr.editThisTheme.SetClass("bold"), "<br/>", | ||||
|                                     tr.editThemeDescription]), | ||||
|                                 {url: `./customGenerator.html#${State.state.layoutDefinition}`, newTab: true}).Render(); | ||||
|                                 new Combine([tr.editThisTheme.Clone().SetClass("bold"), "<br/>", | ||||
|                                     tr.editThemeDescription.Clone()]), | ||||
|                                 {url: `./customGenerator.html#${State.state.layoutDefinition}`, newTab: true}); | ||||
| 
 | ||||
|                         } | ||||
|                     )); | ||||
| 
 | ||||
|         } | ||||
| 
 | ||||
|         this._linkStatus = new UIEventSource<string | Translation>(""); | ||||
|         this.ListenTo(this._linkStatus); | ||||
|         const self = this; | ||||
|         this._link = new VariableUiElement( | ||||
|             url.map((url) => { | ||||
|                 return `<input type="text" value=" ${url}" id="code-link--copyable" style="width:90%">` | ||||
|             }) | ||||
|         const linkStatus = new UIEventSource<string | Translation>(""); | ||||
|         const link = new VariableUiElement( | ||||
|             url.map((url) => `<input type="text" value=" ${url}" id="code-link--copyable" style="width:90%">`) | ||||
|         ).onClick(async () => { | ||||
| 
 | ||||
|             const shareData = { | ||||
|                 title: Translations.W(layout.id)?.InnerRender() ?? "", | ||||
|                 text: Translations.W(layout.description)?.InnerRender() ?? "", | ||||
|                 url: self._link.data, | ||||
|                 title: Translations.W(layout.title)?.ConstructElement().innerText ?? "", | ||||
|                 text: Translations.W(layout.description)?.ConstructElement().innerText ?? "", | ||||
|                 url: url.data, | ||||
|             } | ||||
| 
 | ||||
|             function rejected() { | ||||
|  | @ -230,17 +220,17 @@ export default class ShareScreen extends UIElement { | |||
|                 copyText.setSelectionRange(0, 99999); /*For mobile devices*/ | ||||
| 
 | ||||
|                 document.execCommand("copy"); | ||||
|                 const copied = tr.copiedToClipboard; | ||||
|                 const copied = tr.copiedToClipboard.Clone(); | ||||
|                 copied.SetClass("thanks") | ||||
|                 self._linkStatus.setData(copied); | ||||
|                 linkStatus.setData(copied); | ||||
|             } | ||||
| 
 | ||||
|             try { | ||||
|                 navigator.share(shareData) | ||||
|                     .then(() => { | ||||
|                         const thx = tr.thanksForSharing; | ||||
|                         const thx = tr.thanksForSharing.Clone(); | ||||
|                         thx.SetClass("thanks"); | ||||
|                         this._linkStatus.setData(thx); | ||||
|                         linkStatus.setData(thx); | ||||
|                     }, rejected) | ||||
|                     .catch(rejected) | ||||
|             } catch (err) { | ||||
|  | @ -249,22 +239,19 @@ export default class ShareScreen extends UIElement { | |||
| 
 | ||||
|         }); | ||||
| 
 | ||||
|     } | ||||
| 
 | ||||
|     InnerRender(): string { | ||||
|        super ([ | ||||
|             editLayout, | ||||
|             tr.intro.Clone(), | ||||
|             link, | ||||
|            new VariableUiElement(linkStatus), | ||||
|             tr.addToHomeScreen.Clone(), | ||||
|             tr.embedIntro.Clone(), | ||||
|             options, | ||||
|             iframeCode, | ||||
|         ]) | ||||
|         this.SetClass("flex flex-col link-underline") | ||||
| 
 | ||||
|         const tr = Translations.t.general.sharescreen; | ||||
| 
 | ||||
|         return new VerticalCombine([ | ||||
|             this._editLayout, | ||||
|             tr.intro, | ||||
|             this._link, | ||||
|             Translations.W(this._linkStatus.data), | ||||
|             tr.addToHomeScreen, | ||||
|             tr.embedIntro, | ||||
|             this._options, | ||||
|             this._iframeCode, | ||||
|         ]).Render() | ||||
|     } | ||||
| 
 | ||||
| } | ||||
|  | @ -1,250 +1,232 @@ | |||
| /** | ||||
|  * Asks to add a feature at the last clicked location, at least if zoom is sufficient | ||||
|  */ | ||||
| import Locale from "../i18n/Locale"; | ||||
| import {UIEventSource} from "../../Logic/UIEventSource"; | ||||
| import {UIElement} from "../UIElement"; | ||||
| import Svg from "../../Svg"; | ||||
| import {SubtleButton} from "../Base/SubtleButton"; | ||||
| import State from "../../State"; | ||||
| import Combine from "../Base/Combine"; | ||||
| import {FixedUiElement} from "../Base/FixedUiElement"; | ||||
| import Translations from "../i18n/Translations"; | ||||
| import Constants from "../../Models/Constants"; | ||||
| import LayerConfig from "../../Customizations/JSON/LayerConfig"; | ||||
| import {Tag} from "../../Logic/Tags/Tag"; | ||||
| import {TagUtils} from "../../Logic/Tags/TagUtils"; | ||||
| import BaseUIElement from "../BaseUIElement"; | ||||
| import {VariableUiElement} from "../Base/VariableUIElement"; | ||||
| import Toggle from "../Input/Toggle"; | ||||
| import UserDetails from "../../Logic/Osm/OsmConnection"; | ||||
| import {Translation} from "../i18n/Translation"; | ||||
| 
 | ||||
| export default class SimpleAddUI extends UIElement { | ||||
|     private readonly _loginButton: UIElement; | ||||
| /* | ||||
| * The SimpleAddUI is a single panel, which can have multiple states: | ||||
| * - A list of presets which can be added by the user | ||||
| * - A 'confirm-selection' button (or alternatively: please enable the layer) | ||||
| * - A 'something is wrong - please soom in further' | ||||
| * - A 'read your unread messages before adding a point' | ||||
|  */ | ||||
| 
 | ||||
|     private readonly _confirmPreset: UIEventSource<{ | ||||
|         description: string | UIElement, | ||||
|         name: string | UIElement, | ||||
|         icon: UIElement, | ||||
|         tags: Tag[], | ||||
|         layerToAddTo: { | ||||
|             layerDef: LayerConfig, | ||||
|             isDisplayed: UIEventSource<boolean> | ||||
|         } | ||||
|     }> | ||||
|         = new UIEventSource(undefined); | ||||
| interface PresetInfo { | ||||
|     description: string | Translation, | ||||
|     name: string | BaseUIElement, | ||||
|     icon: BaseUIElement, | ||||
|     tags: Tag[], | ||||
|     layerToAddTo: { | ||||
|         layerDef: LayerConfig, | ||||
|         isDisplayed: UIEventSource<boolean> | ||||
|     } | ||||
| } | ||||
| 
 | ||||
|     private _component: UIElement; | ||||
| 
 | ||||
|     private readonly openLayerControl: UIElement; | ||||
|     private readonly cancelButton: UIElement; | ||||
|     private readonly goToInboxButton: UIElement = new SubtleButton(Svg.envelope_ui(), | ||||
|         Translations.t.general.goToInbox, {url: "https://www.openstreetmap.org/messages/inbox", newTab: false}); | ||||
| export default class SimpleAddUI extends Toggle { | ||||
| 
 | ||||
|     constructor(isShown: UIEventSource<boolean>) { | ||||
|         super(State.state.locationControl.map(loc => loc.zoom)); | ||||
|         const self = this; | ||||
|         this.ListenTo(Locale.language); | ||||
|         this.ListenTo(State.state.osmConnection.userDetails); | ||||
|         this.ListenTo(State.state.layerUpdater.runningQuery); | ||||
|         this.ListenTo(this._confirmPreset); | ||||
|         this.ListenTo(State.state.locationControl); | ||||
|         State.state.filteredLayers.data?.map(layer => { | ||||
|             self.ListenTo(layer.isDisplayed) | ||||
|         }) | ||||
| 
 | ||||
|         this._loginButton = Translations.t.general.add.pleaseLogin.Clone().onClick(() => State.state.osmConnection.AttemptLogin()); | ||||
| 
 | ||||
|         const loginButton = Translations.t.general.add.pleaseLogin.Clone().onClick(State.state.osmConnection.AttemptLogin); | ||||
|         const readYourMessages = new Combine([ | ||||
|             Translations.t.general.readYourMessages.Clone().SetClass("alert"), | ||||
|             new SubtleButton(Svg.envelope_ui(), | ||||
|                 Translations.t.general.goToInbox, {url: "https://www.openstreetmap.org/messages/inbox", newTab: false}) | ||||
|         ]); | ||||
|          | ||||
|          | ||||
|          | ||||
|         const selectedPreset = new UIEventSource<PresetInfo>(undefined); | ||||
|         isShown.addCallback(_ => selectedPreset.setData(undefined)) // Clear preset selection when the UI is closed/opened
 | ||||
|          | ||||
|         function createNewPoint(tags: any[]){ | ||||
|            const loc = State.state.LastClickLocation.data; | ||||
|             let feature = State.state.changes.createElement(tags, loc.lat, loc.lon); | ||||
|             State.state.selectedElement.setData(feature); | ||||
|         } | ||||
|          | ||||
|         const presetsOverview = SimpleAddUI.CreateAllPresetsPanel(selectedPreset) | ||||
| 
 | ||||
|         const addUi = new VariableUiElement( | ||||
|             selectedPreset.map(preset => { | ||||
|                     if (preset === undefined) { | ||||
|                         return presetsOverview | ||||
|                     } | ||||
|                     return SimpleAddUI.CreateConfirmButton(preset, | ||||
|                         tags => { | ||||
|                             createNewPoint(tags) | ||||
|                             selectedPreset.setData(undefined) | ||||
|                         }, () => { | ||||
|                             selectedPreset.setData(undefined) | ||||
|                         }) | ||||
|                 } | ||||
|             )) | ||||
| 
 | ||||
| 
 | ||||
|         super( | ||||
|             new Toggle( | ||||
|                 new Toggle( | ||||
|                     new Toggle( | ||||
|                         Translations.t.general.add.stillLoading.Clone().SetClass("alert"), | ||||
|                         addUi, | ||||
|                         State.state.layerUpdater.runningQuery | ||||
|                     ), | ||||
|                     Translations.t.general.add.zoomInFurther.Clone().SetClass("alert")                    , | ||||
|                     State.state.locationControl.map(loc => loc.zoom >= Constants.userJourney.minZoomLevelToAddNewPoints) | ||||
|                 ), | ||||
|                 readYourMessages, | ||||
|                 State.state.osmConnection.userDetails.map((userdetails: UserDetails) => | ||||
|                     userdetails.csCount >= Constants.userJourney.addNewPointWithUnreadMessagesUnlock || | ||||
|                     userdetails.unreadMessages == 0) | ||||
|             ), | ||||
|             loginButton, | ||||
|             State.state.osmConnection.isLoggedIn | ||||
|         ) | ||||
| 
 | ||||
| 
 | ||||
|         this.SetStyle("font-size:large"); | ||||
|         this.cancelButton = new SubtleButton(Svg.close_ui(), | ||||
|             Translations.t.general.cancel | ||||
|         ).onClick(() => { | ||||
|             self._confirmPreset.setData(undefined); | ||||
|         }) | ||||
| 
 | ||||
|         this.openLayerControl = new SubtleButton(Svg.layers_ui(), | ||||
|             Translations.t.general.add.openLayerControl | ||||
|         ).onClick(() => { | ||||
|             State.state.layerControlIsOpened.setData(true); | ||||
|         }) | ||||
|          | ||||
|         // IS shown is the state of the dialog - we reset the choice if the dialog dissappears
 | ||||
|         isShown.addCallback(isShown =>  | ||||
|         { | ||||
|             if(!isShown){ | ||||
|                 self._confirmPreset.setData(undefined) | ||||
|             } | ||||
|         }) | ||||
|         // If the click location changes, we reset the dialog as well
 | ||||
|         State.state.LastClickLocation.addCallback(() => { | ||||
|             self._confirmPreset.setData(undefined) | ||||
|         }) | ||||
| 
 | ||||
|     } | ||||
| 
 | ||||
|     InnerRender(): string { | ||||
|         this._component = this.CreateContent(); | ||||
|         return this._component.Render(); | ||||
| 
 | ||||
|     } | ||||
| 
 | ||||
|     private CreatePresetsPanel(): UIElement { | ||||
|         const userDetails = State.state.osmConnection.userDetails; | ||||
|         if (userDetails === undefined) { | ||||
|             return undefined; | ||||
|         } | ||||
| 
 | ||||
|         if (!userDetails.data.loggedIn) { | ||||
|             return this._loginButton; | ||||
|         } | ||||
| 
 | ||||
|         if (userDetails.data.unreadMessages > 0 && userDetails.data.csCount < Constants.userJourney.addNewPointWithUnreadMessagesUnlock) { | ||||
|             return new Combine([ | ||||
|                 Translations.t.general.readYourMessages.Clone().SetClass("alert"), | ||||
|                 this.goToInboxButton | ||||
|             ]); | ||||
| 
 | ||||
|         } | ||||
| 
 | ||||
|         if (userDetails.data.csCount < Constants.userJourney.addNewPointsUnlock) { | ||||
|             return new Combine(["<span class='alert'>", | ||||
|                 Translations.t.general.fewChangesBefore, | ||||
|                 "</span>"]); | ||||
|         } | ||||
| 
 | ||||
|         if (State.state.locationControl.data.zoom < Constants.userJourney.minZoomLevelToAddNewPoints) { | ||||
|             return Translations.t.general.add.zoomInFurther.SetClass("alert") | ||||
|         } | ||||
| 
 | ||||
|         if (State.state.layerUpdater.runningQuery.data) { | ||||
|             return Translations.t.general.add.stillLoading | ||||
|         } | ||||
| 
 | ||||
|         const presetButtons = this.CreatePresetButtons() | ||||
|         return new Combine(presetButtons) | ||||
|     } | ||||
| 
 | ||||
| 
 | ||||
|     private CreateContent(): UIElement { | ||||
|         const confirmPanel = this.CreateConfirmPanel(); | ||||
|         if (confirmPanel !== undefined) { | ||||
|             return confirmPanel; | ||||
|         } | ||||
| 
 | ||||
|         let intro: UIElement = Translations.t.general.add.intro; | ||||
|     private static CreateConfirmButton(preset: PresetInfo, | ||||
|                                        confirm: (tags: any[]) => void,  | ||||
|                                        cancel: () => void): BaseUIElement { | ||||
| 
 | ||||
|         let testMode: UIElement = undefined; | ||||
|         if (State.state.osmConnection?.userDetails?.data?.dryRun) { | ||||
|             testMode = new Combine([ | ||||
|                 "<span class='alert'>", | ||||
|                 "Test mode - changes won't be saved", | ||||
|                 "</span>" | ||||
|             ]); | ||||
|         } | ||||
| 
 | ||||
|         let presets = this.CreatePresetsPanel(); | ||||
|         return new Combine([intro, testMode, presets]) | ||||
| 
 | ||||
| 
 | ||||
|     } | ||||
| 
 | ||||
|     private CreateConfirmPanel(): UIElement { | ||||
|         const preset = this._confirmPreset.data; | ||||
|         if (preset === undefined) { | ||||
|             return undefined; | ||||
|         } | ||||
| 
 | ||||
|         const confirmButton = new SubtleButton(preset.icon, | ||||
|             new Combine([ | ||||
|                 "<b>", | ||||
|                 Translations.t.general.add.confirmButton.Subs({category: preset.name}), | ||||
|                 "</b>"])).SetClass("break-words"); | ||||
|         confirmButton.onClick( | ||||
|             this.CreatePoint(preset.tags) | ||||
|         ); | ||||
|                 Translations.t.general.add.addNew.Subs({category: preset.name}), | ||||
|                 Translations.t.general.add.warnVisibleForEveryone.Clone().SetClass("alert") | ||||
|             ]).SetClass("flex flex-col") | ||||
|         ).SetClass("font-bold break-words") | ||||
|             .onClick(() => confirm(preset.tags)); | ||||
| 
 | ||||
|         if (!this._confirmPreset.data.layerToAddTo.isDisplayed.data) { | ||||
|             return new Combine([ | ||||
|                 Translations.t.general.add.layerNotEnabled.Subs({layer: this._confirmPreset.data.layerToAddTo.layerDef.name}) | ||||
|                     .SetClass("alert"), | ||||
|                 this.openLayerControl, | ||||
| 
 | ||||
|                 this.cancelButton | ||||
|             ]); | ||||
|         } | ||||
|         const openLayerControl =   | ||||
|             new SubtleButton( | ||||
|                 Svg.layers_ui(), | ||||
|                 new Combine([ | ||||
|                     Translations.t.general.add.layerNotEnabled | ||||
|                         .Subs({layer: preset.layerToAddTo.layerDef.name}) | ||||
|                         .SetClass("alert"), | ||||
|                     Translations.t.general.add.openLayerControl | ||||
|                 ]) | ||||
|             ) | ||||
|             | ||||
|             .onClick(() => State.state.layerControlIsOpened.setData(true)) | ||||
|          | ||||
|         const openLayerOrConfirm = new Toggle( | ||||
|             confirmButton, | ||||
|             openLayerControl, | ||||
|             preset.layerToAddTo.isDisplayed | ||||
|         ) | ||||
|         const tagInfo = SimpleAddUI.CreateTagInfoFor(preset); | ||||
| 
 | ||||
|         let tagInfo = ""; | ||||
|         const csCount = State.state.osmConnection.userDetails.data.csCount; | ||||
|         if (csCount > Constants.userJourney.tagsVisibleAt) { | ||||
|             tagInfo = this._confirmPreset.data.tags.map(t => t.asHumanString(csCount > Constants.userJourney.tagsVisibleAndWikiLinked, true)).join("&"); | ||||
|             tagInfo = `<br/>More information about the preset: ${tagInfo}` | ||||
|         } | ||||
|         const cancelButton = new SubtleButton(Svg.close_ui(), | ||||
|             Translations.t.general.cancel | ||||
|         ).onClick(cancel        ) | ||||
| 
 | ||||
|         return new Combine([ | ||||
|             Translations.t.general.add.confirmIntro.Subs({title: this._confirmPreset.data.name}), | ||||
|             State.state.osmConnection.userDetails.data.dryRun ? "<span class='alert'>TESTING - changes won't be saved</span>" : "", | ||||
|             confirmButton, | ||||
|             this.cancelButton, | ||||
|             Translations.t.general.add.confirmIntro.Subs({title: preset.name}), | ||||
|             State.state.osmConnection.userDetails.data.dryRun ?  | ||||
|                 Translations.t.general.testing.Clone().SetClass("alert") : undefined           ,  | ||||
|             openLayerOrConfirm, | ||||
|             cancelButton, | ||||
|             preset.description, | ||||
|             tagInfo | ||||
| 
 | ||||
|         ]) | ||||
|         ]).SetClass("flex flex-col") | ||||
| 
 | ||||
|     } | ||||
| 
 | ||||
|     private CreatePresetButtons() { | ||||
|     private static CreateTagInfoFor(preset: PresetInfo, optionallyLinkToWiki = true) { | ||||
|         const csCount = State.state.osmConnection.userDetails.data.csCount; | ||||
|         return new Toggle( | ||||
|             Translations.t.general.presetInfo.Subs({ | ||||
|                 tags: preset.tags.map(t => t.asHumanString(optionallyLinkToWiki && csCount > Constants.userJourney.tagsVisibleAndWikiLinked, true)).join("&"), | ||||
|             }).SetStyle("word-break: break-all"), | ||||
| 
 | ||||
|             undefined, | ||||
|             State.state.osmConnection.userDetails.map(userdetails => userdetails.csCount >= Constants.userJourney.tagsVisibleAt) | ||||
|         ); | ||||
|     } | ||||
| 
 | ||||
|     private static CreateAllPresetsPanel(selectedPreset: UIEventSource<PresetInfo>): BaseUIElement { | ||||
|         const presetButtons = SimpleAddUI.CreatePresetButtons(selectedPreset) | ||||
|         let intro: BaseUIElement = Translations.t.general.add.intro; | ||||
| 
 | ||||
|         let testMode: BaseUIElement = undefined; | ||||
|         if (State.state.osmConnection?.userDetails?.data?.dryRun) { | ||||
|             testMode = Translations.t.general.testing.Clone().SetClass("alert") | ||||
|         } | ||||
| 
 | ||||
|         return new Combine([intro, testMode, presetButtons]).SetClass("flex flex-col") | ||||
| 
 | ||||
|     } | ||||
| 
 | ||||
|     private static CreatePresetSelectButton(preset: PresetInfo){ | ||||
| 
 | ||||
|         const tagInfo =SimpleAddUI.CreateTagInfoFor(preset, false); | ||||
|         return new SubtleButton( | ||||
|             preset.icon, | ||||
|             new Combine([ | ||||
|                 Translations.t.general.add.addNew.Subs({ | ||||
|                     category: preset.name | ||||
|                 }).SetClass("font-bold"), | ||||
|                 Translations.WT(preset.description)?.FirstSentence(), | ||||
|                 tagInfo?.SetClass("subtle") | ||||
|             ]).SetClass("flex flex-col") | ||||
|         ) | ||||
|     } | ||||
|   | ||||
| /* | ||||
| * Generates the list with all the buttons.*/ | ||||
|     private static CreatePresetButtons(selectedPreset: UIEventSource<PresetInfo>): BaseUIElement { | ||||
|         const allButtons = []; | ||||
|         const self = this; | ||||
|         for (const layer of State.state.filteredLayers.data) { | ||||
|              | ||||
|             if(layer.isDisplayed.data === false && State.state.featureSwitchLayers){ | ||||
|                 continue; | ||||
|             } | ||||
|              | ||||
|             const presets = layer.layerDef.presets; | ||||
|             for (const preset of presets) { | ||||
|                 const tags = TagUtils.KVtoProperties(preset.tags ?? []); | ||||
|                 let icon: UIElement = new FixedUiElement(layer.layerDef.GenerateLeafletStyle(new UIEventSource<any>(tags), false).icon.html.Render()).SetClass("simple-add-ui-icon"); | ||||
| 
 | ||||
|                 const csCount = State.state.osmConnection.userDetails.data.csCount; | ||||
|                 let tagInfo = undefined; | ||||
|                 if (csCount > Constants.userJourney.tagsVisibleAt) { | ||||
|                     const presets = preset.tags.map(t => new Combine([t.asHumanString(false, true), " "]).SetClass("subtle break-words")) | ||||
|                     tagInfo = new Combine(presets) | ||||
|                 const tags = TagUtils.KVtoProperties(preset.tags ?? []); | ||||
|                 let icon: BaseUIElement = layer.layerDef.GenerateLeafletStyle(new UIEventSource<any>(tags), false).icon.html | ||||
|                     .SetClass("w-12 h-12 block relative"); | ||||
|                 const presetInfo: PresetInfo = { | ||||
|                     tags: preset.tags, | ||||
|                     layerToAddTo: layer, | ||||
|                     name: preset.title, | ||||
|                     description: preset.description, | ||||
|                     icon: icon | ||||
|                 } | ||||
|                 const button: UIElement = | ||||
|                     new SubtleButton( | ||||
|                         icon, | ||||
|                         new Combine([ | ||||
|                             "<b>", | ||||
|                             preset.title, | ||||
|                             "</b>", | ||||
|                             preset.description !== undefined ? new Combine(["<br/>", preset.description.FirstSentence()]) : "", | ||||
|                             "<br/>", | ||||
|                             tagInfo | ||||
|                         ]) | ||||
|                     ).onClick( | ||||
|                         () => { | ||||
|                             self._confirmPreset.setData({ | ||||
|                                 tags: preset.tags, | ||||
|                                 layerToAddTo: layer, | ||||
|                                 name: preset.title, | ||||
|                                 description: preset.description, | ||||
|                                 icon: icon | ||||
|                             }); | ||||
|                             self.Update(); | ||||
|                         } | ||||
|                     ) | ||||
| 
 | ||||
|                 const button = SimpleAddUI.CreatePresetSelectButton(presetInfo); | ||||
|                 button.onClick(() => { | ||||
|                     selectedPreset.setData(presetInfo) | ||||
|                 }) | ||||
|                 allButtons.push(button); | ||||
|             } | ||||
|         } | ||||
|         return allButtons; | ||||
|         return new Combine(allButtons).SetClass("flex flex-col"); | ||||
|     } | ||||
| 
 | ||||
|     private CreatePoint(tags: Tag[]) { | ||||
|         return () => { | ||||
|             console.log("Create Point Triggered") | ||||
|             const loc = State.state.LastClickLocation.data; | ||||
|             let feature = State.state.changes.createElement(tags, loc.lat, loc.lon); | ||||
|             State.state.selectedElement.setData(feature); | ||||
|             this._confirmPreset.setData(undefined); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     public OnClose(){ | ||||
|         console.log("On close triggered") | ||||
|         this._confirmPreset.setData(undefined) | ||||
|     } | ||||
| 
 | ||||
| } | ||||
|  | @ -1,61 +1,72 @@ | |||
| import Locale from "../i18n/Locale"; | ||||
| import {UIElement} from "../UIElement"; | ||||
| import State from "../../State"; | ||||
| import Combine from "../Base/Combine"; | ||||
| import LanguagePicker from "../LanguagePicker"; | ||||
| import Translations from "../i18n/Translations"; | ||||
| import {VariableUiElement} from "../Base/VariableUIElement"; | ||||
| import LayoutConfig from "../../Customizations/JSON/LayoutConfig"; | ||||
| import Toggle from "../Input/Toggle"; | ||||
| import {SubtleButton} from "../Base/SubtleButton"; | ||||
| import Svg from "../../Svg"; | ||||
| import {UIEventSource} from "../../Logic/UIEventSource"; | ||||
| import {FixedUiElement} from "../Base/FixedUiElement"; | ||||
| 
 | ||||
| export default class ThemeIntroductionPanel extends UIElement { | ||||
|     private languagePicker: UIElement; | ||||
| export default class ThemeIntroductionPanel extends VariableUiElement { | ||||
| 
 | ||||
|     private readonly loginStatus: UIElement; | ||||
|     private _layout: UIEventSource<LayoutConfig>; | ||||
|     constructor(isShown: UIEventSource<boolean>) { | ||||
| 
 | ||||
| 
 | ||||
|     constructor() { | ||||
|         super(State.state.osmConnection.userDetails); | ||||
|         this.ListenTo(Locale.language); | ||||
|         this.languagePicker = LanguagePicker.CreateLanguagePicker(State.state.layoutToUse.data.language, Translations.t.general.pickLanguage); | ||||
|         this._layout = State.state.layoutToUse; | ||||
|         this.ListenTo(State.state.layoutToUse); | ||||
|         const languagePicker = | ||||
|             new VariableUiElement( | ||||
|                 State.state.layoutToUse.map(layout => LanguagePicker.CreateLanguagePicker(layout.language, Translations.t.general.pickLanguage.Clone())) | ||||
|             ) | ||||
|         ; | ||||
|          | ||||
|         const toTheMap = new SubtleButton( | ||||
|             undefined, | ||||
|             Translations.t.general.openTheMap.Clone().SetClass("text-xl font-bold w-full text-center") | ||||
|         ).onClick(() =>{ | ||||
|             isShown.setData(false) | ||||
|         }).SetClass("only-on-mobile") | ||||
| 
 | ||||
|         const plzLogIn = | ||||
|             Translations.t.general.loginWithOpenStreetMap | ||||
|             new SubtleButton( | ||||
|                 Svg.osm_logo_ui(), | ||||
|                  | ||||
|                 new Combine([Translations.t.general.loginWithOpenStreetMap | ||||
|                     .Clone().SetClass("text-xl font-bold"), | ||||
|                     Translations.t.general.loginOnlyNeededToEdit.Clone().SetClass("font-bold")] | ||||
|                     ).SetClass("flex flex-col text-center w-full") | ||||
|             ) | ||||
|                 .onClick(() => { | ||||
|                     State.state.osmConnection.AttemptLogin() | ||||
|                 }); | ||||
| 
 | ||||
| 
 | ||||
|         const welcomeBack = Translations.t.general.welcomeBack.Clone(); | ||||
|          | ||||
|          | ||||
|         const welcomeBack = Translations.t.general.welcomeBack; | ||||
|          | ||||
|         this.loginStatus = new VariableUiElement( | ||||
|             State.state.osmConnection.userDetails.map( | ||||
|                 userdetails => { | ||||
|                     if (State.state.featureSwitchUserbadge.data) { | ||||
|                         return ""; | ||||
|                     } | ||||
|                     return (userdetails.loggedIn ? welcomeBack : plzLogIn).Render(); | ||||
|                 } | ||||
| 
 | ||||
|         const loginStatus = | ||||
|             new Toggle( | ||||
|                 new Toggle( | ||||
|                     welcomeBack, | ||||
|                     plzLogIn, | ||||
|                     State.state.osmConnection.isLoggedIn | ||||
|                 ), | ||||
|                 undefined, | ||||
|                 State.state.featureSwitchUserbadge | ||||
|             ) | ||||
|         ) | ||||
| 
 | ||||
| 
 | ||||
|         super(State.state.layoutToUse.map (layout => new Combine([ | ||||
|             layout.description.Clone(), | ||||
|             "<br/><br/>", | ||||
|             toTheMap, | ||||
|             loginStatus, | ||||
|             layout.descriptionTail.Clone(), | ||||
|             "<br/>", | ||||
|             languagePicker, | ||||
|             ...layout.CustomCodeSnippets() | ||||
|         ]))) | ||||
| 
 | ||||
|         this.SetClass("link-underline") | ||||
|     } | ||||
| 
 | ||||
|     InnerRender(): string { | ||||
|         const layout : LayoutConfig = this._layout.data; | ||||
|         return new Combine([ | ||||
|             layout.description, | ||||
|             "<br/><br/>", | ||||
|             this.loginStatus, | ||||
|             layout.descriptionTail, | ||||
|             "<br/>", | ||||
|             this.languagePicker, | ||||
|             ...layout.CustomCodeSnippets() | ||||
|         ]).Render() | ||||
|     } | ||||
| 
 | ||||
| 
 | ||||
| } | ||||
|  |  | |||
							
								
								
									
										54
									
								
								UI/BigComponents/UploadFlowStateUI.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										54
									
								
								UI/BigComponents/UploadFlowStateUI.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,54 @@ | |||
| import {UIElement} from "../UIElement"; | ||||
| import {UIEventSource} from "../../Logic/UIEventSource"; | ||||
| import BaseUIElement from "../BaseUIElement"; | ||||
| import {VariableUiElement} from "../Base/VariableUIElement"; | ||||
| import Translations from "../i18n/Translations"; | ||||
| 
 | ||||
| /** | ||||
|  * Shows that 'images are uploading', 'all images are uploaded' as relevant... | ||||
|  */ | ||||
| export default class UploadFlowStateUI extends UIElement{ | ||||
|      | ||||
|     private readonly _element: BaseUIElement | ||||
|      | ||||
|     constructor(queue: UIEventSource<string[]>, failed: UIEventSource<string[]>, success: UIEventSource<string[]>) { | ||||
|         super(); | ||||
|         const t = Translations.t.image; | ||||
| 
 | ||||
|         this._element = new VariableUiElement( | ||||
|              | ||||
|           queue.map(queue => { | ||||
|               const failedReasons = failed.data | ||||
|               const successCount = success.data.length | ||||
|               const pendingCount = queue.length - successCount - failedReasons.length; | ||||
|                | ||||
|               let stateMessages : BaseUIElement[] = [] | ||||
|                | ||||
|               if(pendingCount == 1){ | ||||
|                   stateMessages.push(t.uploadingPicture.Clone().SetClass("alert")) | ||||
|               } | ||||
|               if(pendingCount > 1){ | ||||
|                   stateMessages.push(t.uploadingMultiple.Subs({count: ""+pendingCount}).SetClass("alert")) | ||||
|               } | ||||
|               if(failedReasons.length > 0){ | ||||
|                   stateMessages.push(t.uploadFailed.Clone().SetClass("alert")) | ||||
|               } | ||||
|               if(successCount > 0 && pendingCount == 0){ | ||||
|                   stateMessages.push(t.uploadDone.SetClass("thanks")) | ||||
|               } | ||||
|                | ||||
|               stateMessages.forEach(msg => msg.SetStyle("display: block ruby")) | ||||
|                | ||||
|               return stateMessages | ||||
|           }, [failed, success])   | ||||
|              | ||||
|              | ||||
|         ); | ||||
|          | ||||
|          | ||||
|     } | ||||
| 
 | ||||
|     protected InnerRender(): string | BaseUIElement { | ||||
|         return this._element | ||||
|     } | ||||
| } | ||||
|  | @ -1,10 +1,7 @@ | |||
| /** | ||||
|  * Handles and updates the user badge | ||||
|  */ | ||||
| import {UIEventSource} from "../../Logic/UIEventSource"; | ||||
| import {UIElement} from "../UIElement"; | ||||
| import {VariableUiElement} from "../Base/VariableUIElement"; | ||||
| import UserDetails from "../../Logic/Osm/OsmConnection"; | ||||
| import Svg from "../../Svg"; | ||||
| import State from "../../State"; | ||||
| import Combine from "../Base/Combine"; | ||||
|  | @ -12,133 +9,127 @@ import {FixedUiElement} from "../Base/FixedUiElement"; | |||
| import LanguagePicker from "../LanguagePicker"; | ||||
| import Translations from "../i18n/Translations"; | ||||
| import Link from "../Base/Link"; | ||||
| import Toggle from "../Input/Toggle"; | ||||
| import Img from "../Base/Img"; | ||||
| 
 | ||||
| export default class UserBadge extends UIElement { | ||||
|     private _userDetails: UIEventSource<UserDetails>; | ||||
|     private _logout: UIElement; | ||||
|     private _homeButton: UIElement; | ||||
|     private _languagePicker: UIElement; | ||||
| 
 | ||||
|     private _loginButton: UIElement; | ||||
| export default class UserBadge extends Toggle { | ||||
| 
 | ||||
|     constructor() { | ||||
|         super(State.state.osmConnection.userDetails); | ||||
|         this._userDetails = State.state.osmConnection.userDetails; | ||||
|         this._languagePicker = (LanguagePicker.CreateLanguagePicker(State.state.layoutToUse.data.language) ?? new FixedUiElement("")) | ||||
|             .SetStyle("width:min-content;"); | ||||
| 
 | ||||
|         this._loginButton = Translations.t.general.loginWithOpenStreetMap | ||||
| 
 | ||||
|         const userDetails = State.state.osmConnection.userDetails; | ||||
| 
 | ||||
|         const loginButton = Translations.t.general.loginWithOpenStreetMap | ||||
|             .Clone() | ||||
|             .SetClass("userbadge-login pt-3 w-full") | ||||
|             .onClick(() => State.state.osmConnection.AttemptLogin()); | ||||
|         this._logout = | ||||
| 
 | ||||
| 
 | ||||
|         const logout = | ||||
|             Svg.logout_svg() | ||||
|                 .onClick(() => { | ||||
|                     State.state.osmConnection.LogOut(); | ||||
|                 }); | ||||
| 
 | ||||
|         this._userDetails.addCallback(function () { | ||||
|             const profilePic = document.getElementById("profile-pic"); | ||||
|             if (profilePic) { | ||||
| 
 | ||||
|                 profilePic.onload = function () { | ||||
|                     profilePic.style.opacity = "1" | ||||
|                 }; | ||||
|             } | ||||
|         }); | ||||
|         const userBadge = userDetails.map(user => { | ||||
|             { | ||||
|                 const homeButton = new VariableUiElement( | ||||
|                     userDetails.map((userinfo) => { | ||||
|                         if (userinfo.home) { | ||||
|                             return Svg.home_ui(); | ||||
|                         } | ||||
|                         return " "; | ||||
|                     }) | ||||
|                 ).onClick(() => { | ||||
|                     const home = State.state.osmConnection.userDetails.data?.home; | ||||
|                     if (home === undefined) { | ||||
|                         return; | ||||
|                     } | ||||
|                     State.state.leafletMap.data.setView([home.lat, home.lon], 16); | ||||
|                 }); | ||||
| 
 | ||||
|         this._homeButton = new VariableUiElement( | ||||
|             this._userDetails.map((userinfo) => { | ||||
|                 if (userinfo.home) { | ||||
|                     return Svg.home_ui().Render(); | ||||
|                 const linkStyle = "flex items-baseline" | ||||
|                 const languagePicker = (LanguagePicker.CreateLanguagePicker(State.state.layoutToUse.data.language) ?? new FixedUiElement("")) | ||||
|                     .SetStyle("width:min-content;"); | ||||
| 
 | ||||
|                 let messageSpan = | ||||
|                     new Link( | ||||
|                         new Combine([Svg.envelope, "" + user.totalMessages]).SetClass(linkStyle), | ||||
|                         'https://www.openstreetmap.org/messages/inbox', | ||||
|                         true | ||||
|                     ) | ||||
| 
 | ||||
| 
 | ||||
|                 const csCount = | ||||
|                     new Link( | ||||
|                         new Combine([Svg.star, "" + user.csCount]).SetClass(linkStyle), | ||||
|                         `https://www.openstreetmap.org/user/${user.name}/history`, | ||||
|                         true); | ||||
| 
 | ||||
| 
 | ||||
|                 if (user.unreadMessages > 0) { | ||||
|                     messageSpan = new Link( | ||||
|                         new Combine([Svg.envelope, "" + user.unreadMessages]), | ||||
|                         'https://www.openstreetmap.org/messages/inbox', | ||||
|                         true | ||||
|                     ).SetClass("alert") | ||||
|                 } | ||||
|                 return " "; | ||||
|             }) | ||||
|         ).onClick(() => { | ||||
|             const home = State.state.osmConnection.userDetails.data?.home; | ||||
|             if (home === undefined) { | ||||
|                 return; | ||||
| 
 | ||||
|                 let dryrun = new FixedUiElement(""); | ||||
|                 if (user.dryRun) { | ||||
|                     dryrun = new FixedUiElement("TESTING").SetClass("alert"); | ||||
|                 } | ||||
| 
 | ||||
|                 const settings = | ||||
|                     new Link(Svg.gear_svg(), | ||||
|                         `https://www.openstreetmap.org/user/${encodeURIComponent(user.name)}/account`, | ||||
|                         true) | ||||
| 
 | ||||
| 
 | ||||
|                 const userIcon = new Link( | ||||
|                     new Img(user.img) | ||||
|                         .SetClass("rounded-full opacity-0 m-0 p-0 duration-500 w-16 h16 float-left") | ||||
|                     , | ||||
|                     `https://www.openstreetmap.org/user/${encodeURIComponent(user.name)}`, | ||||
|                     true | ||||
|                 ); | ||||
| 
 | ||||
| 
 | ||||
|                 const userName = new Link( | ||||
|                     new FixedUiElement(user.name), | ||||
|                     `https://www.openstreetmap.org/user/${user.name}`, | ||||
|                     true); | ||||
| 
 | ||||
| 
 | ||||
|                 const userStats = new Combine([ | ||||
|                     homeButton, | ||||
|                     settings, | ||||
|                     messageSpan, | ||||
|                     csCount, | ||||
|                     languagePicker, | ||||
|                     logout | ||||
|                 ]) | ||||
|                     .SetClass("userstats") | ||||
| 
 | ||||
|                 const usertext = new Combine([ | ||||
|                     userName, | ||||
|                     dryrun, | ||||
|                     userStats | ||||
|                 ]).SetClass("usertext") | ||||
| 
 | ||||
|                 return new Combine([ | ||||
|                     userIcon, | ||||
|                     usertext, | ||||
|                 ]).SetClass("h-16") | ||||
|             } | ||||
|             State.state.leafletMap.data.setView([home.lat, home.lon], 16); | ||||
|         }); | ||||
| 
 | ||||
|     } | ||||
| 
 | ||||
|     InnerRender(): string { | ||||
|         const user = this._userDetails.data; | ||||
|         if (!user.loggedIn) { | ||||
|             return this._loginButton.Render(); | ||||
|         } | ||||
| 
 | ||||
|         const linkStyle = "flex items-baseline" | ||||
| 
 | ||||
|         let messageSpan: UIElement = | ||||
|             new Link( | ||||
|                 new Combine([Svg.envelope, "" + user.totalMessages]).SetClass(linkStyle), | ||||
|                 'https://www.openstreetmap.org/messages/inbox', | ||||
|                 true | ||||
|             ) | ||||
| 
 | ||||
| 
 | ||||
|         const csCount = | ||||
|             new Link( | ||||
|                 new Combine([Svg.star, "" + user.csCount]).SetClass(linkStyle), | ||||
|                 `https://www.openstreetmap.org/user/${user.name}/history`, | ||||
|                 true); | ||||
| 
 | ||||
| 
 | ||||
|         if (user.unreadMessages > 0) { | ||||
|             messageSpan = new Link( | ||||
|                 new Combine([Svg.envelope, "" + user.unreadMessages]), | ||||
|                 'https://www.openstreetmap.org/messages/inbox', | ||||
|                 true | ||||
|             ).SetClass("alert") | ||||
|         } | ||||
| 
 | ||||
|         let dryrun: UIElement = new FixedUiElement(""); | ||||
|         if (user.dryRun) { | ||||
|             dryrun = new FixedUiElement("TESTING").SetClass("alert"); | ||||
|         } | ||||
| 
 | ||||
|         const settings = | ||||
|             new Link(Svg.gear_svg(), | ||||
|                 `https://www.openstreetmap.org/user/${encodeURIComponent(user.name)}/account`, | ||||
|                 true) | ||||
| 
 | ||||
| 
 | ||||
|         const userIcon = new Link( | ||||
|             new FixedUiElement(`<img id='profile-pic' src='${user.img}' alt='profile-pic'/>`), | ||||
|             `https://www.openstreetmap.org/user/${encodeURIComponent(user.name)}`, | ||||
|             true | ||||
|         ); | ||||
| 
 | ||||
| 
 | ||||
|         const userName = new Link( | ||||
|             new FixedUiElement(user.name), | ||||
|             `https://www.openstreetmap.org/user/${user.name}`, | ||||
|             true); | ||||
| 
 | ||||
| 
 | ||||
|         const userStats = new Combine([ | ||||
|             this._homeButton, | ||||
|             settings, | ||||
|             messageSpan, | ||||
|             csCount, | ||||
|             this._languagePicker, | ||||
|             this._logout | ||||
|         ]) | ||||
|             .SetClass("userstats") | ||||
| 
 | ||||
|         const usertext = new Combine([ | ||||
|             userName, | ||||
|             dryrun, | ||||
|             userStats | ||||
|         ]).SetClass("usertext") | ||||
| 
 | ||||
|         return new Combine([ | ||||
|             userIcon, | ||||
|             usertext, | ||||
|         ]).Render() | ||||
|         super( | ||||
|             new VariableUiElement(userBadge), | ||||
|             loginButton, | ||||
|             State.state.osmConnection.isLoggedIn | ||||
|         ) | ||||
| 
 | ||||
|     } | ||||
| 
 | ||||
|  |  | |||
|  | @ -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"; | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
| } | ||||
|  |  | |||
|  | @ -1,113 +0,0 @@ | |||
| import {UIElement} from "../UIElement"; | ||||
| import {TabbedComponent} from "../Base/TabbedComponent"; | ||||
| import {SubtleButton} from "../Base/SubtleButton"; | ||||
| import {UIEventSource} from "../../Logic/UIEventSource"; | ||||
| import {LayoutConfigJson} from "../../Customizations/JSON/LayoutConfigJson"; | ||||
| import Combine from "../Base/Combine"; | ||||
| import {GenerateEmpty} from "./GenerateEmpty"; | ||||
| import LayerPanelWithPreview from "./LayerPanelWithPreview"; | ||||
| import UserDetails from "../../Logic/Osm/OsmConnection"; | ||||
| import {MultiInput} from "../Input/MultiInput"; | ||||
| import TagRenderingPanel from "./TagRenderingPanel"; | ||||
| import SingleSetting from "./SingleSetting"; | ||||
| import {VariableUiElement} from "../Base/VariableUIElement"; | ||||
| import AvailableBaseLayers from "../../Logic/Actors/AvailableBaseLayers"; | ||||
| import {DropDown} from "../Input/DropDown"; | ||||
| import TagRenderingConfig from "../../Customizations/JSON/TagRenderingConfig"; | ||||
| import Svg from "../../Svg"; | ||||
| 
 | ||||
| export default class AllLayersPanel extends UIElement { | ||||
| 
 | ||||
| 
 | ||||
|     private panel: UIElement; | ||||
|     private readonly _config: UIEventSource<LayoutConfigJson>; | ||||
|     private readonly languages: UIEventSource<string[]>; | ||||
|     private readonly userDetails: UserDetails; | ||||
|     private readonly currentlySelected: UIEventSource<SingleSetting<any>>; | ||||
| 
 | ||||
|     constructor(config: UIEventSource<LayoutConfigJson>, | ||||
|                 languages: UIEventSource<any>, userDetails: UserDetails) { | ||||
|         super(undefined); | ||||
|         this.userDetails = userDetails; | ||||
|         this._config = config; | ||||
|         this.languages = languages; | ||||
| 
 | ||||
|         this.createPanels(userDetails); | ||||
|         const self = this; | ||||
|         this.dumbMode = false; | ||||
|         config.map<number>(config => config.layers.length).addCallback(() => self.createPanels(userDetails)); | ||||
| 
 | ||||
|     } | ||||
| 
 | ||||
| 
 | ||||
|     private createPanels(userDetails: UserDetails) { | ||||
|         const self = this; | ||||
|         const tabs = []; | ||||
| 
 | ||||
| 
 | ||||
|         const roamingTags = new MultiInput("Add a tagrendering", | ||||
|             () => GenerateEmpty.createEmptyTagRendering(), | ||||
|             () => { | ||||
|                 return new TagRenderingPanel(self.languages, self.currentlySelected, self.userDetails) | ||||
| 
 | ||||
|             }, undefined, {allowMovement: true}); | ||||
|         new SingleSetting(this._config, roamingTags, "roamingRenderings", "Roaming Renderings", "These tagrenderings are shown everywhere"); | ||||
| 
 | ||||
|          | ||||
|         const backgroundLayers = AvailableBaseLayers.layerOverview.map(baselayer => ({shown:  | ||||
|             baselayer.name, value: baselayer.id})); | ||||
|         const dropDown = new DropDown("Choose the default background layer", | ||||
|             [{value: "osm",shown:"OpenStreetMap <b>(default)</b>"}, ...backgroundLayers]) | ||||
|         new SingleSetting(self._config, dropDown, "defaultBackgroundId", "Default background layer",  | ||||
|             "Selects the background layer that is used by default. If this layer is not available at the given point, OSM-Carto will be ued"); | ||||
| 
 | ||||
|         const layers = this._config.data.layers; | ||||
|         for (let i = 0; i < layers.length; i++) { | ||||
|             tabs.push({ | ||||
|                 header: new VariableUiElement(this._config.map((config: LayoutConfigJson) => { | ||||
|                     const layer = config.layers[i]; | ||||
|                     if (typeof layer !== "string") { | ||||
|                         try { | ||||
|                             const iconTagRendering = new TagRenderingConfig(layer["icon"], undefined, "icon") | ||||
|                             const icon = iconTagRendering.GetRenderValue({"id": "node/-1"}).txt; | ||||
|                             return `<img src='${icon}'>` | ||||
|                         } catch (e) { | ||||
|                             return Svg.bug_img | ||||
|                             // Nothing to do here
 | ||||
|                         } | ||||
|                     } | ||||
|                     return Svg.help_img; | ||||
|                 })), | ||||
|                 content: new LayerPanelWithPreview(this._config, this.languages, i, userDetails) | ||||
|             }); | ||||
|         } | ||||
|         tabs.push({ | ||||
|             header: Svg.layersAdd_img, | ||||
|             content: new Combine([ | ||||
|                     "<h2>Layer editor</h2>", | ||||
|                     "In this tab page, you can add and edit the layers of the theme. Click the layers above or add a new layer to get started.", | ||||
|                     new SubtleButton( | ||||
|                         Svg.layersAdd_ui(), | ||||
|                         "Add a new layer" | ||||
|                     ).onClick(() => { | ||||
|                         self._config.data.layers.push(GenerateEmpty.createEmptyLayer()) | ||||
|                         self._config.ping(); | ||||
|                     }), | ||||
|                     "<h2>Default background layer</h2>", | ||||
|                     dropDown, | ||||
|                     "<h2>TagRenderings for every layer</h2>", | ||||
|                     "Define tag renderings and questions here that should be shown on every layer of the theme.", | ||||
|                     roamingTags | ||||
|                 ] | ||||
|             ), | ||||
|         }) | ||||
| 
 | ||||
|         this.panel = new TabbedComponent(tabs, new UIEventSource<number>(Math.max(0, layers.length - 1))); | ||||
|         this.Update(); | ||||
|     } | ||||
| 
 | ||||
|     InnerRender(): string { | ||||
|         return this.panel.Render(); | ||||
|     } | ||||
| 
 | ||||
| } | ||||
|  | @ -1,118 +0,0 @@ | |||
| import {UIElement} from "../UIElement"; | ||||
| import UserDetails, {OsmConnection} from "../../Logic/Osm/OsmConnection"; | ||||
| import {UIEventSource} from "../../Logic/UIEventSource"; | ||||
| import SingleSetting from "./SingleSetting"; | ||||
| import GeneralSettings from "./GeneralSettings"; | ||||
| import Combine from "../Base/Combine"; | ||||
| import {VariableUiElement} from "../Base/VariableUIElement"; | ||||
| import {TabbedComponent} from "../Base/TabbedComponent"; | ||||
| import PageSplit from "../Base/PageSplit"; | ||||
| import AllLayersPanel from "./AllLayersPanel"; | ||||
| import SharePanel from "./SharePanel"; | ||||
| import {LayoutConfigJson} from "../../Customizations/JSON/LayoutConfigJson"; | ||||
| import {SubtleButton} from "../Base/SubtleButton"; | ||||
| import {FixedUiElement} from "../Base/FixedUiElement"; | ||||
| import SavePanel from "./SavePanel"; | ||||
| import {LocalStorageSource} from "../../Logic/Web/LocalStorageSource"; | ||||
| import HelpText from "./HelpText"; | ||||
| import Svg from "../../Svg"; | ||||
| import Constants from "../../Models/Constants"; | ||||
| import LZString from "lz-string"; | ||||
| import {Utils} from "../../Utils"; | ||||
| 
 | ||||
| export default class CustomGeneratorPanel extends UIElement { | ||||
|     private mainPanel: UIElement; | ||||
|     private loginButton: UIElement; | ||||
| 
 | ||||
|     private readonly connection: OsmConnection; | ||||
| 
 | ||||
|     constructor(connection: OsmConnection, layout: LayoutConfigJson) { | ||||
|         super(connection.userDetails); | ||||
|         this.connection = connection; | ||||
|         this.SetClass("main-tabs"); | ||||
|         this.loginButton = new SubtleButton("", "Login to create a custom theme").onClick(() => connection.AttemptLogin()) | ||||
|         const self = this; | ||||
|         self.mainPanel = new FixedUiElement("Attempting to log in..."); | ||||
|         connection.OnLoggedIn(userDetails => { | ||||
|             self.InitMainPanel(layout, userDetails, connection); | ||||
|             self.Update(); | ||||
|         }) | ||||
|     } | ||||
| 
 | ||||
|     private InitMainPanel(layout: LayoutConfigJson, userDetails: UserDetails, connection: OsmConnection) { | ||||
|         const es = new UIEventSource(layout); | ||||
|         const encoded = es.map(config => LZString.compressToBase64(Utils.MinifyJSON(JSON.stringify(config, null, 0)))); | ||||
|         encoded.addCallback(encoded => LocalStorageSource.Get("last-custom-theme")) | ||||
|         const liveUrl = encoded.map(encoded => `./index.html?userlayout=${es.data.id}#${encoded}`) | ||||
|         const testUrl = encoded.map(encoded => `./index.html?test=true&userlayout=${es.data.id}#${encoded}`) | ||||
|         const iframe = testUrl.map(url => `<iframe src='${url}' width='100%' height='99%' style="box-sizing: border-box" title='Theme Preview'></iframe>`); | ||||
|         const currentSetting = new UIEventSource<SingleSetting<any>>(undefined) | ||||
|         const generalSettings = new GeneralSettings(es, currentSetting); | ||||
|         const languages = generalSettings.languages; | ||||
| 
 | ||||
|         const chronic = UIEventSource.Chronic(120 * 1000) | ||||
|             .map(date => { | ||||
|                 if (es.data.id == undefined) { | ||||
|                     return undefined | ||||
|                 } | ||||
|                 if (es.data.id === "") { | ||||
|                     return undefined; | ||||
|                 } | ||||
|                 const pref = connection.GetLongPreference("installed-theme-" + es.data.id); | ||||
|                 pref.setData(encoded.data); | ||||
|                 return date; | ||||
|             }); | ||||
| 
 | ||||
|         const preview = new Combine([ | ||||
|             new VariableUiElement(iframe) | ||||
|         ]).SetClass("preview") | ||||
|         this.mainPanel = new TabbedComponent([ | ||||
|             { | ||||
|                 header: Svg.gear_img, | ||||
|                 content: | ||||
|                     new PageSplit( | ||||
|                         generalSettings.SetStyle("width: 50vw;"), | ||||
|                         new Combine([ | ||||
|                             new HelpText(currentSetting).SetStyle("height:calc(100% - 65vh); width: 100%; display:block; overflow-y: auto"), | ||||
|                             preview.SetStyle("height:65vh; width:100%; display:block") | ||||
|                         ]).SetStyle("position:relative; width: 50%;") | ||||
|                     ) | ||||
|             }, | ||||
|             { | ||||
|                 header: Svg.layers_img, | ||||
|                 content: new AllLayersPanel(es, languages, userDetails) | ||||
|             }, | ||||
|             { | ||||
|                 header: Svg.floppy_img, | ||||
|                 content: new SavePanel(this.connection, es, chronic) | ||||
| 
 | ||||
|             }, | ||||
|             { | ||||
|                 header:Svg.share_img, | ||||
|                 content: new SharePanel(es, liveUrl, userDetails) | ||||
|             } | ||||
|         ]) | ||||
|     } | ||||
| 
 | ||||
| 
 | ||||
|     InnerRender(): string { | ||||
|         const ud = this.connection.userDetails.data; | ||||
|         if (!ud.loggedIn) { | ||||
|             return new Combine([ | ||||
|                 "<h3>Not Logged in</h3>", | ||||
|                 "You need to be logged in in order to create a custom theme", | ||||
|                 this.loginButton | ||||
|             ]).Render(); | ||||
|         } | ||||
|         const journey = Constants.userJourney; | ||||
|         if (ud.csCount <= journey.themeGeneratorReadOnlyUnlock) { | ||||
|             return new Combine([ | ||||
|                 "<h3>Too little experience</h3>", | ||||
|                 `<p>Creating your own (readonly) themes can only be done if you have more then <b>${journey.themeGeneratorReadOnlyUnlock}</b> changesets made</p>`, | ||||
|                 `<p>Making a theme including survey options can be done at <b>${journey.themeGeneratorFullUnlock}</b> changesets</p>` | ||||
|             ]).Render(); | ||||
|         } | ||||
|         return this.mainPanel.Render() | ||||
|     } | ||||
| 
 | ||||
| } | ||||
|  | @ -1,88 +0,0 @@ | |||
| import {UIElement} from "../UIElement"; | ||||
| import {UIEventSource} from "../../Logic/UIEventSource"; | ||||
| import {LayoutConfigJson} from "../../Customizations/JSON/LayoutConfigJson"; | ||||
| import Combine from "../Base/Combine"; | ||||
| import SettingsTable from "./SettingsTable"; | ||||
| import SingleSetting from "./SingleSetting"; | ||||
| import {TextField} from "../Input/TextField"; | ||||
| import MultiLingualTextFields from "../Input/MultiLingualTextFields"; | ||||
| import ValidatedTextField from "../Input/ValidatedTextField"; | ||||
| 
 | ||||
| 
 | ||||
| export default class GeneralSettingsPanel extends UIElement { | ||||
|     private panel: Combine; | ||||
|      | ||||
|     public languages : UIEventSource<string[]>; | ||||
| 
 | ||||
|     constructor(configuration: UIEventSource<LayoutConfigJson>, currentSetting: UIEventSource<SingleSetting<any>>) { | ||||
|         super(undefined); | ||||
| 
 | ||||
| 
 | ||||
|         const languagesField = | ||||
|             ValidatedTextField.Mapped( | ||||
|                 str => { | ||||
|                     console.log("Language from str", str); | ||||
|                     return str?.split(";")?.map(str => str.trim().toLowerCase()); | ||||
|                 }, | ||||
|                 languages => languages.join(";")); | ||||
|         this.languages = languagesField.GetValue(); | ||||
| 
 | ||||
|         const version = new TextField(); | ||||
|         const current_datetime = new Date(); | ||||
|         let formatted_date = current_datetime.getFullYear() + "-" + (current_datetime.getMonth() + 1) + "-" + current_datetime.getDate() + " " + current_datetime.getHours() + ":" + current_datetime.getMinutes() + ":" + current_datetime.getSeconds() | ||||
|         version.GetValue().setData(formatted_date); | ||||
|          | ||||
|          | ||||
|         const locationRemark = "<br/>Note that, as soon as an URL-parameter sets the location or a location is known due to a previous visit, that the theme-set location is ignored" | ||||
| 
 | ||||
|         const settingsTable = new SettingsTable( | ||||
|             [ | ||||
|                 new SingleSetting(configuration, new TextField({placeholder:"id"}), "id", | ||||
|                     "Identifier", "The identifier of this theme. This should be a lowercase, unique string"), | ||||
|                 new SingleSetting(configuration, version, "version", "Version", | ||||
|                     "A version to indicate the theme version. Ideal is the date you created or updated the theme"), | ||||
|                 new SingleSetting(configuration, languagesField, "language", | ||||
|                     "Supported languages", "Which languages do you want to support in this theme? Type the two letter code representing your language, seperated by <span class='literal-code'>;</span>. For example:<span class='literal-code'>en;nl</span> "), | ||||
|                 new SingleSetting(configuration, new MultiLingualTextFields(this.languages), "title", | ||||
|                     "Title", "The title as shown in the welcome message, in the browser title bar, in the more screen, ..."), | ||||
|                new SingleSetting(configuration, new MultiLingualTextFields(this.languages), "shortDescription","Short description", | ||||
|                    "The short description is shown as subtext in the social preview and on the 'more screen'-buttons. It should be at most one sentence of around ~25words"), | ||||
|                 new SingleSetting(configuration, new MultiLingualTextFields(this.languages, true), | ||||
|                     "description", "Description", "The description is shown in the welcome-message when opening MapComplete. It is a small text welcoming users"), | ||||
|                 new SingleSetting(configuration, new TextField({placeholder: "URL to icon"}), "icon", | ||||
|                     "Icon", "A visual representation for your theme; used as logo in the welcomeMessage. If your theme is official, used as favicon and webapp logo", | ||||
|                     { | ||||
|                         showIconPreview: true | ||||
|                     }), | ||||
|                  | ||||
|                 new SingleSetting(configuration, ValidatedTextField.NumberInput("nat", n => n < 23), "startZoom","Initial zoom level", | ||||
|                     "When a user first loads MapComplete, this zoomlevel is shown."+locationRemark), | ||||
|                 new SingleSetting(configuration, ValidatedTextField.NumberInput("float", n => (n < 90 && n > -90)), "startLat","Initial latitude", | ||||
|                     "When a user first loads MapComplete, this latitude is shown as location."+locationRemark), | ||||
|                 new SingleSetting(configuration, ValidatedTextField.NumberInput("float", n => (n < 180 && n > -180)), "startLon","Initial longitude", | ||||
|                     "When a user first loads MapComplete, this longitude is shown as location."+locationRemark), | ||||
|              | ||||
|                 new SingleSetting(configuration, ValidatedTextField.NumberInput("pfloat", n => (n < 0.5 )), "widenFactor","Query widening", | ||||
|                     "When a query is run, the data within bounds of the visible map is loaded.\n" + | ||||
|                     "However, users tend to pan and zoom a lot. It is pretty annoying if every single pan means a reloading of the data.\n" + | ||||
|                     "For this, the bounds are widened in order to make a small pan still within bounds of the loaded data.\n" + | ||||
|                     "IF widenfactor is 0, this feature is disabled. A recommended value is between 0.5 and 0.01 (the latter for very dense queries)"), | ||||
| 
 | ||||
|                 new SingleSetting(configuration, new TextField({placeholder: "URL to social image"}), "socialImage", | ||||
|                 "og:image (aka Social Image)", "<span class='alert'>Only works on incorporated themes</span>" + | ||||
|                     "The Social Image is set as og:image for the HTML-site and helps social networks to show a preview", {showIconPreview: true}) | ||||
|             ], currentSetting); | ||||
| 
 | ||||
|         this.panel = new Combine([ | ||||
|             "<h3>General theme settings</h3>", | ||||
|             settingsTable | ||||
|         ]); | ||||
|     } | ||||
| 
 | ||||
| 
 | ||||
|     InnerRender(): string { | ||||
|         return this.panel.Render(); | ||||
|     } | ||||
| 
 | ||||
| 
 | ||||
| } | ||||
|  | @ -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 {}; | ||||
|     } | ||||
| } | ||||
|  | @ -1,51 +0,0 @@ | |||
| import {UIEventSource} from "../../Logic/UIEventSource"; | ||||
| import {UIElement} from "../UIElement"; | ||||
| import {VariableUiElement} from "../Base/VariableUIElement"; | ||||
| import {SubtleButton} from "../Base/SubtleButton"; | ||||
| import Combine from "../Base/Combine"; | ||||
| import SingleSetting from "./SingleSetting"; | ||||
| import Svg from "../../Svg"; | ||||
| 
 | ||||
| export default class HelpText extends UIElement { | ||||
| 
 | ||||
|     private helpText: UIElement; | ||||
|     private returnButton: UIElement; | ||||
| 
 | ||||
|     constructor(currentSetting: UIEventSource<SingleSetting<any>>) { | ||||
|         super(); | ||||
|         this.returnButton = new SubtleButton(Svg.close_ui(), | ||||
|             new VariableUiElement( | ||||
|                 currentSetting.map(currentSetting => { | ||||
|                         if (currentSetting === undefined) { | ||||
|                             return ""; | ||||
|                         } | ||||
|                         return "Return to general help"; | ||||
|                     } | ||||
|                 ) | ||||
|             )) | ||||
|             .ListenTo(currentSetting) | ||||
|             .SetClass("small-button") | ||||
|             .onClick(() => currentSetting.setData(undefined)); | ||||
| 
 | ||||
| 
 | ||||
|         this.helpText = new VariableUiElement(currentSetting.map((setting: SingleSetting<any>) => { | ||||
|             if (setting === undefined) { | ||||
|                 return "<h1>Welcome to the Custom Theme Builder</h1>" + | ||||
|                     "Here, one can make their own custom mapcomplete themes.<br/>" + | ||||
|                     "Fill out the fields to get a working mapcomplete theme. More information on the selected field will appear here when you click it.<br/>" + | ||||
|                     "Want to see how the quests are doing in number of visits? All the stats are open on <a href='https://pietervdvn.goatcounter.com' target='_blank'>goatcounter</a>"; | ||||
|             } | ||||
| 
 | ||||
|             return new Combine(["<h1>", setting._name, "</h1>", setting._description.Render()]).Render(); | ||||
|         })) | ||||
| 
 | ||||
| 
 | ||||
|     } | ||||
| 
 | ||||
|     InnerRender(): string { | ||||
|         return new Combine([this.helpText, | ||||
|             this.returnButton, | ||||
|         ]).Render(); | ||||
|     } | ||||
| 
 | ||||
| } | ||||
|  | @ -1,251 +0,0 @@ | |||
| import {UIElement} from "../UIElement"; | ||||
| import {UIEventSource} from "../../Logic/UIEventSource"; | ||||
| import {LayoutConfigJson} from "../../Customizations/JSON/LayoutConfigJson"; | ||||
| import SettingsTable from "./SettingsTable"; | ||||
| import SingleSetting from "./SingleSetting"; | ||||
| import {SubtleButton} from "../Base/SubtleButton"; | ||||
| import Combine from "../Base/Combine"; | ||||
| import {TextField} from "../Input/TextField"; | ||||
| import {InputElement} from "../Input/InputElement"; | ||||
| import MultiLingualTextFields from "../Input/MultiLingualTextFields"; | ||||
| import CheckBox from "../Input/CheckBox"; | ||||
| import AndOrTagInput from "../Input/AndOrTagInput"; | ||||
| import TagRenderingPanel from "./TagRenderingPanel"; | ||||
| import {DropDown} from "../Input/DropDown"; | ||||
| import {TagRenderingConfigJson} from "../../Customizations/JSON/TagRenderingConfigJson"; | ||||
| import {MultiInput} from "../Input/MultiInput"; | ||||
| import {LayerConfigJson} from "../../Customizations/JSON/LayerConfigJson"; | ||||
| import PresetInputPanel from "./PresetInputPanel"; | ||||
| import UserDetails from "../../Logic/Osm/OsmConnection"; | ||||
| import {FixedUiElement} from "../Base/FixedUiElement"; | ||||
| import ValidatedTextField from "../Input/ValidatedTextField"; | ||||
| import Svg from "../../Svg"; | ||||
| import Constants from "../../Models/Constants"; | ||||
| 
 | ||||
| /** | ||||
|  * Shows the configuration for a single layer | ||||
|  */ | ||||
| export default class LayerPanel extends UIElement { | ||||
|     private readonly _config: UIEventSource<LayoutConfigJson>; | ||||
| 
 | ||||
|     private readonly settingsTable: UIElement; | ||||
|     private readonly mapRendering: UIElement; | ||||
| 
 | ||||
|     private readonly deleteButton: UIElement; | ||||
| 
 | ||||
|     public readonly titleRendering: UIElement; | ||||
| 
 | ||||
|     public readonly selectedTagRendering: UIEventSource<TagRenderingPanel> | ||||
|         = new UIEventSource<TagRenderingPanel>(undefined); | ||||
|     private tagRenderings: UIElement; | ||||
|     private presetsPanel: UIElement; | ||||
| 
 | ||||
|     constructor(config: UIEventSource<LayoutConfigJson>, | ||||
|                 languages: UIEventSource<string[]>, | ||||
|                 index: number, | ||||
|                 currentlySelected: UIEventSource<SingleSetting<any>>, | ||||
|                 userDetails: UserDetails) { | ||||
|         super(); | ||||
|         this._config = config; | ||||
|         this.mapRendering = this.setupRenderOptions(config, languages, index, currentlySelected, userDetails); | ||||
| 
 | ||||
|         const actualDeleteButton = new SubtleButton( | ||||
|             Svg.delete_icon_ui(), | ||||
|             "Yes, delete this layer" | ||||
|         ).onClick(() => { | ||||
|             config.data.layers.splice(index, 1); | ||||
|             config.ping(); | ||||
|         }); | ||||
| 
 | ||||
|         this.deleteButton = new CheckBox( | ||||
|             new Combine( | ||||
|                 [ | ||||
|                     "<h3>Confirm layer deletion</h3>", | ||||
|                     new SubtleButton( | ||||
|                         Svg.close_ui(), | ||||
|                         "No, don't delete" | ||||
|                     ), | ||||
|                     "<span class='alert'>Deleting a layer can not be undone!</span>", | ||||
|                     actualDeleteButton | ||||
|                 ] | ||||
|             ), | ||||
|             new SubtleButton( | ||||
|                 Svg.delete_icon_ui(), | ||||
|                 "Remove this layer" | ||||
|             ) | ||||
|         ) | ||||
| 
 | ||||
|         function setting(input: InputElement<any>, path: string | string[], name: string, description: string | UIElement): SingleSetting<any> { | ||||
|             let pathPre = ["layers", index]; | ||||
|             if (typeof (path) === "string") { | ||||
|                 pathPre.push(path); | ||||
|             } else { | ||||
|                 pathPre = pathPre.concat(path); | ||||
|             } | ||||
| 
 | ||||
|             return new SingleSetting<any>(config, input, pathPre, name, description); | ||||
|         } | ||||
| 
 | ||||
| 
 | ||||
|         this.settingsTable = new SettingsTable([ | ||||
|                 setting(new TextField({placeholder:"Layer id"}), "id", "Id", "An identifier for this layer<br/>This should be a simple, lowercase, human readable string that is used to identify the layer."), | ||||
|                 setting(new MultiLingualTextFields(languages), "name", "Name", "The human-readable name of this layer<br/>Used in the layer control panel and the 'Personal theme'"), | ||||
|                 setting(new MultiLingualTextFields(languages, true), "description", "Description", "A description for this layer.<br/>Shown in the layer selections and in the personal theme"), | ||||
|                 setting(ValidatedTextField.NumberInput("nat", n => n < 23), "minzoom", "Minimal zoom", | ||||
|                     "The minimum zoomlevel needed to load and show this layer."), | ||||
|                 setting(new DropDown("", [ | ||||
|                         {value: 0, shown: "Show ways and areas as ways and lines"}, | ||||
|                         {value: 2, shown: "Show both the ways/areas and the centerpoints"}, | ||||
|                         {value: 1, shown: "Show everything as centerpoint"}]), "wayHandling", "Way handling", | ||||
|                     "Describes how ways and areas are represented on the map: areas can be represented as the area itself, or it can be converted into the centerpoint"), | ||||
|                 setting(new AndOrTagInput(), ["overpassTags"], "Overpass query", | ||||
|                     "The tags of the objects to load from overpass"), | ||||
| 
 | ||||
|             ], | ||||
|             currentlySelected); | ||||
|         const self = this; | ||||
| 
 | ||||
|         const popupTitleRendering = new TagRenderingPanel(languages, currentlySelected, userDetails, { | ||||
|             title: "Popup title", | ||||
|             description: "This is the rendering shown as title in the popup for this element", | ||||
|             disableQuestions: true | ||||
|         }); | ||||
| 
 | ||||
|         new SingleSetting(config, popupTitleRendering, ["layers", index, "title"], "Popup title", "This is the rendering shown as title in the popup"); | ||||
|         this.titleRendering = popupTitleRendering; | ||||
|         this.registerTagRendering(popupTitleRendering); | ||||
| 
 | ||||
| 
 | ||||
|         const renderings = config.map(config => { | ||||
|             const layer = config.layers[index] as LayerConfigJson; | ||||
|             // @ts-ignore
 | ||||
|             const renderings : TagRenderingConfigJson[] = layer.tagRenderings ; | ||||
|             return renderings; | ||||
|         }); | ||||
|         const tagRenderings = new MultiInput<TagRenderingConfigJson>("Add a tag rendering/question", | ||||
|             () => ({}), | ||||
|             () => { | ||||
|                 const tagPanel = new TagRenderingPanel(languages, currentlySelected, userDetails) | ||||
|                 self.registerTagRendering(tagPanel); | ||||
|                 return tagPanel; | ||||
|             }, renderings, | ||||
|             {allowMovement: true}); | ||||
| 
 | ||||
|         tagRenderings.GetValue().addCallback( | ||||
|             tagRenderings => { | ||||
|                 (config.data.layers[index] as LayerConfigJson).tagRenderings = tagRenderings; | ||||
|                 config.ping(); | ||||
|             } | ||||
|         ) | ||||
| 
 | ||||
|         if (userDetails.csCount >= Constants.userJourney.themeGeneratorFullUnlock) { | ||||
| 
 | ||||
|             const presetPanel = new MultiInput("Add a preset", | ||||
|                 () => ({tags: [], title: {}}), | ||||
|                 () => new PresetInputPanel(currentlySelected, languages), | ||||
|                 undefined, {allowMovement: true}); | ||||
|             new SingleSetting(config, presetPanel, ["layers", index, "presets"], "Presets", "") | ||||
|             this.presetsPanel = presetPanel; | ||||
|         } else { | ||||
|             this.presetsPanel = new FixedUiElement(`Creating a custom theme which also edits OSM is only unlocked after ${Constants.userJourney.themeGeneratorFullUnlock} changesets`).SetClass("alert"); | ||||
|         } | ||||
| 
 | ||||
|         function loadTagRenderings() { | ||||
|             const values = (config.data.layers[index] as LayerConfigJson).tagRenderings; | ||||
|             const renderings: TagRenderingConfigJson[] = []; | ||||
|             for (const value of values) { | ||||
|                 if (typeof (value) !== "string") { | ||||
|                     renderings.push(value); | ||||
|                 } | ||||
| 
 | ||||
|             } | ||||
|             tagRenderings.GetValue().setData(renderings); | ||||
|         } | ||||
| 
 | ||||
|         loadTagRenderings(); | ||||
| 
 | ||||
|         this.tagRenderings = tagRenderings; | ||||
| 
 | ||||
| 
 | ||||
|     } | ||||
| 
 | ||||
|     private setupRenderOptions(config: UIEventSource<LayoutConfigJson>, | ||||
|                                languages: UIEventSource<string[]>, | ||||
|                                index: number, | ||||
|                                currentlySelected: UIEventSource<SingleSetting<any>>, | ||||
|                                userDetails: UserDetails | ||||
|     ): UIElement { | ||||
|         const iconSelect = new TagRenderingPanel( | ||||
|             languages, currentlySelected, userDetails, | ||||
|             { | ||||
|                 title: "Icon", | ||||
|                 description: "A visual representation for this layer and for the points on the map.", | ||||
|                 disableQuestions: true, | ||||
|                 noLanguage: true | ||||
|             }); | ||||
|         const size = new TagRenderingPanel(languages, currentlySelected, userDetails, | ||||
|             { | ||||
|                 title: "Icon Size", | ||||
|                 description: "The size of the icons on the map in pixels. Can vary based on the tagging", | ||||
|                 disableQuestions: true, | ||||
|                 noLanguage: true | ||||
|             }); | ||||
|         const color = new TagRenderingPanel(languages, currentlySelected, userDetails, | ||||
|             { | ||||
|                 title: "Way and area color", | ||||
|                 description: "The color or a shown way or area. Can vary based on the tagging", | ||||
|                 disableQuestions: true, | ||||
|                 noLanguage: true | ||||
|             }); | ||||
|         const stroke = new TagRenderingPanel(languages, currentlySelected, userDetails, | ||||
|             { | ||||
|                 title: "Stroke width", | ||||
|                 description: "The width of lines representing ways and the outline of areas. Can vary based on the tags", | ||||
|                 disableQuestions: true, | ||||
|                 noLanguage: true | ||||
|             }); | ||||
|         this.registerTagRendering(iconSelect); | ||||
|         this.registerTagRendering(size); | ||||
|         this.registerTagRendering(color); | ||||
|         this.registerTagRendering(stroke); | ||||
| 
 | ||||
|         function setting(input: InputElement<any>, path, isIcon: boolean = false): SingleSetting<TagRenderingConfigJson> { | ||||
|             return new SingleSetting(config, input, ["layers", index, path], undefined, undefined) | ||||
|         } | ||||
| 
 | ||||
|         return new SettingsTable([ | ||||
|             setting(iconSelect, "icon"), | ||||
|             setting(size, "iconSize"), | ||||
|             setting(color, "color"), | ||||
|             setting(stroke, "width") | ||||
|         ], currentlySelected); | ||||
|     } | ||||
| 
 | ||||
|     private registerTagRendering( | ||||
|         tagRenderingPanel: TagRenderingPanel) { | ||||
| 
 | ||||
|         tagRenderingPanel.IsHovered().addCallback(isHovering => { | ||||
|             if (!isHovering) { | ||||
|                 return; | ||||
|             } | ||||
|             this.selectedTagRendering.setData(tagRenderingPanel); | ||||
|         }) | ||||
|     } | ||||
| 
 | ||||
|     InnerRender(): string { | ||||
|         return new Combine([ | ||||
|             "<h2>General layer settings</h2>", | ||||
|             this.settingsTable, | ||||
|             "<h2>Popup contents</h2>", | ||||
|             this.titleRendering, | ||||
|             this.tagRenderings, | ||||
|             "<h2>Presets</h2>", | ||||
|             "Does this theme support adding a new point?<br/>If this should be the case, add a preset. Make sure that the preset tags do match the overpass-tags, otherwise it might seem like the newly added points dissapear ", | ||||
|             this.presetsPanel, | ||||
|             "<h2>Map rendering options</h2>", | ||||
|             this.mapRendering, | ||||
|             "<h2>Layer delete</h2>", | ||||
|             this.deleteButton | ||||
|         ]).Render(); | ||||
|     } | ||||
| } | ||||
|  | @ -1,58 +0,0 @@ | |||
| import {UIElement} from "../UIElement"; | ||||
| import {UIEventSource} from "../../Logic/UIEventSource"; | ||||
| import SingleSetting from "./SingleSetting"; | ||||
| import LayerPanel from "./LayerPanel"; | ||||
| import HelpText from "./HelpText"; | ||||
| import {MultiTagInput} from "../Input/MultiTagInput"; | ||||
| import {FromJSON} from "../../Customizations/JSON/FromJSON"; | ||||
| import Combine from "../Base/Combine"; | ||||
| import PageSplit from "../Base/PageSplit"; | ||||
| import TagRenderingPreview from "./TagRenderingPreview"; | ||||
| import UserDetails from "../../Logic/Osm/OsmConnection"; | ||||
| 
 | ||||
| 
 | ||||
| export default class LayerPanelWithPreview extends UIElement{ | ||||
|     private panel: UIElement; | ||||
|     constructor(config: UIEventSource<any>, languages: UIEventSource<string[]>, index: number, userDetails: UserDetails) { | ||||
|         super(); | ||||
| 
 | ||||
|         const currentlySelected = new UIEventSource<(SingleSetting<any>)>(undefined); | ||||
|         const layer = new LayerPanel(config, languages, index, currentlySelected, userDetails); | ||||
|         const helpText = new HelpText(currentlySelected); | ||||
| 
 | ||||
|         const previewTagInput = new MultiTagInput(); | ||||
|         previewTagInput.GetValue().setData(["id=123456"]); | ||||
|          | ||||
|         const previewTagValue = previewTagInput.GetValue().map(tags => { | ||||
|             const properties = {}; | ||||
|             for (const str of tags) { | ||||
|                 const tag = FromJSON.SimpleTag(str); | ||||
|                 if (tag !== undefined) { | ||||
|                     properties[tag.key] = tag.value; | ||||
|                 } | ||||
|             } | ||||
|             return properties; | ||||
|         }); | ||||
| 
 | ||||
|         const preview = new TagRenderingPreview(layer.selectedTagRendering, previewTagValue); | ||||
| 
 | ||||
|         this.panel =   new PageSplit( | ||||
|             layer.SetClass("scrollable"), | ||||
|             new Combine([ | ||||
|                 helpText, | ||||
|                 "</br>", | ||||
|                 "<h2>Testing tags</h2>", | ||||
|                 previewTagInput, | ||||
|                 "<h2>Tag Rendering preview</h2>", | ||||
|                 preview | ||||
| 
 | ||||
|             ]), 60 | ||||
|         ); | ||||
| 
 | ||||
|     } | ||||
|      | ||||
|     InnerRender(): string { | ||||
|         return this.panel.Render(); | ||||
|     } | ||||
|      | ||||
| } | ||||
|  | @ -1,64 +0,0 @@ | |||
| import {InputElement} from "../Input/InputElement"; | ||||
| import {AndOrTagConfigJson} from "../../Customizations/JSON/TagConfigJson"; | ||||
| import {UIEventSource} from "../../Logic/UIEventSource"; | ||||
| import {UIElement} from "../UIElement"; | ||||
| import SettingsTable from "./SettingsTable"; | ||||
| import SingleSetting from "./SingleSetting"; | ||||
| import AndOrTagInput from "../Input/AndOrTagInput"; | ||||
| import MultiLingualTextFields from "../Input/MultiLingualTextFields"; | ||||
| import {DropDown} from "../Input/DropDown"; | ||||
| 
 | ||||
| export default class MappingInput extends InputElement<{ if: AndOrTagConfigJson, then: (string | any), hideInAnswer?: boolean }> { | ||||
| 
 | ||||
|     private readonly _value: UIEventSource<{ if: AndOrTagConfigJson; then: any; hideInAnswer?: boolean }>; | ||||
|     private readonly _panel: UIElement; | ||||
| 
 | ||||
|     constructor(languages: UIEventSource<any>, disableQuestions: boolean = false) { | ||||
|         super(); | ||||
|         const currentSelected = new UIEventSource<SingleSetting<any>>(undefined); | ||||
|         this._value = new UIEventSource<{ if: AndOrTagConfigJson, then: any, hideInAnswer?: boolean }>({ | ||||
|             if: undefined, | ||||
|             then: undefined | ||||
|         }); | ||||
|         const self = this; | ||||
| 
 | ||||
|         function setting(inputElement: InputElement<any>, path: string, name: string, description: string | UIElement) { | ||||
|             return new SingleSetting(self._value, inputElement, path, name, description); | ||||
|         } | ||||
| 
 | ||||
|         const withQuestions = [setting(new DropDown("", | ||||
|             [{value: false, shown: "Can be used as answer"}, {value: true, shown: "Not an answer option"}]), | ||||
|             "hideInAnswer", "Answer option", | ||||
|             "Sometimes, multiple tags for the same meaning are used (e.g. <span class='literal-code'>access=yes</span> and <span class='literal-code'>access=public</span>)." + | ||||
|             "Use this toggle to disable an anwer. Alternatively an implied/assumed rendering can be used. In order to do this:" + | ||||
|             "use a single tag in the 'if' with <i>no</i> value defined, e.g. <span class='literal-code'>indoor=</span>. The mapping will then be shown as default until explicitly changed" | ||||
|         )]; | ||||
|          | ||||
|         this._panel = new SettingsTable([ | ||||
|             setting(new AndOrTagInput(), "if", "If matches", "If this condition matches, the template <b>then</b> below will be used"), | ||||
|             setting(new MultiLingualTextFields(languages), | ||||
|                 "then", "Then show", "If the condition above matches, this template <b>then</b> below will be shown to the user."), | ||||
|             ...(disableQuestions ? [] : withQuestions) | ||||
| 
 | ||||
|         ], currentSelected).SetClass("bordered tag-mapping"); | ||||
| 
 | ||||
|     } | ||||
| 
 | ||||
| 
 | ||||
|     InnerRender(): string { | ||||
|         return this._panel.Render(); | ||||
|     } | ||||
| 
 | ||||
| 
 | ||||
|     GetValue(): UIEventSource<{ if: AndOrTagConfigJson; then: any; hideInAnswer?: boolean }> { | ||||
|         return this._value; | ||||
|     } | ||||
| 
 | ||||
| 
 | ||||
|     IsSelected: UIEventSource<boolean> = new UIEventSource<boolean>(false); | ||||
| 
 | ||||
|     IsValid(t: { if: AndOrTagConfigJson; then: any; hideInAnswer: boolean }): boolean { | ||||
|         return false; | ||||
|     } | ||||
| 
 | ||||
| } | ||||
|  | @ -1,58 +0,0 @@ | |||
| import {InputElement} from "../Input/InputElement"; | ||||
| import {UIEventSource} from "../../Logic/UIEventSource"; | ||||
| import {UIElement} from "../UIElement"; | ||||
| import {MultiTagInput} from "../Input/MultiTagInput"; | ||||
| import SettingsTable from "./SettingsTable"; | ||||
| import SingleSetting from "./SingleSetting"; | ||||
| import MultiLingualTextFields from "../Input/MultiLingualTextFields"; | ||||
| import Combine from "../Base/Combine"; | ||||
| 
 | ||||
| export default class PresetInputPanel extends InputElement<{ | ||||
|     title: string | any, | ||||
|     tags: string[], | ||||
|     description?: string | any | ||||
| }> { | ||||
|     private readonly _value: UIEventSource<{ | ||||
|         title: string | any, | ||||
|         tags: string[], | ||||
|         description?: string | any | ||||
|     }>; | ||||
|     private readonly panel: UIElement; | ||||
| 
 | ||||
| 
 | ||||
|     constructor(currentlySelected: UIEventSource<SingleSetting<any>>, languages: UIEventSource<string[]>) { | ||||
|         super(); | ||||
|         this._value = new UIEventSource({tags: [], title: {}}); | ||||
| 
 | ||||
| 
 | ||||
|         const self = this; | ||||
|         function s(input: InputElement<any>, path: string, name: string, description: string){ | ||||
|             return new SingleSetting(self._value, input, path, name, description) | ||||
|         } | ||||
|         this.panel = new SettingsTable([ | ||||
|             s(new MultiTagInput(), "tags","Preset tags","These tags will be applied on the newly created point"), | ||||
|             s(new MultiLingualTextFields(languages), "title","Preset title","This little text is shown in bold on the 'create new point'-button" ), | ||||
|             s(new MultiLingualTextFields(languages), "description","Description", "This text is shown in the button as description when creating a new point") | ||||
|         ], currentlySelected).SetStyle("display: block; border: 1px solid black; border-radius: 1em;padding: 1em;"); | ||||
|     } | ||||
| 
 | ||||
| 
 | ||||
|     InnerRender(): string { | ||||
|         return new Combine([this.panel]).Render(); | ||||
|     } | ||||
| 
 | ||||
|     GetValue(): UIEventSource<{ | ||||
|         title: string | any, | ||||
|         tags: string[], | ||||
|         description?: string | any | ||||
|     }> { | ||||
|         return this._value; | ||||
|     } | ||||
| 
 | ||||
|     IsSelected: UIEventSource<boolean> = new UIEventSource<boolean>(false); | ||||
| 
 | ||||
|     IsValid(t: any): boolean { | ||||
|         return false; | ||||
|     } | ||||
| 
 | ||||
| } | ||||
|  | @ -1,69 +0,0 @@ | |||
| import {UIElement} from "../UIElement"; | ||||
| import {VariableUiElement} from "../Base/VariableUIElement"; | ||||
| import {UIEventSource} from "../../Logic/UIEventSource"; | ||||
| import {LayoutConfigJson} from "../../Customizations/JSON/LayoutConfigJson"; | ||||
| import Combine from "../Base/Combine"; | ||||
| import {OsmConnection} from "../../Logic/Osm/OsmConnection"; | ||||
| import {FixedUiElement} from "../Base/FixedUiElement"; | ||||
| import {TextField} from "../Input/TextField"; | ||||
| import {SubtleButton} from "../Base/SubtleButton"; | ||||
| import Svg from "../../Svg"; | ||||
| 
 | ||||
| export default class SavePanel extends UIElement { | ||||
|     private json: UIElement; | ||||
|     private lastSaveEl: UIElement; | ||||
|     private loadFromJson: UIElement; | ||||
| 
 | ||||
|     constructor( | ||||
|         connection: OsmConnection, | ||||
|         config: UIEventSource<LayoutConfigJson>, | ||||
|         chronic: UIEventSource<Date>) { | ||||
|         super(); | ||||
| 
 | ||||
| 
 | ||||
|         this.lastSaveEl = new VariableUiElement(chronic | ||||
|             .map(date => { | ||||
|                 if (date === undefined) { | ||||
|                     return new FixedUiElement("Your theme will be saved automatically within two minutes... Click here to force saving").SetClass("alert").Render() | ||||
|                 } | ||||
|                 return "Your theme was last saved at " + date.toISOString() | ||||
|             })).onClick(() => chronic.setData(new Date())); | ||||
| 
 | ||||
|         const jsonStr = config.map(config => | ||||
|             JSON.stringify(config, null, 2)); | ||||
| 
 | ||||
| 
 | ||||
|        const jsonTextField = new TextField({ | ||||
|             placeholder: "JSON Config", | ||||
|             value: jsonStr, | ||||
|             textArea: true, | ||||
|             textAreaRows: 20 | ||||
|         }); | ||||
|         this.json = jsonTextField; | ||||
|         this.loadFromJson = new SubtleButton(Svg.reload_ui(), "<b>Load the JSON file below</b>") | ||||
|             .onClick(() => { | ||||
|                 try{ | ||||
|                     const json = jsonTextField.GetValue().data; | ||||
|                     const parsed : LayoutConfigJson = JSON.parse(json); | ||||
|                     config.setData(parsed); | ||||
|                 }catch(e){ | ||||
|                     alert("Invalid JSON: "+e) | ||||
|                 } | ||||
|             }); | ||||
|     } | ||||
| 
 | ||||
|     InnerRender(): string { | ||||
|         return new Combine([ | ||||
|             "<h3>Save your theme</h3>", | ||||
|             this.lastSaveEl, | ||||
|             "<h3>JSON configuration</h3>", | ||||
|             "The url hash is actually no more then a BASE64-encoding of the below JSON. This json contains the full configuration of the theme.<br/>" + | ||||
|             "This configuration is mainly useful for debugging", | ||||
|             "<br/>", | ||||
|             this.loadFromJson, | ||||
|             this.json | ||||
|         ]).SetClass("scrollable") | ||||
|             .Render(); | ||||
|     } | ||||
| 
 | ||||
| } | ||||
|  | @ -1,58 +0,0 @@ | |||
| import SingleSetting from "./SingleSetting"; | ||||
| import {UIElement} from "../UIElement"; | ||||
| import {FixedUiElement} from "../Base/FixedUiElement"; | ||||
| import {UIEventSource} from "../../Logic/UIEventSource"; | ||||
| import PageSplit from "../Base/PageSplit"; | ||||
| import Combine from "../Base/Combine"; | ||||
| 
 | ||||
| export default class SettingsTable extends UIElement { | ||||
| 
 | ||||
|     private _col1: UIElement[] = []; | ||||
|     private _col2: UIElement[] = []; | ||||
| 
 | ||||
|     public selectedSetting: UIEventSource<SingleSetting<any>>; | ||||
| 
 | ||||
|     constructor(elements: (SingleSetting<any> | string)[], | ||||
|                 currentSelectedSetting?: UIEventSource<SingleSetting<any>>) { | ||||
|         super(undefined); | ||||
|         const self = this; | ||||
|         this.selectedSetting = currentSelectedSetting ?? new UIEventSource<SingleSetting<any>>(undefined); | ||||
|         for (const element of elements) { | ||||
|             if(typeof element === "string"){ | ||||
|                 this._col1.push(new FixedUiElement(element)); | ||||
|                 this._col2.push(null); | ||||
|                 continue; | ||||
|             } | ||||
|              | ||||
|             let title: UIElement = element._name === undefined ? null : new FixedUiElement(element._name); | ||||
|             this._col1.push(title); | ||||
|             this._col2.push(element._value); | ||||
|             element._value.SetStyle("display:block"); | ||||
|             element._value.IsSelected.addCallback(isSelected => { | ||||
|                 if (isSelected) { | ||||
|                     self.selectedSetting.setData(element); | ||||
|                 } else if (self.selectedSetting.data === element) { | ||||
|                     self.selectedSetting.setData(undefined); | ||||
|                 } | ||||
|             }) | ||||
|         } | ||||
| 
 | ||||
|     } | ||||
| 
 | ||||
|     InnerRender(): string { | ||||
|         let elements = []; | ||||
| 
 | ||||
|         for (let i = 0; i < this._col1.length; i++) { | ||||
|             if(this._col1[i] !== null && this._col2[i] !== null){ | ||||
|                 elements.push(new PageSplit(this._col1[i], this._col2[i], 25)); | ||||
|             }else if(this._col1[i] !== null){ | ||||
|                 elements.push(this._col1[i]) | ||||
|             }else{ | ||||
|                 elements.push(this._col2[i]) | ||||
|             } | ||||
|         } | ||||
| 
 | ||||
|         return new Combine(elements).Render(); | ||||
|     } | ||||
|      | ||||
| } | ||||
|  | @ -1,34 +0,0 @@ | |||
| import {UIElement} from "../UIElement"; | ||||
| import {UIEventSource} from "../../Logic/UIEventSource"; | ||||
| import {LayoutConfigJson} from "../../Customizations/JSON/LayoutConfigJson"; | ||||
| import Combine from "../Base/Combine"; | ||||
| import {VariableUiElement} from "../Base/VariableUIElement"; | ||||
| import UserDetails from "../../Logic/Osm/OsmConnection"; | ||||
| 
 | ||||
| export default class SharePanel extends UIElement { | ||||
|     private _config: UIEventSource<LayoutConfigJson>; | ||||
| 
 | ||||
|     private _panel: UIElement; | ||||
| 
 | ||||
|     constructor(config: UIEventSource<LayoutConfigJson>, liveUrl: UIEventSource<string>, userDetails: UserDetails) { | ||||
|         super(undefined); | ||||
|         this._config = config; | ||||
| 
 | ||||
|         this._panel = new Combine([ | ||||
|             "<h2>Share</h2>", | ||||
|             "Share the following link with friends:<br/>", | ||||
|             new VariableUiElement(liveUrl.map(url => `<a href='${url}' target="_blank">${url}</a>`)), | ||||
|             "<h2>Publish on some website</h2>", | ||||
|              | ||||
|             "It is possible to load a JSON-file from the wide internet, but you'll need some (public CORS-enabled) server.", | ||||
|             `Put the raw json online, and use ${window.location.host}?userlayout=https://<your-url-here>.json`, | ||||
|             "Please note: it used to be possible to load from the wiki - this is not possible anymore due to technical reasons.", | ||||
|             "</div>" | ||||
|         ]); | ||||
|     } | ||||
| 
 | ||||
|     InnerRender(): string { | ||||
|         return this._panel.Render(); | ||||
|     } | ||||
| 
 | ||||
| } | ||||
|  | @ -1,89 +0,0 @@ | |||
| import {UIEventSource} from "../../Logic/UIEventSource"; | ||||
| import {InputElement} from "../Input/InputElement"; | ||||
| import {UIElement} from "../UIElement"; | ||||
| import Translations from "../i18n/Translations"; | ||||
| import Combine from "../Base/Combine"; | ||||
| import {VariableUiElement} from "../Base/VariableUIElement"; | ||||
| 
 | ||||
| export default class SingleSetting<T> { | ||||
|     public _value: InputElement<T>; | ||||
|     public _name: string; | ||||
|     public _description: UIElement; | ||||
|     public _options: { showIconPreview?: boolean }; | ||||
| 
 | ||||
|     constructor(config: UIEventSource<any>, | ||||
|                 value: InputElement<T>, | ||||
|                 path: string | (string | number)[], | ||||
|                 name: string, | ||||
|                 description: string | UIElement, | ||||
|                 options?: { | ||||
|                     showIconPreview?: boolean | ||||
|                 } | ||||
|     ) { | ||||
|         this._value = value; | ||||
|         this._name = name; | ||||
|         this._description = Translations.W(description); | ||||
| 
 | ||||
|         this._options = options ?? {}; | ||||
|         if (this._options.showIconPreview) { | ||||
|             this._description = new Combine([ | ||||
|                 this._description, | ||||
|                 "<h3>Icon preview</h3>", | ||||
|                 new VariableUiElement(this._value.GetValue().map(url => `<img src='${url}' class="image-large-preview">`)) | ||||
|             ]); | ||||
|         } | ||||
| 
 | ||||
|         if(typeof (path) === "string"){ | ||||
|             path = [path]; | ||||
|         } | ||||
|         const lastPart = path[path.length - 1]; | ||||
|         path.splice(path.length - 1, 1); | ||||
| 
 | ||||
|         function assignValue(value) { | ||||
|             if (value === undefined) { | ||||
|                 return; | ||||
|             } | ||||
|             // We have to rewalk every time as parts might be new
 | ||||
|             let configPart = config.data; | ||||
|             for (const pathPart of path) { | ||||
|                 let newConfigPart = configPart[pathPart]; | ||||
|                 if (newConfigPart === undefined) { | ||||
|                     if (typeof (pathPart) === "string") { | ||||
|                         configPart[pathPart] = {}; | ||||
|                     } else { | ||||
|                         configPart[pathPart] = []; | ||||
|                     } | ||||
|                     newConfigPart = configPart[pathPart]; | ||||
|                 } | ||||
|                 configPart = newConfigPart; | ||||
|             } | ||||
|             configPart[lastPart] = value; | ||||
|             config.ping(); | ||||
|         } | ||||
| 
 | ||||
|         function loadValue() { | ||||
|             let configPart = config.data; | ||||
|             for (const pathPart of path) { | ||||
|                 configPart = configPart[pathPart]; | ||||
|                 if (configPart === undefined) { | ||||
|                     return; | ||||
|                 } | ||||
|             } | ||||
|             const loadedValue = configPart[lastPart]; | ||||
|             if (loadedValue !== undefined) { | ||||
|                 value.GetValue().setData(loadedValue); | ||||
|             } | ||||
|         } | ||||
|         loadValue(); | ||||
|         config.addCallback(() => loadValue()); | ||||
| 
 | ||||
|         value.GetValue().addCallback(assignValue); | ||||
|         assignValue(this._value.GetValue().data); | ||||
|          | ||||
| 
 | ||||
|     } | ||||
|      | ||||
|      | ||||
| 
 | ||||
| 
 | ||||
| } | ||||
|  | @ -1,155 +0,0 @@ | |||
| import {UIElement} from "../UIElement"; | ||||
| import {UIEventSource} from "../../Logic/UIEventSource"; | ||||
| import {InputElement} from "../Input/InputElement"; | ||||
| import SingleSetting from "./SingleSetting"; | ||||
| import SettingsTable from "./SettingsTable"; | ||||
| import {TextField} from "../Input/TextField"; | ||||
| import Combine from "../Base/Combine"; | ||||
| import MultiLingualTextFields from "../Input/MultiLingualTextFields"; | ||||
| import AndOrTagInput from "../Input/AndOrTagInput"; | ||||
| import {MultiTagInput} from "../Input/MultiTagInput"; | ||||
| import {MultiInput} from "../Input/MultiInput"; | ||||
| import MappingInput from "./MappingInput"; | ||||
| import {AndOrTagConfigJson} from "../../Customizations/JSON/TagConfigJson"; | ||||
| import {TagRenderingConfigJson} from "../../Customizations/JSON/TagRenderingConfigJson"; | ||||
| import UserDetails from "../../Logic/Osm/OsmConnection"; | ||||
| import {VariableUiElement} from "../Base/VariableUIElement"; | ||||
| import ValidatedTextField from "../Input/ValidatedTextField"; | ||||
| import SpecialVisualizations from "../SpecialVisualizations"; | ||||
| import TagRenderingConfig from "../../Customizations/JSON/TagRenderingConfig"; | ||||
| import Constants from "../../Models/Constants"; | ||||
| 
 | ||||
| export default class TagRenderingPanel extends InputElement<TagRenderingConfigJson> { | ||||
| 
 | ||||
|     public IsImage = false; | ||||
|     public options: { title?: string; description?: string; disableQuestions?: boolean; isImage?: boolean; }; | ||||
|     public readonly validText: UIElement; | ||||
|     IsSelected: UIEventSource<boolean> = new UIEventSource<boolean>(false); | ||||
|     private intro: UIElement; | ||||
|     private settingsTable: UIElement; | ||||
|     private readonly _value: UIEventSource<TagRenderingConfigJson>; | ||||
| 
 | ||||
|     constructor(languages: UIEventSource<string[]>, | ||||
|                 currentlySelected: UIEventSource<SingleSetting<any>>, | ||||
|                 userDetails: UserDetails, | ||||
|                 options?: { | ||||
|                     title?: string, | ||||
|                     description?: string, | ||||
|                     disableQuestions?: boolean, | ||||
|                     isImage?: boolean, | ||||
|                     noLanguage?: boolean | ||||
|                 }) { | ||||
|         super(); | ||||
| 
 | ||||
|         this.SetClass("bordered"); | ||||
|         this.SetClass("min-height"); | ||||
| 
 | ||||
|         this.options = options ?? {}; | ||||
|         const questionsNotUnlocked = userDetails.csCount < Constants.userJourney.themeGeneratorFullUnlock; | ||||
|         this.options.disableQuestions = | ||||
|             (this.options.disableQuestions ?? false) || | ||||
|             questionsNotUnlocked; | ||||
| 
 | ||||
|         this.intro = new Combine(["<h3>", options?.title ?? "TagRendering", "</h3>", | ||||
|             options?.description ?? "A tagrendering converts OSM-tags into a value on screen. Fill out the field 'render' with the text that should appear. Note that `{key}` will be replaced with the corresponding `value`, if present.<br/>For specific known tags (e.g. if `foo=bar`, make a mapping).  "]) | ||||
|         this.IsImage = options?.isImage ?? false; | ||||
| 
 | ||||
|         const value = new UIEventSource<TagRenderingConfigJson>({}); | ||||
|         this._value = value; | ||||
| 
 | ||||
|         function setting(input: InputElement<any>, id: string | string[], name: string, description: string | UIElement): SingleSetting<any> { | ||||
|             return new SingleSetting<any>(value, input, id, name, description); | ||||
|         } | ||||
| 
 | ||||
|         this._value.addCallback(value => { | ||||
|             let doPing = false; | ||||
|             if (value?.freeform?.key == "") { | ||||
|                 value.freeform = undefined; | ||||
|                 doPing = true; | ||||
|             } | ||||
| 
 | ||||
|             if (value?.render == "") { | ||||
|                 value.render = undefined; | ||||
|                 doPing = true; | ||||
|             } | ||||
| 
 | ||||
|             if (doPing) { | ||||
|                 this._value.ping(); | ||||
|             } | ||||
|         }) | ||||
| 
 | ||||
|         const questionSettings = [ | ||||
| 
 | ||||
| 
 | ||||
|             setting(options?.noLanguage ? new TextField({placeholder: "question"}) : new MultiLingualTextFields(languages) | ||||
|                 , "question", "Question", "If the key or mapping doesn't match, this question is asked"), | ||||
| 
 | ||||
|             "<h3>Freeform key</h3>", | ||||
|             setting(ValidatedTextField.KeyInput(true), ["freeform", "key"], "Freeform key<br/>", | ||||
|                 "If specified, the rendering will search if this key is present." + | ||||
|                 "If it is, the rendering above will be used to display the element.<br/>" + | ||||
|                 "The rendering will go into question mode if <ul><li>this key is not present</li><li>No single mapping matches</li><li>A question is given</li>"), | ||||
| 
 | ||||
|             setting(ValidatedTextField.TypeDropdown(), ["freeform", "type"], "Freeform type", | ||||
|                 "The type of this freeform text field, in order to validate"), | ||||
|             setting(new MultiTagInput(), ["freeform", "addExtraTags"], "Extra tags on freeform", | ||||
|                 "When the freeform text field is used, the user might mean a predefined key. This field allows to add extra tags, e.g. <span class='literal-code'>fixme=User used a freeform field - to check</span>"), | ||||
| 
 | ||||
|         ]; | ||||
| 
 | ||||
|         const settings: (string | SingleSetting<any>)[] = [ | ||||
|             setting( | ||||
|                 options?.noLanguage ? new TextField({placeholder: "Rendering"}) : | ||||
|                     new MultiLingualTextFields(languages), "render", "Value to show", | ||||
|                 "Renders this value. Note that <span class='literal-code'>{key}</span>-parts are substituted by the corresponding values of the element. If neither 'textFieldQuestion' nor 'mappings' are defined, this text is simply shown as default value." + | ||||
|                 "<br/><br/>" + | ||||
|                 "Furhtermore, some special functions are supported:" + SpecialVisualizations.HelpMessage.Render()), | ||||
| 
 | ||||
|             questionsNotUnlocked ? `You need at least ${Constants.userJourney.themeGeneratorFullUnlock} changesets to unlock the 'question'-field and to use your theme to edit OSM data` : "", | ||||
|             ...(options?.disableQuestions ? [] : questionSettings), | ||||
| 
 | ||||
|             "<h3>Mappings</h3>", | ||||
|             setting(new MultiInput<{ if: AndOrTagConfigJson, then: (string | any), hideInAnswer?: boolean }>("Add a mapping", | ||||
|                 () => ({if: {and: []}, then: {}}), | ||||
|                 () => new MappingInput(languages, options?.disableQuestions ?? false), | ||||
|                 undefined, {allowMovement: true}), "mappings", | ||||
|                 "If a tag matches, then show the first respective text", ""), | ||||
| 
 | ||||
|             "<h3>Condition</h3>", | ||||
|             setting(new AndOrTagInput(), "condition", "Only show this tagrendering if the following condition applies", | ||||
|                 "Only show this tag rendering if these tags matches. Optional field.<br/>Note that the Overpass-tags are already always included in this object"), | ||||
| 
 | ||||
| 
 | ||||
|         ]; | ||||
| 
 | ||||
|         this.settingsTable = new SettingsTable(settings, currentlySelected); | ||||
| 
 | ||||
| 
 | ||||
|         this.validText = new VariableUiElement(value.map((json: TagRenderingConfigJson) => { | ||||
|             try { | ||||
|                 new TagRenderingConfig(json, undefined, options?.title ?? ""); | ||||
|                 return ""; | ||||
|             } catch (e) { | ||||
|                 return "<span class='alert'>" + e + "</span>" | ||||
|             } | ||||
|         })); | ||||
| 
 | ||||
|     } | ||||
| 
 | ||||
|     InnerRender(): string { | ||||
|         return new Combine([ | ||||
|             this.intro, | ||||
|             this.settingsTable, | ||||
|             this.validText]).Render(); | ||||
|     } | ||||
| 
 | ||||
|     GetValue(): UIEventSource<TagRenderingConfigJson> { | ||||
|         return this._value; | ||||
|     } | ||||
| 
 | ||||
|     IsValid(t: TagRenderingConfigJson): boolean { | ||||
|         return false; | ||||
|     } | ||||
| 
 | ||||
| 
 | ||||
| } | ||||
|  | @ -1,70 +0,0 @@ | |||
| import {UIElement} from "../UIElement"; | ||||
| import {UIEventSource} from "../../Logic/UIEventSource"; | ||||
| import TagRenderingPanel from "./TagRenderingPanel"; | ||||
| import {VariableUiElement} from "../Base/VariableUIElement"; | ||||
| import {FixedUiElement} from "../Base/FixedUiElement"; | ||||
| import Combine from "../Base/Combine"; | ||||
| import TagRenderingConfig from "../../Customizations/JSON/TagRenderingConfig"; | ||||
| import EditableTagRendering from "../Popup/EditableTagRendering"; | ||||
| 
 | ||||
| export default class TagRenderingPreview extends UIElement { | ||||
| 
 | ||||
|     private readonly previewTagValue: UIEventSource<any>; | ||||
|     private selectedTagRendering: UIEventSource<TagRenderingPanel>; | ||||
|     private panel: UIElement; | ||||
| 
 | ||||
|     constructor(selectedTagRendering: UIEventSource<TagRenderingPanel>, | ||||
|                 previewTagValue: UIEventSource<any>) { | ||||
|         super(selectedTagRendering); | ||||
|         this.selectedTagRendering = selectedTagRendering; | ||||
|         this.previewTagValue = previewTagValue; | ||||
|         this.panel = this.GetPanel(undefined); | ||||
|         const self = this; | ||||
|         this.selectedTagRendering.addCallback(trp => { | ||||
|             self.panel = self.GetPanel(trp); | ||||
|             self.Update(); | ||||
|         }) | ||||
| 
 | ||||
|     } | ||||
| 
 | ||||
|     private GetPanel(tagRenderingPanel: TagRenderingPanel): UIElement { | ||||
|         if (tagRenderingPanel === undefined) { | ||||
|             return new FixedUiElement("No tag rendering selected at the moment. Hover over a tag rendering to see what it looks like"); | ||||
|         } | ||||
| 
 | ||||
|         let es = tagRenderingPanel.GetValue(); | ||||
| 
 | ||||
|         let rendering: UIElement; | ||||
|         const self = this; | ||||
|         try { | ||||
|             rendering = | ||||
|                 new VariableUiElement(es.map(tagRenderingConfig => { | ||||
|                         try { | ||||
|                             const tr = new EditableTagRendering(self.previewTagValue, new TagRenderingConfig(tagRenderingConfig, undefined,"preview")); | ||||
|                             return tr.Render(); | ||||
|                         } catch (e) { | ||||
|                             return new Combine(["Could not show this tagrendering:", e.message]).Render(); | ||||
|                         } | ||||
|                     } | ||||
|                 )); | ||||
| 
 | ||||
|         } catch (e) { | ||||
|             console.error("User defined tag rendering incorrect:", e); | ||||
|             rendering = new FixedUiElement(e).SetClass("alert"); | ||||
|         } | ||||
| 
 | ||||
|         return new Combine([ | ||||
|             "<h3>", | ||||
|             tagRenderingPanel.options.title ?? "Extra tag rendering", | ||||
|             "</h3>", | ||||
|             tagRenderingPanel.options.description ?? "This tag rendering will appear in the popup", | ||||
|             "<br/><br/>", | ||||
|             rendering]); | ||||
| 
 | ||||
|     } | ||||
| 
 | ||||
|     InnerRender(): string { | ||||
|         return this.panel.Render(); | ||||
|     } | ||||
| 
 | ||||
| } | ||||
							
								
								
									
										19
									
								
								UI/Image/AttributedImage.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								UI/Image/AttributedImage.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,19 @@ | |||
| import Combine from "../Base/Combine"; | ||||
| import Attribution from "./Attribution"; | ||||
| import Img from "../Base/Img"; | ||||
| import ImageAttributionSource from "../../Logic/Web/ImageAttributionSource"; | ||||
| 
 | ||||
| 
 | ||||
| export class AttributedImage extends Combine { | ||||
| 
 | ||||
|     constructor(urlSource: string, imgSource: ImageAttributionSource) { | ||||
|         urlSource = imgSource.PrepareUrl(urlSource) | ||||
|         super([ | ||||
|             new Img( urlSource), | ||||
|             new Attribution(imgSource.GetAttributionFor(urlSource), imgSource.SourceIcon()) | ||||
|         ]); | ||||
|         this.SetClass('block relative h-full'); | ||||
|     } | ||||
| 
 | ||||
| 
 | ||||
| } | ||||
|  | @ -1,18 +1,33 @@ | |||
| import {UIElement} from "../UIElement"; | ||||
| import Combine from "../Base/Combine"; | ||||
| import Translations from "../i18n/Translations"; | ||||
| import BaseUIElement from "../BaseUIElement"; | ||||
| import {VariableUiElement} from "../Base/VariableUIElement"; | ||||
| import {UIEventSource} from "../../Logic/UIEventSource"; | ||||
| import {LicenseInfo} from "../../Logic/Web/Wikimedia"; | ||||
| 
 | ||||
| export default class Attribution extends Combine { | ||||
| export default class Attribution extends VariableUiElement { | ||||
| 
 | ||||
|     constructor(author: UIElement | string, license: UIElement | string, icon: UIElement) { | ||||
|         super([ | ||||
|             icon?.SetClass("block left").SetStyle("height: 2em; width: 2em; padding-right: 0.5em"), | ||||
|             new Combine([ | ||||
|                 Translations.W(author).SetClass("block font-bold"), | ||||
|                 Translations.W((license ?? "") === "undefined" ? "CC0" : (license ?? "")) | ||||
|             ]).SetClass("flex flex-col") | ||||
|         ]); | ||||
|         this.SetClass("flex flex-row bg-black text-white text-sm absolute bottom-0 left-0 p-0.5 pl-5 pr-3 rounded-lg"); | ||||
|     constructor(license: UIEventSource<LicenseInfo>, icon: BaseUIElement) { | ||||
|         if (license === undefined) { | ||||
|             throw "No license source given in the attribution element" | ||||
|         } | ||||
|         super( | ||||
|             license.map((license : LicenseInfo) => { | ||||
| 
 | ||||
|                 if (license?.artist === undefined) { | ||||
|                     return undefined; | ||||
|                 } | ||||
|                  | ||||
|                 return new Combine([ | ||||
|                     icon?.SetClass("block left").SetStyle("height: 2em; width: 2em; padding-right: 0.5em;"), | ||||
| 
 | ||||
|                     new Combine([ | ||||
|                         Translations.W(license.artist).SetClass("block font-bold"), | ||||
|                         Translations.W((license.license ?? "") === "" ? "CC0" : (license.license ?? "")) | ||||
|                     ]).SetClass("flex flex-col") | ||||
|                 ]).SetClass("flex flex-row bg-black text-white text-sm absolute bottom-0 left-0 p-0.5 pl-5 pr-3 rounded-lg") | ||||
| 
 | ||||
|             })); | ||||
|     } | ||||
| 
 | ||||
| } | ||||
|  | @ -1,56 +1,55 @@ | |||
| import {UIElement} from "../UIElement"; | ||||
| import {UIEventSource} from "../../Logic/UIEventSource"; | ||||
| import Translations from "../i18n/Translations"; | ||||
| import CheckBox from "../Input/CheckBox"; | ||||
| import Toggle from "../Input/Toggle"; | ||||
| import Combine from "../Base/Combine"; | ||||
| import State from "../../State"; | ||||
| import Svg from "../../Svg"; | ||||
| import {Tag} from "../../Logic/Tags/Tag"; | ||||
| import BaseUIElement from "../BaseUIElement"; | ||||
| 
 | ||||
| 
 | ||||
| export default class DeleteImage extends UIElement { | ||||
|     private readonly key: string; | ||||
|     private readonly tags: UIEventSource<any>; | ||||
| 
 | ||||
|     private readonly isDeletedBadge: UIElement; | ||||
|     private readonly deleteDialog: UIElement; | ||||
| export default class DeleteImage extends Toggle { | ||||
| 
 | ||||
|     constructor(key: string, tags: UIEventSource<any>) { | ||||
|         super(tags); | ||||
|         this.tags = tags; | ||||
|         this.key = key; | ||||
| 
 | ||||
|         this.isDeletedBadge = Translations.t.image.isDeleted; | ||||
|         const oldValue = tags.data[key] | ||||
|         const isDeletedBadge = Translations.t.image.isDeleted.Clone() | ||||
|             .SetClass("rounded-full p-1") | ||||
|             .SetStyle("color:white;background:#ff8c8c") | ||||
|             .onClick(() => { | ||||
|                 State.state?.changes?.addTag(tags.data.id, new Tag(key, oldValue), tags); | ||||
|             }); | ||||
| 
 | ||||
|         const deleteButton = Translations.t.image.doDelete.Clone() | ||||
|             .SetClass("block w-full pl-4 pr-4") | ||||
|             .SetStyle("color:white;background:#ff8c8c; border-top-left-radius:30rem; border-top-right-radius: 30rem;") | ||||
|             .onClick(() => { | ||||
|                 State.state?.changes.addTag(tags.data.id, new Tag(key, "")); | ||||
|                 State.state?.changes?.addTag(tags.data.id, new Tag(key, ""), tags); | ||||
|             }); | ||||
| 
 | ||||
|         const cancelButton = Translations.t.general.cancel.SetClass("bg-white pl-4 pr-4").SetStyle( "border-bottom-left-radius:30rem; border-bottom-right-radius: 30rem;"); | ||||
|         this.deleteDialog = new CheckBox( | ||||
|         const cancelButton = Translations.t.general.cancel.Clone().SetClass("bg-white pl-4 pr-4").SetStyle("border-bottom-left-radius:30rem; border-bottom-right-radius: 30rem;"); | ||||
|         const openDelete = Svg.delete_icon_svg().SetStyle("width: 2em; height: 2em; display:block;") | ||||
|         const deleteDialog = new Toggle( | ||||
|             new Combine([ | ||||
|                 deleteButton, | ||||
|                 cancelButton | ||||
|             ]).SetClass("flex flex-col background-black"), | ||||
|             Svg.delete_icon_svg().SetStyle("width: 2em; height: 2em; display:block;") | ||||
|             openDelete | ||||
|         ) | ||||
| 
 | ||||
|     } | ||||
|         cancelButton.onClick(() => deleteDialog.isEnabled.setData(false)) | ||||
|         openDelete.onClick(() => deleteDialog.isEnabled.setData(true)) | ||||
| 
 | ||||
|     InnerRender(): string { | ||||
|         if(! State.state?.featureSwitchUserbadge?.data){ | ||||
|             return ""; | ||||
|         } | ||||
| 
 | ||||
|         const value = this.tags.data[this.key]; | ||||
|         if (value === undefined || value === "") { | ||||
|             return this.isDeletedBadge.Render(); | ||||
|         } | ||||
| 
 | ||||
|         return this.deleteDialog.Render(); | ||||
|         super( | ||||
|             new Toggle( | ||||
|                 deleteDialog, | ||||
|                 isDeletedBadge, | ||||
|                 tags.map(tags => (tags[key] ?? "") !== "") | ||||
|             ), | ||||
|             undefined /*Login (and thus editing) is disabled*/, | ||||
|             State.state?.featureSwitchUserbadge ?? new UIEventSource<boolean>(true) | ||||
|         ) | ||||
|         this.SetClass("cursor-pointer") | ||||
|     } | ||||
| 
 | ||||
| } | ||||
|  | @ -1,39 +1,43 @@ | |||
| import {UIElement} from "../UIElement"; | ||||
| import {SlideShow} from "./SlideShow"; | ||||
| import {UIEventSource} from "../../Logic/UIEventSource"; | ||||
| import Combine from "../Base/Combine"; | ||||
| import DeleteImage from "./DeleteImage"; | ||||
| import {WikimediaImage} from "./WikimediaImage"; | ||||
| import {ImgurImage} from "./ImgurImage"; | ||||
| import {MapillaryImage} from "./MapillaryImage"; | ||||
| import {SimpleImageElement} from "./SimpleImageElement"; | ||||
| import {AttributedImage} from "./AttributedImage"; | ||||
| import BaseUIElement from "../BaseUIElement"; | ||||
| import Img from "../Base/Img"; | ||||
| import Toggle from "../Input/Toggle"; | ||||
| import ImageAttributionSource from "../../Logic/Web/ImageAttributionSource"; | ||||
| import {Wikimedia} from "../../Logic/Web/Wikimedia"; | ||||
| import {Mapillary} from "../../Logic/Web/Mapillary"; | ||||
| import {Imgur} from "../../Logic/Web/Imgur"; | ||||
| 
 | ||||
| export class ImageCarousel extends UIElement{ | ||||
| export class ImageCarousel extends Toggle { | ||||
| 
 | ||||
|     public readonly slideshow: UIElement; | ||||
| 
 | ||||
|     constructor(images: UIEventSource<{key: string, url:string}[]>, tags: UIEventSource<any>) { | ||||
|         super(images); | ||||
|         const uiElements = images.map((imageURLS: {key: string, url:string}[]) => { | ||||
|             const uiElements: UIElement[] = []; | ||||
|     constructor(images: UIEventSource<{ key: string, url: string }[]>, tags: UIEventSource<any>) { | ||||
|         const uiElements = images.map((imageURLS: { key: string, url: string }[]) => { | ||||
|             const uiElements: BaseUIElement[] = []; | ||||
|             for (const url of imageURLS) { | ||||
|                 let image = ImageCarousel.CreateImageElement(url.url) | ||||
|                 if(url.key !== undefined){ | ||||
|                 if (url.key !== undefined) { | ||||
|                     image = new Combine([ | ||||
|                         image, | ||||
|                         new DeleteImage(url.key, tags).SetClass("delete-image-marker absolute top-0 left-0 pl-3") | ||||
|                     ]).SetClass("relative"); | ||||
|                 } | ||||
|             image | ||||
|                 .SetClass("w-full block") | ||||
|                 image | ||||
|                     .SetClass("w-full block") | ||||
|                     .SetStyle("min-width: 50px; background: grey;") | ||||
|                 uiElements.push(image); | ||||
|             } | ||||
|             return uiElements; | ||||
|         }); | ||||
| 
 | ||||
|         this.slideshow = new SlideShow(uiElements).HideOnEmpty(true); | ||||
|         super( | ||||
|             new SlideShow(uiElements).SetClass("w-full"), | ||||
|             undefined, | ||||
|             uiElements.map(els => els.length > 0) | ||||
|         ) | ||||
|         this.SetClass("block w-full"); | ||||
|         this.slideshow.SetClass("w-full"); | ||||
|     } | ||||
| 
 | ||||
|     /*** | ||||
|  | @ -41,23 +45,22 @@ export class ImageCarousel extends UIElement{ | |||
|      * @param url | ||||
|      * @constructor | ||||
|      */ | ||||
|     private static CreateImageElement(url: string): UIElement { | ||||
|     private static CreateImageElement(url: string): BaseUIElement { | ||||
|         // @ts-ignore
 | ||||
|         let attrSource : ImageAttributionSource = undefined; | ||||
|         if (url.startsWith("File:")) { | ||||
|             return new WikimediaImage(url); | ||||
|             attrSource = Wikimedia.singleton | ||||
|         } else if (url.toLowerCase().startsWith("https://commons.wikimedia.org/wiki/")) { | ||||
|             const commons = url.substr("https://commons.wikimedia.org/wiki/".length); | ||||
|             return new WikimediaImage(commons); | ||||
|             attrSource = Wikimedia.singleton; | ||||
|         } else if (url.toLowerCase().startsWith("https://i.imgur.com/")) { | ||||
|             return new ImgurImage(url); | ||||
|             attrSource = Imgur.singleton | ||||
|         } else if (url.toLowerCase().startsWith("https://www.mapillary.com/map/im/")) { | ||||
|             return new MapillaryImage(url); | ||||
|             attrSource = Mapillary.singleton | ||||
|         } else { | ||||
|             return new SimpleImageElement(new UIEventSource<string>(url)); | ||||
|             return new Img(url); | ||||
|         } | ||||
|     } | ||||
|      | ||||
|     InnerRender(): string { | ||||
|         return this.slideshow.Render(); | ||||
|          | ||||
|         return new AttributedImage(url, attrSource) | ||||
|          | ||||
|     } | ||||
| } | ||||
|  | @ -1,210 +1,103 @@ | |||
| import $ from "jquery" | ||||
| import {UIEventSource} from "../../Logic/UIEventSource"; | ||||
| import {UIElement} from "../UIElement"; | ||||
| import State from "../../State"; | ||||
| import Combine from "../Base/Combine"; | ||||
| import {FixedUiElement} from "../Base/FixedUiElement"; | ||||
| import {Imgur} from "../../Logic/Web/Imgur"; | ||||
| import {DropDown} from "../Input/DropDown"; | ||||
| import Translations from "../i18n/Translations"; | ||||
| import Svg from "../../Svg"; | ||||
| import {Tag} from "../../Logic/Tags/Tag"; | ||||
| import BaseUIElement from "../BaseUIElement"; | ||||
| import LicensePicker from "../BigComponents/LicensePicker"; | ||||
| import Toggle from "../Input/Toggle"; | ||||
| import FileSelectorButton from "../Input/FileSelectorButton"; | ||||
| import ImgurUploader from "../../Logic/Web/ImgurUploader"; | ||||
| import UploadFlowStateUI from "../BigComponents/UploadFlowStateUI"; | ||||
| import LayerConfig from "../../Customizations/JSON/LayerConfig"; | ||||
| 
 | ||||
| export class ImageUploadFlow extends UIElement { | ||||
|     private readonly _licensePicker: UIElement; | ||||
|     private readonly _tags: UIEventSource<any>; | ||||
|     private readonly _selectedLicence: UIEventSource<string>; | ||||
|     private readonly _isUploading: UIEventSource<number> = new UIEventSource<number>(0) | ||||
|     private readonly _didFail: UIEventSource<boolean> = new UIEventSource<boolean>(false); | ||||
|     private readonly _allDone: UIEventSource<boolean> = new UIEventSource<boolean>(false); | ||||
|     private readonly _connectButton: UIElement; | ||||
|     private readonly _imagePrefix: string; | ||||
| export class ImageUploadFlow extends Toggle { | ||||
| 
 | ||||
|     constructor(tags: UIEventSource<any>, imagePrefix: string = "image") { | ||||
|         super(State.state.osmConnection.userDetails); | ||||
|         this._tags = tags; | ||||
|         this._imagePrefix = imagePrefix; | ||||
|     constructor(tagsSource: UIEventSource<any>, imagePrefix: string = "image") { | ||||
|         const uploader = new ImgurUploader(url => { | ||||
|             // A file was uploaded - we add it to the tags of the object
 | ||||
| 
 | ||||
|         this.ListenTo(this._isUploading); | ||||
|         this.ListenTo(this._didFail); | ||||
|         this.ListenTo(this._allDone); | ||||
|             const tags = tagsSource.data | ||||
|             let key = imagePrefix | ||||
|             if (tags[imagePrefix] !== undefined) { | ||||
|                 let freeIndex = 0; | ||||
|                 while (tags[imagePrefix + ":" + freeIndex] !== undefined) { | ||||
|                     freeIndex++; | ||||
|                 } | ||||
|                 key = imagePrefix + ":" + freeIndex; | ||||
|             } | ||||
|             console.log("Adding image:" + key, url); | ||||
|             State.state.changes.addTag(tags.id, new Tag(key, url), tagsSource); | ||||
|         }) | ||||
| 
 | ||||
|         const licensePicker = new DropDown(Translations.t.image.willBePublished, | ||||
|             [ | ||||
|                 {value: "CC0", shown: Translations.t.image.cco}, | ||||
|                 {value: "CC-BY-SA 4.0", shown: Translations.t.image.ccbs}, | ||||
|                 {value: "CC-BY 4.0", shown: Translations.t.image.ccb} | ||||
|             ], | ||||
|             State.state.osmConnection.GetPreference("pictures-license"), | ||||
|             "","", | ||||
|             "flex flex-col sm:flex-row" | ||||
|         ); | ||||
|         licensePicker.SetStyle("float:left"); | ||||
| 
 | ||||
|         const licensePicker = new LicensePicker() | ||||
| 
 | ||||
|         const t = Translations.t.image; | ||||
|         const label = new Combine([ | ||||
|             Svg.camera_plus_ui().SetClass("block w-12 h-12 p-1"), | ||||
|             Translations.t.image.addPicture.Clone().SetClass("block align-middle mt-1 ml-3") | ||||
|         ]).SetClass("p-2 border-4 border-black rounded-full text-4xl font-bold h-full align-middle w-full flex justify-center") | ||||
|          | ||||
|         const fileSelector = new FileSelectorButton(label) | ||||
|         fileSelector.GetValue().addCallback(filelist => { | ||||
|             if (filelist === undefined) { | ||||
|                 return; | ||||
|             } | ||||
| 
 | ||||
|         this._licensePicker = licensePicker; | ||||
|         this._selectedLicence = licensePicker.GetValue(); | ||||
|             console.log("Received images from the user, starting upload") | ||||
|             const license = licensePicker.GetValue()?.data ?? "CC0" | ||||
| 
 | ||||
|         this._connectButton = t.pleaseLogin.Clone() | ||||
|             const tags = tagsSource.data; | ||||
| 
 | ||||
|             const layout = State.state?.layoutToUse?.data | ||||
|             let matchingLayer: LayerConfig = undefined | ||||
|             for (const layer of layout?.layers ?? []) { | ||||
|                 if (layer.source.osmTags.matchesProperties(tags)) { | ||||
|                     matchingLayer = layer; | ||||
|                     break; | ||||
|                 } | ||||
|             } | ||||
| 
 | ||||
| 
 | ||||
|             const title = matchingLayer?.title?.GetRenderValue(tags)?.ConstructElement().innerText ?? tags.name ?? "Unknown area"; | ||||
|             const description = [ | ||||
|                 "author:" + State.state.osmConnection.userDetails.data.name, | ||||
|                 "license:" + license, | ||||
|                 "osmid:" + tags.id, | ||||
|             ].join("\n"); | ||||
| 
 | ||||
|             uploader.uploadMany(title, description, filelist) | ||||
| 
 | ||||
|         }) | ||||
| 
 | ||||
| 
 | ||||
|         const uploadStateUi = new UploadFlowStateUI(uploader.queue, uploader.failed, uploader.success) | ||||
| 
 | ||||
|         const uploadFlow: BaseUIElement = new Combine([ | ||||
|             fileSelector, | ||||
|             Translations.t.image.respectPrivacy.Clone().SetStyle("font-size:small;"), | ||||
|             licensePicker, | ||||
|             uploadStateUi | ||||
|         ]).SetClass("flex flex-col image-upload-flow mt-4 mb-8 text-center") | ||||
| 
 | ||||
| 
 | ||||
|         const pleaseLoginButton = t.pleaseLogin.Clone() | ||||
|             .onClick(() => State.state.osmConnection.AttemptLogin()) | ||||
|             .SetClass("login-button-friendly"); | ||||
| 
 | ||||
|     } | ||||
| 
 | ||||
|     InnerRender(): string { | ||||
|          | ||||
|         if(!State.state.featureSwitchUserbadge.data){ | ||||
|             return ""; | ||||
|         } | ||||
| 
 | ||||
|         const t = Translations.t.image; | ||||
|         if (State.state.osmConnection.userDetails === undefined) { | ||||
|             return ""; // No user details -> logging in is probably disabled or smthing
 | ||||
|         } | ||||
| 
 | ||||
|         if (!State.state.osmConnection.userDetails.data.loggedIn) { | ||||
|             return this._connectButton.Render(); | ||||
|         } | ||||
| 
 | ||||
|         let currentState: UIElement[] = []; | ||||
|         if (this._isUploading.data == 1) { | ||||
|             currentState.push(t.uploadingPicture); | ||||
|         } else if (this._isUploading.data > 0) { | ||||
|             currentState.push(t.uploadingMultiple.Subs({count: ""+this._isUploading.data})); | ||||
|         } | ||||
| 
 | ||||
|         if (this._didFail.data) { | ||||
|             currentState.push(t.uploadFailed); | ||||
|         } | ||||
| 
 | ||||
|         if (this._allDone.data) { | ||||
|             currentState.push(t.uploadDone) | ||||
|         } | ||||
| 
 | ||||
|         let currentStateHtml : UIElement = new FixedUiElement(""); | ||||
|         if (currentState.length > 0) { | ||||
|             currentStateHtml = new Combine(currentState); | ||||
|             if (!this._allDone.data) { | ||||
|                 currentStateHtml.SetClass("alert"); | ||||
|             }else{ | ||||
|                 currentStateHtml.SetClass("thanks"); | ||||
|             } | ||||
|             currentStateHtml.SetStyle("display:block ruby") | ||||
|         } | ||||
| 
 | ||||
|         const extraInfo = new Combine([ | ||||
|             Translations.t.image.respectPrivacy.SetStyle("font-size:small;"), | ||||
|             "<br/>", | ||||
|             this._licensePicker, | ||||
|             "<br/>", | ||||
|             currentStateHtml, | ||||
|             "<br/>" | ||||
|         ]); | ||||
| 
 | ||||
|         const label = new Combine([ | ||||
|             Svg.camera_plus_svg().SetStyle("width: 36px;height: 36px;padding: 0.1em;margin-top: 5px;border-radius: 0;float: left;display:block"), | ||||
|             Translations.t.image.addPicture | ||||
|         ]).SetClass("image-upload-flow-button") | ||||
|      | ||||
|         const actualInputElement = | ||||
|             `<input style='display: none' id='fileselector-${this.id}' type='file' accept='image/*' name='picField' multiple='multiple' alt=''/>`; | ||||
|          | ||||
|         const form = "<form id='fileselector-form-" + this.id + "'>" + | ||||
|             `<label for='fileselector-${this.id}'>` + | ||||
|             label.Render() + | ||||
|             "</label>" + | ||||
|             actualInputElement + | ||||
|             "</form>"; | ||||
| 
 | ||||
|         return new Combine([ | ||||
|             form, | ||||
|             extraInfo | ||||
|         ]).SetClass("image-upload-flow") | ||||
|             .SetStyle("margin-top: 1em;margin-bottom: 2em;text-align: center;") | ||||
|             .Render(); | ||||
|     } | ||||
| 
 | ||||
| 
 | ||||
|     private handleSuccessfulUpload(url) { | ||||
|         const tags = this._tags.data; | ||||
|         let key = this._imagePrefix; | ||||
|         if (tags[this._imagePrefix] !== undefined) { | ||||
| 
 | ||||
|             let freeIndex = 0; | ||||
|             while (tags[this._imagePrefix + ":" + freeIndex] !== undefined) { | ||||
|                 freeIndex++; | ||||
|             } | ||||
|             key = this._imagePrefix + ":" + freeIndex; | ||||
|         } | ||||
|         console.log("Adding image:" + key, url); | ||||
|         State.state.changes.addTag(tags.id, new Tag(key, url)); | ||||
|     } | ||||
| 
 | ||||
|     private handleFiles(files) { | ||||
|         console.log("Received images from the user, starting upload") | ||||
|         this._isUploading.setData(files.length); | ||||
|         this._allDone.setData(false); | ||||
| 
 | ||||
|         if (this._selectedLicence.data === undefined) { | ||||
|             this._selectedLicence.setData("CC0"); | ||||
|         } | ||||
| 
 | ||||
| 
 | ||||
|         const tags = this._tags.data; | ||||
|         const title = tags.name ?? "Unknown area"; | ||||
|         const description = [ | ||||
|             "author:" + State.state.osmConnection.userDetails.data.name, | ||||
|             "license:" + (this._selectedLicence.data ?? "CC0"), | ||||
|             "wikidata:" + tags.wikidata, | ||||
|             "osmid:" + tags.id, | ||||
|             "name:" + tags.name | ||||
|         ].join("\n"); | ||||
| 
 | ||||
|         const self = this; | ||||
| 
 | ||||
|         Imgur.uploadMultiple(title, | ||||
|             description, | ||||
|             files, | ||||
|             function (url) { | ||||
|                 console.log("File saved at", url); | ||||
|                 self._isUploading.setData(self._isUploading.data - 1); | ||||
|                 self.handleSuccessfulUpload(url); | ||||
|             }, | ||||
|             function () { | ||||
|                 console.log("All uploads completed"); | ||||
|                 self._allDone.setData(true); | ||||
|             }, | ||||
|             function (failReason) { | ||||
|                 console.log("Upload failed due to ", failReason) | ||||
|                 // No need to call something from the options -> we handle this here
 | ||||
|                 self._didFail.setData(true); | ||||
|                 self._isUploading.data--; | ||||
|                 self._isUploading.ping(); | ||||
|             }, 0 | ||||
|         super( | ||||
|             new Toggle( | ||||
|                 /*We can show the actual upload button!*/ | ||||
|                 uploadFlow, | ||||
|                 /* User not logged in*/ pleaseLoginButton, | ||||
|                 State.state?.osmConnection?.isLoggedIn | ||||
|             ), | ||||
|             undefined /* Nothing as the user badge is disabled*/,  | ||||
|             State.state.featureSwitchUserbadge | ||||
|         ) | ||||
| 
 | ||||
|     } | ||||
| 
 | ||||
|     InnerUpdate(htmlElement: HTMLElement) { | ||||
|         super.InnerUpdate(htmlElement); | ||||
| 
 | ||||
|         this._licensePicker.Update() | ||||
|         const form = document.getElementById('fileselector-form-' + this.id) as HTMLFormElement | ||||
|         const selector = document.getElementById('fileselector-' + this.id) | ||||
|         const self = this | ||||
| 
 | ||||
|         function submitHandler() { | ||||
|             self.handleFiles($(selector).prop('files')) | ||||
|         } | ||||
| 
 | ||||
|         if (selector != null && form != null) { | ||||
|             selector.onchange = function () { | ||||
|                 submitHandler() | ||||
|             } | ||||
|             form.addEventListener('submit', e => { | ||||
|                 e.preventDefault() | ||||
|                 submitHandler() | ||||
|             }) | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | @ -1,56 +0,0 @@ | |||
| import {UIElement} from "../UIElement"; | ||||
| import {UIEventSource} from "../../Logic/UIEventSource"; | ||||
| import {LicenseInfo} from "../../Logic/Web/Wikimedia"; | ||||
| import {Imgur} from "../../Logic/Web/Imgur"; | ||||
| import Combine from "../Base/Combine"; | ||||
| import Attribution from "./Attribution"; | ||||
| import {SimpleImageElement} from "./SimpleImageElement"; | ||||
| 
 | ||||
| 
 | ||||
| export class ImgurImage extends UIElement { | ||||
| 
 | ||||
| 
 | ||||
|     /*** | ||||
|      * Dictionary from url to alreayd known license info | ||||
|      */ | ||||
|     private static allLicenseInfos: any = {}; | ||||
|     private readonly _imageMeta: UIEventSource<LicenseInfo>; | ||||
|     private readonly _imageLocation: string; | ||||
| 
 | ||||
|     constructor(source: string) { | ||||
|         super(undefined) | ||||
|         this._imageLocation = source; | ||||
|         if (ImgurImage.allLicenseInfos[source] !== undefined) { | ||||
|             this._imageMeta = ImgurImage.allLicenseInfos[source]; | ||||
|         } else { | ||||
|             this._imageMeta = new UIEventSource<LicenseInfo>(null); | ||||
|             ImgurImage.allLicenseInfos[source] = this._imageMeta; | ||||
|             const self = this; | ||||
|             Imgur.getDescriptionOfImage(source, (license) => { | ||||
|                 self._imageMeta.setData(license) | ||||
|             }) | ||||
|         } | ||||
|          | ||||
|         this.ListenTo(this._imageMeta); | ||||
|        | ||||
|     } | ||||
| 
 | ||||
|     InnerRender(): string { | ||||
|         const image = new SimpleImageElement( new UIEventSource (this._imageLocation)); | ||||
|          | ||||
|         if(this._imageMeta.data === null){ | ||||
|             return image.Render(); | ||||
|         } | ||||
|          | ||||
|         const meta = this._imageMeta.data; | ||||
|         return new Combine([ | ||||
|             image, | ||||
|             new Attribution(meta.artist, meta.license, undefined), | ||||
|              | ||||
|         ]).SetClass('block relative') | ||||
|             .Render(); | ||||
| 
 | ||||
|     } | ||||
| 
 | ||||
| 
 | ||||
| } | ||||
|  | @ -1,60 +0,0 @@ | |||
| import {UIElement} from "../UIElement"; | ||||
| import {UIEventSource} from "../../Logic/UIEventSource"; | ||||
| import {LicenseInfo} from "../../Logic/Web/Wikimedia"; | ||||
| import {Mapillary} from "../../Logic/Web/Mapillary"; | ||||
| import Svg from "../../Svg"; | ||||
| import {SimpleImageElement} from "./SimpleImageElement"; | ||||
| import Combine from "../Base/Combine"; | ||||
| import Attribution from "./Attribution"; | ||||
| 
 | ||||
| 
 | ||||
| export class MapillaryImage extends UIElement { | ||||
| 
 | ||||
|     /*** | ||||
|      * Dictionary from url to already known license info | ||||
|      */ | ||||
|     private static allLicenseInfos: any = {}; | ||||
|     private readonly _imageMeta: UIEventSource<LicenseInfo>; | ||||
|     private readonly _imageLocation: string; | ||||
| 
 | ||||
|     constructor(source: string) { | ||||
|         super() | ||||
| 
 | ||||
|         if (source.toLowerCase().startsWith("https://www.mapillary.com/map/im/")) { | ||||
|             source = source.substring("https://www.mapillary.com/map/im/".length); | ||||
|         } | ||||
| 
 | ||||
|         this._imageLocation = source; | ||||
|         if (MapillaryImage.allLicenseInfos[source] !== undefined) { | ||||
|             this._imageMeta = MapillaryImage.allLicenseInfos[source]; | ||||
|         } else { | ||||
|             this._imageMeta = new UIEventSource<LicenseInfo>(null); | ||||
|             MapillaryImage.allLicenseInfos[source] = this._imageMeta; | ||||
|             const self = this; | ||||
|             Mapillary.getDescriptionOfImage(source, (license) => { | ||||
|                 self._imageMeta.setData(license) | ||||
|             }) | ||||
|         } | ||||
| 
 | ||||
|         this.ListenTo(this._imageMeta); | ||||
| 
 | ||||
|     } | ||||
| 
 | ||||
|     InnerRender(): string { | ||||
|         const url = `https://images.mapillary.com/${this._imageLocation}/thumb-640.jpg?client_id=TXhLaWthQ1d4RUg0czVxaTVoRjFJZzowNDczNjUzNmIyNTQyYzI2`; | ||||
|         const image = new SimpleImageElement(new UIEventSource<string>(url)) | ||||
|          | ||||
|         const meta = this._imageMeta?.data; | ||||
|         if (!meta) { | ||||
|             return image.Render(); | ||||
|         } | ||||
| 
 | ||||
|         return new Combine([ | ||||
|             image, | ||||
|             new Attribution(meta.artist, meta.license, Svg.mapillary_svg()) | ||||
|         ]).SetClass("relative block").Render(); | ||||
| 
 | ||||
|     } | ||||
| 
 | ||||
| 
 | ||||
| } | ||||
|  | @ -1,15 +0,0 @@ | |||
| import {UIElement} from "../UIElement"; | ||||
| import {UIEventSource} from "../../Logic/UIEventSource"; | ||||
| 
 | ||||
| 
 | ||||
| export class SimpleImageElement extends UIElement { | ||||
| 
 | ||||
|     constructor(source: UIEventSource<string>) { | ||||
|         super(source); | ||||
|     } | ||||
| 
 | ||||
|     InnerRender(): string { | ||||
|         return "<img src='" + this._source.data + "' alt='img'>"; | ||||
|     } | ||||
| 
 | ||||
| } | ||||
|  | @ -1,56 +1,49 @@ | |||
| import {UIEventSource} from "../../Logic/UIEventSource"; | ||||
| import {UIElement} from "../UIElement"; | ||||
| import BaseUIElement from "../BaseUIElement"; | ||||
| import {Utils} from "../../Utils"; | ||||
| import Combine from "../Base/Combine"; | ||||
| // @ts-ignore
 | ||||
| import $ from "jquery" | ||||
| 
 | ||||
| export class SlideShow extends UIElement { | ||||
| export class SlideShow extends BaseUIElement { | ||||
| 
 | ||||
|     private readonly _embeddedElements: UIEventSource<UIElement[]> | ||||
| 
 | ||||
|     constructor( | ||||
|         embeddedElements: UIEventSource<UIElement[]>) { | ||||
|         super(embeddedElements); | ||||
|         this._embeddedElements = embeddedElements; | ||||
|         this._embeddedElements.addCallbackAndRun(elements => { | ||||
|             for (const element of elements ?? []) { | ||||
|                 element.SetClass("slick-carousel-content") | ||||
|     private readonly embeddedElements: UIEventSource<BaseUIElement[]>; | ||||
| 
 | ||||
|     constructor(embeddedElements: UIEventSource<BaseUIElement[]>) { | ||||
|         super() | ||||
|         this.embeddedElements =embeddedElements; | ||||
|         this.SetStyle("scroll-snap-type: x mandatory; overflow-x: scroll") | ||||
|     }    | ||||
| 
 | ||||
|     protected InnerConstructElement(): HTMLElement { | ||||
|         const el = document.createElement("div") | ||||
|         el.style.minWidth = "min-content" | ||||
|         el.style.display = "flex" | ||||
|         el.style.justifyContent = "center" | ||||
|         this.embeddedElements.addCallbackAndRun(elements => { | ||||
|              | ||||
|             if(elements.length > 1){ | ||||
|                 el.style.justifyContent = "unset" | ||||
|             } | ||||
|              | ||||
|             while (el.firstChild) { | ||||
|                 el.removeChild(el.lastChild) | ||||
|             } | ||||
|         }) | ||||
| 
 | ||||
|     } | ||||
| 
 | ||||
|     InnerRender(): string { | ||||
|         return new Combine( | ||||
|                 this._embeddedElements.data, | ||||
|             ).SetClass("block slick-carousel") | ||||
|             .Render(); | ||||
|     } | ||||
| 
 | ||||
|     Update() { | ||||
|         super.Update(); | ||||
|         for (const uiElement of this._embeddedElements.data) { | ||||
|             uiElement.Update(); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     protected InnerUpdate(htmlElement: HTMLElement) { | ||||
|         super.InnerUpdate(htmlElement); | ||||
|         require("slick-carousel") | ||||
|         if(this._embeddedElements.data.length == 0){ | ||||
|             return; | ||||
|         } | ||||
|         // @ts-ignore
 | ||||
|         $('.slick-carousel').not('.slick-initialized').slick({ | ||||
|             autoplay: true, | ||||
|             arrows: true, | ||||
|             dots: true, | ||||
|             lazyLoad: 'progressive', | ||||
|             variableWidth: true, | ||||
|             centerMode: true, | ||||
|             centerPadding: "60px", | ||||
|             adaptive: true   | ||||
|             elements = Utils.NoNull(elements).map(el => new Combine([el])  | ||||
|                 .SetClass("block relative ml-1 bg-gray-200 m-1 rounded slideshow-item") | ||||
|                 .SetStyle("min-width: 150px; width: max-content; height: var(--image-carousel-height);max-height: var(--image-carousel-height);scroll-snap-align: start;") | ||||
|             ) | ||||
|              | ||||
|             for (const element of elements ?? []) { | ||||
|                 el.appendChild(element.ConstructElement()) | ||||
|             } | ||||
|         }); | ||||
| 
 | ||||
|         const wrapper = document.createElement("div") | ||||
|         wrapper.style.maxWidth = "100%" | ||||
|         wrapper.style.overflowX = "auto" | ||||
|         wrapper.appendChild(el) | ||||
|         return wrapper; | ||||
|     } | ||||
| 
 | ||||
| } | ||||
|  | @ -1,58 +0,0 @@ | |||
| import {UIElement} from "../UIElement"; | ||||
| import {LicenseInfo, Wikimedia} from "../../Logic/Web/Wikimedia"; | ||||
| import {UIEventSource} from "../../Logic/UIEventSource"; | ||||
| import Svg from "../../Svg"; | ||||
| import Link from "../Base/Link"; | ||||
| import Combine from "../Base/Combine"; | ||||
| import {SimpleImageElement} from "./SimpleImageElement"; | ||||
| import Attribution from "./Attribution"; | ||||
| 
 | ||||
| 
 | ||||
| export class WikimediaImage extends UIElement { | ||||
| 
 | ||||
| 
 | ||||
|     static allLicenseInfos: any = {}; | ||||
|     private readonly _imageMeta: UIEventSource<LicenseInfo>; | ||||
|     private readonly _imageLocation: string; | ||||
| 
 | ||||
|     constructor(source: string) { | ||||
|         super(undefined) | ||||
|         this._imageLocation = source; | ||||
|         if (WikimediaImage.allLicenseInfos[source] !== undefined) { | ||||
|             this._imageMeta = WikimediaImage.allLicenseInfos[source]; | ||||
|         } else { | ||||
|             this._imageMeta = new UIEventSource<LicenseInfo>(new LicenseInfo()); | ||||
|             WikimediaImage.allLicenseInfos[source] = this._imageMeta; | ||||
|             const self = this; | ||||
|             Wikimedia.LicenseData(source, (info) => { | ||||
|                 self._imageMeta.setData(info); | ||||
|             }) | ||||
|         } | ||||
| 
 | ||||
|         this.ListenTo(this._imageMeta); | ||||
| 
 | ||||
| 
 | ||||
|     } | ||||
| 
 | ||||
|     InnerRender(): string { | ||||
|         const url = Wikimedia.ImageNameToUrl(this._imageLocation, 500, 400) | ||||
|             .replace(/'/g, '%27'); | ||||
|         const image = new SimpleImageElement(new UIEventSource<string>(url)) | ||||
|         const meta = this._imageMeta?.data; | ||||
| 
 | ||||
|         if (!meta) { | ||||
|             return image.Render(); | ||||
|         } | ||||
|         new Link(Svg.wikimedia_commons_white_img, | ||||
|             `https://commons.wikimedia.org/wiki/${this._imageLocation}`, true) | ||||
|             .SetStyle("width:2em;height: 2em"); | ||||
|          | ||||
|         return new Combine([ | ||||
|             image, | ||||
|             new Attribution(meta.artist, meta.license, Svg.wikimedia_commons_white_svg()) | ||||
|         ]).SetClass("relative block").Render() | ||||
| 
 | ||||
|     } | ||||
| 
 | ||||
| 
 | ||||
| } | ||||
|  | @ -1,164 +0,0 @@ | |||
| import {InputElement} from "./InputElement"; | ||||
| import {UIEventSource} from "../../Logic/UIEventSource"; | ||||
| import {UIElement} from "../UIElement"; | ||||
| import Combine from "../Base/Combine"; | ||||
| import {SubtleButton} from "../Base/SubtleButton"; | ||||
| import CheckBox from "./CheckBox"; | ||||
| import {AndOrTagConfigJson} from "../../Customizations/JSON/TagConfigJson"; | ||||
| import {MultiTagInput} from "./MultiTagInput"; | ||||
| import Svg from "../../Svg"; | ||||
| 
 | ||||
| class AndOrConfig implements AndOrTagConfigJson { | ||||
|     public and: (string | AndOrTagConfigJson)[] = undefined; | ||||
|     public or: (string | AndOrTagConfigJson)[] = undefined; | ||||
| } | ||||
| 
 | ||||
| 
 | ||||
| export default class AndOrTagInput extends InputElement<AndOrTagConfigJson> { | ||||
| 
 | ||||
|     private readonly _rawTags = new MultiTagInput(); | ||||
|     private readonly _subAndOrs: AndOrTagInput[] = []; | ||||
|     private readonly _isAnd: UIEventSource<boolean> = new UIEventSource<boolean>(true); | ||||
|     private readonly _isAndButton; | ||||
|     private readonly _addBlock: UIElement; | ||||
|     private readonly _value: UIEventSource<AndOrConfig> = new UIEventSource<AndOrConfig>(undefined); | ||||
| 
 | ||||
|     public bottomLeftButton: UIElement; | ||||
| 
 | ||||
|     IsSelected: UIEventSource<boolean>; | ||||
| 
 | ||||
|     constructor() { | ||||
|         super(); | ||||
|         const self = this; | ||||
|         this._isAndButton = new CheckBox( | ||||
|             new SubtleButton(Svg.ampersand_ui(), null).SetClass("small-button"), | ||||
|             new SubtleButton(Svg.or_ui(), null).SetClass("small-button"), | ||||
|             this._isAnd); | ||||
| 
 | ||||
| 
 | ||||
|         this._addBlock = | ||||
|             new SubtleButton(Svg.addSmall_ui(), "Add an and/or-expression") | ||||
|                 .SetClass("small-button") | ||||
|                 .onClick(() => {self.createNewBlock()}); | ||||
| 
 | ||||
| 
 | ||||
|         this._isAnd.addCallback(() => self.UpdateValue()); | ||||
|         this._rawTags.GetValue().addCallback(() => { | ||||
|             self.UpdateValue() | ||||
|         }); | ||||
| 
 | ||||
|         this.IsSelected = this._rawTags.IsSelected; | ||||
| 
 | ||||
|         this._value.addCallback(tags => self.loadFromValue(tags)); | ||||
| 
 | ||||
|     } | ||||
|      | ||||
|     private createNewBlock(){ | ||||
|         const inputEl = new AndOrTagInput(); | ||||
|         inputEl.GetValue().addCallback(() => this.UpdateValue()); | ||||
|         const deleteButton = this.createDeleteButton(inputEl.id); | ||||
|         inputEl.bottomLeftButton = deleteButton; | ||||
|         this._subAndOrs.push(inputEl); | ||||
|         this.Update(); | ||||
|     } | ||||
| 
 | ||||
|     private createDeleteButton(elementId: string): UIElement { | ||||
|         const self = this; | ||||
|         return new SubtleButton(Svg.delete_icon_ui(), null).SetClass("small-button") | ||||
|             .onClick(() => { | ||||
|                 for (let i = 0; i < self._subAndOrs.length; i++) { | ||||
|                     if (self._subAndOrs[i].id === elementId) { | ||||
|                         self._subAndOrs.splice(i, 1); | ||||
|                         self.Update(); | ||||
|                         self.UpdateValue(); | ||||
|                         return; | ||||
|                     } | ||||
|                 } | ||||
|             }); | ||||
| 
 | ||||
|     } | ||||
| 
 | ||||
|     private loadFromValue(value: AndOrTagConfigJson) { | ||||
|         this._isAnd.setData(value.and !== undefined); | ||||
|         const tags = value.and ?? value.or; | ||||
|         const rawTags: string[] = []; | ||||
|         const subTags: AndOrTagConfigJson[] = []; | ||||
|         for (const tag of tags) { | ||||
| 
 | ||||
|             if (typeof (tag) === "string") { | ||||
|                 rawTags.push(tag); | ||||
|             } else { | ||||
|                 subTags.push(tag); | ||||
|             } | ||||
|         } | ||||
| 
 | ||||
|         for (let i = 0; i < rawTags.length; i++) { | ||||
|             if (this._rawTags.GetValue().data[i] !== rawTags[i]) { | ||||
|                 // For some reason, 'setData' isn't stable as the comparison between the lists fails
 | ||||
|                 // Probably because we generate a new list object every timee
 | ||||
|                 // So we compare again here and update only if we find a difference
 | ||||
|                 this._rawTags.GetValue().setData(rawTags); | ||||
|                 break; | ||||
|             } | ||||
|         } | ||||
|          | ||||
|         while(this._subAndOrs.length < subTags.length){ | ||||
|             this.createNewBlock(); | ||||
|         } | ||||
| 
 | ||||
|         for (let i = 0; i < subTags.length; i++){ | ||||
|             let subTag = subTags[i]; | ||||
|             this._subAndOrs[i].GetValue().setData(subTag); | ||||
|              | ||||
|         } | ||||
| 
 | ||||
|     } | ||||
| 
 | ||||
|     private UpdateValue() { | ||||
|         const tags: (string | AndOrTagConfigJson)[] = []; | ||||
|         tags.push(...this._rawTags.GetValue().data); | ||||
| 
 | ||||
|         for (const subAndOr of this._subAndOrs) { | ||||
|             const subAndOrData = subAndOr._value.data; | ||||
|             if (subAndOrData === undefined) { | ||||
|                 continue; | ||||
|             } | ||||
|             console.log(subAndOrData); | ||||
|             tags.push(subAndOrData); | ||||
|         } | ||||
| 
 | ||||
|         const tagConfig = new AndOrConfig(); | ||||
| 
 | ||||
|         if (this._isAnd.data) { | ||||
|             tagConfig.and = tags; | ||||
|         } else { | ||||
|             tagConfig.or = tags; | ||||
|         } | ||||
|         this._value.setData(tagConfig); | ||||
|     } | ||||
| 
 | ||||
|     GetValue(): UIEventSource<AndOrTagConfigJson> { | ||||
|         return this._value; | ||||
|     } | ||||
| 
 | ||||
|     InnerRender(): string { | ||||
|         const leftColumn = new Combine([ | ||||
|             this._isAndButton, | ||||
|             "<br/>", | ||||
|             this.bottomLeftButton ?? "" | ||||
|         ]); | ||||
|         const tags = new Combine([ | ||||
|             this._rawTags, | ||||
|             ...this._subAndOrs, | ||||
|             this._addBlock | ||||
|         ]).Render(); | ||||
|         return `<span class="bordered"><table><tr><td>${leftColumn.Render()}</td><td>${tags}</td></tr></table></span>`; | ||||
|     } | ||||
| 
 | ||||
| 
 | ||||
|     IsValid(t: AndOrTagConfigJson): boolean { | ||||
|         return true; | ||||
|     } | ||||
| 
 | ||||
| 
 | ||||
| } | ||||
|  | @ -1,32 +0,0 @@ | |||
| import {UIElement} from "../UIElement"; | ||||
| import Translations from "../../UI/i18n/Translations"; | ||||
| import {UIEventSource} from "../../Logic/UIEventSource"; | ||||
| 
 | ||||
| export default class CheckBox extends UIElement{ | ||||
|     public readonly isEnabled: UIEventSource<boolean>; | ||||
|     private readonly _showEnabled:  UIElement; | ||||
|     private readonly _showDisabled: UIElement; | ||||
| 
 | ||||
|     constructor(showEnabled: string | UIElement, showDisabled: string | UIElement, data: UIEventSource<boolean> | boolean = false) { | ||||
|         super(undefined); | ||||
|         this.isEnabled = | ||||
|             data instanceof UIEventSource ? data : new UIEventSource(data ?? false); | ||||
|         this.ListenTo(this.isEnabled); | ||||
|         this._showEnabled = Translations.W(showEnabled); | ||||
|         this._showDisabled =Translations.W(showDisabled); | ||||
|         const self = this; | ||||
|         this.onClick(() => { | ||||
|             self.isEnabled.setData(!self.isEnabled.data); | ||||
|         }) | ||||
| 
 | ||||
|     } | ||||
| 
 | ||||
|     InnerRender(): string { | ||||
|         if (this.isEnabled.data) { | ||||
|             return Translations.W(this._showEnabled).Render(); | ||||
|         } else { | ||||
|             return Translations.W(this._showDisabled).Render(); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
| } | ||||
|  | @ -1,82 +1,98 @@ | |||
| import {InputElement} from "./InputElement"; | ||||
| import {UIEventSource} from "../../Logic/UIEventSource"; | ||||
| import {Utils} from "../../Utils"; | ||||
| import {UIElement} from "../UIElement"; | ||||
| import BaseUIElement from "../BaseUIElement"; | ||||
| 
 | ||||
| /** | ||||
|  * Supports multi-input | ||||
|  */ | ||||
| export default class CheckBoxes extends InputElement<number[]> { | ||||
|     private static _nextId = 0; | ||||
|     IsSelected: UIEventSource<boolean> = new UIEventSource<boolean>(false); | ||||
|     private readonly value: UIEventSource<number[]> | ||||
|     private readonly _elements: BaseUIElement[]; | ||||
| 
 | ||||
|     private readonly value: UIEventSource<number[]>; | ||||
|     private readonly _elements: UIElement[] | ||||
| 
 | ||||
| 
 | ||||
|     constructor(elements: UIElement[]) { | ||||
|         super(undefined); | ||||
|     constructor(elements: BaseUIElement[], value = new UIEventSource<number[]>([])) { | ||||
|         super(); | ||||
|         this.value = value; | ||||
|         this._elements = Utils.NoNull(elements); | ||||
|         this.dumbMode = false; | ||||
|         this.SetClass("flex flex-col") | ||||
| 
 | ||||
|         this.value = new UIEventSource<number[]>([]) | ||||
|         this.ListenTo(this.value); | ||||
|     } | ||||
| 
 | ||||
| 
 | ||||
|     IsValid(ts: number[]): boolean { | ||||
|         return ts !== undefined; | ||||
|          | ||||
| 
 | ||||
|     } | ||||
| 
 | ||||
|     GetValue(): UIEventSource<number[]> { | ||||
|         return this.value; | ||||
|     } | ||||
| 
 | ||||
|     protected InnerConstructElement(): HTMLElement { | ||||
|         const el = document.createElement("form") | ||||
| 
 | ||||
|     private IdFor(i) { | ||||
|         return 'checkmark-' + this.id + '-' + i; | ||||
|     } | ||||
|         const value = this.value; | ||||
|         const elements = this._elements; | ||||
| 
 | ||||
|     InnerRender(): string { | ||||
|         let body = ""; | ||||
|         for (let i = 0; i < this._elements.length; i++) { | ||||
|             let el = this._elements[i]; | ||||
|             const htmlElement = | ||||
|                 `<input type="checkbox" id="${this.IdFor(i)}"><label for="${this.IdFor(i)}">${el.Render()}</label><br/>`; | ||||
|             body += htmlElement; | ||||
|         for (let i = 0; i < elements.length; i++) { | ||||
| 
 | ||||
|         } | ||||
|          | ||||
|         return `<form id='${this.id}'>${body}</form>`; | ||||
|     } | ||||
|             let inputI = elements[i]; | ||||
|             const input = document.createElement("input") | ||||
|             const id = CheckBoxes._nextId | ||||
|             CheckBoxes._nextId++; | ||||
|             input.id = "checkbox" + id | ||||
| 
 | ||||
|     protected InnerUpdate(htmlElement: HTMLElement) { | ||||
|         super.InnerUpdate(htmlElement); | ||||
|         const self = this; | ||||
|             input.type = "checkbox" | ||||
|             input.classList.add("p-1","cursor-pointer","m-3","pl-3","mr-0") | ||||
| 
 | ||||
|         for (let i = 0; i < this._elements.length; i++) { | ||||
|             const el = document.getElementById(this.IdFor(i)); | ||||
|             const label = document.createElement("label") | ||||
|             label.htmlFor = input.id | ||||
|             label.appendChild(inputI.ConstructElement()) | ||||
|             label.classList.add("block","w-full","p-2","cursor-pointer","bg-red") | ||||
| 
 | ||||
|             const wrapper = document.createElement("span") | ||||
|             wrapper.classList.add("flex","w-full","border", "border-gray-400","m-1") | ||||
|             wrapper.appendChild(input) | ||||
|             wrapper.appendChild(label) | ||||
|             el.appendChild(wrapper) | ||||
|              | ||||
|             if(this.value.data.indexOf(i) >= 0){ | ||||
|                 // @ts-ignore
 | ||||
|                 el.checked = true; | ||||
|             } | ||||
|             value.addCallbackAndRun(selectedValues => { | ||||
|                 if (selectedValues === undefined) { | ||||
|                     return; | ||||
|                 } | ||||
|                 if (selectedValues.indexOf(i) >= 0) { | ||||
|                     input.checked = true; | ||||
|                 } | ||||
| 
 | ||||
|             el.onchange = () => { | ||||
|                 const index = self.value.data.indexOf(i); | ||||
|                 // @ts-ignore
 | ||||
|                 if(el.checked && index < 0){ | ||||
|                     self.value.data.push(i); | ||||
|                     self.value.ping(); | ||||
|                 }else if(index >= 0){ | ||||
|                     self.value.data.splice(index,1); | ||||
|                     self.value.ping(); | ||||
| 
 | ||||
|                 if(input.checked){ | ||||
|                     wrapper.classList.remove("border-gray-400") | ||||
|                     wrapper.classList.add("border-black") | ||||
|                 }else{ | ||||
|                     wrapper.classList.add("border-gray-400") | ||||
|                     wrapper.classList.remove("border-black") | ||||
|                 } | ||||
| 
 | ||||
|             }) | ||||
| 
 | ||||
|             input.onchange = () => { | ||||
|                 // Index = index in the list of already checked items
 | ||||
|                 const index = value.data.indexOf(i); | ||||
|                 if (input.checked && index < 0) { | ||||
|                     value.data.push(i); | ||||
|                     value.ping(); | ||||
|                 } else if (index >= 0) { | ||||
|                     value.data.splice(index, 1); | ||||
|                     value.ping(); | ||||
|                 } | ||||
|             } | ||||
| 
 | ||||
| 
 | ||||
|         } | ||||
| 
 | ||||
| 
 | ||||
|         return el; | ||||
|     } | ||||
| 
 | ||||
| 
 | ||||
|  |  | |||
|  | @ -1,50 +1,36 @@ | |||
| import {InputElement} from "./InputElement"; | ||||
| import {UIEventSource} from "../../Logic/UIEventSource"; | ||||
| import {Utils} from "../../Utils"; | ||||
| 
 | ||||
| export default class ColorPicker extends InputElement<string> { | ||||
| 
 | ||||
|     private readonly value: UIEventSource<string> | ||||
| 
 | ||||
| private readonly _element : HTMLElement | ||||
|     constructor( | ||||
|         value?: UIEventSource<string> | ||||
|         value: UIEventSource<string> = new UIEventSource<string>(undefined) | ||||
|     ) { | ||||
|         super(); | ||||
|         this.value = value ?? new UIEventSource<string>(undefined); | ||||
|         const self = this; | ||||
|         this.value = value ; | ||||
|          | ||||
|         const el = document.createElement("input") | ||||
|         this._element = el; | ||||
|          | ||||
|         el.type = "color" | ||||
|          | ||||
|         this.value.addCallbackAndRun(v => { | ||||
|             if(v === undefined){ | ||||
|                 return; | ||||
|             } | ||||
|             self.SetValue(v); | ||||
|            el.value =v | ||||
|         }); | ||||
|          | ||||
|         el.oninput = () => { | ||||
|             const hex = el.value; | ||||
|             value.setData(hex); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
| 
 | ||||
|     InnerRender(): string { | ||||
|         return `<span id="${this.id}"><input type='color' id='color-${this.id}'></span>`; | ||||
|     } | ||||
|      | ||||
|     private SetValue(color: string){ | ||||
|         const field = document.getElementById("color-" + this.id); | ||||
|         if (field === undefined || field === null) { | ||||
|             return; | ||||
|         } | ||||
|         // @ts-ignore
 | ||||
|         field.value = color; | ||||
|     } | ||||
| 
 | ||||
|     protected InnerUpdate() { | ||||
|         const field = document.getElementById("color-" + this.id); | ||||
|         if (field === undefined || field === null) { | ||||
|             return; | ||||
|         } | ||||
|         const self = this; | ||||
|         field.oninput = () => { | ||||
|             const hex = field["value"]; | ||||
|             self.value.setData(hex); | ||||
|         } | ||||
| 
 | ||||
|     protected InnerConstructElement(): HTMLElement { | ||||
|         return this._element; | ||||
|     } | ||||
| 
 | ||||
|     GetValue(): UIEventSource<string> { | ||||
|  |  | |||
|  | @ -1,14 +1,16 @@ | |||
| import {InputElement} from "./InputElement"; | ||||
| import {UIEventSource} from "../../Logic/UIEventSource"; | ||||
| import Combine from "../Base/Combine"; | ||||
| import {UIElement} from "../UIElement"; | ||||
| import BaseUIElement from "../BaseUIElement"; | ||||
| 
 | ||||
| export default class CombinedInputElement<T> extends InputElement<T> { | ||||
|     protected InnerConstructElement(): HTMLElement { | ||||
|        return this._combined.ConstructElement(); | ||||
|     } | ||||
|     private readonly _a: InputElement<T>; | ||||
|     private readonly _b: UIElement; | ||||
|     private readonly _combined: UIElement; | ||||
|     private readonly _b: BaseUIElement; | ||||
|     private readonly _combined: BaseUIElement; | ||||
|     public readonly IsSelected: UIEventSource<boolean>; | ||||
| 
 | ||||
|     constructor(a: InputElement<T>, b: InputElement<T>) { | ||||
|         super(); | ||||
|         this._a = a; | ||||
|  | @ -23,11 +25,6 @@ export default class CombinedInputElement<T> extends InputElement<T> { | |||
|         return this._a.GetValue(); | ||||
|     } | ||||
| 
 | ||||
|     InnerRender(): string { | ||||
|         return this._combined.Render(); | ||||
|     } | ||||
| 
 | ||||
| 
 | ||||
|     IsValid(t: T): boolean { | ||||
|         return this._a.IsValid(t); | ||||
|     } | ||||
|  |  | |||
|  | @ -2,6 +2,7 @@ import {InputElement} from "./InputElement"; | |||
| import {UIEventSource} from "../../Logic/UIEventSource"; | ||||
| import Combine from "../Base/Combine"; | ||||
| import Svg from "../../Svg"; | ||||
| import {FixedUiElement} from "../Base/FixedUiElement"; | ||||
| 
 | ||||
| 
 | ||||
| /** | ||||
|  | @ -9,34 +10,29 @@ import Svg from "../../Svg"; | |||
|  */ | ||||
| export default class DirectionInput extends InputElement<string> { | ||||
| 
 | ||||
|     private readonly value: UIEventSource<string>; | ||||
|     public readonly IsSelected: UIEventSource<boolean> = new UIEventSource<boolean>(false); | ||||
|     private readonly value: UIEventSource<string>; | ||||
| 
 | ||||
|     constructor(value?: UIEventSource<string>) { | ||||
|         super(); | ||||
|         this.dumbMode = false; | ||||
|         this.value = value ?? new UIEventSource<string>(undefined); | ||||
| 
 | ||||
|         this.value.addCallbackAndRun(rotation => { | ||||
|             const selfElement = document.getElementById(this.id); | ||||
|             if (selfElement === null) { | ||||
|                 return; | ||||
|             } | ||||
|             const cone = selfElement.getElementsByClassName("direction-svg")[0] as HTMLElement | ||||
|             cone.style.transform = `rotate(${rotation}deg)`; | ||||
| 
 | ||||
|         }) | ||||
| 
 | ||||
|     } | ||||
| 
 | ||||
| 
 | ||||
|     GetValue(): UIEventSource<string> { | ||||
|         return this.value; | ||||
|     } | ||||
| 
 | ||||
|     InnerRender(): string { | ||||
|         return new Combine([ | ||||
|             `<div id="direction-leaflet-div-${this.id}" style="width:100%;height: 100%;position: absolute;top:0;left:0;border-radius:100%;"></div>`, | ||||
|     IsValid(str: string): boolean { | ||||
|         const t = Number(str); | ||||
|         return !isNaN(t) && t >= 0 && t <= 360; | ||||
|     } | ||||
| 
 | ||||
|     protected InnerConstructElement(): HTMLElement { | ||||
| 
 | ||||
| 
 | ||||
|         const element = new Combine([ | ||||
|             new FixedUiElement("").SetClass("w-full h-full absolute top-0 left-O rounded-full"), | ||||
|             Svg.direction_svg().SetStyle( | ||||
|                 `position: absolute;top: 0;left: 0;width: 100%;height: 100%;transform:rotate(${this.value.data ?? 0}deg);`) | ||||
|                 .SetClass("direction-svg"), | ||||
|  | @ -44,11 +40,21 @@ export default class DirectionInput extends InputElement<string> { | |||
|                 "position: absolute;top: 0;left: 0;width: 100%;height: 100%;") | ||||
|         ]) | ||||
|             .SetStyle("position:relative;display:block;width: min(100%, 25em); padding-top: min(100% , 25em); background:white; border: 1px solid black; border-radius: 999em") | ||||
|             .Render(); | ||||
|             .ConstructElement() | ||||
| 
 | ||||
| 
 | ||||
|         this.value.addCallbackAndRun(rotation => { | ||||
|             const cone = element.getElementsByClassName("direction-svg")[0] as HTMLElement | ||||
|             cone.style.transform = `rotate(${rotation}deg)`; | ||||
| 
 | ||||
|         }) | ||||
| 
 | ||||
|         this.RegisterTriggers(element) | ||||
| 
 | ||||
|         return element; | ||||
|     } | ||||
| 
 | ||||
|     protected InnerUpdate(htmlElement: HTMLElement) { | ||||
|         super.InnerUpdate(htmlElement); | ||||
|     private RegisterTriggers(htmlElement: HTMLElement) { | ||||
|         const self = this; | ||||
| 
 | ||||
|         function onPosChange(x: number, y: number) { | ||||
|  | @ -79,19 +85,16 @@ export default class DirectionInput extends InputElement<string> { | |||
|         } | ||||
| 
 | ||||
|         htmlElement.onmouseup = (ev) => { | ||||
|             isDown = false; ev.preventDefault(); | ||||
|             isDown = false; | ||||
|             ev.preventDefault(); | ||||
|         } | ||||
| 
 | ||||
|         htmlElement.onmousemove = (ev: MouseEvent) => { | ||||
|             if (isDown) { | ||||
|                 onPosChange(ev.clientX, ev.clientY); | ||||
|             } ev.preventDefault(); | ||||
|             } | ||||
|             ev.preventDefault(); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     IsValid(str: string): boolean { | ||||
|         const t = Number(str); | ||||
|         return !isNaN(t) && t >= 0 && t <= 360; | ||||
|     } | ||||
| 
 | ||||
| } | ||||
|  | @ -1,50 +1,92 @@ | |||
| import {UIElement} from "../UIElement"; | ||||
| import {InputElement} from "./InputElement"; | ||||
| import Translations from "../i18n/Translations"; | ||||
| import {UIEventSource} from "../../Logic/UIEventSource"; | ||||
| import BaseUIElement from "../BaseUIElement"; | ||||
| 
 | ||||
| export class DropDown<T> extends InputElement<T> { | ||||
| 
 | ||||
|     private readonly _label: UIElement; | ||||
|     private readonly _values: { value: T; shown: UIElement }[]; | ||||
|     private static _nextDropdownId = 0; | ||||
|     public IsSelected: UIEventSource<boolean> = new UIEventSource<boolean>(false); | ||||
| 
 | ||||
|     private readonly _element: HTMLElement; | ||||
| 
 | ||||
|     private readonly _value: UIEventSource<T>; | ||||
|     private readonly _values: { value: T; shown: string | BaseUIElement }[]; | ||||
| 
 | ||||
|     public IsSelected: UIEventSource<boolean> = new UIEventSource<boolean>(false); | ||||
|     private readonly _label_class: string; | ||||
|     private readonly _select_class: string; | ||||
|     private _form_style: string; | ||||
| 
 | ||||
|     constructor(label: string | UIElement, | ||||
|                 values: { value: T, shown: string | UIElement }[], | ||||
|     constructor(label: string | BaseUIElement, | ||||
|                 values: { value: T, shown: string | BaseUIElement }[], | ||||
|                 value: UIEventSource<T> = undefined, | ||||
|                 label_class: string = "", | ||||
|                 select_class: string = "", | ||||
|                 form_style: string = "flex") { | ||||
|         super(undefined); | ||||
|         this._form_style = form_style; | ||||
|         this._value = value ?? new UIEventSource<T>(undefined); | ||||
|         this._label = Translations.W(label); | ||||
|         this._label_class = label_class || ''; | ||||
|         this._select_class = select_class || ''; | ||||
|         this._values = values.map(v => { | ||||
|             return { | ||||
|                 value: v.value, | ||||
|                     shown: Translations.W(v.shown) | ||||
|                 options?: { | ||||
|                     select_class?: string | ||||
|                 } | ||||
|             } | ||||
|         ); | ||||
|         for (const v of this._values) { | ||||
|             this.ListenTo(v.shown._source); | ||||
|     ) { | ||||
|         super(); | ||||
|         value = value ?? new UIEventSource<T>(undefined) | ||||
|         this._value = value | ||||
|         this._values = values; | ||||
|         if (values.length <= 1) { | ||||
|             return; | ||||
|         } | ||||
|         this.ListenTo(this._value); | ||||
| 
 | ||||
|         this.onClick(() => {}) // by registering a click, the click event is consumed and doesn't bubble furter to other elements, e.g. checkboxes
 | ||||
|         const id = DropDown._nextDropdownId; | ||||
|         DropDown._nextDropdownId++; | ||||
| 
 | ||||
| 
 | ||||
|         const el = document.createElement("form") | ||||
|         this._element = el; | ||||
|         el.id = "dropdown" + id; | ||||
| 
 | ||||
|         { | ||||
|             const labelEl = Translations.W(label).ConstructElement() | ||||
|             if (labelEl !== undefined) { | ||||
|                 const labelHtml = document.createElement("label") | ||||
|                 labelHtml.appendChild(labelEl) | ||||
|                 labelHtml.htmlFor = el.id; | ||||
|                 el.appendChild(labelHtml) | ||||
|             } | ||||
|         } | ||||
| 
 | ||||
|         options = options ?? {} | ||||
|         options.select_class = options.select_class ?? 'bg-indigo-100 p-1 rounded hover:bg-indigo-200' | ||||
| 
 | ||||
| 
 | ||||
|         { | ||||
|             const select = document.createElement("select") | ||||
|             select.classList.add(...(options.select_class.split(" ") ?? [])) | ||||
|             for (let i = 0; i < values.length; i++) { | ||||
| 
 | ||||
|                 const option = document.createElement("option") | ||||
|                 option.value = "" + i | ||||
|                 option.appendChild(Translations.W(values[i].shown).ConstructElement()) | ||||
|                 select.appendChild(option) | ||||
|             } | ||||
|             el.appendChild(select) | ||||
| 
 | ||||
| 
 | ||||
|             select.onchange = (() => { | ||||
|                 var index = select.selectedIndex; | ||||
|                 value.setData(values[index].value); | ||||
|             }); | ||||
| 
 | ||||
|             value.addCallbackAndRun(selected => { | ||||
|                 for (let i = 0; i < values.length; i++) { | ||||
|                     const value = values[i].value; | ||||
|                     if (value === selected) { | ||||
|                         select.selectedIndex = i; | ||||
|                     } | ||||
|                 } | ||||
|             }) | ||||
|         } | ||||
| 
 | ||||
| 
 | ||||
|         this.onClick(() => { | ||||
|         }) // by registering a click, the click event is consumed and doesn't bubble further to other elements, e.g. checkboxes
 | ||||
|     } | ||||
| 
 | ||||
|     GetValue(): UIEventSource<T> { | ||||
|         return this._value; | ||||
|     } | ||||
| 
 | ||||
|     IsValid(t: T): boolean { | ||||
|         for (const value of this._values) { | ||||
|             if (value.value === t) { | ||||
|  | @ -54,44 +96,8 @@ export class DropDown<T> extends InputElement<T> { | |||
|         return false | ||||
|     } | ||||
| 
 | ||||
| 
 | ||||
|     InnerRender(): string { | ||||
|         if(this._values.length <=1){ | ||||
|             return ""; | ||||
|         } | ||||
| 
 | ||||
|         let options = ""; | ||||
|         for (let i = 0; i < this._values.length; i++) { | ||||
|             options += "<option value='" + i + "'>" + this._values[i].shown.InnerRender() + "</option>" | ||||
|         } | ||||
| 
 | ||||
|         return `<form class="${this._form_style}">` + | ||||
|             `<label class='${this._label_class}' for='dropdown-${this.id}'>${this._label.Render()}</label>` + | ||||
|             `<select class='${this._select_class}' name='dropdown-${this.id}' id='dropdown-${this.id}'>` + | ||||
|             options + | ||||
|             `</select>` + | ||||
|             `</form>`; | ||||
|     protected InnerConstructElement(): HTMLElement { | ||||
|         return this._element; | ||||
|     } | ||||
| 
 | ||||
|     protected InnerUpdate(element) { | ||||
|         var e = document.getElementById("dropdown-" + this.id); | ||||
|         if(e === null){ | ||||
|             return; | ||||
|         } | ||||
|         const self = this; | ||||
|         e.onchange = (() => { | ||||
|             // @ts-ignore
 | ||||
|             var index = parseInt(e.selectedIndex); | ||||
|             self._value.setData(self._values[index].value); | ||||
|         }); | ||||
| 
 | ||||
|         var t = this._value.data; | ||||
|         for (let i = 0; i < this._values.length ; i++) { | ||||
|             const value = this._values[i].value; | ||||
|             if (value === t) { | ||||
|                 // @ts-ignore
 | ||||
|                 e.selectedIndex = i; | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										66
									
								
								UI/Input/FileSelectorButton.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										66
									
								
								UI/Input/FileSelectorButton.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,66 @@ | |||
| import BaseUIElement from "../BaseUIElement"; | ||||
| import {InputElement} from "./InputElement"; | ||||
| import {UIEventSource} from "../../Logic/UIEventSource"; | ||||
| 
 | ||||
| export default class FileSelectorButton extends InputElement<FileList> { | ||||
| 
 | ||||
|     private static _nextid; | ||||
|     IsSelected: UIEventSource<boolean>; | ||||
|     private readonly _value = new UIEventSource(undefined); | ||||
|     private readonly _label: BaseUIElement; | ||||
|     private readonly _acceptType: string; | ||||
| 
 | ||||
|     constructor(label: BaseUIElement, acceptType: string = "image/*") { | ||||
|         super(); | ||||
|         this._label = label; | ||||
|         this._acceptType = acceptType; | ||||
|         this.SetClass("block cursor-pointer") | ||||
|         label.SetClass("cursor-pointer") | ||||
|     } | ||||
| 
 | ||||
|     GetValue(): UIEventSource<FileList> { | ||||
|         return this._value; | ||||
|     } | ||||
| 
 | ||||
|     IsValid(t: FileList): boolean { | ||||
|         return true; | ||||
|     } | ||||
| 
 | ||||
|     protected InnerConstructElement(): HTMLElement { | ||||
|         const self = this; | ||||
|         const el = document.createElement("form") | ||||
|         const label = document.createElement("label") | ||||
|         label.appendChild(this._label.ConstructElement()) | ||||
|         el.appendChild(label) | ||||
| 
 | ||||
|         const actualInputElement = document.createElement("input"); | ||||
|         actualInputElement.style.cssText = "display:none"; | ||||
|         actualInputElement.type = "file"; | ||||
|         actualInputElement.accept = this._acceptType; | ||||
|         actualInputElement.name = "picField"; | ||||
|         actualInputElement.multiple = true; | ||||
|         actualInputElement.id = "fileselector" + FileSelectorButton._nextid; | ||||
|         FileSelectorButton._nextid++; | ||||
| 
 | ||||
|         label.htmlFor = actualInputElement.id; | ||||
| 
 | ||||
|         actualInputElement.onchange = () => { | ||||
|             if (actualInputElement.files !== null) { | ||||
|                 self._value.setData(actualInputElement.files) | ||||
|             } | ||||
|         } | ||||
| 
 | ||||
|         el.addEventListener('submit', e => { | ||||
|             if (actualInputElement.files !== null) { | ||||
|                 self._value.setData(actualInputElement.files) | ||||
|             } | ||||
|             e.preventDefault() | ||||
|         }) | ||||
| 
 | ||||
|         el.appendChild(actualInputElement) | ||||
| 
 | ||||
|         return el; | ||||
|     } | ||||
| 
 | ||||
| 
 | ||||
| } | ||||
|  | @ -1,44 +1,46 @@ | |||
| import {InputElement} from "./InputElement"; | ||||
| import {UIElement} from "../UIElement"; | ||||
| import {FixedUiElement} from "../Base/FixedUiElement"; | ||||
| import {UIEventSource} from "../../Logic/UIEventSource"; | ||||
| import Translations from "../i18n/Translations"; | ||||
| import BaseUIElement from "../BaseUIElement"; | ||||
| 
 | ||||
| export class FixedInputElement<T> extends InputElement<T> { | ||||
|     private readonly rendering: UIElement; | ||||
|     private readonly value: UIEventSource<T>; | ||||
|     public readonly IsSelected : UIEventSource<boolean> = new UIEventSource<boolean>(false); | ||||
|     private readonly _comparator: (t0: T, t1: T) => boolean; | ||||
| 
 | ||||
|     constructor(rendering: UIElement | string,  | ||||
|     private readonly _el : HTMLElement; | ||||
|      | ||||
|     constructor(rendering: BaseUIElement | string,  | ||||
|                 value: T, | ||||
|                 comparator: ((t0: T, t1: T) => boolean ) = undefined) { | ||||
|         super(undefined); | ||||
|         super(); | ||||
|         this._comparator = comparator ?? ((t0, t1) => t0 == t1); | ||||
|         this.value = new UIEventSource<T>(value); | ||||
|         this.rendering = typeof (rendering) === 'string' ? new FixedUiElement(rendering) : rendering; | ||||
|         const self = this; | ||||
| 
 | ||||
|         const selected = this.IsSelected; | ||||
|         this._el = document.createElement("span") | ||||
|         this._el.addEventListener("mouseout", () => selected.setData(false)) | ||||
|         const e = Translations.W(rendering)?.ConstructElement() | ||||
|         if(e){ | ||||
|             this._el.appendChild( e) | ||||
|         } | ||||
|          | ||||
|         this.onClick(() => { | ||||
|             self.IsSelected.setData(true) | ||||
|             selected.setData(true) | ||||
|         }) | ||||
|     } | ||||
| 
 | ||||
|     protected InnerConstructElement(): HTMLElement { | ||||
|         return this._el; | ||||
|     } | ||||
| 
 | ||||
|     GetValue(): UIEventSource<T> { | ||||
|         return this.value; | ||||
|     } | ||||
|     InnerRender(): string { | ||||
|         return this.rendering.Render(); | ||||
|     } | ||||
| 
 | ||||
|     IsValid(t: T): boolean { | ||||
|         return this._comparator(t, this.value.data); | ||||
|     } | ||||
| 
 | ||||
|     protected InnerUpdate(htmlElement: HTMLElement) { | ||||
|         super.InnerUpdate(htmlElement); | ||||
|         const self = this; | ||||
|         htmlElement.addEventListener("mouseout", () => self.IsSelected.setData(false)) | ||||
| 
 | ||||
|     } | ||||
| 
 | ||||
| 
 | ||||
| } | ||||
|  | @ -1,7 +1,7 @@ | |||
| import {UIElement} from "../UIElement"; | ||||
| import {UIEventSource} from "../../Logic/UIEventSource"; | ||||
| import BaseUIElement from "../BaseUIElement"; | ||||
| 
 | ||||
| export abstract class InputElement<T> extends UIElement{ | ||||
| export abstract class InputElement<T> extends BaseUIElement{ | ||||
|      | ||||
|     abstract GetValue() : UIEventSource<T>; | ||||
|     abstract IsSelected: UIEventSource<boolean>; | ||||
|  |  | |||
Some files were not shown because too many files have changed in this diff Show more
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue