diff --git a/assets/layers/adult_changing_table/adult_changing_table.json b/assets/layers/adult_changing_table/adult_changing_table.json index f32be1606e..03781d6206 100644 --- a/assets/layers/adult_changing_table/adult_changing_table.json +++ b/assets/layers/adult_changing_table/adult_changing_table.json @@ -79,7 +79,20 @@ ], "freeform": { "key": "height", - "type": "pfloat" + "type": "pfloat", + "unit": { + "quantity": "distance", + "denominations": [ + "m", + "cm" + ] + }, + "range": { + "warnBelow": 0.8, + "warnAbove": 1.7, + "min": 0.4, + "max": 2 + } }, "render": { "en": "The changing table is {canonical(height)} high", @@ -104,7 +117,20 @@ }, "freeform": { "key": "min_height", - "type": "pfloat" + "type": "pfloat", + "unit": { + "quantity": "distance", + "denominations": [ + "m", + "cm" + ] + }, + "range": { + "warnBelow": 0.8, + "warnAbove": 1.7, + "min": 0.4, + "max": 2 + } }, "render": { "en": "The lowest height of the adult changing table is {canonical(min_height)}", @@ -134,7 +160,20 @@ }, "freeform": { "key": "max_height", - "type": "pfloat" + "type": "pfloat", + "unit": { + "quantity": "distance", + "denominations": [ + "m", + "cm" + ] + }, + "range": { + "warnBelow": 0.8, + "warnAbove": 1.7, + "min": 0.4, + "max": 2 + } }, "render": { "en": "The highest height of the adult changing table is {canonical(max_height)}", @@ -226,34 +265,5 @@ "en": "Adult changing table", "nl": "Verzorgingstafel voor volwassenen", "it": "Fasciatoio per adulti" - }, - "units": [ - { - "adult:height": { - "quantity": "distance", - "denominations": [ - "m", - "cm" - ] - } - }, - { - "adult:min_height": { - "quantity": "distance", - "denominations": [ - "m", - "cm" - ] - } - }, - { - "adult:max_height": { - "quantity": "distance", - "denominations": [ - "m", - "cm" - ] - } - } - ] + } } diff --git a/assets/layers/entrance/entrance.json b/assets/layers/entrance/entrance.json index 3f95503155..296dc45c58 100644 --- a/assets/layers/entrance/entrance.json +++ b/assets/layers/entrance/entrance.json @@ -588,7 +588,20 @@ }, "freeform": { "key": "width", - "type": "pfloat" + "type": "pfloat", + "unit": { + "quantity": "distance", + "canonical": "m", + "denominations": [ + "cm" + ] + }, + "range": { + "warnBelow": 0.8, + "warnAbove": 1.7, + "min": 0.4, + "max": 2 + } } }, { @@ -629,7 +642,18 @@ "es": "Altura del bordillo de la puerta", "it": "Altezza del gradino della porta" }, - "type": "pfloat" + "type": "pfloat", + "unit": { + "quantity": "distance", + "canonical": "m", + "denominations": [ + "cm" + ] + }, + "range": { + "warnAbove": 0.25, + "max": 0.5 + } }, "mappings": [ { @@ -720,23 +744,5 @@ "allowMove": { "enableImproveAccuracy": true, "enableRelocation": false - }, - "units": [ - { - "kerb:height": { - "quantity": "distance", - "canonical": "m", - "denominations": [ - "cm" - ] - }, - "width": { - "quantity": "distance", - "canonical": "m", - "denominations": [ - "cm" - ] - } - } - ] + } } diff --git a/assets/layers/toilet/toilet.json b/assets/layers/toilet/toilet.json index 91c6087704..f9be205a91 100644 --- a/assets/layers/toilet/toilet.json +++ b/assets/layers/toilet/toilet.json @@ -1534,7 +1534,7 @@ }, "render": { "en": "The door to the wheelchair-accessible toilet is {canonical(door:width)} wide", - "nl": "De deur naar de rolstoeltoegankelijke toilet is {canonical(door:width)} wide", + "nl": "De deur naar de rolstoeltoegankelijke toilet is {canonical(door:width)}", "fr": "La porte des toilettes accessibles aux fauteuils roulants a une large de {canonical(door:width)}", "de": "Die Tür zur rollstuhlgerechten Toilette ist {canonical(door:width)} breit", "da": "Døren til det kørestolsvenlige toilet er {canonical(door:width)} bred", @@ -1545,7 +1545,20 @@ }, "freeform": { "key": "door:width", - "type": "pfloat" + "type": "pfloat", + "unit": { + "quantity": "distance", + "denominations": [ + "m", + "cm" + ] + }, + "range": { + "warnBelow": 0.6, + "min": 0.4, + "warnAbove": 2, + "max": 4 + } } }, { @@ -1734,16 +1747,5 @@ "allowMove": { "enableRelocation": false, "enableImproveAccuracy": true - }, - "units": [ - { - "door:width": { - "quantity": "distance", - "denominations": [ - "m", - "cm" - ] - } - } - ] + } } diff --git a/assets/layers/unit/unit.json b/assets/layers/unit/unit.json index 194af9e280..9aad86f5a9 100644 --- a/assets/layers/unit/unit.json +++ b/assets/layers/unit/unit.json @@ -36,7 +36,8 @@ "da": "{quantity} Megawatt", "cs": "{quantity} megawatty", "es": "{quantity} megavatios" - } + }, + "factorToCanonical": 1000000 }, { "canonicalDenomination": "kW", @@ -60,7 +61,8 @@ "da": "{quantity} Kilowatt", "cs": "{quantity} kilowatty", "es": "{quantity} kilovatios" - } + }, + "factorToCanonical": 1000 }, { "canonicalDenomination": "W", @@ -106,7 +108,8 @@ "cs": "{quantity} gigawatty", "zh_Hant": "{quantity} 千兆瓦", "es": "{quantity} gigavatios" - } + }, + "factorToCanonical": 1000000000 } ], "eraseInvalidValues": true @@ -231,7 +234,8 @@ "hu": "egy centiméter", "es": "un centímetro", "it": "one centimeter" - } + }, + "factorToCanonical": 0.01 }, { "canonicalDenomination": "mm", @@ -259,7 +263,8 @@ "hu": "egy milliméter", "es": "un milímetro", "it": "one millimeter" - } + }, + "factorToCanonical": 0.001 }, { "canonicalDenomination": "ft", @@ -283,7 +288,8 @@ "nb_NO": "{quantity} fot", "pa_PK": "{quantity} ؜ فوٹ", "hu": "{quantity} láb" - } + }, + "factorToCanonical": 0.3048 } ] }, diff --git a/langs/en.json b/langs/en.json index aee22b63c0..36e88bed16 100644 --- a/langs/en.json +++ b/langs/en.json @@ -858,6 +858,12 @@ "description": "a number", "feedback": "This is not a number" }, + "generic": { + "suspiciouslyHigh": "This value is suspiciously high. Are you sure it is correct?", + "suspiciouslyLow": "This value is suspiciously low. Are you sure it is correct?", + "tooHigh": "This value is too high - the highest allowed value is {max}", + "tooLow": "This value is too low - the lowest allowed value is {min}" + }, "id": { "description": "an identifier", "invalidCharacter": "An id can only contain letters, digits and underscores", diff --git a/scripts/generateDocs.ts b/scripts/generateDocs.ts index 6a09cb28ac..f85da064c7 100644 --- a/scripts/generateDocs.ts +++ b/scripts/generateDocs.ts @@ -280,11 +280,19 @@ export class GenerateDocs extends Script { "Units ", "## " + layer.id, ] - for (const unit of layer.units) { els.push("### " + unit.quantity) + const defaultUnit = unit.getDefaultDenomination(() => undefined) for (const denomination of unit.denominations) { els.push("#### " + denomination.canonical) + if (denomination.validator) { + els.push(`Validator is *${denomination.validator.name}*`) + } + + if (denomination.factorToCanonical) { + els.push(`1${denomination.canonical} = ${denomination.factorToCanonical}${defaultUnit.canonical}`) + } + if (denomination.useIfNoUnitGiven === true) { els.push("*Default denomination*") } else if ( diff --git a/src/Models/Denomination.ts b/src/Models/Denomination.ts index 94b9abfe54..6de24f3f7d 100644 --- a/src/Models/Denomination.ts +++ b/src/Models/Denomination.ts @@ -16,7 +16,12 @@ export class Denomination { public readonly alternativeDenominations: string[] public readonly human: TypedTranslation<{ quantity: string }> public readonly humanSingular?: Translation - private readonly _validator: Validator + public readonly validator: Validator + /** + * IF a conversion to the canonical value is possible, this is the factor. + * E.g. for "cm", the factor is 0.01, as "1cm = 0.01m" + */ + public readonly factorToCanonical?: number private constructor( canonical: string, @@ -27,7 +32,8 @@ export class Denomination { alternativeDenominations: string[], _human: TypedTranslation<{ quantity: string }>, _humanSingular: Translation, - validator: Validator + validator: Validator, + factorToCanonical: number ) { this.canonical = canonical this._canonicalSingular = _canonicalSingular @@ -37,7 +43,8 @@ export class Denomination { this.alternativeDenominations = alternativeDenominations this.human = _human this.humanSingular = _humanSingular - this._validator = validator + this.validator = validator + this.factorToCanonical = factorToCanonical } public static fromJson(json: DenominationConfigJson, validator: Validator, context: string) { @@ -73,7 +80,8 @@ export class Denomination { json.alternativeDenomination?.map((v) => v.trim()) ?? [], humanTexts, Translations.T(json.humanSingular, context + "humanSingular"), - validator + validator, + json.factorToCanonical ) } @@ -87,7 +95,8 @@ export class Denomination { this.alternativeDenominations, this.human, this.humanSingular, - this._validator + this.validator, + this.factorToCanonical ) } @@ -101,7 +110,8 @@ export class Denomination { [this.canonical, ...this.alternativeDenominations], this.human, this.humanSingular, - this._validator + this.validator, + this.factorToCanonical ) } @@ -217,10 +227,10 @@ export class Denomination { return null } - if (!this._validator.isValid(value.trim())) { + if (!this.validator.isValid(value.trim())) { return null } - return this._validator.reformat(value.trim()) + return this.validator.reformat(value.trim()) } withValidator(validator: Validator) { @@ -233,7 +243,8 @@ export class Denomination { this.alternativeDenominations, this.human, this.humanSingular, - validator + validator, + this.factorToCanonical ) } } diff --git a/src/Models/ThemeConfig/Conversion/PrepareLayer.ts b/src/Models/ThemeConfig/Conversion/PrepareLayer.ts index b33b76e1de..0fe8b64417 100644 --- a/src/Models/ThemeConfig/Conversion/PrepareLayer.ts +++ b/src/Models/ThemeConfig/Conversion/PrepareLayer.ts @@ -962,6 +962,7 @@ class MoveUnitConfigs extends DesugaringStep { json.units.push({ [qtr.freeform.key]: unitConfig }) + // Note: we do not delete the config - this way, if the tagRendering is imported in another layer, the unit comes along } return json } @@ -1052,6 +1053,7 @@ export class PrepareLayer extends Fuse { ), new AddFiltersFromTagRenderings(), new ExpandFilter(state), + new MoveUnitConfigs(), new PruneFilters() ) } diff --git a/src/Models/ThemeConfig/Json/QuestionableTagRenderingConfigJson.ts b/src/Models/ThemeConfig/Json/QuestionableTagRenderingConfigJson.ts index c27b0fed1a..50bbedff0c 100644 --- a/src/Models/ThemeConfig/Json/QuestionableTagRenderingConfigJson.ts +++ b/src/Models/ThemeConfig/Json/QuestionableTagRenderingConfigJson.ts @@ -312,6 +312,16 @@ export interface QuestionableTagRenderingConfigJson extends TagRenderingConfigJs canonical?: string inverted?: boolean + }, + /** + * question: In what range should the value be? + * For example, a door width under 65cm is suspicious, under 40cm it is a mistake. + */ + range?: { + min?: number, + warnBelow?: number, + warnAbove?: number + max?: number } } diff --git a/src/Models/ThemeConfig/Json/UnitConfigJson.ts b/src/Models/ThemeConfig/Json/UnitConfigJson.ts index 8ac5d25c32..82af97da71 100644 --- a/src/Models/ThemeConfig/Json/UnitConfigJson.ts +++ b/src/Models/ThemeConfig/Json/UnitConfigJson.ts @@ -85,7 +85,8 @@ export default interface UnitConfigJson { * When a default input method should be used, this can be specified by setting the canonical denomination here, e.g. * `defaultInput: "cm"`. This must be a denomination which appears in the applicableUnits */ - defaultInput?: string + defaultInput?: string, + } export interface DenominationConfigJson { @@ -154,4 +155,9 @@ export interface DenominationConfigJson { * E.g.: `50 mph` instad of `50mph` */ addSpace?: boolean + + /** + * If the canonical unit (e.g. 1m) is multiplied with the factorToCanonical (0.01) you will get the current unit (1cm) + */ + factorToCanonical?: number } diff --git a/src/Models/ThemeConfig/TagRenderingConfig.ts b/src/Models/ThemeConfig/TagRenderingConfig.ts index dbdcac8f0c..56ab043a44 100644 --- a/src/Models/ThemeConfig/TagRenderingConfig.ts +++ b/src/Models/ThemeConfig/TagRenderingConfig.ts @@ -5,10 +5,7 @@ import { TagUtils } from "../../Logic/Tags/TagUtils" import { And } from "../../Logic/Tags/And" import { Utils } from "../../Utils" import { Tag } from "../../Logic/Tags/Tag" -import { - MappingConfigJson, - QuestionableTagRenderingConfigJson, -} from "./Json/QuestionableTagRenderingConfigJson" +import { MappingConfigJson, QuestionableTagRenderingConfigJson } from "./Json/QuestionableTagRenderingConfigJson" import Validators, { ValidatorType } from "../../UI/InputElement/Validators" import { TagRenderingConfigJson } from "./Json/TagRenderingConfigJson" import { RegexTag } from "../../Logic/Tags/RegexTag" @@ -20,6 +17,7 @@ import MarkdownUtils from "../../Utils/MarkdownUtils" import { UploadableTag } from "../../Logic/Tags/TagTypes" import LayerConfig from "./LayerConfig" import ComparingTag from "../../Logic/Tags/ComparingTag" +import { Unit } from "../Unit" export interface Mapping { readonly if: UploadableTag @@ -41,6 +39,12 @@ export interface Mapping { readonly priorityIf?: TagsFilter } +export interface ValueRange { + min?: number, + max?: number, + warnBelow?: number, + warnAbove?: number +} /*** * The parsed version of TagRenderingConfigJSON * Identical data, but with some methods and validation @@ -79,6 +83,7 @@ export default class TagRenderingConfig { readonly default?: string readonly postfixDistinguished?: string readonly args?: any + readonly range: ValueRange } public readonly multiAnswer: boolean @@ -233,6 +238,7 @@ export default class TagRenderingConfig { default: json.freeform.default, postfixDistinguished: json.freeform.postfixDistinguished?.trim(), args: json.freeform.helperArgs, + range: json.freeform.range } if (json.freeform["extraTags"] !== undefined) { throw `Freeform.extraTags is defined. This should probably be 'freeform.addExtraTag' (at ${context})` @@ -787,7 +793,8 @@ export default class TagRenderingConfig { freeformValue: string | undefined, singleSelectedMapping: number, multiSelectedMapping: boolean[] | undefined, - currentProperties: Record + currentProperties: Record, + unit?: Unit ): UploadableTag[] { if (typeof freeformValue === "string") { freeformValue = freeformValue?.trim() @@ -795,7 +802,14 @@ export default class TagRenderingConfig { const validator = Validators.get(this.freeform?.type) if (validator && freeformValue) { - freeformValue = validator.reformat(freeformValue, () => currentProperties["_country"]) + // We try to reformat; but a unit might annoy us here + if (unit) { + const [valueNoUnit, denom] = unit.findDenomination(freeformValue, () => currentProperties["_country"]) + const formatted = validator.reformat(valueNoUnit, () => currentProperties["_country"]) + freeformValue = formatted + denom.canonical + } else { + freeformValue = validator.reformat(freeformValue, () => currentProperties["_country"]) + } } if (freeformValue === "") { freeformValue = undefined @@ -918,9 +932,23 @@ export default class TagRenderingConfig { GenerateDocumentation(lang: string = "en"): string { let freeform: string = undefined if (this.render) { - freeform = "*" + this.render.textFor(lang) + "*" + freeform = "\n*" + this.render.textFor(lang) + "*" if (this.freeform?.key) { - freeform += " is shown if `" + this.freeform.key + "` is set" + freeform += " is shown if `" + this.freeform.key + "` is set." + } + if (this.question && this.freeform.range) { + freeform += "\n\nThe allowed input is of type " + (this.freeform.type ?? "string") + if (this.freeform.range) { + const r = this.freeform.range + freeform += ` and is in range ${r.min ?? "-infinty"} until ${r.max ?? "infinity"} (both inclusive).` + if (r.warnAbove && r.warnBelow) { + freeform += ` A warning will appear if the value is outside of ${r.warnBelow} and ${r.warnAbove}.` + } else if (r.warnBelow) { + freeform += ` A warning will appear below ${r.warnBelow}.` + } else if (r.warnAbove) { + freeform += ` A warning will appear above ${r.warnAbove}.` + } + } } } diff --git a/src/Models/Unit.ts b/src/Models/Unit.ts index 4bdb7117be..311e661336 100644 --- a/src/Models/Unit.ts +++ b/src/Models/Unit.ts @@ -5,6 +5,7 @@ import unit from "../../assets/layers/unit/unit.json" import TagRenderingConfig from "./ThemeConfig/TagRenderingConfig" import Validators, { ValidatorType } from "../UI/InputElement/Validators" import { Validator } from "../UI/InputElement/Validator" +import FloatValidator from "../UI/InputElement/Validators/FloatValidator" export class Unit { private static allUnits = this.initUnits() @@ -334,10 +335,11 @@ export class Unit { return [undefined, undefined] } - asHumanLongValue(value: string, country: () => string): BaseUIElement | string { + asHumanLongValue(value: string | number, country: () => string): BaseUIElement | string { if (value === undefined) { return undefined } + value = "" + value const [stripped, denom] = this.findDenomination(value, country) const human = denom?.human if (this.inverted) { @@ -393,4 +395,19 @@ export class Unit { } return this.denominations[0] } + + /** + * Gets the value in the canonical denomination; + * e.g. "1cm -> 0.01" as it is 0.01meter + * @param v + */ + public valueInCanonical(value: string, country: () => string): number { + const denom = this.findDenomination(value, country) + if (!denom) { + return undefined + } + const [v, d] = denom + const vf = new FloatValidator().reformat(v) + return Number(vf) * (d.factorToCanonical ?? 1) + } } diff --git a/src/UI/InputElement/ValidatedInput.svelte b/src/UI/InputElement/ValidatedInput.svelte index 9bf029daaa..4fc47f7524 100644 --- a/src/UI/InputElement/ValidatedInput.svelte +++ b/src/UI/InputElement/ValidatedInput.svelte @@ -4,12 +4,17 @@ import Validators from "./Validators" import { ExclamationIcon } from "@rgossiaux/svelte-heroicons/solid" import { Translation } from "../i18n/Translation" + import { createEventDispatcher, onDestroy } from "svelte" import { Validator } from "./Validator" import { Unit } from "../../Models/Unit" import UnitInput from "../Popup/UnitInput.svelte" import { Utils } from "../../Utils" import { twMerge } from "tailwind-merge" + import type { ValueRange } from "../../Models/ThemeConfig/TagRenderingConfig" + import Translations from "../i18n/Translations" + import FloatValidator from "./Validators/FloatValidator" + import BaseUIElement from "../BaseUIElement" export let type: ValidatorType export let feedback: UIEventSource | undefined = undefined @@ -18,6 +23,7 @@ export let placeholder: string | Translation | undefined = undefined export let autofocus: boolean = false export let unit: Unit = undefined + export let range: ValueRange = undefined /** * Valid state, exported to the calling component */ @@ -42,7 +48,7 @@ function initValueAndDenom() { if (unit && value.data) { - const [v, denom] = unit?.findDenomination(value.data, getCountry) + const [v, denom] = unit.findDenomination(value.data, getCountry) if (denom) { unvalidatedText.setData(v) selectedUnit.setData(denom.canonical) @@ -62,7 +68,6 @@ } } initValueAndDenom() - $: { // The type changed -> reset some values validator = Validators.get(type ?? "string") @@ -77,6 +82,41 @@ initValueAndDenom() } + const t = Translations.t.validation.generic + + /** + * Side effect: sets the feedback, returns true/false if valid + * @param canonicalValue + */ + function validateRange(canonicalValue: number): boolean { + if (!range) { + return true + } + if (canonicalValue < range.warnBelow) { + feedback.set(t.suspiciouslyLow) + } + if (canonicalValue > range.warnAbove) { + feedback.set(t.suspiciouslyHigh) + } + if (canonicalValue > range.max) { + let max: number | string | BaseUIElement = range.max + if (unit) { + max = unit.asHumanLongValue(max) + } + feedback.set(t.tooHigh.Subs({ max })) + return false + } + if (canonicalValue < range.min) { + let min: number | string | BaseUIElement = range.min + if (unit) { + min = unit.asHumanLongValue(min) + } + feedback.set(t.tooLow.Subs({ min })) + return false + } + return true + } + function setValues() { // Update the value stores const v = unvalidatedText.data @@ -92,13 +132,22 @@ } if (selectedUnit.data) { - value.setData(unit.toOsm(v, selectedUnit.data)) + const canonicalValue = unit.valueInCanonical(v + selectedUnit.data) + if (validateRange(canonicalValue)) { + value.setData(unit.toOsm(v, selectedUnit.data)) + } else { + value.set(undefined) + } } else { - value.setData(v) + if (validateRange(v)) { + value.setData(v) + } else { + value.set(undefined) + } } } - onDestroy(unvalidatedText.addCallbackAndRun((_) => setValues())) + onDestroy(unvalidatedText.addCallbackAndRun(() => setValues())) if (unit === undefined) { onDestroy( value.addCallbackAndRunD((fromUpstream) => { @@ -110,7 +159,7 @@ } else { // Handled by the UnitInput } - onDestroy(selectedUnit.addCallback((_) => setValues())) + onDestroy(selectedUnit.addCallback(() => setValues())) if (validator === undefined) { throw ( "Not a valid type (no validator found) for type '" + diff --git a/src/UI/InputElement/Validators/FloatValidator.ts b/src/UI/InputElement/Validators/FloatValidator.ts index 2fb5bfcb4b..81f000ff78 100644 --- a/src/UI/InputElement/Validators/FloatValidator.ts +++ b/src/UI/InputElement/Validators/FloatValidator.ts @@ -17,7 +17,6 @@ export default class FloatValidator extends Validator { * new FloatValidator().isValid("0,2") // => true */ isValid(str: string) { - console.log("Is valid?", str, FloatValidator.formattingHasComma) if (!FloatValidator.formattingHasComma) { str = str.replace(",", ".") } @@ -28,7 +27,11 @@ export default class FloatValidator extends Validator { if (!FloatValidator.formattingHasComma) { str = str.replace(",", ".") } - return "" + Number(str) + let formatted = "" + Number(str) + if (str.startsWith("0") && str.length > 1 && str.indexOf(".") < 0) { + formatted = "0" + formatted + } + return formatted } getFeedback(s: string): Translation { diff --git a/src/UI/Popup/TagRendering/FreeformInput.svelte b/src/UI/Popup/TagRendering/FreeformInput.svelte index e806ceb85e..23e7bacac0 100644 --- a/src/UI/Popup/TagRendering/FreeformInput.svelte +++ b/src/UI/Popup/TagRendering/FreeformInput.svelte @@ -53,6 +53,7 @@ type={config.freeform.type} {placeholder} {value} + range={config.freeform.range} /> {:else if InputHelpers.hideInputField.indexOf(config.freeform.type) < 0} @@ -66,6 +67,7 @@ {placeholder} {value} {unvalidatedText} + range={config.freeform.range} /> {/if} diff --git a/src/UI/Popup/TagRendering/TagRenderingQuestion.svelte b/src/UI/Popup/TagRendering/TagRenderingQuestion.svelte index f565f9fdda..f34f190fae 100644 --- a/src/UI/Popup/TagRendering/TagRenderingQuestion.svelte +++ b/src/UI/Popup/TagRendering/TagRenderingQuestion.svelte @@ -221,7 +221,8 @@ $freeformInput, selectedMapping, checkedMappings, - tags.data + tags.data, + unit ) if (featureSwitchIsDebugging?.data) { console.log(