Studio: add tagInput element

This commit is contained in:
Pieter Vander Vennet 2023-06-22 15:07:14 +02:00
parent 48e976e6b7
commit 2147b8d368
16 changed files with 413 additions and 42 deletions

49
Logic/Web/TagInfo.ts Normal file
View file

@ -0,0 +1,49 @@
import exp from "constants"
import { Utils } from "../../Utils"
export interface TagInfoStats {
/**
* The total number of entries in the data array, **not** the total number of objects known in OSM!
*
* Use `data.find(item => item.type==="all").count` for this
*/
total: number
data: {
type: "all" | "nodes" | "ways" | "relations"
count: number
count_fraction: number
}[]
}
export default class TagInfo {
private readonly _backend: string
public static readonly global = new TagInfo()
constructor(backend = "https://taginfo.openstreetmap.org/") {
this._backend = backend
}
public getStats(key: string, value?: string): Promise<TagInfoStats> {
let url: string
if (value) {
url = `${this._backend}api/4/tag/stats?key=${key}&value=${value}`
} else {
url = `${this._backend}api/4/key/stats?key=${key}`
}
return Utils.downloadJsonCached(url, 1000 * 60 * 60)
}
/**
* Creates the URL to the webpage containing more information
* @param k
* @param v
*/
webUrl(k: string, v: string) {
if (v) {
return `${this._backend}/tags/${k}=${v}#overview`
} else {
return `${this._backend}/keys/${k}#overview`
}
}
}

View file

@ -20,6 +20,7 @@ import Combine from "../Base/Combine"
import Title from "../Base/Title"
import SimpleTagValidator from "./Validators/SimpleTagValidator"
import ImageUrlValidator from "./Validators/ImageUrlValidator"
import TagKeyValidator from "./Validators/TagKeyValidator";
export type ValidatorType = (typeof Validators.availableTypes)[number]
@ -60,8 +61,9 @@ export default class Validators {
new PhoneValidator(),
new OpeningHoursValidator(),
new ColorValidator(),
new SimpleTagValidator(),
new ImageUrlValidator(),
new SimpleTagValidator(),
new TagKeyValidator()
]
private static _byType = Validators._byTypeConstructor()

View file

