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:
Pieter Vander Vennet 2021-06-22 03:16:45 +02:00
parent 89f6f606c8
commit 966fcda8d1
20 changed files with 302 additions and 111 deletions

View file

@ -8,12 +8,14 @@ export default class AllKnownLayers {
// Must be below the list... // Must be below the list...
public static sharedLayers: Map<string, LayerConfig> = AllKnownLayers.getSharedLayers(); public static sharedLayers: Map<string, LayerConfig> = AllKnownLayers.getSharedLayers();
public static sharedLayersJson: Map<string, any> = AllKnownLayers.getSharedLayersJson(); public static sharedLayersJson: Map<string, any> = AllKnownLayers.getSharedLayersJson();
public static sharedUnits: any[] = []
private static getSharedLayers(): Map<string, LayerConfig> { private static getSharedLayers(): Map<string, LayerConfig> {
const sharedLayers = new Map<string, LayerConfig>(); const sharedLayers = new Map<string, LayerConfig>();
for (const layer of known_layers.layers) { for (const layer of known_layers.layers) {
try { try {
const parsed = new LayerConfig(layer, "shared_layers") const parsed = new LayerConfig(layer, AllKnownLayers.sharedUnits,"shared_layers")
sharedLayers.set(layer.id, parsed); sharedLayers.set(layer.id, parsed);
sharedLayers[layer.id] = parsed; sharedLayers[layer.id] = parsed;
} catch (e) { } catch (e) {
@ -33,7 +35,7 @@ export default class AllKnownLayers {
continue; continue;
} }
try { 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.set(layer.id, parsed);
sharedLayers[layer.id] = parsed; sharedLayers[layer.id] = parsed;
} catch (e) { } catch (e) {

View file

@ -1,13 +1,59 @@
import {Translation} from "../../UI/i18n/Translation"; import {Translation} from "../../UI/i18n/Translation";
import UnitConfigJson from "./UnitConfigJson"; import UnitConfigJson from "./UnitConfigJson";
import Translations from "../../UI/i18n/Translations"; import Translations from "../../UI/i18n/Translations";
import BaseUIElement from "../../UI/BaseUIElement";
import Combine from "../../UI/Base/Combine";
export class Unit { 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 alternativeDenominations: string [];
private readonly canonical: string; public readonly canonical: string;
private readonly default: boolean; readonly default: boolean;
private readonly prefix: boolean; readonly prefix: boolean;
constructor(json: UnitConfigJson, context: string) { constructor(json: UnitConfigJson, context: string) {
context = `${context}.unit(${json.canonicalDenomination})` context = `${context}.unit(${json.canonicalDenomination})`
@ -26,15 +72,22 @@ export class Unit {
this.default = json.default ?? false; 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; this.prefix = json.prefix ?? false;
} }
get human() : Translation {
return this._human.Clone()
}
public canonicalValue(value: string) { public canonicalValue(value: string, actAsDefault?: boolean) {
const stripped = this.StrippedValue(value) if(value === undefined){
if(stripped === null){ return undefined;
}
const stripped = this.StrippedValue(value, actAsDefault)
if (stripped === null) {
return null; return null;
} }
return stripped + this.canonical return stripped + this.canonical
@ -46,11 +99,13 @@ export class Unit {
* - the value is a Number (without unit) and default is set * - the value is a Number (without unit) and default is set
* *
* Returns null if it doesn't match this unit * 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 (this.prefix) {
if (value.startsWith(this.canonical)) { if (value.startsWith(this.canonical)) {
return value.substring(this.canonical.length).trim(); return value.substring(this.canonical.length).trim();
@ -72,7 +127,7 @@ export class Unit {
} }
if (this.default) { if (this.default || actAsDefault) {
const parsed = Number(value.trim()) const parsed = Number(value.trim())
if (!isNaN(parsed)) { if (!isNaN(parsed)) {
return value.trim(); return value.trim();

View file

@ -17,6 +17,7 @@ import {TagsFilter} from "../../Logic/Tags/TagsFilter";
import {Tag} from "../../Logic/Tags/Tag"; import {Tag} from "../../Logic/Tags/Tag";
import SubstitutingTag from "../../Logic/Tags/SubstitutingTag"; import SubstitutingTag from "../../Logic/Tags/SubstitutingTag";
import BaseUIElement from "../../UI/BaseUIElement"; import BaseUIElement from "../../UI/BaseUIElement";
import {Denomination, Unit} from "./Denomination";
export default class LayerConfig { export default class LayerConfig {
@ -46,6 +47,7 @@ export default class LayerConfig {
width: TagRenderingConfig; width: TagRenderingConfig;
dashArray: TagRenderingConfig; dashArray: TagRenderingConfig;
wayHandling: number; wayHandling: number;
public readonly units: Unit[];
presets: { presets: {
title: Translation, title: Translation,
@ -56,8 +58,10 @@ export default class LayerConfig {
tagRenderings: TagRenderingConfig []; tagRenderings: TagRenderingConfig [];
constructor(json: LayerConfigJson, constructor(json: LayerConfigJson,
units:Unit[],
context?: string, context?: string,
official: boolean = true,) { official: boolean = true,) {
this.units = units;
context = context + "." + json.id; context = context + "." + json.id;
const self = this; const self = this;
this.id = json.id; this.id = json.id;

View file

@ -109,7 +109,8 @@ export interface LayerConfigJson {
/** /**
* Small icons shown next to the title. * Small icons shown next to the title.
* If not specified, the OsmLink and wikipedia links will be used by default. * 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)[]; titleIcons?: (string | TagRenderingConfigJson)[];

View file

@ -5,7 +5,7 @@ import {LayoutConfigJson} from "./LayoutConfigJson";
import AllKnownLayers from "../AllKnownLayers"; import AllKnownLayers from "../AllKnownLayers";
import SharedTagRenderings from "../SharedTagRenderings"; import SharedTagRenderings from "../SharedTagRenderings";
import {Utils} from "../../Utils"; import {Utils} from "../../Utils";
import {Unit} from "./Unit"; import {Denomination, Unit} from "./Denomination";
export default class LayoutConfig { export default class LayoutConfig {
public readonly id: string; public readonly id: string;
@ -47,7 +47,7 @@ export default class LayoutConfig {
How long is the cache valid, in seconds? How long is the cache valid, in seconds?
*/ */
public readonly cacheTimeout?: number; public readonly cacheTimeout?: number;
public readonly units: { appliesToKeys: Set<string>, applicableUnits: Unit[] }[] = [] public readonly units: Unit[] = []
private readonly _official: boolean; private readonly _official: boolean;
constructor(json: LayoutConfigJson, official = true, context?: string) { constructor(json: LayoutConfigJson, official = true, context?: string) {
@ -73,6 +73,7 @@ export default class LayoutConfig {
if (json.description === undefined) { if (json.description === undefined) {
throw "Description not defined in " + this.id; throw "Description not defined in " + this.id;
} }
this.units = LayoutConfig.ExtractUnits(json, context);
this.title = new Translation(json.title, context + ".title"); this.title = new Translation(json.title, context + ".title");
this.description = new Translation(json.description, context + ".description"); this.description = new Translation(json.description, context + ".description");
this.shortDescription = json.shortDescription === undefined ? this.description.FirstSentence() : new Translation(json.shortDescription, context + ".shortdescription"); 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 (AllKnownLayers.sharedLayersJson[layer] !== undefined) {
if (json.overrideAll !== undefined) { if (json.overrideAll !== undefined) {
let lyr = JSON.parse(JSON.stringify(AllKnownLayers.sharedLayersJson[layer])); 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 { } else {
return AllKnownLayers.sharedLayers[layer] return AllKnownLayers.sharedLayers[layer]
} }
@ -124,7 +125,7 @@ export default class LayoutConfig {
} }
// @ts-ignore // @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! // 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) this.cacheTimeout = json.cacheTimout ?? (60 * 24 * 60 * 60)
}
private static ExtractUnits(json: LayoutConfigJson, context: string) : Unit[]{
const result: Unit[] = []
if ((json.units ?? []).length !== 0) { if ((json.units ?? []).length !== 0) {
for (let i1 = 0; i1 < json.units.length; i1++) { for (let i1 = 0; i1 < json.units.length; i1++) {
let unit = json.units[i1]; let unit = json.units[i1];
@ -206,30 +211,31 @@ export default class LayoutConfig {
const defaultSet = unit.applicableUnits.filter(u => u.default === true) const defaultSet = unit.applicableUnits.filter(u => u.default === true)
// No default is defined - we pick the first as default // No default is defined - we pick the first as default
if(defaultSet.length === 0){ if (defaultSet.length === 0) {
unit.applicableUnits[0].default = true unit.applicableUnits[0].default = true
} }
// Check that there are not multiple defaults // Check that there are not multiple defaults
if (defaultSet.length > 1) { if (defaultSet.length > 1) {
throw `Multiple units are set as default: they have canonical values of ${defaultSet.map(u => u.canonicalDenomination).join(", ")}` 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}]`)) const applicable = unit.applicableUnits.map((u, i) => new Denomination(u, `${context}.units[${i}]`))
this.units.push({ result.push(new Unit( appliesTo, applicable));
appliesToKeys: new Set(appliesTo),
applicableUnits: applicable
})
} }
const seenKeys = new Set<string>() const seenKeys = new Set<string>()
for (const unit of this.units) { for (const unit of result) {
const alreadySeen = Array.from(unit.appliesToKeys).filter(key => seenKeys.has(key)); const alreadySeen = Array.from(unit.appliesToKeys).filter((key: string) => seenKeys.has(key));
if (alreadySeen.length > 0) { if (alreadySeen.length > 0) {
throw `${context}.units: multiple units define the same keys. The key(s) ${alreadySeen.join(",")} occur multiple times` throw `${context}.units: multiple units define the same keys. The key(s) ${alreadySeen.join(",")} occur multiple times`
} }
unit.appliesToKeys.forEach(key => seenKeys.add(key)) unit.appliesToKeys.forEach(key => seenKeys.add(key))
} }
return result;
} }
} }
public CustomCodeSnippets(): string[] { public CustomCodeSnippets(): string[] {

View file

@ -85,7 +85,7 @@ export default class SimpleMetaTagger {
(feature => { (feature => {
const units = State.state.layoutToUse.data.units ?? []; const units = State.state.layoutToUse.data.units ?? [];
for (const key in feature.properties) { for (const key in feature.properties) {
if(!feature.properties.hasOwnProperty(key)){ if (!feature.properties.hasOwnProperty(key)) {
continue; continue;
} }
for (const unit of units) { for (const unit of units) {
@ -93,15 +93,10 @@ export default class SimpleMetaTagger {
continue; continue;
} }
const value = feature.properties[key] const value = feature.properties[key]
const [, denomination] = unit.findDenomination(value)
for (const applicableUnit of unit.applicableUnits) { const canonical = denomination.canonicalValue(value)
const canonical = applicableUnit.canonicalValue(value) console.log("Rewritten ", key, " from", value, "into", canonical)
if (canonical == null) { feature.properties[key] = canonical;
continue
}
console.log("Rewritten ", key, " from", value, "into", canonical)
feature.properties[key] = canonical;
}
} }
} }

View file

@ -2,7 +2,7 @@ import { Utils } from "../Utils";
export default class Constants { 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 // The user journey states thresholds when a new feature gets unlocked
public static userJourney = { public static userJourney = {

View file

@ -3,30 +3,48 @@ import {UIEventSource} from "../../Logic/UIEventSource";
import Combine from "../Base/Combine"; import Combine from "../Base/Combine";
import BaseUIElement from "../BaseUIElement"; import BaseUIElement from "../BaseUIElement";
export default class CombinedInputElement<T> extends InputElement<T> { export default class CombinedInputElement<T, J, X> extends InputElement<X> {
protected InnerConstructElement(): HTMLElement {
return this._combined.ConstructElement();
}
private readonly _a: InputElement<T>;
private readonly _b: BaseUIElement;
private readonly _combined: BaseUIElement;
public readonly IsSelected: UIEventSource<boolean>; 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(); super();
this._a = a; this._a = a;
this._b = b; this._b = b;
this._split = split;
this.IsSelected = this._a.IsSelected.map((isSelected) => { this.IsSelected = this._a.IsSelected.map((isSelected) => {
return isSelected || b.IsSelected.data return isSelected || b.IsSelected.data
}, [b.IsSelected]) }, [b.IsSelected])
this._combined = new Combine([this._a, this._b]); 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> { GetValue(): UIEventSource<X> {
return this._a.GetValue(); return this._value;
} }
IsValid(t: T): boolean { IsValid(x: X): boolean {
return this._a.IsValid(t); const [t, j] = this._split(x)
return this._a.IsValid(t) && this._b.IsValid(j);
}
protected InnerConstructElement(): HTMLElement {
return this._combined.ConstructElement();
} }
} }

View file

@ -270,7 +270,10 @@ export default class ValidatedTextField {
if (tp.inputHelper) { if (tp.inputHelper) {
input = new CombinedInputElement(input, tp.inputHelper(input.GetValue(), { input = new CombinedInputElement(input, tp.inputHelper(input.GetValue(), {
location: options.location location: options.location
})); }),
(a, b) => a, // We can ignore b, as they are linked earlier
a => [a, a]
);
} }
return input; return input;
} }

View file

@ -8,11 +8,13 @@ import State from "../../State";
import Svg from "../../Svg"; import Svg from "../../Svg";
import Toggle from "../Input/Toggle"; import Toggle from "../Input/Toggle";
import BaseUIElement from "../BaseUIElement"; import BaseUIElement from "../BaseUIElement";
import {Unit} from "../../Customizations/JSON/Denomination";
export default class EditableTagRendering extends Toggle { export default class EditableTagRendering extends Toggle {
constructor(tags: UIEventSource<any>, constructor(tags: UIEventSource<any>,
configuration: TagRenderingConfig, configuration: TagRenderingConfig,
units: Unit [],
editMode = new UIEventSource<boolean>(false) editMode = new UIEventSource<boolean>(false)
) { ) {
const answer: BaseUIElement = new TagRenderingAnswer(tags, configuration) const answer: BaseUIElement = new TagRenderingAnswer(tags, configuration)
@ -41,7 +43,7 @@ export default class EditableTagRendering extends Toggle {
editMode.setData(false) editMode.setData(false)
}); });
const question = new TagRenderingQuestion(tags, configuration, const question = new TagRenderingQuestion(tags, configuration,units,
() => { () => {
editMode.setData(false) editMode.setData(false)
}, },

View file

@ -17,7 +17,7 @@ export default class FeatureInfoBox extends ScrollableFullScreen {
public constructor( public constructor(
tags: UIEventSource<any>, tags: UIEventSource<any>,
layerConfig: LayerConfig layerConfig: LayerConfig,
) { ) {
super(() => FeatureInfoBox.GenerateTitleBar(tags, layerConfig), super(() => FeatureInfoBox.GenerateTitleBar(tags, layerConfig),
() => FeatureInfoBox.GenerateContent(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"); .SetClass("break-words font-bold sm:p-0.5 md:p-1 sm:p-1.5 md:p-2");
const titleIcons = new Combine( const titleIcons = new Combine(
layerConfig.titleIcons.map(icon => new TagRenderingAnswer(tags, icon, layerConfig.titleIcons.map(icon => new TagRenderingAnswer(tags, icon,
"block w-8 h-8 align-baseline box-content sm:p-0.5", "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") .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; let questionBox: UIElement = undefined;
if (State.state.featureSwitchUserbadge.data) { if (State.state.featureSwitchUserbadge.data) {
questionBox = new QuestionBox(tags, layerConfig.tagRenderings); questionBox = new QuestionBox(tags, layerConfig.tagRenderings, layerConfig.units);
} }
let questionBoxIsUsed = false; let questionBoxIsUsed = false;
@ -59,7 +59,7 @@ export default class FeatureInfoBox extends ScrollableFullScreen {
questionBoxIsUsed = true; questionBoxIsUsed = true;
return questionBox; return questionBox;
} }
return new EditableTagRendering(tags, tr); return new EditableTagRendering(tags, tr, layerConfig.units);
}); });
if (!questionBoxIsUsed) { if (!questionBoxIsUsed) {
renderings.push(questionBox); renderings.push(questionBox);

View file

@ -5,6 +5,8 @@ import TagRenderingQuestion from "./TagRenderingQuestion";
import Translations from "../i18n/Translations"; import Translations from "../i18n/Translations";
import State from "../../State"; import State from "../../State";
import Combine from "../Base/Combine"; 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 _tags: UIEventSource<any>;
private readonly _tagRenderings: TagRenderingConfig[]; private readonly _tagRenderings: TagRenderingConfig[];
private _tagRenderingQuestions: UIElement[]; private _tagRenderingQuestions: BaseUIElement[];
private _skippedQuestions: UIEventSource<number[]> = new UIEventSource<number[]>([]) 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); super(tags);
this.ListenTo(this._skippedQuestions); this.ListenTo(this._skippedQuestions);
this._tags = tags; this._tags = tags;
@ -28,7 +30,7 @@ export default class QuestionBox extends UIElement {
.filter(tr => tr.question !== undefined) .filter(tr => tr.question !== undefined)
.filter(tr => tr.question !== null); .filter(tr => tr.question !== null);
this._tagRenderingQuestions = this._tagRenderings this._tagRenderingQuestions = this._tagRenderings
.map((tagRendering, i) => new TagRenderingQuestion(this._tags, tagRendering, .map((tagRendering, i) => new TagRenderingQuestion(this._tags, tagRendering,units,
() => { () => {
// We save // We save
self._skippedQuestions.ping(); self._skippedQuestions.ping();
@ -49,7 +51,7 @@ export default class QuestionBox extends UIElement {
} }
InnerRender() { InnerRender() {
const allQuestions : UIElement[] = [] const allQuestions : BaseUIElement[] = []
for (let i = 0; i < this._tagRenderingQuestions.length; i++) { for (let i = 0; i < this._tagRenderingQuestions.length; i++) {
let tagRendering = this._tagRenderings[i]; let tagRendering = this._tagRenderings[i];

View file

@ -24,6 +24,8 @@ import {And} from "../../Logic/Tags/And";
import {TagUtils} from "../../Logic/Tags/TagUtils"; import {TagUtils} from "../../Logic/Tags/TagUtils";
import BaseUIElement from "../BaseUIElement"; import BaseUIElement from "../BaseUIElement";
import {DropDown} from "../Input/DropDown"; import {DropDown} from "../Input/DropDown";
import {Unit} from "../../Customizations/JSON/Denomination";
import CombinedInputElement from "../Input/CombinedInputElement";
/** /**
* Shows the question element. * Shows the question element.
@ -38,14 +40,17 @@ export default class TagRenderingQuestion extends UIElement {
private _inputElement: InputElement<TagsFilter>; private _inputElement: InputElement<TagsFilter>;
private _cancelButton: BaseUIElement; private _cancelButton: BaseUIElement;
private _appliedTags: BaseUIElement; private _appliedTags: BaseUIElement;
private readonly _applicableUnit: Unit;
private _question: BaseUIElement; private _question: BaseUIElement;
constructor(tags: UIEventSource<any>, constructor(tags: UIEventSource<any>,
configuration: TagRenderingConfig, configuration: TagRenderingConfig,
units: Unit[],
afterSave?: () => void, afterSave?: () => void,
cancelButton?: BaseUIElement cancelButton?: BaseUIElement
) { ) {
super(tags); super(tags);
this._applicableUnit = units.filter(unit => unit.isApplicableToKey(configuration.freeform?.key))[0];
this._tags = tags; this._tags = tags;
this._configuration = configuration; this._configuration = configuration;
this._cancelButton = cancelButton; this._cancelButton = cancelButton;
@ -114,9 +119,9 @@ export default class TagRenderingQuestion extends UIElement {
const self = this; const self = this;
let inputEls: InputElement<TagsFilter>[]; let inputEls: InputElement<TagsFilter>[];
const mappings = (this._configuration.mappings??[]) const mappings = (this._configuration.mappings ?? [])
.filter( mapping => { .filter(mapping => {
if(mapping.hideInAnswer === true){ if (mapping.hideInAnswer === true) {
return false; return false;
} }
if (typeof (mapping.hideInAnswer) !== "boolean" && mapping.hideInAnswer.matchesProperties(this._tags.data)) { if (typeof (mapping.hideInAnswer) !== "boolean" && mapping.hideInAnswer.matchesProperties(this._tags.data)) {
@ -124,9 +129,9 @@ export default class TagRenderingQuestion extends UIElement {
} }
return true; return true;
}) })
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 ff = this.GenerateFreeform();
const hasImages = mappings.filter(mapping => mapping.then.ExtractImages().length > 0).length > 0 const hasImages = mappings.filter(mapping => mapping.then.ExtractImages().length > 0).length > 0
@ -272,7 +277,7 @@ export default class TagRenderingQuestion extends UIElement {
then: Translation, then: Translation,
hideInAnswer: boolean | TagsFilter hideInAnswer: boolean | TagsFilter
}, ifNot?: TagsFilter[]): InputElement<TagsFilter> { }, ifNot?: TagsFilter[]): InputElement<TagsFilter> {
let tagging = mapping.if; let tagging = mapping.if;
if (ifNot.length > 0) { if (ifNot.length > 0) {
tagging = new And([tagging, ...ifNot]) tagging = new And([tagging, ...ifNot])
@ -323,16 +328,41 @@ export default class TagRenderingQuestion extends UIElement {
return undefined; 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), isValid: (str) => (str.length <= 255),
country: () => this._tags.data._country, country: () => this._tags.data._country,
location: [this._tags.data._lat, this._tags.data._lon] 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( return new InputElementMap(
textField, (a, b) => a === b || (a?.isEquivalent(b) ?? false), input, (a, b) => a === b || (a?.isEquivalent(b) ?? false),
pickString, toString pickString, toString
); );

View file

@ -33,23 +33,24 @@ export default class SpecialVisualizations {
args: { name: string, defaultValue?: string, doc: string }[] args: { name: string, defaultValue?: string, doc: string }[]
}[] = }[] =
[{ [
funcName: "all_tags", {
docs: "Prints all key-value pairs of the object - used for debugging", funcName: "all_tags",
args: [], docs: "Prints all key-value pairs of the object - used for debugging",
constr: ((state: State, tags: UIEventSource<any>) => { args: [],
return new VariableUiElement(tags.map(tags => { constr: ((state: State, tags: UIEventSource<any>) => {
const parts = []; return new VariableUiElement(tags.map(tags => {
for (const key in tags) { const parts = [];
if (!tags.hasOwnProperty(key)) { for (const key in tags) {
continue; 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", 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 { } else {
return new FixedUiElement("") 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(); static HelpMessage: BaseUIElement = SpecialVisualizations.GenHelpMessage();

View file

@ -323,6 +323,7 @@
} }
] ]
}, },
"tagRenderings": [ "tagRenderings": [
"images", "images",
"questions", "questions",
@ -371,11 +372,11 @@
"nl": "Hoe lang is deze klimroute (in meters)?" "nl": "Hoe lang is deze klimroute (in meters)?"
}, },
"render": { "render": {
"de": "Diese Route ist {climbing:length} Meter lang", "de": "Diese Route ist {canonical(climbing:length)} lang",
"en": "This route is {climbing:length} meter long", "en": "This route is {canonical(climbing:length)} long",
"nl": "Deze klimroute is {climbing:length} meter lang", "nl": "Deze klimroute is {canonical(climbing:length)} lang",
"ja": "このルート長は、 {climbing:length} メーターです", "ja": "このルート長は、 {canonical(climbing:length)} メーターです",
"nb_NO": "Denne ruten er {climbing:length} meter lang" "nb_NO": "Denne ruten er {canonical(climbing:length)} lang"
}, },
"freeform": { "freeform": {
"key": "climbing:length", "key": "climbing:length",
@ -827,10 +828,17 @@
"canonicalDenomination": "m", "canonicalDenomination": "m",
"alternativeDenomination": ["meter","meters"], "alternativeDenomination": ["meter","meters"],
"human": { "human": {
"en": "meter", "en": " meter",
"nl": "meter" "nl": " meter"
}, },
"default": true "default": true
},{
"canonicalDenomination": "ft",
"alternativeDenomination": ["feet","voet"],
"human": {
"en": " feet",
"nl": " voet"
}
}] }]
} }
], ],
@ -955,10 +963,10 @@
{ {
"#": "Avg length?", "#": "Avg length?",
"render": { "render": {
"de": "Die Routen sind durchschnittlich <b>{climbing:length}m</b> lang", "de": "Die Routen sind durchschnittlich <b>{canonical(climbing:length)}</b> lang",
"en": "The routes are <b>{climbing:length}m</b> long on average", "en": "The routes are <b>{canonical(climbing:length)}</b> long on average",
"nl": "De klimroutes zijn gemiddeld <b>{climbing:length}m</b> lang", "nl": "De klimroutes zijn gemiddeld <b>{canonical(climbing:length)}</b> lang",
"ja": "ルートの長さは平均で<b>{climbing:length} m</b>です" "ja": "ルートの長さは平均で<b>{canonical(climbing:length)}</b>です"
}, },
"condition": { "condition": {
"and": [ "and": [
@ -1321,12 +1329,28 @@
} }
], ],
"overrideAll": { "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": [ "+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_feature_properties=feat.overlapWith('climbing').map(f => f.feat.properties).filter(p => p !== undefined).map(p => {return{access: p.access, id: p.id, name: p.name, climbing: p.climbing, 'access:description': p['access:description']}})",
"_embedding_features_with_access=JSON.parse(feat.properties._embedding_feature_properties ?? '[]').filter(p => p.access !== undefined)[0]", "_embedding_features_with_access=JSON.parse(feat.properties._embedding_feature_properties ?? '[]').filter(p => p.access !== undefined)[0]",
"_embedding_feature_with_rock=JSON.parse(feat.properties._embedding_feature_properties ?? '[]').filter(p => p.rock !== undefined)[0]", "_embedding_feature_with_rock=JSON.parse(feat.properties._embedding_feature_properties ?? '[]').filter(p => p.rock !== undefined)[0] ?? '{}'",
"_embedding_features_with_rock:rock=JSON.parse(_embedding_feature_with_rock).rock", "_embedding_features_with_rock:rock=JSON.parse(_embedding_feature_with_rock)?.rock",
"_embedding_features_with_rock:id=JSON.parse(_embedding_feature_with_rock).id", "_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=JSON.parse(feat.properties._embedding_features_with_access ?? '{}').access",
"_embedding_feature:access:description=JSON.parse(feat.properties._embedding_features_with_access ?? '{}')['access:description']", "_embedding_feature:access:description=JSON.parse(feat.properties._embedding_features_with_access ?? '{}')['access:description']",
"_embedding_feature:id=JSON.parse(feat.properties._embedding_features_with_access ?? '{}').id" "_embedding_feature:id=JSON.parse(feat.properties._embedding_features_with_access ?? '{}').id"

View file

@ -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": [ "authors": [
"Polarbear w", "Polarbear w",

View file

@ -9,6 +9,7 @@ import {LayoutConfigJson} from "../Customizations/JSON/LayoutConfigJson";
import {Layer} from "leaflet"; import {Layer} from "leaflet";
import LayerConfig from "../Customizations/JSON/LayerConfig"; import LayerConfig from "../Customizations/JSON/LayerConfig";
import SmallLicense from "../Models/smallLicense"; import SmallLicense from "../Models/smallLicense";
import AllKnownLayers from "../Customizations/AllKnownLayers";
if(process.argv.length == 2){ if(process.argv.length == 2){
console.log("USAGE: ts-node scripts/fixTheme <path to theme>") console.log("USAGE: ts-node scripts/fixTheme <path to theme>")
@ -37,7 +38,7 @@ for (const layerConfigJson of themeConfigJson.layers) {
layerConfigJson["source"] = { osmTags : tags} layerConfigJson["source"] = { osmTags : tags}
} }
// @ts-ignore // @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 images : string[] = Array.from(layerConfig.ExtractImages())
const remoteImages = images.filter(img => img.startsWith("http")) const remoteImages = images.filter(img => img.startsWith("http"))
for (const remoteImage of remoteImages) { for (const remoteImage of remoteImages) {

View file

@ -9,6 +9,7 @@ import LayoutConfig from "../Customizations/JSON/LayoutConfig";
import {LayerConfigJson} from "../Customizations/JSON/LayerConfigJson"; import {LayerConfigJson} from "../Customizations/JSON/LayerConfigJson";
import {Translation} from "../UI/i18n/Translation"; import {Translation} from "../UI/i18n/Translation";
import {LayoutConfigJson} from "../Customizations/JSON/LayoutConfigJson"; 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. // 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 // 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)") 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 { try {
const layer = new LayerConfig(layerJson, "test", true) const layer = new LayerConfig(layerJson, AllKnownLayers.sharedUnits,"test", true)
const images = Array.from(layer.ExtractImages()) const images = Array.from(layer.ExtractImages())
const remoteImages = images.filter(img => img.indexOf("http") == 0) const remoteImages = images.filter(img => img.indexOf("http") == 0)
for (const remoteImage of remoteImages) { for (const remoteImage of remoteImages) {
@ -153,7 +154,7 @@ class LayerOverviewUtils {
for (const layerFile of layerFiles) { for (const layerFile of layerFiles) {
layerErrorCount.push(...this.validateLayer(layerFile.parsed, layerFile.path, knownPaths)) 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 = [] let themeErrorCount = []

View file

@ -50,7 +50,8 @@ function TestTagRendering(){
} }
], ],
}, undefined, "test") }, undefined, "test"),
[]
).AttachTo("maindiv") ).AttachTo("maindiv")
new VariableUiElement(tagsSource.map(tags => tags["valves"])).SetClass("alert").AttachTo("extradiv") new VariableUiElement(tagsSource.map(tags => tags["valves"])).SetClass("alert").AttachTo("extradiv")
} }

View file

@ -1,5 +1,5 @@
import T from "./TestHelper"; import T from "./TestHelper";
import {Unit} from "../Customizations/JSON/Unit"; import {Denomination} from "../Customizations/JSON/Denomination";
import {equal} from "assert"; import {equal} from "assert";
export default class UnitsSpec extends T { export default class UnitsSpec extends T {
@ -8,7 +8,7 @@ export default class UnitsSpec extends T {
super("Units", [ super("Units", [
["Simple canonicalize", () => { ["Simple canonicalize", () => {
const unit = new Unit({ const unit = new Denomination({
canonicalDenomination: "m", canonicalDenomination: "m",
alternativeDenomination: ["meter"], alternativeDenomination: ["meter"],
'default': true, 'default': true,