476 lines
20 KiB
TypeScript
476 lines
20 KiB
TypeScript
import BaseUIElement from "./BaseUIElement"
|
|
import Combine from "./Base/Combine"
|
|
import Svg from "../Svg"
|
|
import Title from "./Base/Title"
|
|
import Toggle from "./Input/Toggle"
|
|
import { SubtleButton } from "./Base/SubtleButton"
|
|
import LayoutConfig from "../Models/ThemeConfig/LayoutConfig"
|
|
import ValidatedTextField from "./Input/ValidatedTextField"
|
|
import { Utils } from "../Utils"
|
|
import { UIEventSource } from "../Logic/UIEventSource"
|
|
import { VariableUiElement } from "./Base/VariableUIElement"
|
|
import { FixedUiElement } from "./Base/FixedUiElement"
|
|
import { Tiles } from "../Models/TileRange"
|
|
import { LocalStorageSource } from "../Logic/Web/LocalStorageSource"
|
|
import { DropDown } from "./Input/DropDown"
|
|
import { AllKnownLayouts } from "../Customizations/AllKnownLayouts"
|
|
import MinimapImplementation from "./Base/MinimapImplementation"
|
|
import { OsmConnection } from "../Logic/Osm/OsmConnection"
|
|
import { BBox } from "../Logic/BBox"
|
|
import MapState from "../Logic/State/MapState"
|
|
import FeaturePipeline from "../Logic/FeatureSource/FeaturePipeline"
|
|
import LayerConfig from "../Models/ThemeConfig/LayerConfig"
|
|
import TagRenderingConfig from "../Models/ThemeConfig/TagRenderingConfig"
|
|
import FeatureSource from "../Logic/FeatureSource/FeatureSource"
|
|
import List from "./Base/List"
|
|
import { QueryParameters } from "../Logic/Web/QueryParameters"
|
|
import { SubstitutedTranslation } from "./SubstitutedTranslation"
|
|
import { AutoAction } from "./Popup/AutoApplyButton"
|
|
import DynamicGeoJsonTileSource from "../Logic/FeatureSource/TiledFeatureSource/DynamicGeoJsonTileSource"
|
|
import * as themeOverview from "../assets/generated/theme_overview.json"
|
|
|
|
class AutomationPanel extends Combine {
|
|
private static readonly openChangeset = new UIEventSource<number>(undefined)
|
|
|
|
constructor(
|
|
layoutToUse: LayoutConfig,
|
|
indices: number[],
|
|
extraCommentText: UIEventSource<string>,
|
|
tagRenderingToAutomate: { layer: LayerConfig; tagRendering: TagRenderingConfig }
|
|
) {
|
|
const layerId = tagRenderingToAutomate.layer.id
|
|
const trId = tagRenderingToAutomate.tagRendering.id
|
|
const tileState = LocalStorageSource.GetParsed(
|
|
"automation-tile_state-" + layerId + "-" + trId,
|
|
{}
|
|
)
|
|
const logMessages = new UIEventSource<string[]>([])
|
|
if (indices === undefined) {
|
|
throw "No tiles loaded - can not automate"
|
|
}
|
|
const openChangeset = AutomationPanel.openChangeset
|
|
|
|
openChangeset.addCallbackAndRun((cs) =>
|
|
console.trace("Sync current open changeset to:", cs)
|
|
)
|
|
|
|
const nextTileToHandle = tileState.map((handledTiles) => {
|
|
for (const index of indices) {
|
|
if (handledTiles[index] !== undefined) {
|
|
// Already handled
|
|
continue
|
|
}
|
|
return index
|
|
}
|
|
return undefined
|
|
})
|
|
nextTileToHandle.addCallback((t) => console.warn("Next tile to handle is", t))
|
|
|
|
const neededTimes = new UIEventSource<number[]>([])
|
|
const automaton = new VariableUiElement(
|
|
nextTileToHandle.map((tileIndex) => {
|
|
if (tileIndex === undefined) {
|
|
return new FixedUiElement("All done!").SetClass("thanks")
|
|
}
|
|
console.warn("Triggered map on nextTileToHandle", tileIndex)
|
|
const start = new Date()
|
|
return AutomationPanel.TileHandler(
|
|
layoutToUse,
|
|
tileIndex,
|
|
layerId,
|
|
tagRenderingToAutomate.tagRendering,
|
|
extraCommentText,
|
|
(result, logMessage) => {
|
|
const end = new Date()
|
|
const timeNeeded = (end.getTime() - start.getTime()) / 1000
|
|
neededTimes.data.push(timeNeeded)
|
|
neededTimes.ping()
|
|
tileState.data[tileIndex] = result
|
|
tileState.ping()
|
|
if (logMessage !== undefined) {
|
|
logMessages.data.push(logMessage)
|
|
logMessages.ping()
|
|
}
|
|
}
|
|
)
|
|
})
|
|
)
|
|
|
|
const statistics = new VariableUiElement(
|
|
tileState.map((states) => {
|
|
let total = 0
|
|
const perResult = new Map<string, number>()
|
|
for (const key in states) {
|
|
total++
|
|
const result = states[key]
|
|
perResult.set(result, (perResult.get(result) ?? 0) + 1)
|
|
}
|
|
|
|
let sum = 0
|
|
neededTimes.data.forEach((v) => {
|
|
sum = sum + v
|
|
})
|
|
let timePerTile = sum / neededTimes.data.length
|
|
|
|
return new Combine([
|
|
"Handled " + total + "/" + indices.length + " tiles: ",
|
|
new List(
|
|
Array.from(perResult.keys()).map((key) => key + ": " + perResult.get(key))
|
|
),
|
|
"Handling one tile needs " +
|
|
Math.floor(timePerTile * 100) / 100 +
|
|
"s on average. Estimated time left: " +
|
|
Utils.toHumanTime((indices.length - total) * timePerTile),
|
|
]).SetClass("flex flex-col")
|
|
})
|
|
)
|
|
|
|
super([
|
|
statistics,
|
|
automaton,
|
|
new SubtleButton(undefined, "Clear fixed").onClick(() => {
|
|
const st = tileState.data
|
|
for (const tileIndex in st) {
|
|
if (st[tileIndex] === "fixed") {
|
|
delete st[tileIndex]
|
|
}
|
|
}
|
|
|
|
tileState.ping()
|
|
}),
|
|
new VariableUiElement(logMessages.map((logMessages) => new List(logMessages))),
|
|
])
|
|
this.SetClass("flex flex-col")
|
|
}
|
|
|
|
private static TileHandler(
|
|
layoutToUse: LayoutConfig,
|
|
tileIndex: number,
|
|
targetLayer: string,
|
|
targetAction: TagRenderingConfig,
|
|
extraCommentText: UIEventSource<string>,
|
|
whenDone: (result: string, logMessage?: string) => void
|
|
): BaseUIElement {
|
|
const state = new MapState(layoutToUse, { attemptLogin: false })
|
|
extraCommentText.syncWith(state.changes.extraComment)
|
|
const [z, x, y] = Tiles.tile_from_index(tileIndex)
|
|
state.locationControl.setData({
|
|
zoom: z,
|
|
lon: x,
|
|
lat: y,
|
|
})
|
|
state.currentBounds.setData(BBox.fromTileIndex(tileIndex))
|
|
|
|
let targetTiles: UIEventSource<FeatureSource[]> = new UIEventSource<FeatureSource[]>([])
|
|
const pipeline = new FeaturePipeline((tile) => {
|
|
const layerId = tile.layer.layerDef.id
|
|
if (layerId === targetLayer) {
|
|
targetTiles.data.push(tile)
|
|
targetTiles.ping()
|
|
}
|
|
}, state)
|
|
|
|
state.locationControl.ping()
|
|
state.currentBounds.ping()
|
|
const stateToShow = new UIEventSource("")
|
|
|
|
pipeline.runningQuery.map(
|
|
async (isRunning) => {
|
|
if (targetTiles.data.length === 0) {
|
|
stateToShow.setData("No data loaded yet...")
|
|
return
|
|
}
|
|
if (isRunning) {
|
|
stateToShow.setData(
|
|
"Waiting for all layers to be loaded... Has " +
|
|
targetTiles.data.length +
|
|
" tiles already"
|
|
)
|
|
return
|
|
}
|
|
if (targetTiles.data.length === 0) {
|
|
stateToShow.setData("No features found to apply the action")
|
|
whenDone("empty")
|
|
return true
|
|
}
|
|
stateToShow.setData("Gathering applicable elements")
|
|
|
|
let handled = 0
|
|
let inspected = 0
|
|
let log = []
|
|
for (const targetTile of targetTiles.data) {
|
|
for (const ffs of targetTile.features.data) {
|
|
inspected++
|
|
if (inspected % 10 === 0) {
|
|
stateToShow.setData(
|
|
"Inspected " +
|
|
inspected +
|
|
" features, updated " +
|
|
handled +
|
|
" features"
|
|
)
|
|
}
|
|
const feature = ffs.feature
|
|
const renderingTr = targetAction.GetRenderValue(feature.properties)
|
|
const rendering = renderingTr.txt
|
|
log.push(
|
|
"<a href='https://openstreetmap.org/" +
|
|
feature.properties.id +
|
|
"' target='_blank'>" +
|
|
feature.properties.id +
|
|
"</a>: " +
|
|
new SubstitutedTranslation(
|
|
renderingTr,
|
|
new UIEventSource<any>(feature.properties),
|
|
undefined
|
|
).ConstructElement().textContent
|
|
)
|
|
const actions = Utils.NoNull(
|
|
SubstitutedTranslation.ExtractSpecialComponents(rendering).map(
|
|
(obj) => obj.special
|
|
)
|
|
)
|
|
for (const action of actions) {
|
|
const auto = <AutoAction>action.func
|
|
if (auto.supportsAutoAction !== true) {
|
|
continue
|
|
}
|
|
|
|
await auto.applyActionOn(
|
|
{
|
|
layoutToUse: state.layoutToUse,
|
|
changes: state.changes,
|
|
},
|
|
state.allElements.getEventSourceById(feature.properties.id),
|
|
action.args
|
|
)
|
|
handled++
|
|
}
|
|
}
|
|
}
|
|
stateToShow.setData(
|
|
"Done! Inspected " + inspected + " features, updated " + handled + " features"
|
|
)
|
|
|
|
if (inspected === 0) {
|
|
whenDone("empty")
|
|
return true
|
|
}
|
|
|
|
if (handled === 0) {
|
|
whenDone("no-action", "Inspected " + inspected + " elements: " + log.join("; "))
|
|
} else {
|
|
state.osmConnection.AttemptLogin()
|
|
state.changes.flushChanges("handled tile automatically, time to flush!")
|
|
whenDone(
|
|
"fixed",
|
|
"Updated " +
|
|
handled +
|
|
" elements, inspected " +
|
|
inspected +
|
|
": " +
|
|
log.join("; ")
|
|
)
|
|
}
|
|
return true
|
|
},
|
|
[targetTiles]
|
|
)
|
|
|
|
return new Combine([
|
|
new Title("Performing action for tile " + tileIndex, 1),
|
|
new VariableUiElement(stateToShow),
|
|
]).SetClass("flex flex-col")
|
|
}
|
|
}
|
|
|
|
class AutomatonGui {
|
|
constructor() {
|
|
const osmConnection = new OsmConnection({
|
|
singlePage: false,
|
|
oauth_token: QueryParameters.GetQueryParameter("oauth_token", "OAuth token"),
|
|
})
|
|
|
|
new Combine([
|
|
new Combine([
|
|
Svg.robot_svg().SetClass("w-24 h-24 p-4 rounded-full subtle-background"),
|
|
new Combine([
|
|
new Title("MapComplete Automaton", 1),
|
|
"This page helps to automate certain tasks for a theme. Expert use only.",
|
|
]).SetClass("flex flex-col m-4"),
|
|
]).SetClass("flex"),
|
|
new Toggle(
|
|
AutomatonGui.GenerateMainPanel(),
|
|
new SubtleButton(Svg.osm_logo_svg(), "Login to get started").onClick(() =>
|
|
osmConnection.AttemptLogin()
|
|
),
|
|
osmConnection.isLoggedIn
|
|
),
|
|
])
|
|
.SetClass("block p-4")
|
|
.AttachTo("main")
|
|
}
|
|
|
|
private static GenerateMainPanel(): BaseUIElement {
|
|
const themeSelect = new DropDown<string>(
|
|
"Select a theme",
|
|
Array.from(themeOverview).map((l) => ({ value: l.id, shown: l.id }))
|
|
)
|
|
|
|
LocalStorageSource.Get("automation-theme-id", "missing_streets").syncWith(
|
|
themeSelect.GetValue()
|
|
)
|
|
|
|
const tilepath = ValidatedTextField.ForType("url").ConstructInputElement({
|
|
placeholder: "Specifiy the path of the overview",
|
|
inputStyle: "width: 100%",
|
|
})
|
|
tilepath.SetClass("w-full")
|
|
LocalStorageSource.Get("automation-tile_path").syncWith(tilepath.GetValue(), true)
|
|
|
|
let tilesToRunOver = tilepath.GetValue().bind((path) => {
|
|
if (path === undefined) {
|
|
return undefined
|
|
}
|
|
return UIEventSource.FromPromiseWithErr(Utils.downloadJsonCached(path, 1000 * 60 * 60))
|
|
})
|
|
|
|
const targetZoom = 14
|
|
|
|
const tilesPerIndex = tilesToRunOver.map((tiles) => {
|
|
if (tiles === undefined || tiles["error"] !== undefined) {
|
|
return undefined
|
|
}
|
|
let indexes: number[] = []
|
|
const tilesS = tiles["success"]
|
|
DynamicGeoJsonTileSource.RegisterWhitelist(tilepath.GetValue().data, tilesS)
|
|
const z = Number(tilesS["zoom"])
|
|
for (const key in tilesS) {
|
|
if (key === "zoom") {
|
|
continue
|
|
}
|
|
const x = Number(key)
|
|
const ys = tilesS[key]
|
|
indexes.push(...ys.map((y) => Tiles.tile_index(z, x, y)))
|
|
}
|
|
|
|
console.log("Got ", indexes.length, "indexes")
|
|
let rezoomed = new Set<number>()
|
|
for (const index of indexes) {
|
|
let [z, x, y] = Tiles.tile_from_index(index)
|
|
while (z > targetZoom) {
|
|
z--
|
|
x = Math.floor(x / 2)
|
|
y = Math.floor(y / 2)
|
|
}
|
|
rezoomed.add(Tiles.tile_index(z, x, y))
|
|
}
|
|
|
|
return Array.from(rezoomed)
|
|
})
|
|
|
|
const extraComment = ValidatedTextField.ForType("text").ConstructInputElement()
|
|
LocalStorageSource.Get("automaton-extra-comment").syncWith(extraComment.GetValue())
|
|
|
|
return new Combine([
|
|
themeSelect,
|
|
"Specify the path to a tile overview. This is a hosted .json of the format {x : [y0, y1, y2], x1: [y0, ...]} where x is a string and y are numbers",
|
|
tilepath,
|
|
"Add an extra comment:",
|
|
extraComment,
|
|
new VariableUiElement(
|
|
extraComment
|
|
.GetValue()
|
|
.map((c) => "Your comment is " + (c?.length ?? 0) + "/200 characters long")
|
|
).SetClass("subtle"),
|
|
new VariableUiElement(
|
|
tilesToRunOver.map((t) => {
|
|
if (t === undefined) {
|
|
return "No path given or still loading..."
|
|
}
|
|
if (t["error"] !== undefined) {
|
|
return new FixedUiElement("Invalid URL or data: " + t["error"]).SetClass(
|
|
"alert"
|
|
)
|
|
}
|
|
|
|
return new FixedUiElement(
|
|
"Loaded " + tilesPerIndex.data.length + " tiles to automated over"
|
|
).SetClass("thanks")
|
|
})
|
|
),
|
|
new VariableUiElement(
|
|
themeSelect
|
|
.GetValue()
|
|
.map((id) => AllKnownLayouts.allKnownLayouts.get(id))
|
|
.map(
|
|
(layoutToUse) => {
|
|
if (layoutToUse === undefined) {
|
|
return new FixedUiElement("Select a valid layout")
|
|
}
|
|
if (
|
|
tilesPerIndex.data === undefined ||
|
|
tilesPerIndex.data.length === 0
|
|
) {
|
|
return "No tiles given"
|
|
}
|
|
|
|
const automatableTagRenderings: {
|
|
layer: LayerConfig
|
|
tagRendering: TagRenderingConfig
|
|
}[] = []
|
|
for (const layer of layoutToUse.layers) {
|
|
for (const tagRendering of layer.tagRenderings) {
|
|
if (tagRendering.group === "auto") {
|
|
automatableTagRenderings.push({
|
|
layer,
|
|
tagRendering: tagRendering,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
console.log("Automatable tag renderings:", automatableTagRenderings)
|
|
if (automatableTagRenderings.length === 0) {
|
|
return new FixedUiElement(
|
|
'This theme does not have any tagRendering with "group": "auto" set'
|
|
).SetClass("alert")
|
|
}
|
|
const pickAuto = new DropDown("Pick the action to automate", [
|
|
{
|
|
value: undefined,
|
|
shown: "Pick an option",
|
|
},
|
|
...automatableTagRenderings.map((config) => ({
|
|
shown: config.layer.id + " - " + config.tagRendering.id,
|
|
value: config,
|
|
})),
|
|
])
|
|
|
|
return new Combine([
|
|
pickAuto,
|
|
new VariableUiElement(
|
|
pickAuto
|
|
.GetValue()
|
|
.map((auto) =>
|
|
auto === undefined
|
|
? undefined
|
|
: new AutomationPanel(
|
|
layoutToUse,
|
|
tilesPerIndex.data,
|
|
extraComment.GetValue(),
|
|
auto
|
|
)
|
|
)
|
|
),
|
|
])
|
|
},
|
|
[tilesPerIndex]
|
|
)
|
|
).SetClass("flex flex-col"),
|
|
]).SetClass("flex flex-col")
|
|
}
|
|
}
|
|
|
|
MinimapImplementation.initialize()
|
|
|
|
new AutomatonGui()
|