@ -1,11 +1,13 @@
import { Validator } from "../Validator"
import { Translation } from "../../i18n/Translation"
import Translations from "../../i18n/Translations"
import TagKeyValidator from "./TagKeyValidator"
/**
* Checks that the input conforms `key=value`, where `key` and `value` don't have too much weird characters
*/
export default class SimpleTagValidator extends Validator {
private static readonly KeyValidator = new TagKeyValidator()
constructor() {
super(
"simple_tag",
@ -13,7 +15,7 @@ export default class SimpleTagValidator extends Validator {
)
}
getFeedback(tag: string): Translation | undefined {
getFeedback(tag: string, _): Translation | undefined {
const parts = tag.split("=")
if (parts.length < 2) {
return Translations.T("A tag should contain a = to separate the 'key' and 'value'")
@ -27,31 +29,23 @@ export default class SimpleTagValidator extends Validator {
}
const [key, value] = parts
if (key.length > 255) {
return Translations.T("A `key` should be at most 255 characters")
const keyFeedback = SimpleTagValidator.KeyValidator.getFeedback(key, _)
if (keyFeedback) {
return keyFeedback
}
if (value.length > 255) {
return Translations.T("A `value should be at most 255 characters")
}
if (key.length == 0) {
return Translations.T("A `key` should not be empty")
}
if (value.length == 0) {
return Translations.T("A `value should not be empty")
}
const keyRegex = /[a-zA-Z0-9:_]+/
if (!key.match(keyRegex)) {
return Translations.T(
"A `key` should only have the characters `a-zA-Z0-9`, `:` or `_`"
)
}
return undefined
}
isValid(tag: string): boolean {
return this.getFeedback(tag) === undefined
isValid(tag: string, _): boolean {
return this.getFeedback(tag, _) === undefined
}
}

View file

@ -0,0 +1,30 @@
import { Validator } from "../Validator"
import { Translation } from "../../i18n/Translation"
import Translations from "../../i18n/Translations"
export default class TagKeyValidator extends Validator {
constructor() {
super("key", "Validates a key, mostly that no weird characters are used")
}
getFeedback(key: string, _?: () => string): Translation | undefined {
if (key.length > 255) {
return Translations.T("A `key` should be at most 255 characters")
}
if (key.length == 0) {
return Translations.T("A `key` should not be empty")
}
const keyRegex = /[a-zA-Z0-9:_]+/
if (!key.match(keyRegex)) {
return Translations.T(
"A `key` should only have the characters `a-zA-Z0-9`, `:` or `_`"
)
}
return undefined
}
isValid(key: string, getCountry?: () => string): boolean {
return this.getFeedback(key, getCountry) === undefined
}
}

View file

@ -22,7 +22,7 @@ export default class EditLayerState {
this.configuration.addCallback((config) => console.log("Current config is", config))
}
public register(path: ReadonlyArray<string | number>, value: Store<string>) {
public register(path: ReadonlyArray<string | number>, value: Store<any>) {
value.addCallbackAndRun((v) => {
let entry = this.configuration.data
for (let i = 0; i < path.length - 1; i++) {

View file

@ -0,0 +1,18 @@
<script lang="ts">
import EditLayerState from "./EditLayerState";
import {UIEventSource} from "../../Logic/UIEventSource";
import type {TagConfigJson} from "../../Models/ThemeConfig/Json/TagConfigJson";
import TagInput from "./TagInput/TagInput.svelte";
/**
* Thin wrapper around 'TagInput' which registers the output with the state
*/
export let path : (string | number)[]
export let state : EditLayerState
let tag: UIEventSource<TagConfigJson> = new UIEventSource<TagConfigJson>(undefined)
state.register(path, tag)
</script>
<TagInput {tag} />

View file

@ -4,17 +4,21 @@
import EditLayerState from "./EditLayerState";
import SchemaBasedArray from "./SchemaBasedArray.svelte";
import SchemaBaseMultiType from "./SchemaBaseMultiType.svelte";
import RegisteredTagInput from "./RegisteredTagInput.svelte";
export let schema: ConfigMeta
export let state: EditLayerState
export let path: (string | number)[] = []
</script>
{#if schema.type === "array"}
<SchemaBasedArray {path} {state} {schema}/>
{:else if schema.hints.typehint === "tag"}
<RegisteredTagInput {state} {path}/>
{:else if schema.hints.types}
<SchemaBaseMultiType {path} {state} {schema}></SchemaBaseMultiType>
<SchemaBaseMultiType {path} {state} {schema}/>
{:else}
<SchemaBasedField {path} {state} {schema}/>
{/if}

View file

@ -0,0 +1,95 @@
<script lang="ts">/**
* Allows to create `and` and `or` expressions graphically
*/
import {UIEventSource} from "../../Logic/UIEventSource";
import type {TagConfigJson} from "../../Models/ThemeConfig/Json/TagConfigJson";
import BasicTagInput from "./TagInput/BasicTagInput.svelte";
import {onDestroy} from "svelte";
import TagInfoStats from "./TagInfoStats.svelte";
import TagInput from "./TagInput/TagInput.svelte";
import exp from "constants";
export let tag: UIEventSource<TagConfigJson>
let mode: "and" | "or" = "and"
let basicTags: UIEventSource<UIEventSource<string>[]> = new UIEventSource([])
/**
* Sub-expressions
*/
let expressions: UIEventSource<UIEventSource<TagConfigJson>[]> = new UIEventSource([])
export let uploadableOnly: boolean
export let overpassSupportNeeded: boolean
function update(_) {
let config: TagConfigJson = <any>{}
if (!mode) {
return
}
const tags = []
const subpartSources = (<UIEventSource<string | TagConfigJson>[]>basicTags.data).concat(expressions.data)
for (const src of subpartSources) {
const t = src.data
if (!t) {
// We indicate upstream that this value is invalid
tag.setData(undefined)
return
}
tags.push(t)
}
config[mode] = tags
tag.setData(config)
}
function addBasicTag() {
const src = new UIEventSource(undefined)
basicTags.data.push(src);
basicTags.ping()
src.addCallbackAndRunD(_ => update(_))
}
function addExpression() {
const src = new UIEventSource(undefined)
expressions.data.push(src);
expressions.ping()
src.addCallbackAndRunD(_ => update(_))
}
$: update(mode)
addBasicTag()
</script>
<div class="flex items-center">
<select bind:value={mode}>
<option value="and">and</option>
{#if !uploadableOnly}
<option value="or">or</option>
{/if}
</select>
<div class="border-l-4 border-black flex flex-col ml-1 pl-1">
{#each $basicTags as basicTag}
<BasicTagInput {overpassSupportNeeded} {uploadableOnly} tag={basicTag}/>
{/each}
{#each $expressions as expression}
<TagInput {overpassSupportNeeded} {uploadableOnly} tag={expression}/>
{/each}
<div class="flex">
<button class="primary w-fit" on:click={addBasicTag}>
Add a tag
</button>
<button class="w-fit" on:click={addExpression}>
Add an expression
</button>
</div>
</div>
</div>

View file

@ -0,0 +1,60 @@
<script lang="ts">/**
* A small component showing statistics from tagInfo.
* Will show this in an 'alert' if very little (<250) tags are known
*/
import {TagUtils} from "../../Logic/Tags/TagUtils";
import {Store, UIEventSource} from "../../Logic/UIEventSource";
import type {TagInfoStats} from "../../Logic/Web/TagInfo";
import TagInfo from "../../Logic/Web/TagInfo";
import {twMerge} from "tailwind-merge";
import Loading from "../Base/Loading.svelte";
export let tag: UIEventSource<string>
const tagStabilized = tag.stabilized(500)
const tagInfoStats: Store<TagInfoStats> = tagStabilized.bind(tag => {
if (!tag) {
return undefined
}
try {
const t = TagUtils.Tag(tag)
const k = t["key"]
let v = t["value"]
if (typeof v !== "string") {
v = undefined
}
if (!k) {
return undefined
}
return UIEventSource.FromPromise(TagInfo.global.getStats(k, v))
} catch (e) {
return undefined
}
})
const tagInfoUrl: Store<string> = tagStabilized.mapD(tag => {
try {
const t = TagUtils.Tag(tag)
const k = t["key"]
let v = t["value"]
if (typeof v !== "string") {
v = undefined
}
if (!k) {
return undefined
}
return TagInfo.global.webUrl(k, v)
} catch (e) {
return undefined
}
})
const total = tagInfoStats.mapD(data => data.data.find(i => i.type === "all").count)
</script>
{#if $tagStabilized !== $tag}
<Loading/>
{:else if $tagInfoStats }
<a href={$tagInfoUrl} target="_blank" class={twMerge(($total < 250) ? "alert" : "thanks", "w-fit link-underline")}>
{$total} features on OSM have this tag
</a>
{/if}

View file

@ -0,0 +1,98 @@
<script lang="ts">
import ValidatedInput from "../../InputElement/ValidatedInput.svelte";
import {UIEventSource} from "../../../Logic/UIEventSource";
import {onDestroy} from "svelte";
import Tr from "../../Base/Tr.svelte";
import {TagUtils} from "../../../Logic/Tags/TagUtils";
import TagInfoStats from "../TagInfoStats.svelte";
export let tag: UIEventSource<string> = new UIEventSource<string>(undefined)
export let uploadableOnly: boolean
export let overpassSupportNeeded: boolean
tag.addCallback(tag => console.log("Current tag is", tag))
console.log({uploadableOnly, overpassSupportNeeded})
let feedbackGlobal = tag.map(tag => {
if (!tag) {
return undefined
}
try {
TagUtils.Tag(tag)
return undefined
} catch (e) {
return e
}
})
let feedbackKey = new UIEventSource<string>(undefined)
let keyValue = new UIEventSource<string>(undefined)
let feedbackValue = new UIEventSource<string>(undefined)
/**
* The value of the tag. The name is a bit confusing
*/
let valueValue = new UIEventSource<string>(undefined)
let mode: string = "="
let modes: string[] = []
for (const k in TagUtils.modeDocumentation) {
const docs = TagUtils.modeDocumentation[k]
if (overpassSupportNeeded && !docs.overpassSupport) {
continue
}
if (uploadableOnly && !docs.uploadable) {
continue
}
modes.push(k)
}
if (!uploadableOnly && !overpassSupportNeeded) {
modes.push(...TagUtils.comparators.map(c => c[0]))
}
onDestroy(valueValue.addCallbackAndRun(setTag))
onDestroy(keyValue.addCallbackAndRun(setTag))
$: {
setTag(mode)
}
function setTag(_) {
const k = keyValue.data
const v = valueValue.data
tag.setData(k + mode + v)
}
</script>
<div>
<ValidatedInput feedback={feedbackKey} placeholder="The key of the tag" type="key"
value={keyValue}></ValidatedInput>
<select bind:value={mode}>
{#each modes as option}
<option value={option}>
{option}
</option>
{/each}
</select>
<ValidatedInput feedback={feedbackValue} placeholder="The value of the tag" type="string"
value={valueValue}></ValidatedInput>
{#if $feedbackKey}
<Tr cls="alert" t={$feedbackKey}/>
{:else if $feedbackValue}
<Tr cls="alert" t={$feedbackValue}/>
{:else if $feedbackGlobal}
<Tr cls="alert" t={$feedbackGlobal}/>
{/if}
<TagInfoStats {tag}/>
</div>

View file

@ -0,0 +1,16 @@
<script lang="ts">/**
* An element input a tag; has `and`, `or`, `regex`, ...
*/
import type {TagConfigJson} from "../../../Models/ThemeConfig/Json/TagConfigJson";
import {UIEventSource} from "../../../Logic/UIEventSource";
import TagExpression from "../TagExpression.svelte";
export let tag: UIEventSource<TagConfigJson>
export let uploadableOnly: boolean
export let overpassSupportNeeded: boolean
</script>
<div class="m-4">
<TagExpression {overpassSupportNeeded} {tag} {uploadableOnly}/>
</div>

View file

@ -73,7 +73,7 @@
"properties": {
"osmTags": {
"$ref": "#/definitions/TagConfigJson",
"description": "question: Which tags must be present on the feature to show it in this layer?\nEvery source must set which tags have to be present in order to load the given layer."
"description": "question: Which tags must be present on the feature to show it in this layer?\n\n Every source must set which tags have to be present in order to load the given layer."
},
"maxCacheAge": {
"description": "question: How long (in seconds) is the data allowed to remain cached until it must be refreshed?\nThe maximum amount of seconds that a tile is allowed to linger in the cache\n\ntype: nat",

View file

@ -320,7 +320,7 @@
"properties": {
"osmTags": {
"$ref": "#/definitions/TagConfigJson",
"description": "question: Which tags must be present on the feature to show it in this layer?\nEvery source must set which tags have to be present in order to load the given layer."
"description": "question: Which tags must be present on the feature to show it in this layer?\n\n Every source must set which tags have to be present in order to load the given layer."
},
"maxCacheAge": {
"description": "question: How long (in seconds) is the data allowed to remain cached until it must be refreshed?\nThe maximum amount of seconds that a tile is allowed to linger in the cache\n\ntype: nat",
@ -33700,7 +33700,7 @@
"properties": {
"osmTags": {
"$ref": "#/definitions/TagConfigJson",
"description": "question: Which tags must be present on the feature to show it in this layer?\nEvery source must set which tags have to be present in order to load the given layer."
"description": "question: Which tags must be present on the feature to show it in this layer?\n\n Every source must set which tags have to be present in order to load the given layer."
},
"maxCacheAge": {
"description": "question: How long (in seconds) is the data allowed to remain cached until it must be refreshed?\nThe maximum amount of seconds that a tile is allowed to linger in the cache\n\ntype: nat",

View file

@ -1589,10 +1589,6 @@ video {
border-width: 2px;
}
.border-8 {
border-width: 8px;
}
.border-x {
border-left-width: 1px;
border-right-width: 1px;
@ -1611,6 +1607,10 @@ video {
border-bottom-width: 2px;
}
.border-l-4 {
border-left-width: 4px;
}
.border-t {
border-top-width: 1px;
}

View file

@ -25,10 +25,12 @@ import DependencyCalculator from "../Models/ThemeConfig/DependencyCalculator"
import { AllSharedLayers } from "../Customizations/AllSharedLayers"
import ThemeViewState from "../Models/ThemeViewState"
import Validators from "../UI/InputElement/Validators"
import { TagUtils } from "../Logic/Tags/TagUtils"
import { Utils } from "../Utils"
function WriteFile(
filename,
html: BaseUIElement,
html: string | BaseUIElement,
autogenSource: string[],
options?: {
noTableOfContents: boolean
@ -106,7 +108,7 @@ function GenerateDocumentationForTheme(theme: LayoutConfig): BaseUIElement {
function GenLayerOverviewText(): BaseUIElement {
for (const id of Constants.priviliged_layers) {
if (!AllSharedLayers.sharedLayers.has(id)) {
throw "Priviliged layer definition not found: " + id
console.error("Priviliged layer definition not found: " + id)
}
}
@ -149,9 +151,9 @@ function GenLayerOverviewText(): BaseUIElement {
"MapComplete has a few data layers available in the theme which have special properties through builtin-hooks. Furthermore, there are some normal layers (which are built from normal Theme-config files) but are so general that they get a mention here.",
new Title("Priviliged layers", 1),
new List(Constants.priviliged_layers.map((id) => "[" + id + "](#" + id + ")")),
...Constants.priviliged_layers
.map((id) => AllSharedLayers.sharedLayers.get(id))
.map((l) =>
...Utils.NoNull(
Constants.priviliged_layers.map((id) => AllSharedLayers.sharedLayers.get(id))
).map((l) =>
l.GenerateDocumentation(
themesPerLayer.get(l.id),
layerIsNeededBy,
@ -350,6 +352,7 @@ WriteFile("./Docs/BuiltinQuestions.md", SharedTagRenderings.HelpText(), [
"Customizations/SharedTagRenderings.ts",
"assets/tagRenderings/questions.json",
])
WriteFile("./Docs/Tags_format.md", TagUtils.generateDocs(), ["Logic/Tags/TagUtils.ts"])
{
// Generate the builtinIndex which shows interlayer dependencies

10
test.ts
View file

@ -3,13 +3,12 @@ import * as theme from "./assets/generated/themes/bookcases.json"
import ThemeViewState from "./Models/ThemeViewState"
import Combine from "./UI/Base/Combine"
import SpecialVisualizations from "./UI/SpecialVisualizations"
import ValidatedInput from "./UI/InputElement/ValidatedInput.svelte"
import SvelteUIElement from "./UI/Base/SvelteUIElement"
import TagInput from "./UI/Studio/TagInput/TagInput.svelte"
import { UIEventSource } from "./Logic/UIEventSource"
import { Unit } from "./Models/Unit"
import { Denomination } from "./Models/Denomination"
import { TagsFilter } from "./Logic/Tags/TagsFilter"
import { VariableUiElement } from "./UI/Base/VariableUIElement"
import { FixedUiElement } from "./UI/Base/FixedUiElement"
import { TagConfigJson } from "./Models/ThemeConfig/Json/TagConfigJson"
function testspecial() {
const layout = new LayoutConfig(<any>theme, true) // qp.data === "" ? : new AllKnownLayoutsLazy().get(qp.data)
@ -21,6 +20,9 @@ function testspecial() {
new Combine(all).AttachTo("maindiv")
}
const tag = new UIEventSource<TagConfigJson>(undefined)
new SvelteUIElement(TagInput, { tag }).AttachTo("maindiv")
new VariableUiElement(tag.map((t) => JSON.stringify(t))).AttachTo("extradiv")
/*/
testspecial()
//*/