Studio: WIP: add theme edit possibility

This commit is contained in:
Pieter Vander Vennet 2023-10-26 13:58:45 +02:00
parent 09e511ca10
commit 7520c44059
6 changed files with 275 additions and 152 deletions

View file

@ -3,26 +3,31 @@
import NextButton from "../Base/NextButton.svelte";
import { createEventDispatcher } from "svelte";
import { AllSharedLayers } from "../../Customizations/AllSharedLayers";
import { AllKnownLayouts, AllKnownLayoutsLazy } from "../../Customizations/AllKnownLayouts";
export let layerIds : { id: string }[]
const dispatch = createEventDispatcher<{layerSelected: string}>()
export let layerIds: { id: string }[];
export let category: "layers" | "themes" = "layers";
const dispatch = createEventDispatcher<{ layerSelected: string }>();
function fetchIconDescription(layerId): any {
if(category === "themes"){
return AllKnownLayouts.allKnownLayouts.get(layerId).icon
}
return AllSharedLayers.getSharedLayersConfigs().get(layerId)?._layerIcon;
}
</script>
{#if layerIds.length > 0}
<slot name="title"/>
<div class="flex flex-wrap">
{#each Array.from(layerIds) as layer}
<NextButton clss="small" on:click={() => dispatch("layerSelected", layer.id)}>
<div class="w-4 h-4 mr-1">
<Marker icons={fetchIconDescription(layer.id)} />
</div>
{layer.id}
</NextButton>
{/each}
</div>
{/if}
<slot name="title" />
<div class="flex flex-wrap">
{#each Array.from(layerIds) as layer}
<NextButton clss="small" on:click={() => dispatch("layerSelected", layer.id)}>
<div class="w-4 h-4 mr-1">
<Marker icons={fetchIconDescription(layer.id)} />
</div>
{layer.id}
</NextButton>
{/each}
</div>
{/if}

View file

@ -1,13 +1,12 @@
<script lang="ts">
import type { HighlightedTagRendering } from "./EditLayerState";
import EditLayerState, { LayerStateSender } from "./EditLayerState";
import EditLayerState from "./EditLayerState";
import layerSchemaRaw from "../../assets/schemas/layerconfigmeta.json";
import Region from "./Region.svelte";
import TabbedGroup from "../Base/TabbedGroup.svelte";
import { Store, UIEventSource } from "../../Logic/UIEventSource";
import type { ConfigMeta } from "./configMeta";
import { Utils } from "../../Utils";
import type { LayerConfigJson } from "../../Models/ThemeConfig/Json/LayerConfigJson";
import type { ConversionMessage } from "../../Models/ThemeConfig/Conversion/Conversion";
import ErrorIndicatorForRegion from "./ErrorIndicatorForRegion.svelte";
import { ChevronRightIcon } from "@rgossiaux/svelte-heroicons/solid";
@ -17,20 +16,15 @@
import FromHtml from "../Base/FromHtml.svelte";
import AllTagsPanel from "../Popup/AllTagsPanel.svelte";
import QuestionPreview from "./QuestionPreview.svelte";
import ShowConversionMessages from "./ShowConversionMessages.svelte";
const layerSchema: ConfigMeta[] = <any>layerSchemaRaw;
export let state: EditLayerState;
const messages = state.messages;
const hasErrors = messages.map((m: ConversionMessage[]) => m.filter(m => m.level === "error").length);
export let initialLayerConfig: Partial<LayerConfigJson> = {};
state.configuration.setData(initialLayerConfig);
const configuration = state.configuration;
new LayerStateSender(state);
/**
* Blacklist of regions for the general area tab
* These are regions which are handled by a different tab
*/
const allNames = Utils.Dedup(layerSchema.map(meta => meta.hints.group));
const perRegion: Record<string, ConfigMeta[]> = {};
@ -153,18 +147,11 @@
<div class="literal-code">
<FromHtml src={JSON.stringify($configuration, null, " ").replaceAll("\n","</br>")} />
</div>
{#each $messages as message}
<li>
{message.level}
<span class="literal-code">{message.context.path.join(".")}</span>
{message.message}
<span class="literal-code">
{message.context.operation.join(".")}
</span>
</li>
{/each}
<ShowConversionMessages messages={$messages}/>
<div>
The testobject (which is used to render the questions in the 'information panel' item has the following tags:
</div>
<AllTagsPanel tags={state.testTags}></AllTagsPanel>
</div>

View file

@ -2,13 +2,14 @@ import { ConfigMeta } from "./configMeta"
import { Store, UIEventSource } from "../../Logic/UIEventSource"
import { LayerConfigJson } from "../../Models/ThemeConfig/Json/LayerConfigJson"
import {
Conversion,
ConversionContext,
ConversionMessage,
DesugaringContext,
Pipe,
} from "../../Models/ThemeConfig/Conversion/Conversion"
import { PrepareLayer } from "../../Models/ThemeConfig/Conversion/PrepareLayer"
import { ValidateLayer } from "../../Models/ThemeConfig/Conversion/Validation"
import { ValidateLayer, ValidateTheme } from "../../Models/ThemeConfig/Conversion/Validation"
import { AllSharedLayers } from "../../Customizations/AllSharedLayers"
import { QuestionableTagRenderingConfigJson } from "../../Models/ThemeConfig/Json/QuestionableTagRenderingConfigJson"
import { TagUtils } from "../../Logic/Tags/TagUtils"
@ -18,64 +19,21 @@ import { OsmConnection } from "../../Logic/Osm/OsmConnection"
import { OsmTags } from "../../Models/OsmFeature"
import { Feature, Point } from "geojson"
import LayerConfig from "../../Models/ThemeConfig/LayerConfig"
/**
* Sends changes back to the server
*/
export class LayerStateSender {
constructor(layerState: EditLayerState) {
const layerId = layerState.configuration.map((config) => config.id)
layerState.configuration
.mapD((config) => JSON.stringify(config, null, " "))
.stabilized(100)
.addCallbackD(async (config) => {
const id = layerId.data
if (id === undefined) {
console.warn("No id found in layer, not updating")
return
}
await layerState.server.updateLayer(id, config)
})
}
}
import { LayoutConfigJson } from "../../Models/ThemeConfig/Json/LayoutConfigJson"
import { PrepareTheme } from "../../Models/ThemeConfig/Conversion/PrepareTheme"
export interface HighlightedTagRendering {
path: ReadonlyArray<string | number>
schema: ConfigMeta
}
export default class EditLayerState {
export abstract class EditJsonState<T> {
public readonly schema: ConfigMeta[]
public readonly featureSwitches: {
featureSwitchIsDebugging: UIEventSource<boolean>
}
/**
* Used to preview and interact with the questions
*/
public readonly testTags = new UIEventSource<OsmTags>({ id: "node/-12345" })
public readonly exampleFeature: Feature<Point> = {
type: "Feature",
properties: this.testTags.data,
geometry: {
type: "Point",
coordinates: [3.21, 51.2],
},
}
public readonly configuration: UIEventSource<Partial<LayerConfigJson>> = new UIEventSource<
Partial<LayerConfigJson>
>({})
public readonly messages: Store<ConversionMessage[]>
public readonly category: "layers" | "themes"
public readonly server: StudioServer
// Needed for the special visualisations
public readonly osmConnection: OsmConnection
public readonly imageUploadManager = {
getCountsFor() {
return 0
},
}
public readonly layout: { getMatchingLayer: (key: any) => LayerConfig }
public readonly configuration: UIEventSource<Partial<T>> = new UIEventSource<Partial<T>>({})
public readonly messages: Store<ConversionMessage[]>
/**
* The EditLayerUI shows a 'schemaBasedInput' for this path to pop advanced questions out
@ -85,69 +43,25 @@ export default class EditLayerState {
)
private readonly _stores = new Map<string, UIEventSource<any>>()
constructor(schema: ConfigMeta[], server: StudioServer, osmConnection: OsmConnection) {
constructor(schema: ConfigMeta[], server: StudioServer, category: "layers" | "themes") {
this.schema = schema
this.server = server
this.osmConnection = osmConnection
this.featureSwitches = {
featureSwitchIsDebugging: new UIEventSource<boolean>(true),
}
let state: DesugaringContext
{
const layers = AllSharedLayers.getSharedLayersConfigs()
const questions = layers.get("questions")
const sharedQuestions = new Map<string, QuestionableTagRenderingConfigJson>()
for (const question of questions.tagRenderings) {
sharedQuestions.set(question["id"], <QuestionableTagRenderingConfigJson>question)
}
state = {
tagRenderings: sharedQuestions,
sharedLayers: layers,
}
}
this.category = category
this.highlightedItem.addCallback((h) => console.log("Highlighted is now", h))
this.messages = this.setupErrorsForLayers()
const prepare = new Pipe(
new PrepareLayer(state),
new ValidateLayer("dynamic", false, undefined, true)
)
this.messages = this.configuration.mapD((config) => {
const trs = Utils.NoNull(config.tagRenderings ?? [])
for (let i = 0; i < trs.length; i++) {
const tr = trs[i]
if (typeof tr === "string") {
continue
const layerId = this.getId()
this.configuration
.mapD((config) => JSON.stringify(config, null, " "))
.stabilized(100)
.addCallbackD(async (config) => {
const id = layerId.data
if (id === undefined) {
console.warn("No id found in layer, not updating")
return
}
if (!tr["id"] && !tr["override"]) {
const qtr = <QuestionableTagRenderingConfigJson>tr
let id = "" + i
if (qtr?.freeform?.key) {
id = qtr?.freeform?.key
} else if (qtr.mappings?.[0]?.if) {
id =
qtr.freeform?.key ??
TagUtils.Tag(qtr.mappings[0].if).usedKeys()?.[0] ??
"" + i
}
qtr["id"] = id
}
}
const context = ConversionContext.construct([], ["prepare"])
prepare.convert(<LayerConfigJson>config, context)
return context.messages
})
this.layout = {
getMatchingLayer: (_) => {
try {
return new LayerConfig(<LayerConfigJson>this.configuration.data, "dynamic")
} catch (e) {
return undefined
}
},
}
await server.update(id, config, category)
})
}
public getCurrentValueFor(path: ReadonlyArray<string | number>): any | undefined {
@ -273,4 +187,130 @@ export default class EditLayerState {
})
})
}
protected abstract buildValidation(state: DesugaringContext): Conversion<T, any>
protected abstract getId(): Store<string>
private setupErrorsForLayers(): Store<ConversionMessage[]> {
const layers = AllSharedLayers.getSharedLayersConfigs()
const questions = layers.get("questions")
const sharedQuestions = new Map<string, QuestionableTagRenderingConfigJson>()
for (const question of questions.tagRenderings) {
sharedQuestions.set(question["id"], <QuestionableTagRenderingConfigJson>question)
}
let state: DesugaringContext = {
tagRenderings: sharedQuestions,
sharedLayers: layers,
}
const prepare = this.buildValidation(state)
return this.configuration.mapD((config) => {
const context = ConversionContext.construct([], ["prepare"])
try {
prepare.convert(<T>config, context)
} catch (e) {
context.err(e)
}
return context.messages
})
}
}
export default class EditLayerState extends EditJsonState<LayerConfigJson> {
// Needed for the special visualisations
public readonly osmConnection: OsmConnection
public readonly imageUploadManager = {
getCountsFor() {
return 0
},
}
public readonly layout: { getMatchingLayer: (key: any) => LayerConfig }
public readonly featureSwitches: {
featureSwitchIsDebugging: UIEventSource<boolean>
}
/**
* Used to preview and interact with the questions
*/
public readonly testTags = new UIEventSource<OsmTags>({ id: "node/-12345" })
public readonly exampleFeature: Feature<Point> = {
type: "Feature",
properties: this.testTags.data,
geometry: {
type: "Point",
coordinates: [3.21, 51.2],
},
}
constructor(schema: ConfigMeta[], server: StudioServer, osmConnection: OsmConnection) {
super(schema, server, "layers")
this.osmConnection = osmConnection
this.layout = {
getMatchingLayer: (_) => {
try {
return new LayerConfig(<LayerConfigJson>this.configuration.data, "dynamic")
} catch (e) {
return undefined
}
},
}
this.featureSwitches = {
featureSwitchIsDebugging: new UIEventSource<boolean>(true),
}
this.addMissingTagRenderingIds()
}
protected buildValidation(state: DesugaringContext) {
return new Pipe(
new PrepareLayer(state),
new ValidateLayer("dynamic", false, undefined, true)
)
}
protected getId(): Store<string> {
return this.configuration.mapD((config) => config.id)
}
private addMissingTagRenderingIds() {
this.configuration.addCallbackD((config) => {
const trs = Utils.NoNull(config.tagRenderings ?? [])
for (let i = 0; i < trs.length; i++) {
const tr = trs[i]
if (typeof tr === "string") {
continue
}
if (!tr["id"] && !tr["override"]) {
const qtr = <QuestionableTagRenderingConfigJson>tr
let id = "" + i
if (qtr?.freeform?.key) {
id = qtr?.freeform?.key
} else if (qtr.mappings?.[0]?.if) {
id =
qtr.freeform?.key ??
TagUtils.Tag(qtr.mappings[0].if).usedKeys()?.[0] ??
"" + i
}
qtr["id"] = id
}
}
})
}
}
export class EditThemeState extends EditJsonState<LayoutConfigJson> {
protected buildValidation(state: DesugaringContext): Conversion<LayoutConfigJson, any> {
return new Pipe(
new PrepareTheme(state),
new ValidateTheme(undefined, "", false, new Set(state.tagRenderings.keys()))
)
}
constructor(schema: ConfigMeta[], server: StudioServer) {
super(schema, server, "themes")
}
protected getId(): Store<string> {
return this.configuration.mapD((config) => config.id)
}
}

View file

@ -0,0 +1,64 @@
<script lang="ts">
import { EditThemeState } from "./EditLayerState";
import type { ConfigMeta } from "./configMeta";
import { ChevronRightIcon } from "@rgossiaux/svelte-heroicons/solid";
import type { ConversionMessage } from "../../Models/ThemeConfig/Conversion/Conversion";
import TabbedGroup from "../Base/TabbedGroup.svelte";
import ShowConversionMessages from "./ShowConversionMessages.svelte";
import Region from "./Region.svelte";
export let state: EditThemeState;
let schema: ConfigMeta[] = state.schema.filter(schema => schema.path.length > 0 && schema.path[0] !== "layers");
let config = state.configuration;
const messages = state.messages;
const hasErrors = messages.map((m: ConversionMessage[]) => m.filter(m => m.level === "error").length);
let title = state.getStoreFor(["id"]);
const wl = window.location;
const baseUrl = wl.protocol + "//" + wl.host + "/theme.html?userlayout=";
const perRegion: Record<string, ConfigMeta[]> = {};
for (const schemaElement of schema) {
const key = schemaElement.hints.group ?? "no-group"
const list = perRegion[key] ?? (perRegion[key] = [])
list.push(schemaElement)
}
</script>
<div class="w-full flex justify-between my-2">
<slot />
<h3>Editing theme {$title}</h3>
{#if $hasErrors > 0}
<div class="alert">{$hasErrors} errors detected</div>
{:else}
<a class="primary button" href={baseUrl+state.server.urlFor($title, "themes")} target="_blank" rel="noopener">
Try it out
<ChevronRightIcon class="h-6 w-6 shrink-0" />
</a>
{/if}
</div>
<div class="m4 h-full overflow-y-auto">
{Object.keys(perRegion).join(";")}
<TabbedGroup>
<div slot="title0">Basic properties</div>
<div slot="content0">
<Region {state} configs={perRegion["basic"]} path={[]}></Region>
<Region {state} configs={perRegion["no-group"]} path={[]}></Region>
</div>
<div slot="title1">Feature switches</div>
<div slot="content1">
<Region {state} configs={perRegion["feature_switches"]} path={[]}></Region>
</div>
<div slot="title2">Configuration file</div>
<div slot="content2">
<div class="literal-code">
{JSON.stringify($config)}
</div>
<ShowConversionMessages messages={$messages}></ShowConversionMessages>
</div>
</TabbedGroup>
</div>

View file

@ -0,0 +1,23 @@
<script lang="ts">
import type { ConversionMessage } from "../../Models/ThemeConfig/Conversion/Conversion";
export let messages: ConversionMessage[];
console.log(messages)
</script>
{#if messages.length === 0}
<div class="thanks">
No errors, warnings or messages
</div>
{/if}
{#each messages as message}
<li>
{message.level}
<span class="literal-code">{message.context.path.join(".")}</span>
{message.message}
<span class="literal-code">
{message.context.operation.join(".")}
</span>
</li>
{/each}

View file

@ -12,10 +12,11 @@ export default class StudioServer {
this._userId = userId
}
public async fetchLayerOverview(): Promise<
public async fetchOverview(): Promise<
{
id: string
owner: number
category: "layers" | "themes"
}[]
> {
const uid = this._userId.data
@ -29,6 +30,7 @@ export default class StudioServer {
const layerOverview: {
id: string
owner: number | undefined
category: "layers" | "themes"
}[] = []
for (let file of allFiles) {
let owner = undefined
@ -36,31 +38,29 @@ export default class StudioServer {
owner = uid
file = file.substring(file.indexOf("/") + 1)
}
if (!file.startsWith("layers/")) {
continue
}
const category = <"layers" | "themes">file.substring(0, file.indexOf("/"))
const id = file.substring(file.lastIndexOf("/") + 1, file.length - ".json".length)
if (Constants.priviliged_layers.indexOf(<any>id) > 0) {
continue
}
layerOverview.push({ id, owner })
layerOverview.push({ id, owner, category })
}
return layerOverview
}
async fetchLayer(layerId: string): Promise<LayerConfigJson> {
async fetch(layerId: string, category: "layers" | "themes"): Promise<LayerConfigJson> {
try {
return await Utils.downloadJson(this.layerUrl(layerId))
return await Utils.downloadJson(this.urlFor(layerId, category))
} catch (e) {
return undefined
}
}
async updateLayer(id: string, config: string) {
async update(id: string, config: string, category: "layers" | "themes") {
if (id === undefined || id === "") {
return
}
await fetch(this.layerUrl(id), {
await fetch(this.urlFor(id, category), {
method: "POST",
headers: {
"Content-Type": "application/json;charset=utf-8",
@ -70,8 +70,12 @@ export default class StudioServer {
}
public layerUrl(id: string) {
return this.urlFor(id, "layers")
}
public urlFor(id: string, category: "layers" | "themes") {
const uid = this._userId.data
const uidStr = uid !== undefined ? "/" + uid : ""
return `${this.url}${uidStr}/layers/${id}/${id}.json`
return `${this.url}${uidStr}/${category}/${id}/${id}.json`
}
}