Rework units to allow picking different default units in different locations, fixes #1011

This commit is contained in:
Pieter Vander Vennet 2022-08-18 19:17:15 +02:00
parent e981abd2aa
commit 5da76b9418
17 changed files with 149 additions and 100 deletions

View file

@ -235,7 +235,7 @@ export default class SimpleMetaTaggers {
private static canonicalize = new SimpleMetaTagger( private static canonicalize = new SimpleMetaTagger(
{ {
doc: "If 'units' is defined in the layoutConfig, then this metatagger will rewrite the specified keys to have the canonical form (e.g. `1meter` will be rewritten to `1m`)", doc: "If 'units' is defined in the layoutConfig, then this metatagger will rewrite the specified keys to have the canonical form (e.g. `1meter` will be rewritten to `1m`; `1` will be rewritten to `1m` as well)",
keys: ["Theme-defined keys"], keys: ["Theme-defined keys"],
}, },
@ -261,13 +261,14 @@ export default class SimpleMetaTaggers {
continue; continue;
} }
const value = feature.properties[key] const value = feature.properties[key]
const denom = unit.findDenomination(value) const denom = unit.findDenomination(value, () => feature.properties["_country"])
if (denom === undefined) { if (denom === undefined) {
// no valid value found // no valid value found
break; break;
} }
const [, denomination] = denom; const [, denomination] = denom;
let canonical = denomination?.canonicalValue(value) ?? undefined; const defaultDenom = unit.getDefaultDenomination(() => feature.properties["_country"])
let canonical = denomination?.canonicalValue(value, defaultDenom == denomination) ?? undefined;
if (canonical === value) { if (canonical === value) {
break; break;
} }

View file

@ -1,21 +1,22 @@
import {Translation} from "../UI/i18n/Translation"; import {Translation} from "../UI/i18n/Translation";
import {ApplicableUnitJson} from "./ThemeConfig/Json/UnitConfigJson"; import {DenominationConfigJson} from "./ThemeConfig/Json/UnitConfigJson";
import Translations from "../UI/i18n/Translations"; import Translations from "../UI/i18n/Translations";
import {Store, UIEventSource} from "../Logic/UIEventSource"; import {Store} from "../Logic/UIEventSource";
import BaseUIElement from "../UI/BaseUIElement"; import BaseUIElement from "../UI/BaseUIElement";
import Toggle from "../UI/Input/Toggle"; import Toggle from "../UI/Input/Toggle";
export class Denomination { export class Denomination {
public readonly canonical: string; public readonly canonical: string;
public readonly _canonicalSingular: string; public readonly _canonicalSingular: string;
public readonly default: boolean; public readonly useAsDefaultInput: boolean | string[]
public readonly useIfNoUnitGiven : boolean | string[]
public readonly prefix: boolean; public readonly prefix: boolean;
public readonly alternativeDenominations: string []; public readonly alternativeDenominations: string [];
private readonly _human: Translation; private readonly _human: Translation;
private readonly _humanSingular?: Translation; private readonly _humanSingular?: Translation;
constructor(json: ApplicableUnitJson, context: string) { constructor(json: DenominationConfigJson, context: string) {
context = `${context}.unit(${json.canonicalDenomination})` context = `${context}.unit(${json.canonicalDenomination})`
this.canonical = json.canonicalDenomination.trim() this.canonical = json.canonicalDenomination.trim()
if (this.canonical === undefined) { if (this.canonical === undefined) {
@ -32,8 +33,12 @@ export class Denomination {
this.alternativeDenominations = json.alternativeDenomination?.map(v => v.trim()) ?? [] this.alternativeDenominations = json.alternativeDenomination?.map(v => v.trim()) ?? []
this.default = json.default ?? false; if(json["default"] !== undefined) {
throw `${context} uses the old 'default'-key. Use "useIfNoUnitGiven" or "useAsDefaultInput" instead`
}
this.useIfNoUnitGiven = json.useIfNoUnitGiven
this.useAsDefaultInput = json.useAsDefaultInput ?? json.useIfNoUnitGiven
this._human = Translations.T(json.human, context + "human") this._human = Translations.T(json.human, context + "human")
this._humanSingular = Translations.T(json.humanSingular, context + "humanSingular") this._humanSingular = Translations.T(json.humanSingular, context + "humanSingular")
@ -68,32 +73,31 @@ export class Denomination {
* const unit = new Denomination({ * const unit = new Denomination({
* canonicalDenomination: "m", * canonicalDenomination: "m",
* alternativeDenomination: ["meter"], * alternativeDenomination: ["meter"],
* 'default': true,
* human: { * human: {
* en: "meter" * en: "meter"
* } * }
* }, "test") * }, "test")
* unit.canonicalValue("42m") // =>"42 m" * unit.canonicalValue("42m", true) // =>"42 m"
* unit.canonicalValue("42") // =>"42 m" * unit.canonicalValue("42", true) // =>"42 m"
* unit.canonicalValue("42 m") // =>"42 m" * unit.canonicalValue("42 m", true) // =>"42 m"
* unit.canonicalValue("42 meter") // =>"42 m" * unit.canonicalValue("42 meter", true) // =>"42 m"
* * unit.canonicalValue("42m", true) // =>"42 m"
* unit.canonicalValue("42", true) // =>"42 m"
* *
* // Should be trimmed if canonical is empty * // Should be trimmed if canonical is empty
* const unit = new Denomination({ * const unit = new Denomination({
* canonicalDenomination: "", * canonicalDenomination: "",
* alternativeDenomination: ["meter","m"], * alternativeDenomination: ["meter","m"],
* 'default': true,
* human: { * human: {
* en: "meter" * en: "meter"
* } * }
* }, "test") * }, "test")
* unit.canonicalValue("42m") // =>"42" * unit.canonicalValue("42m", true) // =>"42"
* unit.canonicalValue("42") // =>"42" * unit.canonicalValue("42", true) // =>"42"
* unit.canonicalValue("42 m") // =>"42" * unit.canonicalValue("42 m", true) // =>"42"
* unit.canonicalValue("42 meter") // =>"42" * unit.canonicalValue("42 meter", true) // =>"42"
*/ */
public canonicalValue(value: string, actAsDefault?: boolean) : string { public canonicalValue(value: string, actAsDefault: boolean) : string {
if (value === undefined) { if (value === undefined) {
return undefined; return undefined;
} }
@ -114,7 +118,7 @@ export class Denomination {
* *
* Returns null if it doesn't match this unit * Returns null if it doesn't match this unit
*/ */
public StrippedValue(value: string, actAsDefault?: boolean): string { public StrippedValue(value: string, actAsDefault: boolean): string {
if (value === undefined) { if (value === undefined) {
return undefined; return undefined;
@ -153,15 +157,26 @@ export class Denomination {
} }
} }
if (this.default || actAsDefault) { if (!actAsDefault) {
const parsed = Number(value.trim()) return null
if (!isNaN(parsed)) { }
return value.trim();
} const parsed = Number(value.trim())
if (!isNaN(parsed)) {
return value.trim();
} }
return null; return null;
} }
isDefaultUnit(country: () => string) {
if(this.useIfNoUnitGiven === true){
return true
}
if(this.useIfNoUnitGiven === false){
return false
}
return this.useIfNoUnitGiven.indexOf(country()) >= 0
}
} }

View file

@ -14,13 +14,32 @@ export default interface UnitConfigJson {
/** /**
* The possible denominations * The possible denominations
*/ */
applicableUnits: ApplicableUnitJson[] applicableUnits: DenominationConfigJson[]
} }
export interface ApplicableUnitJson { export interface DenominationConfigJson {
/** /**
* The canonical value which will be added to the value in OSM. * If this evaluates to true and the value to interpret has _no_ unit given, assumes that this unit is meant.
* Alternatively, a list of country codes can be given where this acts as the default interpretation
*
* E.g., a denomination using "meter" would probably set this flag to "true";
* a denomination for "mp/h" will use the condition "_country=gb" to indicate that it is the default in the UK.
*
* If none of the units indicate that they are the default, the first denomination will be used instead
*/
useIfNoUnitGiven?: boolean | string[]
/**
* Use this value as default denomination when the user inputs a value (e.g. to force using 'centimeters' instead of 'meters' by default).
* If unset for all values, this will use 'useIfNoUnitGiven'. If at least one denomination has this set, this will default to false
*/
useAsDefaultInput?: boolean | string[]
/**
* The canonical value for this denomination which will be added to the value in OSM.
* e.g. "m" for meters * e.g. "m" for meters
* If the user inputs '42', the canonical value will be added and it'll become '42m'. * If the user inputs '42', the canonical value will be added and it'll become '42m'.
* *
@ -28,8 +47,11 @@ export interface ApplicableUnitJson {
* In this case, an empty string should be used * In this case, an empty string should be used
*/ */
canonicalDenomination: string, canonicalDenomination: string,
/** /**
* The canonical denomination in the case that the unit is precisely '1' * The canonical denomination in the case that the unit is precisely '1'.
* Used for display purposes
*/ */
canonicalDenominationSingular?: string, canonicalDenominationSingular?: string,
@ -63,9 +85,5 @@ export interface ApplicableUnitJson {
*/ */
prefix?: boolean prefix?: boolean
/**
* The default interpretation - only one can be set.
* If none is set, the first unit will be considered the default interpretation of a value without a unit
*/
default?: boolean
} }

View file

@ -8,14 +8,11 @@ export class Unit {
public readonly appliesToKeys: Set<string>; public readonly appliesToKeys: Set<string>;
public readonly denominations: Denomination[]; public readonly denominations: Denomination[];
public readonly denominationsSorted: Denomination[]; public readonly denominationsSorted: Denomination[];
public readonly defaultDenom: Denomination;
public readonly eraseInvalid: boolean; public readonly eraseInvalid: boolean;
private readonly possiblePostFixes: string[] = []
constructor(appliesToKeys: string[], applicableUnits: Denomination[], eraseInvalid: boolean) { constructor(appliesToKeys: string[], applicableDenominations: Denomination[], eraseInvalid: boolean) {
this.appliesToKeys = new Set(appliesToKeys); this.appliesToKeys = new Set(appliesToKeys);
this.denominations = applicableUnits; this.denominations = applicableDenominations;
this.defaultDenom = applicableUnits.filter(denom => denom.default)[0]
this.eraseInvalid = eraseInvalid this.eraseInvalid = eraseInvalid
const seenUnitExtensions = new Set<string>(); const seenUnitExtensions = new Set<string>();
@ -52,8 +49,6 @@ export class Unit {
addPostfixesOf(denomination._canonicalSingular) addPostfixesOf(denomination._canonicalSingular)
denomination.alternativeDenominations.forEach(addPostfixesOf) denomination.alternativeDenominations.forEach(addPostfixesOf)
} }
this.possiblePostFixes = Array.from(possiblePostFixes)
this.possiblePostFixes.sort((a, b) => b.length - a.length)
} }
@ -71,16 +66,12 @@ export class Unit {
} }
// Some keys do have unit handling // Some keys do have unit handling
const defaultSet = json.applicableUnits.filter(u => u.default === true) if(json.applicableUnits.some(denom => denom.useAsDefaultInput !== undefined)){
// No default is defined - we pick the first as default json.applicableUnits.forEach(denom => {
if (defaultSet.length === 0) { denom.useAsDefaultInput = denom.useAsDefaultInput ?? false
json.applicableUnits[0].default = true })
}
// Check that there are not multiple defaults
if (defaultSet.length > 1) {
throw `Multiple units are set as default: they have canonical values of ${defaultSet.map(u => u.canonicalDenomination).join(", ")}`
} }
const applicable = json.applicableUnits.map((u, i) => new Denomination(u, `${ctx}.units[${i}]`)) const applicable = json.applicableUnits.map((u, i) => new Denomination(u, `${ctx}.units[${i}]`))
return new Unit(appliesTo, applicable, json.eraseInvalidValues ?? false) return new Unit(appliesTo, applicable, json.eraseInvalidValues ?? false)
} }
@ -96,12 +87,13 @@ export class Unit {
/** /**
* Finds which denomination is applicable and gives the stripped value back * Finds which denomination is applicable and gives the stripped value back
*/ */
findDenomination(valueWithDenom: string): [string, Denomination] { findDenomination(valueWithDenom: string, country: () => string): [string, Denomination] {
if (valueWithDenom === undefined) { if (valueWithDenom === undefined) {
return undefined; return undefined;
} }
const defaultDenom = this.getDefaultDenomination(country)
for (const denomination of this.denominationsSorted) { for (const denomination of this.denominationsSorted) {
const bare = denomination.StrippedValue(valueWithDenom) const bare = denomination.StrippedValue(valueWithDenom, defaultDenom === denomination)
if (bare !== null) { if (bare !== null) {
return [bare, denomination] return [bare, denomination]
} }
@ -109,11 +101,11 @@ export class Unit {
return [undefined, undefined] return [undefined, undefined]
} }
asHumanLongValue(value: string): BaseUIElement { asHumanLongValue(value: string, country: () => string): BaseUIElement {
if (value === undefined) { if (value === undefined) {
return undefined; return undefined;
} }
const [stripped, denom] = this.findDenomination(value) const [stripped, denom] = this.findDenomination(value, country)
const human = stripped === "1" ? denom?.humanSingular : denom?.human const human = stripped === "1" ? denom?.humanSingular : denom?.human
if (human === undefined) { if (human === undefined) {
return new FixedUiElement(stripped ?? value); return new FixedUiElement(stripped ?? value);
@ -124,24 +116,46 @@ export class Unit {
} }
/**
* Returns the value without any (sub)parts of any denomination - usefull as preprocessing step for validating inputs.
* E.g.
* if 'megawatt' is a possible denomination, then '5 Meg' will be rewritten to '5' (which can then be validated as a valid pnat)
*
* Returns the original string if nothign matches
*/
stripUnitParts(str: string) {
if (str === undefined) {
return undefined;
}
for (const denominationPart of this.possiblePostFixes) { public getDefaultInput(country: () => string | string[]) {
if (str.endsWith(denominationPart)) { console.log("Searching the default denomination for input", country)
return str.substring(0, str.length - denominationPart.length).trim() for (const denomination of this.denominations) {
if (denomination.useAsDefaultInput === true) {
return denomination
}
if (denomination.useAsDefaultInput === undefined || denomination.useAsDefaultInput === false) {
continue
}
let countries: string | string[] = country()
if (typeof countries === "string") {
countries = countries.split(",")
}
const denominationCountries: string[] = denomination.useAsDefaultInput
if (countries.some(country => denominationCountries.indexOf(country) >= 0)) {
return denomination
} }
} }
return this.denominations[0]
return str;
} }
public getDefaultDenomination(country: () => string){
for (const denomination of this.denominations) {
if (denomination.useIfNoUnitGiven === true || denomination.canonical === "") {
return denomination
}
if (denomination.useIfNoUnitGiven === undefined || denomination.useIfNoUnitGiven === false) {
continue
}
let countries: string | string[] = country()
if (typeof countries === "string") {
countries = countries.split(",")
}
const denominationCountries: string[] = denomination.useIfNoUnitGiven
if (countries.some(country => denominationCountries.indexOf(country) >= 0)) {
return denomination
}
}
return this.denominations[0]
}
} }

View file

@ -20,6 +20,7 @@ import {QueryParameters} from "../../Logic/Web/QueryParameters";
import {TagUtils} from "../../Logic/Tags/TagUtils"; import {TagUtils} from "../../Logic/Tags/TagUtils";
import {InputElement} from "../Input/InputElement"; import {InputElement} from "../Input/InputElement";
import {DropDown} from "../Input/DropDown"; import {DropDown} from "../Input/DropDown";
import {FixedUiElement} from "../Base/FixedUiElement";
export default class FilterView extends VariableUiElement { export default class FilterView extends VariableUiElement {
constructor(filteredLayer: UIEventSource<FilteredLayer[]>, constructor(filteredLayer: UIEventSource<FilteredLayer[]>,
@ -91,7 +92,7 @@ export default class FilterView extends VariableUiElement {
if (filteredLayer.layerDef.name === undefined) { if (filteredLayer.layerDef.name === undefined) {
// Name is not defined: we hide this one // Name is not defined: we hide this one
return new Toggle( return new Toggle(
filteredLayer?.layerDef?.description?.Clone()?.SetClass("subtle") , new FixedUiElement(filteredLayer?.layerDef?.id ).SetClass("block") ,
undefined, undefined,
state?.featureSwitchIsDebugging state?.featureSwitchIsDebugging
); );

View file

@ -90,7 +90,7 @@ export class TextFieldDef {
if (options.unit !== undefined) { if (options.unit !== undefined) {
// Reformatting is handled by the unit in this case // Reformatting is handled by the unit in this case
options["isValid"] = str => { options["isValid"] = str => {
const denom = options.unit.findDenomination(str); const denom = options.unit.findDenomination(str, options?.country);
if (denom === undefined) { if (denom === undefined) {
return false; return false;
} }
@ -153,7 +153,7 @@ export class TextFieldDef {
} }
}) })
) )
unitDropDown.GetValue().setData(unit.defaultDenom) unitDropDown.GetValue().setData(unit.getDefaultInput(options.country))
unitDropDown.SetClass("w-min") unitDropDown.SetClass("w-min")
const fixedDenom = unit.denominations.length === 1 ? unit.denominations[0] : undefined const fixedDenom = unit.denominations.length === 1 ? unit.denominations[0] : undefined
@ -169,7 +169,7 @@ export class TextFieldDef {
}, },
(valueWithDenom: string) => { (valueWithDenom: string) => {
// Take the value from OSM and feed it into the textfield and the dropdown // Take the value from OSM and feed it into the textfield and the dropdown
const withDenom = unit.findDenomination(valueWithDenom); const withDenom = unit.findDenomination(valueWithDenom, options?.country);
if (withDenom === undefined) { if (withDenom === undefined) {
// Not a valid value at all - we give it undefined and leave the details up to the other elements (but we keep the previous denomination) // Not a valid value at all - we give it undefined and leave the details up to the other elements (but we keep the previous denomination)
return [undefined, fixedDenom] return [undefined, fixedDenom]

View file

@ -617,6 +617,7 @@ export default class TagRenderingQuestion extends Combine {
const tagsData = tags.data; const tagsData = tags.data;
const feature = state?.allElements?.ContainingFeatures?.get(tagsData.id) const feature = state?.allElements?.ContainingFeatures?.get(tagsData.id)
const center = feature != undefined ? GeoOperations.centerpointCoordinates(feature) : [0, 0] const center = feature != undefined ? GeoOperations.centerpointCoordinates(feature) : [0, 0]
console.log("Creating a tr-question with applicableUnit", applicableUnit)
const input: InputElement<string> = ValidatedTextField.ForType(configuration.freeform.type)?.ConstructInputElement({ const input: InputElement<string> = ValidatedTextField.ForType(configuration.freeform.type)?.ConstructInputElement({
country: () => tagsData._country, country: () => tagsData._country,
location: [center[1], center[0]], location: [center[1], center[0]],

View file

@ -1273,6 +1273,9 @@ export default class SpecialVisualizations {
const tagRendering = layer.tagRenderings.find(tr => tr.id === tagRenderingId) const tagRendering = layer.tagRenderings.find(tr => tr.id === tagRenderingId)
tagRenderings.push([layer, tagRendering]) tagRenderings.push([layer, tagRendering])
} }
if(tagRenderings.length === 0){
throw "Could not create stolen tagrenddering: tagRenderings not found"
}
return new VariableUiElement(featureTags.map(tags => { return new VariableUiElement(featureTags.map(tags => {
const featureId = tags[featureIdKey] const featureId = tags[featureIdKey]
if (featureId === undefined) { if (featureId === undefined) {

View file

@ -185,6 +185,7 @@
"alternativeDenomination": [ "alternativeDenomination": [
"meter" "meter"
], ],
"useIfNoUnitGiven": true,
"human": { "human": {
"en": "meter", "en": "meter",
"fr": "mètre", "fr": "mètre",
@ -193,7 +194,7 @@
} }
}, },
{ {
"default": true, "useAsDefaultInput": true,
"canonicalDenomination": "cm", "canonicalDenomination": "cm",
"alternativeDenomination": [ "alternativeDenomination": [
"centimeter", "centimeter",

View file

@ -467,10 +467,12 @@
"units": [ "units": [
{ {
"appliesToKey": [ "appliesToKey": [
"kerb:height" "kerb:height",
"width"
], ],
"applicableUnits": [ "applicableUnits": [
{ {
"useIfNoUnitGiven": true,
"canonicalDenomination": "m", "canonicalDenomination": "m",
"alternativeDenomination": [ "alternativeDenomination": [
"meter" "meter"
@ -483,7 +485,7 @@
} }
}, },
{ {
"default": true, "useAsDefaultInput": true,
"canonicalDenomination": "cm", "canonicalDenomination": "cm",
"alternativeDenomination": [ "alternativeDenomination": [
"centimeter", "centimeter",

View file

@ -456,7 +456,6 @@
{ {
"applicableUnits": [ "applicableUnits": [
{ {
"default": true,
"canonicalDenomination": "", "canonicalDenomination": "",
"alternativeDenomination": [ "alternativeDenomination": [
"mm", "mm",

View file

@ -339,8 +339,7 @@
"nl": "centimeter", "nl": "centimeter",
"de": "Zentimeter", "de": "Zentimeter",
"fr": "centimètre" "fr": "centimètre"
}, }
"default": true
}, },
{ {
"canonicalDenomination": "m", "canonicalDenomination": "m",

View file

@ -154,7 +154,6 @@
"kmh", "kmh",
"kph" "kph"
], ],
"default": true,
"human": { "human": {
"en": "kilometers/hour", "en": "kilometers/hour",
"ca": "quilòmetres/hora", "ca": "quilòmetres/hora",
@ -172,6 +171,7 @@
}, },
{ {
"canonicalDenomination": "mph", "canonicalDenomination": "mph",
"useIfNoUnitGiven": ["gb","us"],
"alternativeDenomination": [ "alternativeDenomination": [
"m/u", "m/u",
"mh", "mh",

View file

@ -63,6 +63,7 @@
], ],
"applicableUnits": [ "applicableUnits": [
{ {
"useIfNoUnitGiven": true,
"canonicalDenomination": "m", "canonicalDenomination": "m",
"alternativeDenomination": [ "alternativeDenomination": [
"meter" "meter"
@ -74,16 +75,16 @@
} }
}, },
{ {
"default": true, "useAsDefaultInput": true,
"canonicalDenomination": "cm", "canonicalDenomination": "cm",
"alternativeDenomination": [ "alternativeDenomination": [
"centimeter", "centimeter",
"cms" "cms"
], ],
"human": { "human": {
"en": "centimeter", "en": " centimeter",
"fr": "centimètre", "fr": " centimètre",
"de": "Zentimeter" "de": " Zentimeter"
} }
} }
] ]

View file

@ -132,8 +132,7 @@
"ca": " metre", "ca": " metre",
"nb_NO": " meter", "nb_NO": " meter",
"es": " metro" "es": " metro"
}, }
"default": true
}, },
{ {
"canonicalDenomination": "ft", "canonicalDenomination": "ft",

View file

@ -215,10 +215,6 @@
"if": "theme=indoors", "if": "theme=indoors",
"then": "./assets/layers/entrance/entrance.svg" "then": "./assets/layers/entrance/entrance.svg"
}, },
{
"if": "theme=kakampink",
"then": "bug"
},
{ {
"if": "theme=kerbs_and_crossings", "if": "theme=kerbs_and_crossings",
"then": "./assets/layers/kerbs/KerbIcon.svg" "then": "./assets/layers/kerbs/KerbIcon.svg"

View file

@ -7,22 +7,21 @@ describe("Unit", () => {
it("should convert a value back and forth", () => { it("should convert a value back and forth", () => {
const unit = new Denomination({ const denomintion = new Denomination({
"canonicalDenomination": "MW", "canonicalDenomination": "MW",
"alternativeDenomination": ["megawatts", "megawatt"], "alternativeDenomination": ["megawatts", "megawatt"],
"human": { "human": {
"en": " megawatts", "en": " megawatts",
"nl": " megawatt" "nl": " megawatt"
}, },
"default": true
}, "test"); }, "test");
const canonical = unit.canonicalValue("5") const canonical = denomintion.canonicalValue("5", true)
expect(canonical).eq( "5 MW") expect(canonical).eq( "5 MW")
const units = new Unit(["key"], [unit], false) const units = new Unit(["key"], [denomintion], false)
const [detected, detectedDenom] = units.findDenomination("5 MW") const [detected, detectedDenom] = units.findDenomination("5 MW", () => "be")
expect(detected).eq( "5") expect(detected).eq( "5")
expect(detectedDenom).eq( unit) expect(detectedDenom).eq( denomintion)
} }
) )
}) })