forked from MapComplete/MapComplete
		
	Add support for units to clean up tags when they enter mapcomplete; add example of this usage in the climbing theme, add climbing theme title icons with length and needed number of carabiners
This commit is contained in:
		
							parent
							
								
									89f6f606c8
								
							
						
					
					
						commit
						966fcda8d1
					
				
					 20 changed files with 302 additions and 111 deletions
				
			
		|  | @ -9,11 +9,13 @@ export default class AllKnownLayers { | |||
|     public static sharedLayers: Map<string, LayerConfig> = AllKnownLayers.getSharedLayers(); | ||||
|     public static sharedLayersJson: Map<string, any> = AllKnownLayers.getSharedLayersJson(); | ||||
|      | ||||
|     public static sharedUnits: any[] = [] | ||||
| 
 | ||||
|     private static getSharedLayers(): Map<string, LayerConfig> { | ||||
|         const sharedLayers = new Map<string, LayerConfig>(); | ||||
|         for (const layer of known_layers.layers) { | ||||
|             try { | ||||
|                 const parsed = new LayerConfig(layer, "shared_layers") | ||||
|                 const parsed = new LayerConfig(layer, AllKnownLayers.sharedUnits,"shared_layers") | ||||
|                 sharedLayers.set(layer.id, parsed); | ||||
|                 sharedLayers[layer.id] = parsed; | ||||
|             } catch (e) { | ||||
|  | @ -33,7 +35,7 @@ export default class AllKnownLayers { | |||
|                     continue; | ||||
|                 } | ||||
|                 try { | ||||
|                     const parsed = new LayerConfig(layer, "shared_layer_in_theme") | ||||
|                     const parsed = new LayerConfig(layer, AllKnownLayers.sharedUnits ,"shared_layer_in_theme") | ||||
|                     sharedLayers.set(layer.id, parsed); | ||||
|                     sharedLayers[layer.id] = parsed; | ||||
|                 } catch (e) { | ||||
|  |  | |||
|  | @ -1,13 +1,59 @@ | |||
| import {Translation} from "../../UI/i18n/Translation"; | ||||
| import UnitConfigJson from "./UnitConfigJson"; | ||||
| import Translations from "../../UI/i18n/Translations"; | ||||
| import BaseUIElement from "../../UI/BaseUIElement"; | ||||
| import Combine from "../../UI/Base/Combine"; | ||||
| 
 | ||||
| export class Unit { | ||||
|     public readonly human: Translation; | ||||
|     public readonly appliesToKeys: Set<string>; | ||||
|     public readonly denominations : Denomination[]; | ||||
|     public readonly defaultDenom: Denomination; | ||||
|     constructor(appliesToKeys: string[], applicableUnits: Denomination[]) { | ||||
|         this.appliesToKeys = new Set( appliesToKeys); | ||||
|         this.denominations = applicableUnits; | ||||
| this.defaultDenom = applicableUnits.filter(denom => denom.default)[0] | ||||
|     } | ||||
| 
 | ||||
|     isApplicableToKey(key: string | undefined) : boolean { | ||||
|         if(key === undefined){ | ||||
|             return false; | ||||
|         } | ||||
|          | ||||
|         return this.appliesToKeys.has(key); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Finds which denomination is applicable and gives the stripped value back | ||||
|      */ | ||||
|     findDenomination(valueWithDenom: string) : [string, Denomination] { | ||||
|         for (const denomination of this.denominations) { | ||||
|           const bare =  denomination.StrippedValue(valueWithDenom) | ||||
|             if(bare !== null){ | ||||
|                 return [bare, denomination] | ||||
|             } | ||||
|         }  | ||||
|         return [undefined, undefined] | ||||
|     } | ||||
| 
 | ||||
|     asHumanLongValue(value: string): BaseUIElement { | ||||
|         if(value === undefined){ | ||||
|             return undefined; | ||||
|         } | ||||
|         const [stripped, denom] = this.findDenomination(value) | ||||
|         const human = denom.human | ||||
|          | ||||
|         const elems = denom.prefix ? [human, stripped] : [stripped , human]; | ||||
|         return new Combine(elems) | ||||
|          | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| export class Denomination { | ||||
|     private readonly _human: Translation; | ||||
|     private readonly alternativeDenominations: string []; | ||||
|     private readonly canonical: string; | ||||
|     private readonly default: boolean; | ||||
|     private readonly prefix: boolean; | ||||
|     public readonly canonical: string; | ||||
|     readonly default: boolean; | ||||
|     readonly prefix: boolean; | ||||
| 
 | ||||
|     constructor(json: UnitConfigJson, context: string) { | ||||
|         context = `${context}.unit(${json.canonicalDenomination})` | ||||
|  | @ -26,15 +72,22 @@ export class Unit { | |||
| 
 | ||||
|         this.default = json.default ?? false; | ||||
| 
 | ||||
|         this.human = Translations.T(json.human, context + "human") | ||||
|         this._human = Translations.T(json.human, context + "human") | ||||
| 
 | ||||
|         this.prefix = json.prefix ?? false; | ||||
| 
 | ||||
|     } | ||||
|      | ||||
|     public canonicalValue(value: string) { | ||||
|         const stripped = this.StrippedValue(value) | ||||
|         if(stripped === null){ | ||||
|     get human() : Translation { | ||||
|         return this._human.Clone() | ||||
|     } | ||||
| 
 | ||||
|     public canonicalValue(value: string, actAsDefault?: boolean) { | ||||
|         if(value === undefined){ | ||||
|             return undefined; | ||||
|         } | ||||
|         const stripped = this.StrippedValue(value, actAsDefault) | ||||
|         if (stripped === null) { | ||||
|             return null; | ||||
|         } | ||||
|         return stripped + this.canonical | ||||
|  | @ -46,10 +99,12 @@ export class Unit { | |||
|      * - the value is a Number (without unit) and default is set | ||||
|      * | ||||
|      * Returns null if it doesn't match this unit | ||||
|      * @param value | ||||
|      * @constructor | ||||
|      */ | ||||
|     private StrippedValue(value: string): string { | ||||
|     public StrippedValue(value: string, actAsDefault?: boolean): string { | ||||
| 
 | ||||
|         if(value === undefined){ | ||||
|             return undefined; | ||||
|         } | ||||
|          | ||||
|         if (this.prefix) { | ||||
|             if (value.startsWith(this.canonical)) { | ||||
|  | @ -72,7 +127,7 @@ export class Unit { | |||
|         } | ||||
| 
 | ||||
| 
 | ||||
|         if (this.default) { | ||||
|         if (this.default || actAsDefault) { | ||||
|             const parsed = Number(value.trim()) | ||||
|             if (!isNaN(parsed)) { | ||||
|                 return value.trim(); | ||||
|  | @ -17,6 +17,7 @@ import {TagsFilter} from "../../Logic/Tags/TagsFilter"; | |||
| import {Tag} from "../../Logic/Tags/Tag"; | ||||
| import SubstitutingTag from "../../Logic/Tags/SubstitutingTag"; | ||||
| import BaseUIElement from "../../UI/BaseUIElement"; | ||||
| import {Denomination, Unit} from "./Denomination"; | ||||
| 
 | ||||
| export default class LayerConfig { | ||||
| 
 | ||||
|  | @ -46,6 +47,7 @@ export default class LayerConfig { | |||
|     width: TagRenderingConfig; | ||||
|     dashArray: TagRenderingConfig; | ||||
|     wayHandling: number; | ||||
|     public readonly units: Unit[]; | ||||
| 
 | ||||
|     presets: { | ||||
|         title: Translation, | ||||
|  | @ -56,8 +58,10 @@ export default class LayerConfig { | |||
|     tagRenderings: TagRenderingConfig []; | ||||
| 
 | ||||
|     constructor(json: LayerConfigJson, | ||||
|                 units:Unit[], | ||||
|                 context?: string, | ||||
|                 official: boolean = true,) { | ||||
|         this.units = units; | ||||
|         context = context + "." + json.id; | ||||
|         const self = this; | ||||
|         this.id = json.id; | ||||
|  |  | |||
|  | @ -109,7 +109,8 @@ export interface LayerConfigJson { | |||
|     /** | ||||
|      * Small icons shown next to the title. | ||||
|      * If not specified, the OsmLink and wikipedia links will be used by default. | ||||
|      * Use an empty array to hide them | ||||
|      * Use an empty array to hide them. | ||||
|      * Note that "defaults" will insert all the default titleIcons | ||||
|      */ | ||||
|     titleIcons?: (string | TagRenderingConfigJson)[]; | ||||
| 
 | ||||
|  |  | |||
|  | @ -5,7 +5,7 @@ import {LayoutConfigJson} from "./LayoutConfigJson"; | |||
| import AllKnownLayers from "../AllKnownLayers"; | ||||
| import SharedTagRenderings from "../SharedTagRenderings"; | ||||
| import {Utils} from "../../Utils"; | ||||
| import {Unit} from "./Unit"; | ||||
| import {Denomination, Unit} from "./Denomination"; | ||||
| 
 | ||||
| export default class LayoutConfig { | ||||
|     public readonly id: string; | ||||
|  | @ -47,7 +47,7 @@ export default class LayoutConfig { | |||
|     How long is the cache valid, in seconds? | ||||
|      */ | ||||
|     public readonly cacheTimeout?: number; | ||||
|     public readonly units: { appliesToKeys: Set<string>, applicableUnits: Unit[] }[] = [] | ||||
|     public readonly units: Unit[] = [] | ||||
|     private readonly _official: boolean; | ||||
| 
 | ||||
|     constructor(json: LayoutConfigJson, official = true, context?: string) { | ||||
|  | @ -73,6 +73,7 @@ export default class LayoutConfig { | |||
|         if (json.description === undefined) { | ||||
|             throw "Description not defined in " + this.id; | ||||
|         } | ||||
|         this.units = LayoutConfig.ExtractUnits(json, context); | ||||
|         this.title = new Translation(json.title, context + ".title"); | ||||
|         this.description = new Translation(json.description, context + ".description"); | ||||
|         this.shortDescription = json.shortDescription === undefined ? this.description.FirstSentence() : new Translation(json.shortDescription, context + ".shortdescription"); | ||||
|  | @ -98,7 +99,7 @@ export default class LayoutConfig { | |||
|                 if (AllKnownLayers.sharedLayersJson[layer] !== undefined) { | ||||
|                     if (json.overrideAll !== undefined) { | ||||
|                         let lyr = JSON.parse(JSON.stringify(AllKnownLayers.sharedLayersJson[layer])); | ||||
|                         return new LayerConfig(Utils.Merge(json.overrideAll, lyr), `${this.id}+overrideAll.layers[${i}]`, official); | ||||
|                         return new LayerConfig(Utils.Merge(json.overrideAll, lyr), this.units,`${this.id}+overrideAll.layers[${i}]`, official); | ||||
|                     } else { | ||||
|                         return AllKnownLayers.sharedLayers[layer] | ||||
|                     } | ||||
|  | @ -124,7 +125,7 @@ export default class LayoutConfig { | |||
|             } | ||||
| 
 | ||||
|             // @ts-ignore
 | ||||
|             return new LayerConfig(layer, `${this.id}.layers[${i}]`, official) | ||||
|             return new LayerConfig(layer, this.units, `${this.id}.layers[${i}]`, official) | ||||
|         }); | ||||
| 
 | ||||
|         // ALl the layers are constructed, let them share tags in now!
 | ||||
|  | @ -187,6 +188,10 @@ export default class LayoutConfig { | |||
|         this.cacheTimeout = json.cacheTimout ?? (60 * 24 * 60 * 60) | ||||
| 
 | ||||
| 
 | ||||
|     } | ||||
| 
 | ||||
|     private static ExtractUnits(json: LayoutConfigJson, context: string) : Unit[]{ | ||||
|         const result: Unit[] = [] | ||||
|         if ((json.units ?? []).length !== 0) { | ||||
|             for (let i1 = 0; i1 < json.units.length; i1++) { | ||||
|                 let unit = json.units[i1]; | ||||
|  | @ -206,7 +211,7 @@ export default class LayoutConfig { | |||
| 
 | ||||
|                 const defaultSet = unit.applicableUnits.filter(u => u.default === true) | ||||
|                 // No default is defined - we pick the first as default
 | ||||
|                 if(defaultSet.length === 0){ | ||||
|                 if (defaultSet.length === 0) { | ||||
|                     unit.applicableUnits[0].default = true | ||||
|                 } | ||||
| 
 | ||||
|  | @ -214,22 +219,23 @@ export default class LayoutConfig { | |||
|                 if (defaultSet.length > 1) { | ||||
|                     throw `Multiple units are set as default: they have canonical values of ${defaultSet.map(u => u.canonicalDenomination).join(", ")}` | ||||
|                 } | ||||
|                 const applicable = unit.applicableUnits.map((u, i) => new Unit(u, `${context}.units[${i}]`)) | ||||
|                 this.units.push({ | ||||
|                     appliesToKeys: new Set(appliesTo), | ||||
|                     applicableUnits: applicable | ||||
|                 }) | ||||
|                 const applicable = unit.applicableUnits.map((u, i) => new Denomination(u, `${context}.units[${i}]`)) | ||||
|                 result.push(new Unit(                   appliesTo, applicable)); | ||||
|             } | ||||
| 
 | ||||
|             const seenKeys = new Set<string>() | ||||
|             for (const unit of this.units) { | ||||
|                 const alreadySeen = Array.from(unit.appliesToKeys).filter(key => seenKeys.has(key)); | ||||
|             for (const unit of result) { | ||||
|                 const alreadySeen = Array.from(unit.appliesToKeys).filter((key: string) => seenKeys.has(key)); | ||||
|                 if (alreadySeen.length > 0) { | ||||
|                     throw `${context}.units: multiple units define the same keys. The key(s) ${alreadySeen.join(",")} occur multiple times` | ||||
|                 } | ||||
|                 unit.appliesToKeys.forEach(key => seenKeys.add(key)) | ||||
|             } | ||||
|             return result; | ||||
| 
 | ||||
|         } | ||||
| 
 | ||||
| 
 | ||||
|     } | ||||
| 
 | ||||
|     public CustomCodeSnippets(): string[] { | ||||
|  |  | |||
|  | @ -85,7 +85,7 @@ export default class SimpleMetaTagger { | |||
|         (feature => { | ||||
|             const units = State.state.layoutToUse.data.units ?? []; | ||||
|             for (const key in feature.properties) { | ||||
|                 if(!feature.properties.hasOwnProperty(key)){ | ||||
|                 if (!feature.properties.hasOwnProperty(key)) { | ||||
|                     continue; | ||||
|                 } | ||||
|                 for (const unit of units) { | ||||
|  | @ -93,15 +93,10 @@ export default class SimpleMetaTagger { | |||
|                         continue; | ||||
|                     } | ||||
|                     const value = feature.properties[key] | ||||
|                      | ||||
|                     for (const applicableUnit of unit.applicableUnits) { | ||||
|                         const canonical = applicableUnit.canonicalValue(value) | ||||
|                         if (canonical == null) { | ||||
|                             continue | ||||
|                         } | ||||
|                         console.log("Rewritten ", key, " from", value, "into", canonical) | ||||
|                         feature.properties[key] = canonical; | ||||
|                     } | ||||
|                     const [, denomination] = unit.findDenomination(value) | ||||
|                     const canonical = denomination.canonicalValue(value) | ||||
|                     console.log("Rewritten ", key, " from", value, "into", canonical) | ||||
|                     feature.properties[key] = canonical; | ||||
|                 } | ||||
| 
 | ||||
|             } | ||||
|  |  | |||
|  | @ -2,7 +2,7 @@ import { Utils } from "../Utils"; | |||
| 
 | ||||
| export default class Constants { | ||||
|      | ||||
|     public static vNumber = "0.8.0a"; | ||||
|     public static vNumber = "0.8.1"; | ||||
| 
 | ||||
|     // The user journey states thresholds when a new feature gets unlocked
 | ||||
|     public static userJourney = { | ||||
|  |  | |||
|  | @ -3,30 +3,48 @@ import {UIEventSource} from "../../Logic/UIEventSource"; | |||
| import Combine from "../Base/Combine"; | ||||
| 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: BaseUIElement; | ||||
|     private readonly _combined: BaseUIElement; | ||||
| export default class CombinedInputElement<T, J, X> extends InputElement<X> { | ||||
| 
 | ||||
|     public readonly IsSelected: UIEventSource<boolean>; | ||||
|     constructor(a: InputElement<T>, b: InputElement<T>) { | ||||
|     private readonly _a: InputElement<T>; | ||||
|     private readonly _b: InputElement<J>; | ||||
|     private readonly _combined: BaseUIElement; | ||||
|     private readonly _value: UIEventSource<X> | ||||
|     private readonly _split: (x: X) => [T, J]; | ||||
| 
 | ||||
|     constructor(a: InputElement<T>, b: InputElement<J>, | ||||
|                 combine: (t: T, j: J) => X, | ||||
|                 split: (x: X) => [T, J]) { | ||||
|         super(); | ||||
|         this._a = a; | ||||
|         this._b = b; | ||||
|         this._split = split; | ||||
|         this.IsSelected = this._a.IsSelected.map((isSelected) => { | ||||
|             return isSelected || b.IsSelected.data | ||||
|         }, [b.IsSelected]) | ||||
|         this._combined = new Combine([this._a, this._b]); | ||||
|         this._value = this._a.GetValue().map( | ||||
|             t => combine(t, this._b.GetValue().data), | ||||
|             [this._b.GetValue()], | ||||
|         ) | ||||
|             .addCallback(x => { | ||||
|                 const [t, j] = split(x) | ||||
|                 this._a.GetValue().setData(t) | ||||
|                 this._b.GetValue().setData(j) | ||||
|             }) | ||||
|     } | ||||
| 
 | ||||
|     GetValue(): UIEventSource<T> { | ||||
|         return this._a.GetValue(); | ||||
|     GetValue(): UIEventSource<X> { | ||||
|         return this._value; | ||||
|     } | ||||
| 
 | ||||
|     IsValid(t: T): boolean { | ||||
|         return this._a.IsValid(t); | ||||
|     IsValid(x: X): boolean { | ||||
|         const [t, j] = this._split(x) | ||||
|         return this._a.IsValid(t) && this._b.IsValid(j); | ||||
|     } | ||||
| 
 | ||||
|     protected InnerConstructElement(): HTMLElement { | ||||
|         return this._combined.ConstructElement(); | ||||
|     } | ||||
| 
 | ||||
| } | ||||
|  | @ -270,7 +270,10 @@ export default class ValidatedTextField { | |||
|         if (tp.inputHelper) { | ||||
|             input = new CombinedInputElement(input, tp.inputHelper(input.GetValue(), { | ||||
|                 location: options.location | ||||
|             })); | ||||
|             }), | ||||
|                 (a, b) => a, // We can ignore b, as they are linked earlier
 | ||||
|                 a => [a, a] | ||||
|                 ); | ||||
|         } | ||||
|         return input; | ||||
|     } | ||||
|  |  | |||
|  | @ -8,11 +8,13 @@ import State from "../../State"; | |||
| import Svg from "../../Svg"; | ||||
| import Toggle from "../Input/Toggle"; | ||||
| import BaseUIElement from "../BaseUIElement"; | ||||
| import {Unit} from "../../Customizations/JSON/Denomination"; | ||||
| 
 | ||||
| export default class EditableTagRendering extends Toggle { | ||||
| 
 | ||||
|     constructor(tags: UIEventSource<any>, | ||||
|                 configuration: TagRenderingConfig, | ||||
|                 units: Unit [], | ||||
|                 editMode = new UIEventSource<boolean>(false) | ||||
|                 ) { | ||||
|         const answer: BaseUIElement = new TagRenderingAnswer(tags, configuration) | ||||
|  | @ -41,7 +43,7 @@ export default class EditableTagRendering extends Toggle { | |||
|                         editMode.setData(false) | ||||
|                     }); | ||||
| 
 | ||||
|             const question = new TagRenderingQuestion(tags, configuration, | ||||
|             const question = new TagRenderingQuestion(tags, configuration,units, | ||||
|                 () => { | ||||
|                     editMode.setData(false) | ||||
|                 }, | ||||
|  |  | |||
|  | @ -17,7 +17,7 @@ export default class FeatureInfoBox extends ScrollableFullScreen { | |||
| 
 | ||||
|     public constructor( | ||||
|         tags: UIEventSource<any>, | ||||
|         layerConfig: LayerConfig | ||||
|         layerConfig: LayerConfig, | ||||
|     ) { | ||||
|         super(() => FeatureInfoBox.GenerateTitleBar(tags, layerConfig), | ||||
|             () => FeatureInfoBox.GenerateContent(tags, layerConfig), | ||||
|  | @ -35,7 +35,7 @@ export default class FeatureInfoBox extends ScrollableFullScreen { | |||
|             .SetClass("break-words font-bold sm:p-0.5 md:p-1 sm:p-1.5 md:p-2"); | ||||
|         const titleIcons = new Combine( | ||||
|             layerConfig.titleIcons.map(icon => new TagRenderingAnswer(tags, icon, | ||||
|                 "block w-8 h-8 align-baseline box-content sm:p-0.5", "width: 2rem !important;") | ||||
|                 "block w-8 h-8 align-baseline box-content sm:p-0.5") | ||||
|             )) | ||||
|             .SetClass("flex flex-row flex-wrap pt-0.5 sm:pt-1 items-center mr-2") | ||||
| 
 | ||||
|  | @ -49,7 +49,7 @@ export default class FeatureInfoBox extends ScrollableFullScreen { | |||
|         let questionBox: UIElement = undefined; | ||||
| 
 | ||||
|         if (State.state.featureSwitchUserbadge.data) { | ||||
|             questionBox = new QuestionBox(tags, layerConfig.tagRenderings); | ||||
|             questionBox = new QuestionBox(tags, layerConfig.tagRenderings, layerConfig.units); | ||||
|         } | ||||
| 
 | ||||
|         let questionBoxIsUsed = false; | ||||
|  | @ -59,7 +59,7 @@ export default class FeatureInfoBox extends ScrollableFullScreen { | |||
|                 questionBoxIsUsed = true; | ||||
|                 return questionBox; | ||||
|             } | ||||
|             return new EditableTagRendering(tags, tr); | ||||
|             return new EditableTagRendering(tags, tr, layerConfig.units); | ||||
|         }); | ||||
|         if (!questionBoxIsUsed) { | ||||
|             renderings.push(questionBox); | ||||
|  |  | |||
|  | @ -5,6 +5,8 @@ import TagRenderingQuestion from "./TagRenderingQuestion"; | |||
| import Translations from "../i18n/Translations"; | ||||
| import State from "../../State"; | ||||
| import Combine from "../Base/Combine"; | ||||
| import BaseUIElement from "../BaseUIElement"; | ||||
| import {Unit} from "../../Customizations/JSON/Denomination"; | ||||
| 
 | ||||
| 
 | ||||
| /** | ||||
|  | @ -14,12 +16,12 @@ export default class QuestionBox extends UIElement { | |||
|     private readonly _tags: UIEventSource<any>; | ||||
| 
 | ||||
|     private readonly _tagRenderings: TagRenderingConfig[]; | ||||
|     private _tagRenderingQuestions: UIElement[]; | ||||
|     private _tagRenderingQuestions: BaseUIElement[]; | ||||
| 
 | ||||
|     private _skippedQuestions: UIEventSource<number[]> = new UIEventSource<number[]>([]) | ||||
|     private _skippedQuestionsButton: UIElement; | ||||
|     private _skippedQuestionsButton: BaseUIElement; | ||||
| 
 | ||||
|     constructor(tags: UIEventSource<any>, tagRenderings: TagRenderingConfig[]) { | ||||
|     constructor(tags: UIEventSource<any>, tagRenderings: TagRenderingConfig[], units: Unit[]) { | ||||
|         super(tags); | ||||
|         this.ListenTo(this._skippedQuestions); | ||||
|         this._tags = tags; | ||||
|  | @ -28,7 +30,7 @@ export default class QuestionBox extends UIElement { | |||
|             .filter(tr => tr.question !== undefined) | ||||
|             .filter(tr => tr.question !== null); | ||||
|         this._tagRenderingQuestions = this._tagRenderings | ||||
|             .map((tagRendering, i) => new TagRenderingQuestion(this._tags, tagRendering, | ||||
|             .map((tagRendering, i) => new TagRenderingQuestion(this._tags, tagRendering,units, | ||||
|                 () => { | ||||
|                     // We save
 | ||||
|                     self._skippedQuestions.ping(); | ||||
|  | @ -49,7 +51,7 @@ export default class QuestionBox extends UIElement { | |||
|     } | ||||
| 
 | ||||
|     InnerRender() { | ||||
|         const allQuestions : UIElement[] = [] | ||||
|         const allQuestions : BaseUIElement[] = [] | ||||
|         for (let i = 0; i < this._tagRenderingQuestions.length; i++) { | ||||
|             let tagRendering = this._tagRenderings[i]; | ||||
| 
 | ||||
|  |  | |||
|  | @ -24,6 +24,8 @@ import {And} from "../../Logic/Tags/And"; | |||
| import {TagUtils} from "../../Logic/Tags/TagUtils"; | ||||
| import BaseUIElement from "../BaseUIElement"; | ||||
| import {DropDown} from "../Input/DropDown"; | ||||
| import {Unit} from "../../Customizations/JSON/Denomination"; | ||||
| import CombinedInputElement from "../Input/CombinedInputElement"; | ||||
| 
 | ||||
| /** | ||||
|  * Shows the question element. | ||||
|  | @ -38,14 +40,17 @@ export default class TagRenderingQuestion extends UIElement { | |||
|     private _inputElement: InputElement<TagsFilter>; | ||||
|     private _cancelButton: BaseUIElement; | ||||
|     private _appliedTags: BaseUIElement; | ||||
|     private readonly _applicableUnit: Unit; | ||||
|     private _question: BaseUIElement; | ||||
| 
 | ||||
|     constructor(tags: UIEventSource<any>, | ||||
|                 configuration: TagRenderingConfig, | ||||
|                 units: Unit[], | ||||
|                 afterSave?: () => void, | ||||
|                 cancelButton?: BaseUIElement | ||||
|     ) { | ||||
|         super(tags); | ||||
|         this._applicableUnit = units.filter(unit => unit.isApplicableToKey(configuration.freeform?.key))[0]; | ||||
|         this._tags = tags; | ||||
|         this._configuration = configuration; | ||||
|         this._cancelButton = cancelButton; | ||||
|  | @ -114,9 +119,9 @@ export default class TagRenderingQuestion extends UIElement { | |||
|         const self = this; | ||||
|         let inputEls: InputElement<TagsFilter>[]; | ||||
| 
 | ||||
|         const mappings = (this._configuration.mappings??[]) | ||||
|             .filter(            mapping => { | ||||
|                 if(mapping.hideInAnswer === true){ | ||||
|         const mappings = (this._configuration.mappings ?? []) | ||||
|             .filter(mapping => { | ||||
|                 if (mapping.hideInAnswer === true) { | ||||
|                     return false; | ||||
|                 } | ||||
|                 if (typeof (mapping.hideInAnswer) !== "boolean" && mapping.hideInAnswer.matchesProperties(this._tags.data)) { | ||||
|  | @ -126,7 +131,7 @@ export default class TagRenderingQuestion extends UIElement { | |||
|             }) | ||||
| 
 | ||||
| 
 | ||||
|         let allIfNots: TagsFilter[] = Utils.NoNull(this._configuration.mappings?.map(m => m.ifnot) ?? []    ); | ||||
|         let allIfNots: TagsFilter[] = Utils.NoNull(this._configuration.mappings?.map(m => m.ifnot) ?? []); | ||||
|         const ff = this.GenerateFreeform(); | ||||
|         const hasImages = mappings.filter(mapping => mapping.then.ExtractImages().length > 0).length > 0 | ||||
| 
 | ||||
|  | @ -323,16 +328,41 @@ export default class TagRenderingQuestion extends UIElement { | |||
|             return undefined; | ||||
|         } | ||||
| 
 | ||||
|         const textField = ValidatedTextField.InputForType(this._configuration.freeform.type, { | ||||
|         let input: InputElement<string> = ValidatedTextField.InputForType(this._configuration.freeform.type, { | ||||
|             isValid: (str) => (str.length <= 255), | ||||
|             country: () => this._tags.data._country, | ||||
|             location: [this._tags.data._lat, this._tags.data._lon] | ||||
|         }); | ||||
| 
 | ||||
|         textField.GetValue().setData(this._tags.data[this._configuration.freeform.key]); | ||||
|         if (this._applicableUnit) { | ||||
|             // We need to apply a unit.
 | ||||
|             // This implies:
 | ||||
|             // We have to create a dropdown with applicable denominations, and fuse those values
 | ||||
|             const unit = this._applicableUnit | ||||
|             const unitDropDown = new DropDown("", | ||||
|                 unit.denominations.map(denom => { | ||||
|                     return { | ||||
|                         shown: denom.human, | ||||
|                         value: denom | ||||
|                     } | ||||
|                 }) | ||||
|             ) | ||||
|             unitDropDown.GetValue().setData(this._applicableUnit.defaultDenom) | ||||
|             unitDropDown.SetStyle("width: min-content") | ||||
| 
 | ||||
|             input = new CombinedInputElement( | ||||
|                 input, | ||||
|                 unitDropDown, | ||||
|                 (text, denom) => denom?.canonicalValue(text, true) ?? text, | ||||
|                 (valueWithDenom: string) => unit.findDenomination(valueWithDenom) | ||||
|             ).SetClass("flex") | ||||
|         } | ||||
| 
 | ||||
| 
 | ||||
|         input.GetValue().setData(this._tags.data[this._configuration.freeform.key]); | ||||
| 
 | ||||
|         return new InputElementMap( | ||||
|             textField, (a, b) => a === b || (a?.isEquivalent(b) ?? false), | ||||
|             input, (a, b) => a === b || (a?.isEquivalent(b) ?? false), | ||||
|             pickString, toString | ||||
|         ); | ||||
| 
 | ||||
|  |  | |||
|  | @ -33,23 +33,24 @@ export default class SpecialVisualizations { | |||
|         args: { name: string, defaultValue?: string, doc: string }[] | ||||
|     }[] = | ||||
| 
 | ||||
|         [{ | ||||
|             funcName: "all_tags", | ||||
|             docs: "Prints all key-value pairs of the object - used for debugging", | ||||
|             args: [], | ||||
|             constr: ((state: State, tags: UIEventSource<any>) => { | ||||
|                 return new VariableUiElement(tags.map(tags => { | ||||
|                     const parts = []; | ||||
|                     for (const key in tags) { | ||||
|                         if (!tags.hasOwnProperty(key)) { | ||||
|                             continue; | ||||
|         [ | ||||
|             { | ||||
|                 funcName: "all_tags", | ||||
|                 docs: "Prints all key-value pairs of the object - used for debugging", | ||||
|                 args: [], | ||||
|                 constr: ((state: State, tags: UIEventSource<any>) => { | ||||
|                     return new VariableUiElement(tags.map(tags => { | ||||
|                         const parts = []; | ||||
|                         for (const key in tags) { | ||||
|                             if (!tags.hasOwnProperty(key)) { | ||||
|                                 continue; | ||||
|                             } | ||||
|                             parts.push(key + "=" + tags[key]); | ||||
|                         } | ||||
|                         parts.push(key + "=" + tags[key]); | ||||
|                     } | ||||
|                     return parts.join("<br/>") | ||||
|                 })).SetStyle("border: 1px solid black; border-radius: 1em;padding:1em;display:block;") | ||||
|             }) | ||||
|         }, | ||||
|                         return parts.join("<br/>") | ||||
|                     })).SetStyle("border: 1px solid black; border-radius: 1em;padding:1em;display:block;") | ||||
|                 }) | ||||
|             }, | ||||
| 
 | ||||
|             { | ||||
|                 funcName: "image_carousel", | ||||
|  | @ -252,13 +253,40 @@ export default class SpecialVisualizations { | |||
|                             } | ||||
|                         } | ||||
| 
 | ||||
|                         return new ShareButton(Svg.share_ui(), generateShareData) | ||||
|                         return new ShareButton(Svg.share_svg().SetClass("w-8 h-8"), generateShareData) | ||||
|                     } else { | ||||
|                         return new FixedUiElement("") | ||||
|                     } | ||||
| 
 | ||||
|                 } | ||||
|             } | ||||
|             }, | ||||
|             {funcName: "canonical", | ||||
|             docs: "Converts a short, canonical value into the long, translated text", | ||||
|             example: "{canonical(length)} will give 42 metre (in french)", | ||||
|             args:[{ | ||||
|                 name:"key", | ||||
|                 doc: "The key of the tag to give the canonical text for" | ||||
|             }], | ||||
|             constr: (state, tagSource, args) => { | ||||
|                 const key = args [0] | ||||
|                 return new VariableUiElement( | ||||
|                     tagSource.map(tags => tags[key]).map(value => { | ||||
|                         if(value === undefined){ | ||||
|                             return undefined | ||||
|                         } | ||||
|                         const unit = state.layoutToUse.data.units.filter(unit => unit.isApplicableToKey(key))[0] | ||||
|                         if(unit === undefined){ | ||||
|                             return value; | ||||
|                         } | ||||
|                          | ||||
|                      return unit.asHumanLongValue(value); | ||||
|                          | ||||
|                         }, | ||||
|                        [ state.layoutToUse]) | ||||
|                      | ||||
|                      | ||||
|                 ) | ||||
|             }} | ||||
| 
 | ||||
|         ] | ||||
|     static HelpMessage: BaseUIElement = SpecialVisualizations.GenHelpMessage(); | ||||
|  |  | |||
|  | @ -323,6 +323,7 @@ | |||
|           } | ||||
|         ] | ||||
|       }, | ||||
|       | ||||
|       "tagRenderings": [ | ||||
|         "images", | ||||
|         "questions", | ||||
|  | @ -371,11 +372,11 @@ | |||
|             "nl": "Hoe lang is deze klimroute (in meters)?" | ||||
|           }, | ||||
|           "render": { | ||||
|             "de": "Diese Route ist {climbing:length} Meter lang", | ||||
|             "en": "This route is {climbing:length} meter long", | ||||
|             "nl": "Deze klimroute is {climbing:length} meter lang", | ||||
|             "ja": "このルート長は、 {climbing:length} メーターです", | ||||
|             "nb_NO": "Denne ruten er {climbing:length} meter lang" | ||||
|             "de": "Diese Route ist {canonical(climbing:length)} lang", | ||||
|             "en": "This route is {canonical(climbing:length)} long", | ||||
|             "nl": "Deze klimroute is {canonical(climbing:length)} lang", | ||||
|             "ja": "このルート長は、 {canonical(climbing:length)} メーターです", | ||||
|             "nb_NO": "Denne ruten er {canonical(climbing:length)} lang" | ||||
|           }, | ||||
|           "freeform": { | ||||
|             "key": "climbing:length", | ||||
|  | @ -827,10 +828,17 @@ | |||
|         "canonicalDenomination": "m", | ||||
|         "alternativeDenomination": ["meter","meters"], | ||||
|         "human": { | ||||
|           "en": "meter", | ||||
|           "nl": "meter" | ||||
|           "en": " meter", | ||||
|           "nl": " meter" | ||||
|         }, | ||||
|         "default": true | ||||
|       },{ | ||||
|         "canonicalDenomination": "ft", | ||||
|         "alternativeDenomination": ["feet","voet"], | ||||
|         "human": { | ||||
|           "en": " feet", | ||||
|           "nl": " voet" | ||||
|         } | ||||
|       }] | ||||
|     } | ||||
|   ], | ||||
|  | @ -955,10 +963,10 @@ | |||
|     { | ||||
|       "#": "Avg length?", | ||||
|       "render": { | ||||
|         "de": "Die Routen sind durchschnittlich <b>{climbing:length}m</b> lang", | ||||
|         "en": "The routes are <b>{climbing:length}m</b> long on average", | ||||
|         "nl": "De klimroutes zijn gemiddeld <b>{climbing:length}m</b> lang", | ||||
|         "ja": "ルートの長さは平均で<b>{climbing:length} m</b>です" | ||||
|         "de": "Die Routen sind durchschnittlich <b>{canonical(climbing:length)}</b> lang", | ||||
|         "en": "The routes are <b>{canonical(climbing:length)}</b> long on average", | ||||
|         "nl": "De klimroutes zijn gemiddeld <b>{canonical(climbing:length)}</b> lang", | ||||
|         "ja": "ルートの長さは平均で<b>{canonical(climbing:length)}</b>です" | ||||
|       }, | ||||
|       "condition": { | ||||
|         "and": [ | ||||
|  | @ -1321,12 +1329,28 @@ | |||
|     } | ||||
|   ], | ||||
|   "overrideAll": { | ||||
|     "titleIcons": [ | ||||
|       { | ||||
|         "render": "<div style='display:block ruby;' class='m-1 '><img src='./assets/themes/climbing/height.svg' style='width:2rem; height:2rem'/>{climbing:length}</div>", | ||||
|         "freeform": { | ||||
|           "key": "climbing:length" | ||||
|         } | ||||
|       }, | ||||
| 
 | ||||
|       { | ||||
|         "render": "<div style='display:block ruby;' class='m-1 '><img src='./assets/themes/climbing/carabiner.svg' style='width:2rem; height:2rem'/>{climbing:bolted}</div>", | ||||
|         "freeform": { | ||||
|           "key": "climbing:bolted" | ||||
|         } | ||||
|       }, | ||||
| 
 | ||||
|       "defaults"], | ||||
|     "+calculatedTags": [ | ||||
|       "_embedding_feature_properties=feat.overlapWith('climbing').map(f => f.feat.properties).filter(p => p !== undefined).map(p => {return{access: p.access, id: p.id, name: p.name, climbing: p.climbing, 'access:description': p['access:description']}})", | ||||
|       "_embedding_features_with_access=JSON.parse(feat.properties._embedding_feature_properties ?? '[]').filter(p => p.access !== undefined)[0]", | ||||
|       "_embedding_feature_with_rock=JSON.parse(feat.properties._embedding_feature_properties ?? '[]').filter(p => p.rock !== undefined)[0]", | ||||
|       "_embedding_features_with_rock:rock=JSON.parse(_embedding_feature_with_rock).rock", | ||||
|       "_embedding_features_with_rock:id=JSON.parse(_embedding_feature_with_rock).id", | ||||
|       "_embedding_feature_with_rock=JSON.parse(feat.properties._embedding_feature_properties ?? '[]').filter(p => p.rock !== undefined)[0] ?? '{}'", | ||||
|       "_embedding_features_with_rock:rock=JSON.parse(_embedding_feature_with_rock)?.rock", | ||||
|       "_embedding_features_with_rock:id=JSON.parse(_embedding_feature_with_rock)?.id", | ||||
|       "_embedding_feature:access=JSON.parse(feat.properties._embedding_features_with_access ?? '{}').access", | ||||
|       "_embedding_feature:access:description=JSON.parse(feat.properties._embedding_features_with_access ?? '{}')['access:description']", | ||||
|       "_embedding_feature:id=JSON.parse(feat.properties._embedding_features_with_access ?? '{}').id" | ||||
|  |  | |||
|  | @ -1,4 +1,22 @@ | |||
| [ | ||||
|   { | ||||
|     "authors": [ | ||||
|       "Matthew Dera" | ||||
|     ], | ||||
|     "path": "carabiner.svg", | ||||
|     "license": "CC-BY-SA 4.0", | ||||
|     "sources": [ | ||||
|       "https://thenounproject.com/term/carabiner/30076/" | ||||
|     ] | ||||
|   }, | ||||
|   { | ||||
|     "authors": [ | ||||
|       "Pieter Vander Vennet" | ||||
|     ], | ||||
|     "path": "height.svg", | ||||
|     "license": "CC0", | ||||
|     "sources": [] | ||||
|   }, | ||||
|   { | ||||
|     "authors": [ | ||||
|       "Polarbear w", | ||||
|  |  | |||
|  | @ -9,6 +9,7 @@ import {LayoutConfigJson} from "../Customizations/JSON/LayoutConfigJson"; | |||
| import {Layer} from "leaflet"; | ||||
| import LayerConfig from "../Customizations/JSON/LayerConfig"; | ||||
| import SmallLicense from "../Models/smallLicense"; | ||||
| import AllKnownLayers from "../Customizations/AllKnownLayers"; | ||||
| 
 | ||||
| if(process.argv.length == 2){ | ||||
|     console.log("USAGE: ts-node scripts/fixTheme <path to theme>") | ||||
|  | @ -37,7 +38,7 @@ for (const layerConfigJson of themeConfigJson.layers) { | |||
|         layerConfigJson["source"] = { osmTags : tags} | ||||
|     } | ||||
|     // @ts-ignore
 | ||||
|     const layerConfig = new LayerConfig(layerConfigJson, true) | ||||
|     const layerConfig = new LayerConfig(layerConfigJson, AllKnownLayers.sharedUnits, "fix theme",true) | ||||
|     const images : string[] = Array.from(layerConfig.ExtractImages()) | ||||
|     const remoteImages = images.filter(img => img.startsWith("http")) | ||||
|     for (const remoteImage of remoteImages) { | ||||
|  |  | |||
|  | @ -9,6 +9,7 @@ import LayoutConfig from "../Customizations/JSON/LayoutConfig"; | |||
| import {LayerConfigJson} from "../Customizations/JSON/LayerConfigJson"; | ||||
| import {Translation} from "../UI/i18n/Translation"; | ||||
| import {LayoutConfigJson} from "../Customizations/JSON/LayoutConfigJson"; | ||||
| import AllKnownLayers from "../Customizations/AllKnownLayers"; | ||||
| // This scripts scans 'assets/layers/*.json' for layer definition files and 'assets/themes/*.json' for theme definition files.
 | ||||
| // It spits out an overview of those to be used to load them
 | ||||
| 
 | ||||
|  | @ -48,7 +49,7 @@ class LayerOverviewUtils { | |||
|             errorCount.push("Layer " + layerJson.id + "still uses the old 'overpassTags'-format. Please use \"source\": {\"osmTags\": <tags>}' instead of \"overpassTags\": <tags> (note: this isn't your fault, the custom theme generator still spits out the old format)") | ||||
|         } | ||||
|         try { | ||||
|             const layer = new LayerConfig(layerJson, "test", true) | ||||
|             const layer = new LayerConfig(layerJson, AllKnownLayers.sharedUnits,"test", true) | ||||
|             const images = Array.from(layer.ExtractImages()) | ||||
|             const remoteImages = images.filter(img => img.indexOf("http") == 0) | ||||
|             for (const remoteImage of remoteImages) { | ||||
|  | @ -153,7 +154,7 @@ class LayerOverviewUtils { | |||
|         for (const layerFile of layerFiles) { | ||||
| 
 | ||||
|             layerErrorCount.push(...this.validateLayer(layerFile.parsed, layerFile.path, knownPaths)) | ||||
|             knownLayerIds.set(layerFile.parsed.id, new LayerConfig(layerFile.parsed)) | ||||
|             knownLayerIds.set(layerFile.parsed.id, new LayerConfig(layerFile.parsed,AllKnownLayers.sharedUnits)) | ||||
|         } | ||||
| 
 | ||||
|         let themeErrorCount = [] | ||||
|  |  | |||
							
								
								
									
										3
									
								
								test.ts
									
										
									
									
									
								
							
							
						
						
									
										3
									
								
								test.ts
									
										
									
									
									
								
							|  | @ -50,7 +50,8 @@ function TestTagRendering(){ | |||
|                 } | ||||
|             ], | ||||
|              | ||||
|         }, undefined, "test") | ||||
|         }, undefined, "test"), | ||||
|         [] | ||||
|     ).AttachTo("maindiv") | ||||
|     new VariableUiElement(tagsSource.map(tags => tags["valves"])).SetClass("alert").AttachTo("extradiv") | ||||
| } | ||||
|  |  | |||
|  | @ -1,5 +1,5 @@ | |||
| import T from "./TestHelper"; | ||||
| import {Unit} from "../Customizations/JSON/Unit"; | ||||
| import {Denomination} from "../Customizations/JSON/Denomination"; | ||||
| import {equal} from "assert"; | ||||
| 
 | ||||
| export default class UnitsSpec extends T { | ||||
|  | @ -8,7 +8,7 @@ export default class UnitsSpec extends T { | |||
|         super("Units", [ | ||||
|             ["Simple canonicalize", () => { | ||||
| 
 | ||||
|                 const unit = new Unit({ | ||||
|                 const unit = new Denomination({ | ||||
|                     canonicalDenomination: "m", | ||||
|                     alternativeDenomination: ["meter"], | ||||
|                     'default': true, | ||||
|  |  | |||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue