Merge branch 'develop' into feature/fitness-station

This commit is contained in:
Robin van der Linde 2022-10-11 14:45:53 +02:00
commit 4cb0cf8b8b
Signed by untrusted user: Robin-van-der-Linde
GPG key ID: 53956B3252478F0D
714 changed files with 93439 additions and 41823 deletions

View file

@ -1,46 +1,47 @@
import {VariableUiElement} from "./Base/VariableUIElement";
import {UIEventSource} from "../Logic/UIEventSource";
import Table from "./Base/Table";
import { VariableUiElement } from "./Base/VariableUIElement"
import { UIEventSource } from "../Logic/UIEventSource"
import Table from "./Base/Table"
export class AllTagsPanel extends VariableUiElement {
constructor(tags: UIEventSource<any>, state?) {
const calculatedTags = [].concat(
// SimpleMetaTagger.lazyTags,
...(state?.layoutToUse?.layers?.map(l => l.calculatedTags?.map(c => c[0]) ?? []) ?? []))
// SimpleMetaTagger.lazyTags,
...(state?.layoutToUse?.layers?.map((l) => l.calculatedTags?.map((c) => c[0]) ?? []) ??
[])
)
super(
tags.map((tags) => {
const parts = []
for (const key in tags) {
if (!tags.hasOwnProperty(key)) {
continue
}
let v = tags[key]
if (v === "") {
v = "<b>empty string</b>"
}
parts.push([key, v ?? "<b>undefined</b>"])
}
super(tags.map(tags => {
const parts = [];
for (const key in tags) {
if (!tags.hasOwnProperty(key)) {
continue
for (const key of calculatedTags) {
const value = tags[key]
if (value === undefined) {
continue
}
let type = ""
if (typeof value !== "string") {
type = " <i>" + typeof value + "</i>"
}
parts.push(["<i>" + key + "</i>", value])
}
let v = tags[key]
if (v === "") {
v = "<b>empty string</b>"
}
parts.push([key, v ?? "<b>undefined</b>"]);
}
for (const key of calculatedTags) {
const value = tags[key]
if (value === undefined) {
continue
}
let type = "";
if (typeof value !== "string") {
type = " <i>" + (typeof value) + "</i>"
}
parts.push(["<i>" + key + "</i>", value])
}
return new Table(
["key", "value"],
parts
)
.SetStyle("border: 1px solid black; border-radius: 1em;padding:1em;display:block;").SetClass("zebra-table")
}))
return new Table(["key", "value"], parts)
.SetStyle(
"border: 1px solid black; border-radius: 1em;padding:1em;display:block;"
)
.SetClass("zebra-table")
})
)
}
}
}

View file

@ -1,58 +1,69 @@
import UserRelatedState from "../Logic/State/UserRelatedState";
import {FixedUiElement} from "./Base/FixedUiElement";
import Combine from "./Base/Combine";
import MoreScreen from "./BigComponents/MoreScreen";
import Translations from "./i18n/Translations";
import Constants from "../Models/Constants";
import {Utils} from "../Utils";
import LanguagePicker1 from "./LanguagePicker";
import IndexText from "./BigComponents/IndexText";
import FeaturedMessage from "./BigComponents/FeaturedMessage";
import Toggle from "./Input/Toggle";
import {SubtleButton} from "./Base/SubtleButton";
import {VariableUiElement} from "./Base/VariableUIElement";
import Svg from "../Svg";
import UserRelatedState from "../Logic/State/UserRelatedState"
import { FixedUiElement } from "./Base/FixedUiElement"
import Combine from "./Base/Combine"
import MoreScreen from "./BigComponents/MoreScreen"
import Translations from "./i18n/Translations"
import Constants from "../Models/Constants"
import { Utils } from "../Utils"
import LanguagePicker1 from "./LanguagePicker"
import IndexText from "./BigComponents/IndexText"
import FeaturedMessage from "./BigComponents/FeaturedMessage"
import Toggle from "./Input/Toggle"
import { SubtleButton } from "./Base/SubtleButton"
import { VariableUiElement } from "./Base/VariableUIElement"
import Svg from "../Svg"
export default class AllThemesGui {
setup() {
try {
new FixedUiElement("").AttachTo("centermessage")
const state = new UserRelatedState(undefined);
const state = new UserRelatedState(undefined)
const intro = new Combine([
new LanguagePicker1(Translations.t.index.title.SupportedLanguages(), "")
.SetClass("flex absolute top-2 right-3"),
new IndexText()
]);
new LanguagePicker1(Translations.t.index.title.SupportedLanguages(), "").SetClass(
"flex absolute top-2 right-3"
),
new IndexText(),
])
new Combine([
intro,
new FeaturedMessage().SetClass("mb-4 block"),
new MoreScreen(state, true),
new Toggle(
undefined,
new SubtleButton(undefined, Translations.t.index.logIn).SetStyle("height:min-content").onClick(() => state.osmConnection.AttemptLogin()),
state.osmConnection.isLoggedIn),
new VariableUiElement(state.osmConnection.userDetails.map(ud => {
if (ud.csCount < Constants.userJourney.importHelperUnlock) {
return undefined;
}
return new Combine([
new SubtleButton(undefined, Translations.t.importHelper.title, {url: "import_helper.html"}),
new SubtleButton(Svg.note_svg(), Translations.t.importInspector.title, {url: "import_viewer.html"})
]).SetClass("p-4 border-2 border-gray-500 m-4 block")
})),
new SubtleButton(undefined, Translations.t.index.logIn)
.SetStyle("height:min-content")
.onClick(() => state.osmConnection.AttemptLogin()),
state.osmConnection.isLoggedIn
),
new VariableUiElement(
state.osmConnection.userDetails.map((ud) => {
if (ud.csCount < Constants.userJourney.importHelperUnlock) {
return undefined
}
return new Combine([
new SubtleButton(undefined, Translations.t.importHelper.title, {
url: "import_helper.html",
}),
new SubtleButton(Svg.note_svg(), Translations.t.importInspector.title, {
url: "import_viewer.html",
}),
]).SetClass("p-4 border-2 border-gray-500 m-4 block")
})
),
Translations.t.general.aboutMapcomplete
.Subs({"osmcha_link": Utils.OsmChaLinkFor(7)})
.Subs({ osmcha_link: Utils.OsmChaLinkFor(7) })
.SetClass("link-underline"),
new FixedUiElement("v" + Constants.vNumber)
]).SetClass("block m-5 lg:w-3/4 lg:ml-40")
new FixedUiElement("v" + Constants.vNumber),
])
.SetClass("block m-5 lg:w-3/4 lg:ml-40")
.SetStyle("pointer-events: all;")
.AttachTo("topleft-tools");
.AttachTo("topleft-tools")
} catch (e) {
console.error(">>>> CRITICAL", e)
new FixedUiElement("Seems like no layers are compiled - check the output of `npm run generate:layeroverview`. Is this visible online? Contact pietervdvn immediately!").SetClass("alert")
new FixedUiElement(
"Seems like no layers are compiled - check the output of `npm run generate:layeroverview`. Is this visible online? Contact pietervdvn immediately!"
)
.SetClass("alert")
.AttachTo("centermessage")
}
}

View file

@ -1,51 +1,60 @@
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 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);
private static readonly openChangeset = new UIEventSource<number>(undefined)
constructor(layoutToUse: LayoutConfig, indices: number[], extraCommentText: UIEventSource<string>, tagRenderingToAutomate: { layer: LayerConfig, tagRendering: TagRenderingConfig }) {
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 tileState = LocalStorageSource.GetParsed(
"automation-tile_state-" + layerId + "-" + trId,
{}
)
const logMessages = new UIEventSource<string[]>([])
if (indices === undefined) {
throw ("No tiles loaded - can not automate")
throw "No tiles loaded - can not automate"
}
const openChangeset = AutomationPanel.openChangeset;
const openChangeset = AutomationPanel.openChangeset
openChangeset.addCallbackAndRun(cs => console.trace("Sync current open changeset to:", cs))
openChangeset.addCallbackAndRun((cs) =>
console.trace("Sync current open changeset to:", cs)
)
const nextTileToHandle = tileState.map(handledTiles => {
const nextTileToHandle = tileState.map((handledTiles) => {
for (const index of indices) {
if (handledTiles[index] !== undefined) {
// Already handled
@ -55,53 +64,70 @@ class AutomationPanel extends Combine {
}
return undefined
})
nextTileToHandle.addCallback(t => console.warn("Next tile to handle is", t))
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 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")
}))
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)
}
super([statistics, automaton,
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) {
@ -110,54 +136,62 @@ class AutomationPanel extends Combine {
}
}
tileState.ping();
tileState.ping()
}),
new VariableUiElement(logMessages.map(logMessages => new List(logMessages)))])
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})
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
lat: y,
})
state.currentBounds.setData(
BBox.fromTileIndex(tileIndex)
)
state.currentBounds.setData(BBox.fromTileIndex(tileIndex))
let targetTiles: UIEventSource<FeatureSource[]> = new UIEventSource<FeatureSource[]>([])
const pipeline = new FeaturePipeline((tile => {
const pipeline = new FeaturePipeline((tile) => {
const layerId = tile.layer.layerDef.id
if (layerId === targetLayer) {
targetTiles.data.push(tile)
targetTiles.ping()
}
}), state)
}, state)
state.locationControl.ping();
state.currentBounds.ping();
state.locationControl.ping()
state.currentBounds.ping()
const stateToShow = new UIEventSource("")
pipeline.runningQuery.map(
async isRunning => {
async (isRunning) => {
if (targetTiles.data.length === 0) {
stateToShow.setData("No data loaded yet...")
return;
return
}
if (isRunning) {
stateToShow.setData("Waiting for all layers to be loaded... Has " + targetTiles.data.length + " tiles already")
return;
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;
return true
}
stateToShow.setData("Gathering applicable elements")
@ -165,37 +199,62 @@ class AutomationPanel extends Combine {
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")
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))
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)
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")
stateToShow.setData(
"Done! Inspected " + inspected + " features, updated " + handled + " features"
)
if (inspected === 0) {
whenDone("empty")
return true;
return true
}
if (handled === 0) {
@ -203,61 +262,73 @@ class AutomationPanel extends Combine {
} else {
state.osmConnection.AttemptLogin()
state.changes.flushChanges("handled tile automatically, time to flush!")
whenDone("fixed", "Updated " + handled + " elements, inspected " + inspected + ": " + log.join("; "))
whenDone(
"fixed",
"Updated " +
handled +
" elements, inspected " +
inspected +
": " +
log.join("; ")
)
}
return true;
}, [targetTiles])
return true
},
[targetTiles]
)
return new Combine([
new Title("Performing action for tile " + tileIndex, 1),
new VariableUiElement(stateToShow)]).SetClass("flex flex-col")
new VariableUiElement(stateToShow),
]).SetClass("flex flex-col")
}
}
class AutomatonGui {
constructor() {
const osmConnection = new OsmConnection({
singlePage: false,
oauth_token: QueryParameters.GetQueryParameter("oauth_token", "OAuth token")
});
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")
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()),
new SubtleButton(Svg.osm_logo_svg(), "Login to get started").onClick(() =>
osmConnection.AttemptLogin()
),
osmConnection.isLoggedIn
)]).SetClass("block p-4")
),
])
.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}))
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())
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%"
inputStyle: "width: 100%",
})
tilepath.SetClass("w-full")
LocalStorageSource.Get("automation-tile_path").syncWith(tilepath.GetValue(), true)
let tilesToRunOver = tilepath.GetValue().bind(path => {
let tilesToRunOver = tilepath.GetValue().bind((path) => {
if (path === undefined) {
return undefined
}
@ -266,12 +337,11 @@ class AutomatonGui {
const targetZoom = 14
const tilesPerIndex = tilesToRunOver.map(tiles => {
const tilesPerIndex = tilesToRunOver.map((tiles) => {
if (tiles === undefined || tiles["error"] !== undefined) {
return undefined
}
let indexes: number[] = [];
let indexes: number[] = []
const tilesS = tiles["success"]
DynamicGeoJsonTileSource.RegisterWhitelist(tilepath.GetValue().data, tilesS)
const z = Number(tilesS["zoom"])
@ -281,7 +351,7 @@ class AutomatonGui {
}
const x = Number(key)
const ys = tilesS[key]
indexes.push(...ys.map(y => Tiles.tile_index(z, x, y)))
indexes.push(...ys.map((y) => Tiles.tile_index(z, x, y)))
}
console.log("Got ", indexes.length, "indexes")
@ -296,7 +366,6 @@ class AutomatonGui {
rezoomed.add(Tiles.tile_index(z, x, y))
}
return Array.from(rezoomed)
})
@ -309,69 +378,99 @@ class AutomatonGui {
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})
}
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..."
}
}
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
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"
}
))
]
)
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")
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()

View file

@ -1,21 +1,21 @@
import BaseUIElement from "../BaseUIElement";
import {VariableUiElement} from "./VariableUIElement";
import {Stores, UIEventSource} from "../../Logic/UIEventSource";
import Loading from "./Loading";
import BaseUIElement from "../BaseUIElement"
import { VariableUiElement } from "./VariableUIElement"
import { Stores, UIEventSource } from "../../Logic/UIEventSource"
import Loading from "./Loading"
export default class AsyncLazy extends BaseUIElement {
private readonly _f: () => Promise<BaseUIElement>;
private readonly _f: () => Promise<BaseUIElement>
constructor(f: () => Promise<BaseUIElement>) {
super();
this._f = f;
super()
this._f = f
}
protected InnerConstructElement(): HTMLElement {
// The caching of the BaseUIElement will guarantee that _f will only be called once
return new VariableUiElement(
Stores.FromPromise(this._f()).map(el => {
Stores.FromPromise(this._f()).map((el) => {
if (el === undefined) {
return new Loading()
}
@ -23,5 +23,4 @@ export default class AsyncLazy extends BaseUIElement {
})
).ConstructElement()
}
}
}

View file

@ -1,26 +1,25 @@
import Translations from "../i18n/Translations";
import BaseUIElement from "../BaseUIElement";
import Translations from "../i18n/Translations"
import BaseUIElement from "../BaseUIElement"
export class Button extends BaseUIElement {
private _text: BaseUIElement;
private _text: BaseUIElement
constructor(text: string | BaseUIElement, onclick: (() => void | Promise<void>)) {
super();
this._text = Translations.W(text);
constructor(text: string | BaseUIElement, onclick: () => void | Promise<void>) {
super()
this._text = Translations.W(text)
this.onClick(onclick)
}
protected InnerConstructElement(): HTMLElement {
const el = this._text.ConstructElement();
const el = this._text.ConstructElement()
if (el === undefined) {
return undefined;
return undefined
}
const form = document.createElement("form")
const button = document.createElement("button")
button.type = "button"
button.appendChild(el)
form.appendChild(button)
return form;
return form
}
}
}

View file

@ -1,32 +1,32 @@
import BaseUIElement from "../BaseUIElement";
import BaseUIElement from "../BaseUIElement"
export class CenterFlexedElement extends BaseUIElement {
private _html: string;
private _html: string
constructor(html: string) {
super();
this._html = html ?? "";
super()
this._html = html ?? ""
}
InnerRender(): string {
return this._html;
return this._html
}
AsMarkdown(): string {
return this._html;
return this._html
}
protected InnerConstructElement(): HTMLElement {
const e = document.createElement("div");
e.innerHTML = this._html;
e.style.display = "flex";
e.style.height = "100%";
e.style.width = "100%";
e.style.flexDirection = "column";
e.style.flexWrap = "nowrap";
e.style.alignContent = "center";
e.style.justifyContent = "center";
e.style.alignItems = "center";
return e;
const e = document.createElement("div")
e.innerHTML = this._html
e.style.display = "flex"
e.style.height = "100%"
e.style.width = "100%"
e.style.flexDirection = "column"
e.style.flexWrap = "nowrap"
e.style.alignContent = "center"
e.style.justifyContent = "center"
e.style.alignItems = "center"
return e
}
}

View file

@ -1,35 +1,38 @@
import BaseUIElement from "../BaseUIElement";
import {Chart, ChartConfiguration, ChartType, DefaultDataPoint, registerables} from 'chart.js';
Chart?.register(...(registerables ?? []));
import BaseUIElement from "../BaseUIElement"
import { Chart, ChartConfiguration, ChartType, DefaultDataPoint, registerables } from "chart.js"
Chart?.register(...(registerables ?? []))
export default class ChartJs<
TType extends ChartType = ChartType,
TData = DefaultDataPoint<TType>,
TLabel = unknown
> extends BaseUIElement{
private readonly _config: ChartConfiguration<TType, TData, TLabel>;
> extends BaseUIElement {
private readonly _config: ChartConfiguration<TType, TData, TLabel>
constructor(config: ChartConfiguration<TType, TData, TLabel>) {
super();
this._config = config;
super()
this._config = config
}
protected InnerConstructElement(): HTMLElement {
const canvas = document.createElement("canvas");
const canvas = document.createElement("canvas")
// A bit exceptional: we apply the styles before giving them to 'chartJS'
if(this.style !== undefined){
if (this.style !== undefined) {
canvas.style.cssText = this.style
}
if (this.clss?.size > 0) {
try {
canvas.classList.add(...Array.from(this.clss))
} catch (e) {
console.error("Invalid class name detected in:", Array.from(this.clss).join(" "), "\nErr msg is ", e)
console.error(
"Invalid class name detected in:",
Array.from(this.clss).join(" "),
"\nErr msg is ",
e
)
}
}
new Chart(canvas, this._config);
return canvas;
new Chart(canvas, this._config)
return canvas
}
}
}

View file

@ -1,31 +1,30 @@
import {FixedUiElement} from "./FixedUiElement";
import {Utils} from "../../Utils";
import BaseUIElement from "../BaseUIElement";
import { FixedUiElement } from "./FixedUiElement"
import { Utils } from "../../Utils"
import BaseUIElement from "../BaseUIElement"
export default class Combine extends BaseUIElement {
private readonly uiElements: BaseUIElement[];
private readonly uiElements: BaseUIElement[]
constructor(uiElements: (string | BaseUIElement)[]) {
super();
this.uiElements = Utils.NoNull(uiElements)
.map(el => {
if (typeof el === "string") {
return new FixedUiElement(el);
}
return el;
});
super()
this.uiElements = Utils.NoNull(uiElements).map((el) => {
if (typeof el === "string") {
return new FixedUiElement(el)
}
return el
})
}
AsMarkdown(): string {
let sep = " ";
let sep = " "
if (this.HasClass("flex-col")) {
sep = "\n\n"
}
return this.uiElements.map(el => el.AsMarkdown()).join(sep);
return this.uiElements.map((el) => el.AsMarkdown()).join(sep)
}
Destroy() {
super.Destroy();
super.Destroy()
for (const uiElement of this.uiElements) {
uiElement.Destroy()
}
@ -38,15 +37,17 @@ export default class Combine extends BaseUIElement {
protected InnerConstructElement(): HTMLElement {
const el = document.createElement("span")
try {
if(this.uiElements === undefined){
console.error("PANIC")
if (this.uiElements === undefined) {
console.error(
"PANIC: this.uiElements is undefined. (This might indicate a constructor which did not call 'super'. The constructor name is",
this.constructor /*Disable code quality: used for debugging*/.name + ")"
)
}
for (const subEl of this.uiElements) {
if (subEl === undefined || subEl === null) {
continue;
continue
}
try {
const subHtml = subEl.ConstructElement()
if (subHtml !== undefined) {
el.appendChild(subHtml)
@ -58,11 +59,13 @@ export default class Combine extends BaseUIElement {
} catch (e) {
const domExc = e as DOMException
console.error("DOMException: ", domExc.name)
el.appendChild(new FixedUiElement("Could not generate this combine!").SetClass("alert").ConstructElement())
el.appendChild(
new FixedUiElement("Could not generate this combine!")
.SetClass("alert")
.ConstructElement()
)
}
return el;
return el
}
}
}

View file

@ -1,12 +1,11 @@
import BaseUIElement from "../BaseUIElement";
import {UIEventSource} from "../../Logic/UIEventSource";
import {VariableUiElement} from "./VariableUIElement";
import Combine from "./Combine";
import Locale from "../i18n/Locale";
import {Utils} from "../../Utils";
import BaseUIElement from "../BaseUIElement"
import { UIEventSource } from "../../Logic/UIEventSource"
import { VariableUiElement } from "./VariableUIElement"
import Combine from "./Combine"
import Locale from "../i18n/Locale"
import { Utils } from "../../Utils"
export default class FilteredCombine extends VariableUiElement {
/**
* Only shows item matching the search
* If predicate of an item is undefined, it will be filtered out as soon as a non-null or non-empty search term is given
@ -14,27 +13,38 @@ export default class FilteredCombine extends VariableUiElement {
* @param searchedValue
* @param options
*/
constructor(entries: {
element: BaseUIElement | string,
predicate?: (s: string) => boolean
}[],
searchedValue: UIEventSource<string>,
options?: {
onEmpty?: BaseUIElement | string,
innerClasses: string
}
constructor(
entries: {
element: BaseUIElement | string
predicate?: (s: string) => boolean
}[],
searchedValue: UIEventSource<string>,
options?: {
onEmpty?: BaseUIElement | string
innerClasses: string
}
) {
entries = Utils.NoNull(entries)
super(searchedValue.map(searchTerm => {
if(searchTerm === undefined || searchTerm === ""){
return new Combine(entries.map(e => e.element)).SetClass(options?.innerClasses ?? "")
}
const kept = entries.filter(entry => entry?.predicate !== undefined && entry.predicate(searchTerm))
if (kept.length === 0) {
return options?.onEmpty
}
return new Combine(kept.map(entry => entry.element)).SetClass(options?.innerClasses ?? "")
}, [Locale.language]))
super(
searchedValue.map(
(searchTerm) => {
if (searchTerm === undefined || searchTerm === "") {
return new Combine(entries.map((e) => e.element)).SetClass(
options?.innerClasses ?? ""
)
}
const kept = entries.filter(
(entry) => entry?.predicate !== undefined && entry.predicate(searchTerm)
)
if (kept.length === 0) {
return options?.onEmpty
}
return new Combine(kept.map((entry) => entry.element)).SetClass(
options?.innerClasses ?? ""
)
},
[Locale.language]
)
)
}
}
}

View file

@ -1,29 +1,27 @@
import BaseUIElement from "../BaseUIElement";
import BaseUIElement from "../BaseUIElement"
export class FixedUiElement extends BaseUIElement {
public readonly content: string;
public readonly content: string
constructor(html: string) {
super();
this.content = html ?? "";
super()
this.content = html ?? ""
}
InnerRender(): string {
return this.content;
return this.content
}
AsMarkdown(): string {
if(this.HasClass("code")){
return "`"+this.content+"`"
if (this.HasClass("code")) {
return "`" + this.content + "`"
}
return this.content;
return this.content
}
protected InnerConstructElement(): HTMLElement {
const e = document.createElement("span")
e.innerHTML = this.content
return e;
return e
}
}
}

View file

@ -1,31 +1,34 @@
import {Utils} from "../../Utils";
import BaseUIElement from "../BaseUIElement";
import { Utils } from "../../Utils"
import BaseUIElement from "../BaseUIElement"
export default class Img extends BaseUIElement {
private readonly _src: string;
private readonly _rawSvg: boolean;
private readonly _options: { readonly fallbackImage?: string };
private readonly _src: string
private readonly _rawSvg: boolean
private readonly _options: { readonly fallbackImage?: string }
constructor(src: string, rawSvg = false, options?: {
fallbackImage?: string
}) {
super();
constructor(
src: string,
rawSvg = false,
options?: {
fallbackImage?: string
}
) {
super()
if (src === undefined || src === "undefined") {
throw "Undefined src for image"
}
this._src = src;
this._rawSvg = rawSvg;
this._options = options;
this._src = src
this._rawSvg = rawSvg
this._options = options
}
static AsData(source: string) {
if (Utils.runningFromConsole) {
return source;
return source
}
try{
return `data:image/svg+xml;base64,${(btoa(source))}`;
}catch (e){
try {
return `data:image/svg+xml;base64,${btoa(source)}`
} catch (e) {
console.error("Cannot create an image for", source.slice(0, 100))
console.trace("Cannot create an image for the given source string due to ", e)
return ""
@ -33,31 +36,31 @@ export default class Img extends BaseUIElement {
}
static AsImageElement(source: string, css_class: string = "", style = ""): string {
return `<img class="${css_class}" style="${style}" alt="" src="${Img.AsData(source)}">`;
return `<img class="${css_class}" style="${style}" alt="" src="${Img.AsData(source)}">`
}
AsMarkdown(): string {
if (this._rawSvg === true) {
console.warn("Converting raw svgs to markdown is not supported");
console.warn("Converting raw svgs to markdown is not supported")
return undefined
}
let src = this._src
if (this._src.startsWith("./")) {
src = "https://mapcomplete.osm.be/" + src
}
return "![](" + src + ")";
return "![](" + src + ")"
}
protected InnerConstructElement(): HTMLElement {
const self = this;
const self = this
if (this._rawSvg) {
const e = document.createElement("div")
e.innerHTML = this._src
return e;
return e
}
const el = document.createElement("img")
el.src = this._src;
el.src = this._src
el.onload = () => {
el.style.opacity = "1"
}
@ -65,12 +68,11 @@ export default class Img extends BaseUIElement {
if (self._options?.fallbackImage) {
if (el.src === self._options.fallbackImage) {
// Sigh... nothing to be done anymore
return;
return
}
el.src = self._options.fallbackImage
}
}
return el;
return el
}
}

View file

@ -1,16 +1,15 @@
import BaseUIElement from "../BaseUIElement";
import BaseUIElement from "../BaseUIElement"
export default class Lazy extends BaseUIElement {
private readonly _f: () => BaseUIElement;
private readonly _f: () => BaseUIElement
constructor(f: () => BaseUIElement) {
super();
this._f = f;
super()
this._f = f
}
protected InnerConstructElement(): HTMLElement {
// The caching of the BaseUIElement will guarantee that _f will only be called once
return this._f().ConstructElement();
return this._f().ConstructElement()
}
}
}

View file

@ -1,25 +1,25 @@
import BaseUIElement from "../BaseUIElement";
import Combine from "./Combine";
import BackToIndex from "../BigComponents/BackToIndex";
import BaseUIElement from "../BaseUIElement"
import Combine from "./Combine"
import BackToIndex from "../BigComponents/BackToIndex"
export default class LeftIndex extends Combine {
constructor(leftContents: BaseUIElement[], mainContent: BaseUIElement, options?: {
hideBackButton: false | boolean
}) {
let back: BaseUIElement = undefined;
constructor(
leftContents: BaseUIElement[],
mainContent: BaseUIElement,
options?: {
hideBackButton: false | boolean
}
) {
let back: BaseUIElement = undefined
if (options?.hideBackButton ?? true) {
back = new BackToIndex()
}
super([
new Combine([
new Combine([back, ...leftContents]).SetClass("sticky top-4"),
]).SetClass("ml-4 block w-full md:w-2/6 lg:w-1/6"),
mainContent.SetClass("m-8 w-full mb-24")
new Combine([new Combine([back, ...leftContents]).SetClass("sticky top-4")]).SetClass(
"ml-4 block w-full md:w-2/6 lg:w-1/6"
),
mainContent.SetClass("m-8 w-full mb-24"),
])
this.SetClass("h-full block md:flex")
}
}
}

View file

@ -1,59 +1,64 @@
import Translations from "../i18n/Translations";
import BaseUIElement from "../BaseUIElement";
import {Store, UIEventSource} from "../../Logic/UIEventSource";
import Translations from "../i18n/Translations"
import BaseUIElement from "../BaseUIElement"
import { Store, UIEventSource } from "../../Logic/UIEventSource"
export default class Link extends BaseUIElement {
private readonly _href: string | Store<string>;
private readonly _embeddedShow: BaseUIElement;
private readonly _newTab: boolean;
private readonly _href: string | Store<string>
private readonly _embeddedShow: BaseUIElement
private readonly _newTab: boolean
constructor(embeddedShow: BaseUIElement | string, href: string | Store<string>, newTab: boolean = false) {
super();
this._embeddedShow = Translations.W(embeddedShow);
this._href = href;
this._newTab = newTab;
constructor(
embeddedShow: BaseUIElement | string,
href: string | Store<string>,
newTab: boolean = false
) {
super()
this._embeddedShow = Translations.W(embeddedShow)
this._href = href
this._newTab = newTab
if (this._embeddedShow === undefined) {
throw "Error: got a link where embeddedShow is undefined"
}
this.onClick(() => {})
}
public static OsmWiki(key: string, value?: string, hideKey = false) {
if (value !== undefined) {
let k = "";
let k = ""
if (!hideKey) {
k = key + "="
}
return new Link(k + value, `https://wiki.openstreetmap.org/wiki/Tag:${key}%3D${value}`, true)
return new Link(
k + value,
`https://wiki.openstreetmap.org/wiki/Tag:${key}%3D${value}`,
true
)
}
return new Link(key, "https://wiki.openstreetmap.org/wiki/Key:" + key, true)
}
AsMarkdown(): string {
// @ts-ignore
return `[${this._embeddedShow.AsMarkdown()}](${this._href.data ?? this._href})`;
return `[${this._embeddedShow.AsMarkdown()}](${this._href.data ?? this._href})`
}
protected InnerConstructElement(): HTMLElement {
const embeddedShow = this._embeddedShow?.ConstructElement();
const embeddedShow = this._embeddedShow?.ConstructElement()
if (embeddedShow === undefined) {
return undefined;
return undefined
}
const el = document.createElement("a")
if (typeof this._href === "string") {
el.href = this._href
} else {
this._href.addCallbackAndRun(href => {
el.href = href;
this._href.addCallbackAndRun((href) => {
el.href = href
})
}
if (this._newTab) {
el.target = "_blank"
}
el.appendChild(embeddedShow)
return el;
return el
}
}
}

View file

@ -1,46 +1,66 @@
import {VariableUiElement} from "./VariableUIElement";
import Locale from "../i18n/Locale";
import Link from "./Link";
import Svg from "../../Svg";
import { VariableUiElement } from "./VariableUIElement"
import Locale from "../i18n/Locale"
import Link from "./Link"
import Svg from "../../Svg"
/**
* The little 'translate'-icon next to every icon + some static helper functions
*/
export default class LinkToWeblate extends VariableUiElement {
private static URI: any;
constructor(context: string, availableTranslations: object) {
super( Locale.language.map(ln => {
if (Locale.showLinkToWeblate.data === false) {
return undefined;
}
if(availableTranslations["*"] !== undefined){
return undefined
}
if(context === undefined || context.indexOf(":") < 0){
return undefined
}
const icon = Svg.translate_svg()
.SetClass("rounded-full border border-gray-400 inline-block w-4 h-4 m-1 weblate-link self-center")
if(availableTranslations[ln] === undefined){
icon.SetClass("bg-red-400")
}
return new Link(icon,
LinkToWeblate.hrefToWeblate(ln, context), true)
} ,[Locale.showLinkToWeblate]));
super(
Locale.language.map(
(ln) => {
if (Locale.showLinkToWeblate.data === false) {
return undefined
}
if (availableTranslations["*"] !== undefined) {
return undefined
}
if (context === undefined || context.indexOf(":") < 0) {
return undefined
}
const icon = Svg.translate_svg().SetClass(
"rounded-full border border-gray-400 inline-block w-4 h-4 m-1 weblate-link self-center"
)
if (availableTranslations[ln] === undefined) {
icon.SetClass("bg-red-400")
}
return new Link(icon, LinkToWeblate.hrefToWeblate(ln, context), true)
},
[Locale.showLinkToWeblate]
)
)
this.SetClass("enable-links hidden-on-mobile")
}
public static hrefToWeblate(language: string, contextKey: string): string{
if(contextKey === undefined || contextKey.indexOf(":") < 0){
public static hrefToWeblate(language: string, contextKey: string): string {
if (contextKey === undefined || contextKey.indexOf(":") < 0) {
return undefined
}
const [category, ...rest] = contextKey.split(":")
const key = rest.join(":")
const baseUrl = "https://hosted.weblate.org/translate/mapcomplete/"
return baseUrl + category + "/" + language + "/?offset=1&q=context%3A%3D%22" + key + "%22"
}
public static hrefToWeblateZen(language: string, category: "core" | "themes" | "layers" | "shared-questions" | "glossary" | string, searchKey: string): string{
public static hrefToWeblateZen(
language: string,
category: "core" | "themes" | "layers" | "shared-questions" | "glossary" | string,
searchKey: string
): string {
const baseUrl = "https://hosted.weblate.org/zen/mapcomplete/"
// ?offset=1&q=+state%3A%3Ctranslated+context%3Acampersite&sort_by=-priority%2Cposition&checksum=
return baseUrl + category + "/" + language + "?offset=1&q=+state%3A%3Ctranslated+context%3A"+encodeURIComponent(searchKey)+"&sort_by=-priority%2Cposition&checksum="
return (
baseUrl +
category +
"/" +
language +
"?offset=1&q=+state%3A%3Ctranslated+context%3A" +
encodeURIComponent(searchKey) +
"&sort_by=-priority%2Cposition&checksum="
)
}
}
}

View file

@ -1,24 +1,34 @@
import {Utils} from "../../Utils";
import BaseUIElement from "../BaseUIElement";
import Translations from "../i18n/Translations";
import { Utils } from "../../Utils"
import BaseUIElement from "../BaseUIElement"
import Translations from "../i18n/Translations"
export default class List extends BaseUIElement {
private readonly uiElements: BaseUIElement[];
private readonly _ordered: boolean;
private readonly uiElements: BaseUIElement[]
private readonly _ordered: boolean
constructor(uiElements: (string | BaseUIElement)[], ordered = false) {
super();
this._ordered = ordered;
this.uiElements = Utils.NoNull(uiElements)
.map(s => Translations.W(s));
super()
this._ordered = ordered
this.uiElements = Utils.NoNull(uiElements).map((s) => Translations.W(s))
}
AsMarkdown(): string {
if (this._ordered) {
return "\n\n" + this.uiElements.map((el, i) => " " + i + ". " + el.AsMarkdown().replace(/\n/g, ' \n')).join("\n") + "\n"
return (
"\n\n" +
this.uiElements
.map((el, i) => " " + i + ". " + el.AsMarkdown().replace(/\n/g, " \n"))
.join("\n") +
"\n"
)
} else {
return "\n\n" + this.uiElements.map(el => " - " + el.AsMarkdown().replace(/\n/g, ' \n')).join("\n") + "\n"
return (
"\n\n" +
this.uiElements
.map((el) => " - " + el.AsMarkdown().replace(/\n/g, " \n"))
.join("\n") +
"\n"
)
}
}
@ -27,7 +37,7 @@ export default class List extends BaseUIElement {
for (const subEl of this.uiElements) {
if (subEl === undefined || subEl === null) {
continue;
continue
}
const subHtml = subEl.ConstructElement()
if (subHtml !== undefined) {
@ -37,7 +47,6 @@ export default class List extends BaseUIElement {
}
}
return el;
return el
}
}
}

View file

@ -1,18 +1,18 @@
import Combine from "./Combine";
import Svg from "../../Svg";
import Translations from "../i18n/Translations";
import BaseUIElement from "../BaseUIElement";
import Combine from "./Combine"
import Svg from "../../Svg"
import Translations from "../i18n/Translations"
import BaseUIElement from "../BaseUIElement"
export default class Loading extends Combine {
constructor(msg?: BaseUIElement | string) {
const t = Translations.W(msg) ?? Translations.t.general.loading;
const t = Translations.W(msg) ?? Translations.t.general.loading
t.SetClass("pl-2")
super([
Svg.loading_svg()
.SetClass("animate-spin self-center")
.SetStyle("width: 1.5rem; height: 1.5rem; min-width: 1.5rem;"),
t
t,
])
this.SetClass("flex p-1")
}
}
}

View file

@ -1,31 +1,34 @@
import BaseUIElement from "../BaseUIElement";
import Loc from "../../Models/Loc";
import BaseLayer from "../../Models/BaseLayer";
import {UIEventSource} from "../../Logic/UIEventSource";
import {BBox} from "../../Logic/BBox";
import BaseUIElement from "../BaseUIElement"
import Loc from "../../Models/Loc"
import BaseLayer from "../../Models/BaseLayer"
import { UIEventSource } from "../../Logic/UIEventSource"
import { BBox } from "../../Logic/BBox"
import {deprecate} from "util";
export interface MinimapOptions {
background?: UIEventSource<BaseLayer>,
location?: UIEventSource<Loc>,
bounds?: UIEventSource<BBox>,
allowMoving?: boolean,
leafletOptions?: any,
attribution?: BaseUIElement | boolean,
onFullyLoaded?: (leaflet: L.Map) => void,
leafletMap?: UIEventSource<any>,
lastClickLocation?: UIEventSource<{ lat: number, lon: number }>,
background?: UIEventSource<BaseLayer>
location?: UIEventSource<Loc>
bounds?: UIEventSource<BBox>
allowMoving?: boolean
leafletOptions?: any
attribution?: BaseUIElement | boolean
onFullyLoaded?: (leaflet: L.Map) => void
leafletMap?: UIEventSource<any>
lastClickLocation?: UIEventSource<{ lat: number; lon: number }>
addLayerControl?: boolean | false
}
export interface MinimapObj {
readonly leafletMap: UIEventSource<any>,
readonly location: UIEventSource<Loc>;
readonly bounds: UIEventSource<BBox>;
readonly leafletMap: UIEventSource<any>
readonly location: UIEventSource<Loc>
readonly bounds: UIEventSource<BBox>
installBounds(factor: number | BBox, showRange?: boolean): void
TakeScreenshot(): Promise<any>;
TakeScreenshot(format): Promise<string>
TakeScreenshot(format: "image"): Promise<string>
TakeScreenshot(format:"blob"): Promise<Blob>
TakeScreenshot(format?: "image" | "blob"): Promise<string | Blob>
}
export default class Minimap {
@ -34,15 +37,12 @@ export default class Minimap {
* importing leaflet crashes node-ts, which is pretty annoying considering the fact that a lot of scripts use it
*/
private constructor() {
}
private constructor() {}
/**
* Construct a minimap
*/
public static createMiniMap: (options?: MinimapOptions) => (BaseUIElement & MinimapObj) = (_) => {
public static createMiniMap: (options?: MinimapOptions) => BaseUIElement & MinimapObj = (_) => {
throw "CreateMinimap hasn't been initialized yet. Please call MinimapImplementation.initialize()"
}
}
}

View file

@ -1,67 +1,67 @@
import {Utils} from "../../Utils";
import BaseUIElement from "../BaseUIElement";
import {UIEventSource} from "../../Logic/UIEventSource";
import Loc from "../../Models/Loc";
import BaseLayer from "../../Models/BaseLayer";
import AvailableBaseLayers from "../../Logic/Actors/AvailableBaseLayers";
import * as L from "leaflet";
import {Map} from "leaflet";
import Minimap, {MinimapObj, MinimapOptions} from "./Minimap";
import {BBox} from "../../Logic/BBox";
import 'leaflet-polylineoffset'
import {SimpleMapScreenshoter} from "leaflet-simple-map-screenshoter";
import BackgroundMapSwitch from "../BigComponents/BackgroundMapSwitch";
import AvailableBaseLayersImplementation from "../../Logic/Actors/AvailableBaseLayersImplementation";
import ShowDataLayer from "../ShowDataLayer/ShowDataLayer";
import ShowDataLayerImplementation from "../ShowDataLayer/ShowDataLayerImplementation";
import { Utils } from "../../Utils"
import BaseUIElement from "../BaseUIElement"
import { UIEventSource } from "../../Logic/UIEventSource"
import Loc from "../../Models/Loc"
import BaseLayer from "../../Models/BaseLayer"
import AvailableBaseLayers from "../../Logic/Actors/AvailableBaseLayers"
import * as L from "leaflet"
import { Map } from "leaflet"
import Minimap, { MinimapObj, MinimapOptions } from "./Minimap"
import { BBox } from "../../Logic/BBox"
import "leaflet-polylineoffset"
import { SimpleMapScreenshoter } from "leaflet-simple-map-screenshoter"
import BackgroundMapSwitch from "../BigComponents/BackgroundMapSwitch"
import AvailableBaseLayersImplementation from "../../Logic/Actors/AvailableBaseLayersImplementation"
import ShowDataLayer from "../ShowDataLayer/ShowDataLayer"
import ShowDataLayerImplementation from "../ShowDataLayer/ShowDataLayerImplementation"
export default class MinimapImplementation extends BaseUIElement implements MinimapObj {
private static _nextId = 0;
private static _nextId = 0
public readonly leafletMap: UIEventSource<Map>
public readonly location: UIEventSource<Loc>;
public readonly bounds: UIEventSource<BBox> | undefined;
private readonly _id: string;
private readonly _background: UIEventSource<BaseLayer>;
private _isInited = false;
private _allowMoving: boolean;
private readonly _leafletoptions: any;
public readonly location: UIEventSource<Loc>
public readonly bounds: UIEventSource<BBox> | undefined
private readonly _id: string
private readonly _background: UIEventSource<BaseLayer>
private _isInited = false
private _allowMoving: boolean
private readonly _leafletoptions: any
private readonly _onFullyLoaded: (leaflet: L.Map) => void
private readonly _attribution: BaseUIElement | boolean;
private readonly _addLayerControl: boolean;
private readonly _options: MinimapOptions;
private readonly _attribution: BaseUIElement | boolean
private readonly _addLayerControl: boolean
private readonly _options: MinimapOptions
private constructor(options?: MinimapOptions) {
super()
options = options ?? {}
this._id = "minimap" + MinimapImplementation._nextId;
this._id = "minimap" + MinimapImplementation._nextId
MinimapImplementation._nextId++
this.leafletMap = options.leafletMap ?? new UIEventSource<Map>(undefined)
this._background = options?.background ?? new UIEventSource<BaseLayer>(AvailableBaseLayers.osmCarto)
this.location = options?.location ?? new UIEventSource<Loc>({lat: 0, lon: 0, zoom: 1})
this.bounds = options?.bounds;
this._allowMoving = options.allowMoving ?? true;
this._background =
options?.background ?? new UIEventSource<BaseLayer>(AvailableBaseLayers.osmCarto)
this.location = options?.location ?? new UIEventSource<Loc>({ lat: 0, lon: 0, zoom: 1 })
this.bounds = options?.bounds
this._allowMoving = options.allowMoving ?? true
this._leafletoptions = options.leafletOptions ?? {}
this._onFullyLoaded = options.onFullyLoaded
this._attribution = options.attribution
this._addLayerControl = options.addLayerControl ?? false
this._options = options
this.SetClass("relative")
}
public static initialize() {
AvailableBaseLayers.implement(new AvailableBaseLayersImplementation())
Minimap.createMiniMap = options => new MinimapImplementation(options)
ShowDataLayer.actualContstructor = options => new ShowDataLayerImplementation(options)
Minimap.createMiniMap = (options) => new MinimapImplementation(options)
ShowDataLayer.actualContstructor = (options) => new ShowDataLayerImplementation(options)
}
public installBounds(factor: number | BBox, showRange?: boolean) {
this.leafletMap.addCallbackD(leaflet => {
let bounds: { getEast(), getNorth(), getWest(), getSouth() };
this.leafletMap.addCallbackD((leaflet) => {
let bounds: { getEast(); getNorth(); getWest(); getSouth() }
if (typeof factor === "number") {
const lbounds = leaflet.getBounds().pad(factor)
leaflet.setMaxBounds(lbounds)
bounds = lbounds;
bounds = lbounds
} else {
// @ts-ignore
leaflet.setMaxBounds(factor.toLeaflet())
@ -71,50 +71,37 @@ export default class MinimapImplementation extends BaseUIElement implements Mini
if (showRange) {
const data = {
type: "FeatureCollection",
features: [{
"type": "Feature",
"geometry": {
"type": "LineString",
"coordinates": [
[
bounds.getEast(),
bounds.getNorth()
],
[
bounds.getWest(),
bounds.getNorth()
],
[
bounds.getWest(),
bounds.getSouth()
],
features: [
{
type: "Feature",
geometry: {
type: "LineString",
coordinates: [
[bounds.getEast(), bounds.getNorth()],
[bounds.getWest(), bounds.getNorth()],
[bounds.getWest(), bounds.getSouth()],
[
bounds.getEast(),
bounds.getSouth()
[bounds.getEast(), bounds.getSouth()],
[bounds.getEast(), bounds.getNorth()],
],
[
bounds.getEast(),
bounds.getNorth()
]
]
}
}]
},
},
],
}
// @ts-ignore
L.geoJSON(data, {
style: {
color: "#f44",
weight: 4,
opacity: 0.7
}
opacity: 0.7,
},
}).addTo(leaflet)
}
})
}
Destroy() {
super.Destroy();
super.Destroy()
console.warn("Decomissioning minimap", this._id)
const mp = this.leafletMap.data
this.leafletMap.setData(null)
@ -122,15 +109,32 @@ export default class MinimapImplementation extends BaseUIElement implements Mini
mp.remove()
}
public async TakeScreenshot() {
const screenshotter = new SimpleMapScreenshoter();
screenshotter.addTo(this.leafletMap.data);
return await screenshotter.takeScreen('image')
/**
* Takes a screenshot of the current map
* @param format: image: give a base64 encoded png image;
* @constructor
*/
public async TakeScreenshot(): Promise<string> ;
public async TakeScreenshot(format: "image"): Promise<string> ;
public async TakeScreenshot(format: "blob"): Promise<Blob> ;
public async TakeScreenshot(format: "image" | "blob"): Promise<string | Blob> ;
public async TakeScreenshot(format: "image" | "blob" = "image"): Promise<string | Blob> {
console.log("Taking a screenshot...")
const screenshotter = new SimpleMapScreenshoter()
screenshotter.addTo(this.leafletMap.data)
const result = <any> await screenshotter.takeScreen((<any> format) ?? "image")
if(format === "image" && typeof result === "string"){
return result
}
if(format === "blob" && result instanceof Blob){
return result
}
throw "Something went wrong while creating the screenshot: "+result
}
protected InnerConstructElement(): HTMLElement {
const div = document.createElement("div")
div.id = this._id;
div.id = this._id
div.style.height = "100%"
div.style.width = "100%"
div.style.minWidth = "40px"
@ -138,19 +142,21 @@ export default class MinimapImplementation extends BaseUIElement implements Mini
div.style.position = "relative"
const wrapper = document.createElement("div")
wrapper.appendChild(div)
const self = this;
const self = this
// @ts-ignore
const resizeObserver = new ResizeObserver(_ => {
const resizeObserver = new ResizeObserver((_) => {
if (wrapper.clientHeight === 0 || wrapper.clientWidth === 0) {
return;
return
}
if (wrapper.offsetParent === null || window.getComputedStyle(wrapper).display === 'none') {
if (
wrapper.offsetParent === null ||
window.getComputedStyle(wrapper).display === "none"
) {
// Not visible
return;
return
}
try {
self.InitMap();
self.InitMap()
} catch (e) {
console.warn("Could not construct a minimap:", e)
}
@ -160,41 +166,41 @@ export default class MinimapImplementation extends BaseUIElement implements Mini
} catch (e) {
console.warn("Could not invalidate size of a minimap:", e)
}
});
})
resizeObserver.observe(div);
resizeObserver.observe(div)
if (this._addLayerControl) {
const switcher = new BackgroundMapSwitch({
const switcher = new BackgroundMapSwitch(
{
locationControl: this.location,
backgroundLayer: this._background
backgroundLayer: this._background,
},
this._background
).SetClass("top-0 right-0 z-above-map absolute")
wrapper.appendChild(switcher.ConstructElement())
}
return wrapper;
return wrapper
}
private InitMap() {
if (this._constructedHtmlElement === undefined) {
// This element isn't initialized yet
return;
return
}
if (document.getElementById(this._id) === null) {
// not yet attached, we probably got some other event
return;
return
}
if (this._isInited) {
return;
return
}
this._isInited = true;
const location = this.location;
const self = this;
this._isInited = true
const location = this.location
const self = this
let currentLayer = this._background.data.layer()
let latLon = <[number, number]>[location.data?.lat ?? 0, location.data?.lon ?? 0]
if (isNaN(latLon[0]) || isNaN(latLon[1])) {
@ -213,22 +219,20 @@ export default class MinimapImplementation extends BaseUIElement implements Mini
touchZoom: this._allowMoving,
// Disabling this breaks the geojson layer - don't ask me why! zoomAnimation: this._allowMoving,
fadeAnimation: this._allowMoving,
maxZoom: 21
maxZoom: 21,
}
Utils.Merge(this._leafletoptions, options)
/*
* Somehow, the element gets '_leaflet_id' set on chrome.
* When attempting to init this leaflet map, it'll throw an exception and the map won't show up.
* Simply removing '_leaflet_id' fixes the issue.
* See https://github.com/pietervdvn/MapComplete/issues/726
* */
* Somehow, the element gets '_leaflet_id' set on chrome.
* When attempting to init this leaflet map, it'll throw an exception and the map won't show up.
* Simply removing '_leaflet_id' fixes the issue.
* See https://github.com/pietervdvn/MapComplete/issues/726
* */
delete document.getElementById(this._id)["_leaflet_id"]
const map = L.map(this._id, options);
const map = L.map(this._id, options)
if (self._onFullyLoaded !== undefined) {
currentLayer.on("load", () => {
console.log("Fully loaded all tiles!")
self._onFullyLoaded(map)
@ -239,95 +243,90 @@ export default class MinimapImplementation extends BaseUIElement implements Mini
// We give a bit of leeway for people on the edges
// Also see: https://www.reddit.com/r/openstreetmap/comments/ih4zzc/mapcomplete_a_new_easytouse_editor/g31ubyv/
map.setMaxBounds(
[[-100, -200], [100, 200]]
);
map.setMaxBounds([
[-100, -200],
[100, 200],
])
if (this._attribution !== undefined) {
if (this._attribution === true) {
map.attributionControl.setPrefix(false)
} else {
map.attributionControl.setPrefix(
"<span id='leaflet-attribution'></span>");
map.attributionControl.setPrefix("<span id='leaflet-attribution'></span>")
}
}
this._background.addCallbackAndRun(layer => {
this._background.addCallbackAndRun((layer) => {
const newLayer = layer.layer()
if (currentLayer !== undefined) {
map.removeLayer(currentLayer);
map.removeLayer(currentLayer)
}
currentLayer = newLayer;
currentLayer = newLayer
if (self._onFullyLoaded !== undefined) {
currentLayer.on("load", () => {
console.log("Fully loaded all tiles!")
self._onFullyLoaded(map)
})
}
map.addLayer(newLayer);
map.addLayer(newLayer)
if (self._attribution !== true && self._attribution !== false) {
self._attribution?.AttachTo('leaflet-attribution')
self._attribution?.AttachTo("leaflet-attribution")
}
})
let isRecursing = false;
let isRecursing = false
map.on("moveend", function () {
if (isRecursing) {
return
}
if (map.getZoom() === location.data.zoom &&
if (
map.getZoom() === location.data.zoom &&
map.getCenter().lat === location.data.lat &&
map.getCenter().lng === location.data.lon) {
return;
map.getCenter().lng === location.data.lon
) {
return
}
location.data.zoom = map.getZoom();
location.data.lat = map.getCenter().lat;
location.data.lon = map.getCenter().lng;
isRecursing = true;
location.ping();
location.data.zoom = map.getZoom()
location.data.lat = map.getCenter().lat
location.data.lon = map.getCenter().lng
isRecursing = true
location.ping()
if (self.bounds !== undefined) {
self.bounds.setData(BBox.fromLeafletBounds(map.getBounds()))
}
isRecursing = false; // This is ugly, I know
isRecursing = false // This is ugly, I know
})
location.addCallback(loc => {
location.addCallback((loc) => {
const mapLoc = map.getCenter()
const dlat = Math.abs(loc.lat - mapLoc[0])
const dlon = Math.abs(loc.lon - mapLoc[1])
if (dlat < 0.000001 && dlon < 0.000001 && map.getZoom() === loc.zoom) {
return;
return
}
map.setView([loc.lat, loc.lon], loc.zoom)
})
if (self.bounds !== undefined) {
self.bounds.setData(BBox.fromLeafletBounds(map.getBounds()))
}
if (this._options.lastClickLocation) {
const lastClickLocation = this._options.lastClickLocation
map.on("click", function (e) {
// @ts-ignore
lastClickLocation?.setData({lat: e.latlng.lat, lon: e.latlng.lng})
});
lastClickLocation?.setData({ lat: e.latlng.lat, lon: e.latlng.lng })
})
map.on("contextmenu", function (e) {
// @ts-ignore
lastClickLocation?.setData({lat: e.latlng.lat, lon: e.latlng.lng});
});
lastClickLocation?.setData({ lat: e.latlng.lat, lon: e.latlng.lng })
})
}
this.leafletMap.setData(map)
}
}
}

View file

@ -1,33 +1,30 @@
import BaseUIElement from "../BaseUIElement";
import BaseUIElement from "../BaseUIElement"
export class Paragraph extends BaseUIElement {
public readonly content: (string | BaseUIElement);
public readonly content: string | BaseUIElement
constructor(html: (string | BaseUIElement)) {
super();
this.content = html ?? "";
constructor(html: string | BaseUIElement) {
super()
this.content = html ?? ""
}
AsMarkdown(): string {
let c:string ;
if(typeof this.content !== "string"){
c = this.content.AsMarkdown()
}else{
c = this.content
}
return "\n\n"+c+"\n\n"
let c: string
if (typeof this.content !== "string") {
c = this.content.AsMarkdown()
} else {
c = this.content
}
return "\n\n" + c + "\n\n"
}
protected InnerConstructElement(): HTMLElement {
const e = document.createElement("p")
if(typeof this.content !== "string"){
if (typeof this.content !== "string") {
e.appendChild(this.content.ConstructElement())
}else{
} else {
e.innerHTML = this.content
}
return e;
return e
}
}
}

View file

@ -1,11 +1,11 @@
import {UIElement} from "../UIElement";
import Svg from "../../Svg";
import Combine from "./Combine";
import {FixedUiElement} from "./FixedUiElement";
import {UIEventSource} from "../../Logic/UIEventSource";
import Hash from "../../Logic/Web/Hash";
import BaseUIElement from "../BaseUIElement";
import Title from "./Title";
import { UIElement } from "../UIElement"
import Svg from "../../Svg"
import Combine from "./Combine"
import { FixedUiElement } from "./FixedUiElement"
import { UIEventSource } from "../../Logic/UIEventSource"
import Hash from "../../Logic/Web/Hash"
import BaseUIElement from "../BaseUIElement"
import Title from "./Title"
/**
*
@ -17,99 +17,107 @@ import Title from "./Title";
*
*/
export default class ScrollableFullScreen extends UIElement {
private static readonly empty = new FixedUiElement("");
private static _currentlyOpen: ScrollableFullScreen;
public isShown: UIEventSource<boolean>;
private hashToShow: string;
private _component: BaseUIElement;
private _fullscreencomponent: BaseUIElement;
private _resetScrollSignal: UIEventSource<void> = new UIEventSource<void>(undefined);
private static readonly empty = new FixedUiElement("")
private static _currentlyOpen: ScrollableFullScreen
public isShown: UIEventSource<boolean>
private hashToShow: string
private _component: BaseUIElement
private _fullscreencomponent: BaseUIElement
private _resetScrollSignal: UIEventSource<void> = new UIEventSource<void>(undefined)
constructor(title: ((options: { mode: string }) => BaseUIElement),
content: ((options: { mode: string, resetScrollSignal: UIEventSource<void> }) => BaseUIElement),
hashToShow: string,
isShown: UIEventSource<boolean> = new UIEventSource<boolean>(false),
options?: {
setHash?: true | boolean
}
constructor(
title: (options: { mode: string }) => BaseUIElement,
content: (options: {
mode: string
resetScrollSignal: UIEventSource<void>
}) => BaseUIElement,
hashToShow: string,
isShown: UIEventSource<boolean> = new UIEventSource<boolean>(false),
options?: {
setHash?: true | boolean
}
) {
super();
this.hashToShow = hashToShow;
this.isShown = isShown;
super()
this.hashToShow = hashToShow
this.isShown = isShown
if (hashToShow === undefined) {
throw "HashToShow should be defined as it is vital for the 'back' key functionality"
}
const desktopOptions = {
mode: "desktop",
resetScrollSignal: this._resetScrollSignal
resetScrollSignal: this._resetScrollSignal,
}
const mobileOptions = {
mode: "mobile",
resetScrollSignal: this._resetScrollSignal
resetScrollSignal: this._resetScrollSignal,
}
this._component = this.BuildComponent(title(desktopOptions), content(desktopOptions)) .SetClass("hidden md:block");
this._fullscreencomponent = this.BuildComponent(title(mobileOptions), content(mobileOptions).SetClass("pb-20"));
this._component = this.BuildComponent(
title(desktopOptions),
content(desktopOptions)
).SetClass("hidden md:block")
this._fullscreencomponent = this.BuildComponent(
title(mobileOptions),
content(mobileOptions).SetClass("pb-20")
)
const self = this;
const setHash = options?.setHash ?? true;
if(setHash){
Hash.hash.addCallback(h => {
const self = this
const setHash = options?.setHash ?? true
if (setHash) {
Hash.hash.addCallback((h) => {
if (h === undefined) {
isShown.setData(false)
}
})
}
isShown.addCallback(isShown => {
isShown.addCallback((isShown) => {
if (isShown) {
// We first must set the hash, then activate the panel
// If the order is wrong, this will cause the panel to disactivate again
if(setHash){
if (setHash) {
Hash.hash.setData(hashToShow)
}
self.Activate();
self.Activate()
} else {
// Some cleanup...
const fs = document.getElementById("fullscreen");
const fs = document.getElementById("fullscreen")
if (fs !== null) {
ScrollableFullScreen.empty.AttachTo("fullscreen")
fs.classList.add("hidden")
}
ScrollableFullScreen._currentlyOpen?.isShown?.setData(false);
ScrollableFullScreen._currentlyOpen?.isShown?.setData(false)
}
})
}
InnerRender(): BaseUIElement {
return this._component;
return this._component
}
Destroy() {
super.Destroy();
super.Destroy()
this._component.Destroy()
this._fullscreencomponent.Destroy()
}
Activate(): void {
this.isShown.setData(true)
this._fullscreencomponent.AttachTo("fullscreen");
const fs = document.getElementById("fullscreen");
ScrollableFullScreen._currentlyOpen = this;
this._fullscreencomponent.AttachTo("fullscreen")
const fs = document.getElementById("fullscreen")
ScrollableFullScreen._currentlyOpen = this
fs.classList.remove("hidden")
}
private BuildComponent(title: BaseUIElement, content: BaseUIElement) :BaseUIElement {
const returnToTheMap =
new Combine([
Svg.back_svg().SetClass("block md:hidden w-12 h-12 p-2 svg-foreground"),
Svg.close_svg() .SetClass("hidden md:block w-12 h-12 p-3 svg-foreground")
]).SetClass("rounded-full p-0 flex-shrink-0 self-center")
private BuildComponent(title: BaseUIElement, content: BaseUIElement): BaseUIElement {
const returnToTheMap = new Combine([
Svg.back_svg().SetClass("block md:hidden w-12 h-12 p-2 svg-foreground"),
Svg.close_svg().SetClass("hidden md:block w-12 h-12 p-3 svg-foreground"),
]).SetClass("rounded-full p-0 flex-shrink-0 self-center")
returnToTheMap.onClick(() => {
this.isShown.setData(false)
@ -117,24 +125,28 @@ export default class ScrollableFullScreen extends UIElement {
})
title = new Title(title, 2)
title.SetClass("text-l sm:text-xl md:text-2xl w-full p-0 max-h-20vh overflow-y-auto self-center")
const contentWrapper = new Combine([content])
.SetClass("block p-2 md:pt-4 w-full h-full overflow-y-auto desktop:max-h-65vh")
this._resetScrollSignal.addCallback(_ => {
contentWrapper.ScrollToTop();
title.SetClass(
"text-l sm:text-xl md:text-2xl w-full p-0 max-h-20vh overflow-y-auto self-center"
)
const contentWrapper = new Combine([content]).SetClass(
"block p-2 md:pt-4 w-full h-full overflow-y-auto desktop:max-h-65vh"
)
this._resetScrollSignal.addCallback((_) => {
contentWrapper.ScrollToTop()
})
return new Combine([
return new Combine([
new Combine([
new Combine([returnToTheMap, title])
.SetClass("border-b-1 border-black shadow bg-white flex flex-shrink-0 pt-1 pb-1 md:pt-0 md:pb-0"),
contentWrapper ,
new Combine([returnToTheMap, title]).SetClass(
"border-b-1 border-black shadow bg-white flex flex-shrink-0 pt-1 pb-1 md:pt-0 md:pb-0"
),
contentWrapper,
// We add an ornament which takes around 5em. This is in order to make sure the Web UI doesn't hide
]).SetClass("flex flex-col h-full relative bg-white")
]).SetClass("fixed top-0 left-0 right-0 h-screen w-screen desktop:max-h-65vh md:w-auto md:relative z-above-controls md:rounded-xl overflow-hidden");
]).SetClass("flex flex-col h-full relative bg-white"),
]).SetClass(
"fixed top-0 left-0 right-0 h-screen w-screen desktop:max-h-65vh md:w-auto md:relative z-above-controls md:rounded-xl overflow-hidden"
)
}
}
}

View file

@ -1,88 +1,85 @@
import Translations from "../i18n/Translations";
import Combine from "./Combine";
import BaseUIElement from "../BaseUIElement";
import Link from "./Link";
import Img from "./Img";
import {Store, UIEventSource} from "../../Logic/UIEventSource";
import {UIElement} from "../UIElement";
import {VariableUiElement} from "./VariableUIElement";
import Lazy from "./Lazy";
import Loading from "./Loading";
import Translations from "../i18n/Translations"
import Combine from "./Combine"
import BaseUIElement from "../BaseUIElement"
import Link from "./Link"
import Img from "./Img"
import { Store, UIEventSource } from "../../Logic/UIEventSource"
import { UIElement } from "../UIElement"
import { VariableUiElement } from "./VariableUIElement"
import Lazy from "./Lazy"
import Loading from "./Loading"
export class SubtleButton extends UIElement {
private readonly imageUrl: string | BaseUIElement;
private readonly message: string | BaseUIElement;
private readonly options: { url?: string | Store<string>; newTab?: boolean ; imgSize?: string};
private readonly imageUrl: string | BaseUIElement
private readonly message: string | BaseUIElement
private readonly options: { url?: string | Store<string>; newTab?: boolean; imgSize?: string }
constructor(imageUrl: string | BaseUIElement, message: string | BaseUIElement, options: {
url?: string | Store<string>,
newTab?: boolean,
imgSize?: "h-11 w-11" | string
} = undefined) {
super();
this.imageUrl = imageUrl;
this.message = message;
this.options = options;
constructor(
imageUrl: string | BaseUIElement,
message: string | BaseUIElement,
options: {
url?: string | Store<string>
newTab?: boolean
imgSize?: "h-11 w-11" | string
} = undefined
) {
super()
this.imageUrl = imageUrl
this.message = message
this.options = options
}
protected InnerRender(): string | BaseUIElement {
const classes = "block flex p-3 my-2 bg-subtle rounded-lg hover:shadow-xl hover:bg-unsubtle transition-colors transition-shadow link-no-underline";
const message = Translations.W(this.message)?.SetClass("block overflow-ellipsis no-images flex-shrink");
let img;
const imgClasses = "block justify-center flex-none mr-4 " + (this.options?.imgSize ?? "h-11 w-11")
const classes =
"block flex p-3 my-2 bg-subtle rounded-lg hover:shadow-xl hover:bg-unsubtle transition-colors transition-shadow link-no-underline"
const message = Translations.W(this.message)?.SetClass(
"block text-ellipsis no-images flex-shrink"
)
let img
const imgClasses =
"block justify-center flex-none mr-4 " + (this.options?.imgSize ?? "h-11 w-11")
if ((this.imageUrl ?? "") === "") {
img = undefined;
} else if (typeof (this.imageUrl) === "string") {
img = undefined
} else if (typeof this.imageUrl === "string") {
img = new Img(this.imageUrl)?.SetClass(imgClasses)
} else {
img = this.imageUrl?.SetClass(imgClasses);
img = this.imageUrl?.SetClass(imgClasses)
}
const button = new Combine([
img,
message
]).SetClass("flex items-center group w-full")
const button = new Combine([img, message]).SetClass("flex items-center group w-full")
if (this.options?.url == undefined) {
this.SetClass(classes)
return button
}
return new Link(
button,
this.options.url,
this.options.newTab ?? false
).SetClass(classes)
return new Link(button, this.options.url, this.options.newTab ?? false).SetClass(classes)
}
public OnClickWithLoading(
loadingText: BaseUIElement | string,
action: () => Promise<void> ) : BaseUIElement{
action: () => Promise<void>
): BaseUIElement {
const state = new UIEventSource<"idle" | "running">("idle")
const button = this;
button.onClick(async() => {
const button = this
button.onClick(async () => {
state.setData("running")
try{
await action()
}catch(e){
try {
await action()
} catch (e) {
console.error(e)
}finally {
} finally {
state.setData("idle")
}
})
const loading = new Lazy(() => new Loading(loadingText) )
return new VariableUiElement(state.map(st => {
if(st === "idle"){
return button
}
return loading
}))
const loading = new Lazy(() => new Loading(loadingText))
return new VariableUiElement(
state.map((st) => {
if (st === "idle") {
return button
}
return loading
})
)
}
}
}

View file

@ -1,26 +1,29 @@
import Translations from "../i18n/Translations";
import {UIEventSource} from "../../Logic/UIEventSource";
import Combine from "./Combine";
import BaseUIElement from "../BaseUIElement";
import {VariableUiElement} from "./VariableUIElement";
import Translations from "../i18n/Translations"
import { UIEventSource } from "../../Logic/UIEventSource"
import Combine from "./Combine"
import BaseUIElement from "../BaseUIElement"
import { VariableUiElement } from "./VariableUIElement"
export class TabbedComponent extends Combine {
constructor(elements: { header: BaseUIElement | string, content: BaseUIElement | string }[],
openedTab: (UIEventSource<number> | number) = 0,
options?: {
leftOfHeader?: BaseUIElement
styleHeader?: (header: BaseUIElement) => void
}) {
const openedTabSrc = typeof (openedTab) === "number" ? new UIEventSource(openedTab) : (openedTab ?? new UIEventSource<number>(0))
constructor(
elements: { header: BaseUIElement | string; content: BaseUIElement | string }[],
openedTab: UIEventSource<number> | number = 0,
options?: {
leftOfHeader?: BaseUIElement
styleHeader?: (header: BaseUIElement) => void
}
) {
const openedTabSrc =
typeof openedTab === "number"
? new UIEventSource(openedTab)
: openedTab ?? new UIEventSource<number>(0)
const tabs: BaseUIElement[] = [options?.leftOfHeader]
const contentElements: BaseUIElement[] = [];
const contentElements: BaseUIElement[] = []
for (let i = 0; i < elements.length; i++) {
let element = elements[i];
let element = elements[i]
const header = Translations.W(element.header).onClick(() => openedTabSrc.setData(i))
openedTabSrc.addCallbackAndRun(selected => {
openedTabSrc.addCallbackAndRun((selected) => {
if (selected >= elements.length) {
selected = 0
}
@ -34,7 +37,7 @@ export class TabbedComponent extends Combine {
})
const content = Translations.W(element.content)
content.SetClass("relative w-full inline-block")
contentElements.push(content);
contentElements.push(content)
const tab = header.SetClass("block tab-single-header")
tabs.push(tab)
}
@ -44,10 +47,8 @@ export class TabbedComponent extends Combine {
options.styleHeader(header)
}
const actualContent = new VariableUiElement(
openedTabSrc.map(i => contentElements[i])
openedTabSrc.map((i) => contentElements[i])
).SetStyle("max-height: inherit; height: inherit")
super([header, actualContent])
}
}
}

View file

@ -1,34 +1,36 @@
import BaseUIElement from "../BaseUIElement";
import {Utils} from "../../Utils";
import Translations from "../i18n/Translations";
import {UIEventSource} from "../../Logic/UIEventSource";
import BaseUIElement from "../BaseUIElement"
import { Utils } from "../../Utils"
import Translations from "../i18n/Translations"
import { UIEventSource } from "../../Logic/UIEventSource"
export default class Table extends BaseUIElement {
private readonly _header: BaseUIElement[]
private readonly _contents: BaseUIElement[][]
private readonly _contentStyle: string[][]
private readonly _sortable: boolean
private readonly _header: BaseUIElement[];
private readonly _contents: BaseUIElement[][];
private readonly _contentStyle: string[][];
private readonly _sortable: boolean;
constructor(header: (BaseUIElement | string)[],
contents: (BaseUIElement | string)[][],
options?: {
contentStyle?: string[][],
sortable?: false | boolean
}) {
super();
this._contentStyle = options?.contentStyle ?? [["min-width: 9rem"]];
this._header = header?.map(Translations.W);
this._contents = contents.map(row => row.map(Translations.W));
constructor(
header: (BaseUIElement | string)[],
contents: (BaseUIElement | string)[][],
options?: {
contentStyle?: string[][]
sortable?: false | boolean
}
) {
super()
this._contentStyle = options?.contentStyle ?? [["min-width: 9rem"]]
this._header = header?.map(Translations.W)
this._contents = contents.map((row) => row.map(Translations.W))
this._sortable = options?.sortable ?? false
}
AsMarkdown(): string {
const headerMarkdownParts = this._header.map(hel => hel?.AsMarkdown() ?? " ")
const header = headerMarkdownParts.join(" | ");
const headerSep = headerMarkdownParts.map(part => '-'.repeat(part.length + 2)).join(" | ")
const table = this._contents.map(row => row.map(el => el.AsMarkdown() ?? " ").join(" | ")).join("\n")
const headerMarkdownParts = this._header.map((hel) => hel?.AsMarkdown() ?? " ")
const header = headerMarkdownParts.join(" | ")
const headerSep = headerMarkdownParts.map((part) => "-".repeat(part.length + 2)).join(" | ")
const table = this._contents
.map((row) => row.map((el) => el.AsMarkdown() ?? " ").join(" | "))
.join("\n")
return "\n\n" + [header, headerSep, table, ""].join("\n")
}
@ -37,58 +39,61 @@ export default class Table extends BaseUIElement {
const table = document.createElement("table")
/**
* Sortmode: i: sort column i ascending;
* Sortmode: i: sort column i ascending;
* if i is negative : sort column (-i - 1) descending
*/
const sortmode = new UIEventSource<number>(undefined);
const self = this;
const headerElems = Utils.NoNull((this._header ?? []).map((elem, i) => {
if(self._sortable){
elem.onClick(() => {
const current = sortmode.data
if(current == i){
sortmode.setData(- 1 - i )
}else{
sortmode.setData(i)
}
})
}
return elem.ConstructElement();
}))
const sortmode = new UIEventSource<number>(undefined)
const self = this
const headerElems = Utils.NoNull(
(this._header ?? []).map((elem, i) => {
if (self._sortable) {
elem.onClick(() => {
const current = sortmode.data
if (current == i) {
sortmode.setData(-1 - i)
} else {
sortmode.setData(i)
}
})
}
return elem.ConstructElement()
})
)
if (headerElems.length > 0) {
const thead = document.createElement("thead")
const tr = document.createElement("tr");
headerElems.forEach(headerElem => {
const tr = document.createElement("tr")
headerElems.forEach((headerElem) => {
const td = document.createElement("th")
td.appendChild(headerElem)
tr.appendChild(td)
})
thead.appendChild(tr)
table.appendChild(thead)
}
for (let i = 0; i < this._contents.length; i++) {
let row = this._contents[i];
let row = this._contents[i]
const tr = document.createElement("tr")
for (let j = 0; j < row.length; j++) {
try {
let elem = row[j];
let elem = row[j]
const htmlElem = elem?.ConstructElement()
if (htmlElem === undefined) {
continue;
continue
}
let style = undefined;
if (this._contentStyle !== undefined && this._contentStyle[i] !== undefined && this._contentStyle[j] !== undefined) {
let style = undefined
if (
this._contentStyle !== undefined &&
this._contentStyle[i] !== undefined &&
this._contentStyle[j] !== undefined
) {
style = this._contentStyle[i][j]
}
const td = document.createElement("td")
td.style.cssText = style;
td.style.cssText = style
td.appendChild(htmlElem)
tr.appendChild(td)
} catch (e) {
@ -97,33 +102,31 @@ export default class Table extends BaseUIElement {
}
table.appendChild(tr)
}
sortmode.addCallback(sortCol => {
if(sortCol === undefined){
sortmode.addCallback((sortCol) => {
if (sortCol === undefined) {
return
}
const descending = sortCol < 0
const col = descending ? - sortCol - 1: sortCol;
const col = descending ? -sortCol - 1 : sortCol
let rows: HTMLTableRowElement[] = Array.from(table.rows)
rows.splice(0,1) // remove header row
rows.splice(0, 1) // remove header row
rows = rows.sort((a, b) => {
const ac = a.cells[col]?.textContent?.toLowerCase()
const bc = b.cells[col]?.textContent?.toLowerCase()
if(ac === bc){
if (ac === bc) {
return 0
}
return( ac < bc !== descending) ? -1 : 1;
return ac < bc !== descending ? -1 : 1
})
for (let j = rows.length ; j > 1; j--) {
for (let j = rows.length; j > 1; j--) {
table.deleteRow(j)
}
for (const row of rows) {
table.appendChild(row)
}
})
return table;
return table
}
}
}

View file

@ -1,21 +1,23 @@
import Combine from "./Combine";
import BaseUIElement from "../BaseUIElement";
import {Translation} from "../i18n/Translation";
import {FixedUiElement} from "./FixedUiElement";
import Title from "./Title";
import List from "./List";
import Hash from "../../Logic/Web/Hash";
import Link from "./Link";
import {Utils} from "../../Utils";
import Combine from "./Combine"
import BaseUIElement from "../BaseUIElement"
import { Translation } from "../i18n/Translation"
import { FixedUiElement } from "./FixedUiElement"
import Title from "./Title"
import List from "./List"
import Hash from "../../Logic/Web/Hash"
import Link from "./Link"
import { Utils } from "../../Utils"
export default class TableOfContents extends Combine {
private readonly titles: Title[]
constructor(elements: Combine | Title[], options?: {
noTopLevel: false | boolean,
maxDepth?: number
}) {
constructor(
elements: Combine | Title[],
options?: {
noTopLevel: false | boolean
maxDepth?: number
}
) {
let titles: Title[]
if (elements instanceof Combine) {
titles = TableOfContents.getTitles(elements.getElements()) ?? []
@ -23,7 +25,7 @@ export default class TableOfContents extends Combine {
titles = elements ?? []
}
let els: { level: number, content: BaseUIElement }[] = []
let els: { level: number; content: BaseUIElement }[] = []
for (const title of titles) {
let content: BaseUIElement
if (title.title instanceof Translation) {
@ -41,29 +43,27 @@ export default class TableOfContents extends Combine {
const vis = new Link(content, "#" + title.id)
Hash.hash.addCallbackAndRun(h => {
Hash.hash.addCallbackAndRun((h) => {
if (h === title.id) {
vis.SetClass("font-bold")
} else {
vis.RemoveClass("font-bold")
}
})
els.push({level: title.level, content: vis})
els.push({ level: title.level, content: vis })
}
const minLevel = Math.min(...els.map(e => e.level))
const minLevel = Math.min(...els.map((e) => e.level))
if (options?.noTopLevel) {
els = els.filter(e => e.level !== minLevel)
els = els.filter((e) => e.level !== minLevel)
}
if (options?.maxDepth) {
els = els.filter(e => e.level <= (options.maxDepth + minLevel))
els = els.filter((e) => e.level <= options.maxDepth + minLevel)
}
super(TableOfContents.mergeLevel(els).map(el => el.SetClass("mt-2")));
super(TableOfContents.mergeLevel(els).map((el) => el.SetClass("mt-2")))
this.SetClass("flex flex-col")
this.titles = titles;
this.titles = titles
}
private static getTitles(elements: BaseUIElement[]): Title[] {
@ -78,13 +78,15 @@ export default class TableOfContents extends Combine {
return titles
}
private static mergeLevel(elements: { level: number, content: BaseUIElement }[]): BaseUIElement[] {
const maxLevel = Math.max(...elements.map(e => e.level))
const minLevel = Math.min(...elements.map(e => e.level))
private static mergeLevel(
elements: { level: number; content: BaseUIElement }[]
): BaseUIElement[] {
const maxLevel = Math.max(...elements.map((e) => e.level))
const minLevel = Math.min(...elements.map((e) => e.level))
if (maxLevel === minLevel) {
return elements.map(e => e.content)
return elements.map((e) => e.content)
}
const result: { level: number, content: BaseUIElement } [] = []
const result: { level: number; content: BaseUIElement }[] = []
let running: BaseUIElement[] = []
for (const element of elements) {
if (element.level === maxLevel) {
@ -94,7 +96,7 @@ export default class TableOfContents extends Combine {
if (running.length !== undefined) {
result.push({
content: new List(running),
level: maxLevel - 1
level: maxLevel - 1,
})
running = []
}
@ -103,7 +105,7 @@ export default class TableOfContents extends Combine {
if (running.length !== undefined) {
result.push({
content: new List(running),
level: maxLevel - 1
level: maxLevel - 1,
})
}
@ -112,8 +114,8 @@ export default class TableOfContents extends Combine {
AsMarkdown(): string {
const depthIcons = ["1.", " -", " +", " *"]
const lines = ["## Table of contents\n"];
const minLevel = Math.min(...this.titles.map(t => t.level))
const lines = ["## Table of contents\n"]
const minLevel = Math.min(...this.titles.map((t) => t.level))
for (const title of this.titles) {
const prefix = depthIcons[title.level - minLevel] ?? " ~"
const text = title.title.AsMarkdown().replace("\n", "")
@ -123,4 +125,4 @@ export default class TableOfContents extends Combine {
return lines.join("\n") + "\n\n"
}
}
}

View file

@ -1,11 +1,17 @@
import BaseUIElement from "../BaseUIElement";
import {FixedUiElement} from "./FixedUiElement";
import {Utils} from "../../Utils";
import BaseUIElement from "../BaseUIElement"
import { FixedUiElement } from "./FixedUiElement"
import { Utils } from "../../Utils"
export default class Title extends BaseUIElement {
private static readonly defaultClassesPerLevel = ["", "text-3xl font-bold", "text-2xl font-bold", "text-xl font-bold", "text-lg font-bold"]
public readonly title: BaseUIElement;
public readonly level: number;
private static readonly defaultClassesPerLevel = [
"",
"text-3xl font-bold",
"text-2xl font-bold",
"text-xl font-bold",
"text-lg font-bold",
]
public readonly title: BaseUIElement
public readonly level: number
public readonly id: string
constructor(embedded: string | BaseUIElement, level: number = 3) {
@ -18,9 +24,9 @@ export default class Title extends BaseUIElement {
} else {
this.title = embedded
}
this.level = level;
this.level = level
let text: string = undefined;
let text: string = undefined
if (typeof embedded === "string") {
text = embedded
} else if (embedded instanceof FixedUiElement) {
@ -31,14 +37,16 @@ export default class Title extends BaseUIElement {
}
}
this.id = text?.replace(/ /g, '-')
?.replace(/[?#.;:/]/, "")
?.toLowerCase() ?? ""
this.id =
text
?.replace(/ /g, "-")
?.replace(/[?#.;:/]/, "")
?.toLowerCase() ?? ""
this.SetClass(Title.defaultClassesPerLevel[level] ?? "")
}
AsMarkdown(): string {
const embedded = " " + this.title.AsMarkdown() + " ";
const embedded = " " + this.title.AsMarkdown() + " "
if (this.level == 1) {
return "\n\n" + embedded + "\n" + "=".repeat(embedded.length) + "\n\n"
@ -48,17 +56,17 @@ export default class Title extends BaseUIElement {
return "\n\n" + embedded + "\n" + "-".repeat(embedded.length) + "\n\n"
}
return "\n\n" + "#".repeat(this.level) + embedded + "\n\n";
return "\n\n" + "#".repeat(this.level) + embedded + "\n\n"
}
protected InnerConstructElement(): HTMLElement {
const el = this.title.ConstructElement()
if (el === undefined) {
return undefined;
return undefined
}
const h = document.createElement("h" + this.level)
h.appendChild(el)
el.id = this.id
return h;
return h
}
}
}

View file

@ -1,34 +1,36 @@
import BaseUIElement from "../BaseUIElement";
import {UIEventSource} from "../../Logic/UIEventSource";
import Combine from "./Combine";
import Title from "./Title";
import Hash from "../../Logic/Web/Hash";
import BaseUIElement from "../BaseUIElement"
import { UIEventSource } from "../../Logic/UIEventSource"
import Combine from "./Combine"
import Title from "./Title"
import Hash from "../../Logic/Web/Hash"
export class Accordeon extends Combine {
constructor(toggles: Toggleable[]) {
for (const el of toggles) {
el.isVisible.addCallbackAndRun(isVisible => toggles.forEach(toggle => {
if (toggle !== el && isVisible) {
toggle.isVisible.setData(false)
}
}))
el.isVisible.addCallbackAndRun((isVisible) =>
toggles.forEach((toggle) => {
if (toggle !== el && isVisible) {
toggle.isVisible.setData(false)
}
})
)
}
super(toggles);
super(toggles)
}
}
export default class Toggleable extends Combine {
public readonly isVisible = new UIEventSource(false)
constructor(title: Title | Combine | BaseUIElement, content: BaseUIElement, options?: {
closeOnClick?: true | boolean,
height?: "100vh" | string
}) {
constructor(
title: Title | Combine | BaseUIElement,
content: BaseUIElement,
options?: {
closeOnClick?: true | boolean
height?: "100vh" | string
}
) {
super([title, content])
content.SetClass("animate-height border-l-4 pl-2 block")
title.SetClass("background-subtle rounded-lg")
@ -47,14 +49,14 @@ export default class Toggleable extends Combine {
if (title instanceof Combine) {
for (const el of title.getElements()) {
if (el instanceof Title) {
title = el;
break;
title = el
break
}
}
}
if (title instanceof Title) {
Hash.hash.addCallbackAndRun(h => {
Hash.hash.addCallbackAndRun((h) => {
if (h === (<Title>title).id) {
self.isVisible.setData(true)
content.RemoveClass("border-gray-300")
@ -64,20 +66,21 @@ export default class Toggleable extends Combine {
content.RemoveClass("border-red-300")
}
})
this.isVisible.addCallbackAndRun(isVis => {
this.isVisible.addCallbackAndRun((isVis) => {
if (isVis) {
Hash.hash.setData((<Title>title).id)
}
})
}
this.isVisible.addCallbackAndRun(isVisible => {
this.isVisible.addCallbackAndRun((isVisible) => {
if (isVisible) {
contentElement.style.maxHeight = options?.height ?? "100vh"
contentElement.style.overflowY = "auto"
contentElement.style["-webkit-mask-image"] = "unset"
} else {
contentElement.style["-webkit-mask-image"] = "-webkit-gradient(linear, left top, left bottom, from(rgba(0,0,0,1)), to(rgba(0,0,0,0)))"
contentElement.style["-webkit-mask-image"] =
"-webkit-gradient(linear, left top, left bottom, from(rgba(0,0,0,1)), to(rgba(0,0,0,0)))"
contentElement.style.maxHeight = "2rem"
}
})
@ -85,7 +88,6 @@ export default class Toggleable extends Combine {
public Collapse(): Toggleable {
this.isVisible.setData(false)
return this;
return this
}
}
}

View file

@ -1,24 +1,24 @@
import {Store} from "../../Logic/UIEventSource";
import BaseUIElement from "../BaseUIElement";
import Combine from "./Combine";
import { Store } from "../../Logic/UIEventSource"
import BaseUIElement from "../BaseUIElement"
import Combine from "./Combine"
export class VariableUiElement extends BaseUIElement {
private readonly _contents?: Store<string | BaseUIElement | BaseUIElement[]>;
private readonly _contents?: Store<string | BaseUIElement | BaseUIElement[]>
constructor(contents?: Store<string | BaseUIElement | BaseUIElement[]>) {
super();
this._contents = contents;
super()
this._contents = contents
}
Destroy() {
super.Destroy();
this.isDestroyed = true;
super.Destroy()
this.isDestroyed = true
}
AsMarkdown(): string {
const d = this._contents?.data;
const d = this._contents?.data
if (typeof d === "string") {
return d;
return d
}
if (d instanceof BaseUIElement) {
return d.AsMarkdown()
@ -27,36 +27,36 @@ export class VariableUiElement extends BaseUIElement {
}
protected InnerConstructElement(): HTMLElement {
const el = document.createElement("span");
const self = this;
const el = document.createElement("span")
const self = this
this._contents?.addCallbackAndRun((contents) => {
if (self.isDestroyed) {
return true;
return true
}
while (el.firstChild) {
el.removeChild(el.lastChild);
el.removeChild(el.lastChild)
}
if (contents === undefined) {
return
}
if (typeof contents === "string") {
el.innerHTML = contents;
el.innerHTML = contents
} else if (contents instanceof Array) {
for (const content of contents) {
const c = content?.ConstructElement();
const c = content?.ConstructElement()
if (c !== undefined && c !== null) {
el.appendChild(c);
el.appendChild(c)
}
}
} else {
const c = contents.ConstructElement();
const c = contents.ConstructElement()
if (c !== undefined && c !== null) {
el.appendChild(c);
el.appendChild(c)
}
}
});
return el;
})
return el
}
}

View file

@ -4,38 +4,37 @@
* Assumes a read-only configuration, so it has no 'ListenTo'
*/
export default abstract class BaseUIElement {
protected _constructedHtmlElement: HTMLElement
protected isDestroyed = false
protected readonly clss: Set<string> = new Set<string>()
protected style: string
private _onClick: () => void | Promise<void>
protected _constructedHtmlElement: HTMLElement;
protected isDestroyed = false;
protected readonly clss: Set<string> = new Set<string>();
protected style: string;
private _onClick: () => void | Promise<void>;
public onClick(f: (() => void)) {
this._onClick = f;
public onClick(f: () => void) {
this._onClick = f
this.SetClass("clickable")
if (this._constructedHtmlElement !== undefined) {
this._constructedHtmlElement.onclick = f;
this._constructedHtmlElement.onclick = f
}
return this;
return this
}
AttachTo(divId: string) {
let element = document.getElementById(divId);
let element = document.getElementById(divId)
if (element === null) {
throw "SEVERE: could not attach UIElement to " + divId;
throw "SEVERE: could not attach UIElement to " + divId
}
while (element.firstChild) {
//The list is LIVE so it will re-index each call
element.removeChild(element.firstChild);
element.removeChild(element.firstChild)
}
const el = this.ConstructElement();
const el = this.ConstructElement()
if (el !== undefined) {
element.appendChild(el)
}
return this;
return this
}
public ScrollToTop() {
@ -49,34 +48,34 @@ export default abstract class BaseUIElement {
if (clss == undefined) {
return this
}
const all = clss.split(" ").map(clsName => clsName.trim());
let recordedChange = false;
const all = clss.split(" ").map((clsName) => clsName.trim())
let recordedChange = false
for (let c of all) {
c = c.trim();
c = c.trim()
if (this.clss.has(clss)) {
continue;
continue
}
if (c === undefined || c === "") {
continue;
continue
}
this.clss.add(c);
recordedChange = true;
this.clss.add(c)
recordedChange = true
}
if (recordedChange) {
this._constructedHtmlElement?.classList.add(...Array.from(this.clss));
this._constructedHtmlElement?.classList.add(...Array.from(this.clss))
}
return this;
return this
}
public RemoveClass(classes: string): BaseUIElement {
const all = classes.split(" ").map(clsName => clsName.trim());
const all = classes.split(" ").map((clsName) => clsName.trim())
for (let clss of all) {
if (this.clss.has(clss)) {
this.clss.delete(clss);
this.clss.delete(clss)
this._constructedHtmlElement?.classList.remove(clss)
}
}
return this;
return this
}
public HasClass(clss: string): boolean {
@ -84,11 +83,11 @@ export default abstract class BaseUIElement {
}
public SetStyle(style: string): BaseUIElement {
this.style = style;
this.style = style
if (this._constructedHtmlElement !== undefined) {
this._constructedHtmlElement.style.cssText = style;
this._constructedHtmlElement.style.cssText = style
}
return this;
return this
}
/**
@ -96,7 +95,7 @@ export default abstract class BaseUIElement {
*/
public ConstructElement(): HTMLElement {
if (typeof window === undefined) {
return undefined;
return undefined
}
if (this._constructedHtmlElement !== undefined) {
@ -104,13 +103,13 @@ export default abstract class BaseUIElement {
}
try {
const el = this.InnerConstructElement();
const el = this.InnerConstructElement()
if (el === undefined) {
return undefined;
return undefined
}
this._constructedHtmlElement = el;
this._constructedHtmlElement = el
const style = this.style
if (style !== undefined && style !== "") {
el.style.cssText = style
@ -119,32 +118,37 @@ export default abstract class BaseUIElement {
try {
el.classList.add(...Array.from(this.clss))
} catch (e) {
console.error("Invalid class name detected in:", Array.from(this.clss).join(" "), "\nErr msg is ", e)
console.error(
"Invalid class name detected in:",
Array.from(this.clss).join(" "),
"\nErr msg is ",
e
)
}
}
if (this._onClick !== undefined) {
const self = this;
const self = this
el.onclick = async (e) => {
// @ts-ignore
if (e.consumed) {
return;
return
}
const v = self._onClick();
if(typeof v === "object"){
const v = self._onClick()
if (typeof v === "object") {
await v
}
// @ts-ignore
e.consumed = true;
e.consumed = true
}
el.classList.add("pointer-events-none", "cursor-pointer");
el.classList.add("pointer-events-none", "cursor-pointer")
}
return el
} catch (e) {
const domExc = e as DOMException;
const domExc = e as DOMException
if (domExc) {
console.log("An exception occured", domExc.code, domExc.message, domExc.name)
console.error("An exception occured", domExc.code, domExc.message, domExc.name, domExc)
}
console.error(e)
}
@ -155,8 +159,8 @@ export default abstract class BaseUIElement {
}
public Destroy() {
this.isDestroyed = true;
this.isDestroyed = true
}
protected abstract InnerConstructElement(): HTMLElement;
protected abstract InnerConstructElement(): HTMLElement
}

View file

@ -1,64 +1,75 @@
import {UIEventSource} from "../../Logic/UIEventSource";
import Combine from "../Base/Combine";
import Translations from "../i18n/Translations";
import {VariableUiElement} from "../Base/VariableUIElement";
import FilteredLayer from "../../Models/FilteredLayer";
import {TagUtils} from "../../Logic/Tags/TagUtils";
import Svg from "../../Svg";
import { UIEventSource } from "../../Logic/UIEventSource"
import Combine from "../Base/Combine"
import Translations from "../i18n/Translations"
import { VariableUiElement } from "../Base/VariableUIElement"
import FilteredLayer from "../../Models/FilteredLayer"
import { TagUtils } from "../../Logic/Tags/TagUtils"
import Svg from "../../Svg"
/**
* The icon with the 'plus'-sign and the preset icons spinning
*
*
*/
export default class AddNewMarker extends Combine {
constructor(filteredLayers: UIEventSource<FilteredLayer[]>) {
const icons = new VariableUiElement(filteredLayers.map(filteredLayers => {
const icons = []
let last = undefined;
for (const filteredLayer of filteredLayers) {
const layer = filteredLayer.layerDef;
if(layer.name === undefined && !filteredLayer.isDisplayed.data){
continue
}
for (const preset of filteredLayer.layerDef.presets) {
const tags = TagUtils.KVtoProperties(preset.tags)
const icon = layer.mapRendering[0].GenerateLeafletStyle(new UIEventSource<any>(tags), false).html
.SetClass("block relative")
.SetStyle("width: 42px; height: 42px;");
icons.push(icon)
if (last === undefined) {
last = layer.mapRendering[0].GenerateLeafletStyle(new UIEventSource<any>(tags), false).html
.SetClass("block relative")
.SetStyle("width: 42px; height: 42px;");
const icons = new VariableUiElement(
filteredLayers.map((filteredLayers) => {
const icons = []
let last = undefined
for (const filteredLayer of filteredLayers) {
const layer = filteredLayer.layerDef
if (layer.name === undefined && !filteredLayer.isDisplayed.data) {
continue
}
for (const preset of filteredLayer.layerDef.presets) {
const tags = TagUtils.KVtoProperties(preset.tags)
const icon = layer.mapRendering[0]
.GenerateLeafletStyle(new UIEventSource<any>(tags), false)
.html.SetClass("block relative")
.SetStyle("width: 42px; height: 42px;")
icons.push(icon)
if (last === undefined) {
last = layer.mapRendering[0]
.GenerateLeafletStyle(new UIEventSource<any>(tags), false)
.html.SetClass("block relative")
.SetStyle("width: 42px; height: 42px;")
}
}
}
}
if (icons.length === 0) {
return undefined
}
if (icons.length === 1) {
return icons[0]
}
icons.push(last)
const elem = new Combine(icons).SetClass("flex")
elem.SetClass("slide min-w-min").SetStyle("animation: slide " + icons.length + "s linear infinite;")
return elem;
}))
const label = Translations.t.general.add.addNewMapLabel.Clone()
.SetClass("block center absolute text-sm min-w-min pl-1 pr-1 bg-gray-400 rounded-3xl text-white opacity-65 whitespace-nowrap")
if (icons.length === 0) {
return undefined
}
if (icons.length === 1) {
return icons[0]
}
icons.push(last)
const elem = new Combine(icons).SetClass("flex")
elem.SetClass("slide min-w-min").SetStyle(
"animation: slide " + icons.length + "s linear infinite;"
)
return elem
})
)
const label = Translations.t.general.add.addNewMapLabel
.Clone()
.SetClass(
"block center absolute text-sm min-w-min pl-1 pr-1 bg-gray-400 rounded-3xl text-white opacity-65 whitespace-nowrap"
)
.SetStyle("top: 65px; transform: translateX(-50%)")
super([
new Combine([
Svg.add_pin_svg().SetClass("absolute").SetStyle("width: 50px; filter: drop-shadow(grey 0 0 10px"),
Svg.add_pin_svg()
.SetClass("absolute")
.SetStyle("width: 50px; filter: drop-shadow(grey 0 0 10px"),
new Combine([icons])
.SetStyle("width: 50px")
.SetClass("absolute p-1 rounded-full overflow-hidden"),
Svg.addSmall_svg().SetClass("absolute animate-pulse").SetStyle("width: 30px; left: 30px; top: 35px;")
Svg.addSmall_svg()
.SetClass("absolute animate-pulse")
.SetStyle("width: 30px; left: 30px; top: 35px;"),
]).SetClass("absolute"),
new Combine([label]).SetStyle("position: absolute; left: 50%")
new Combine([label]).SetStyle("position: absolute; left: 50%"),
])
this.SetClass("block relative");
this.SetClass("block relative")
}
}
}

View file

@ -1,88 +1,86 @@
import Combine from "../Base/Combine";
import ScrollableFullScreen from "../Base/ScrollableFullScreen";
import Translations from "../i18n/Translations";
import {UIEventSource} from "../../Logic/UIEventSource";
import BaseUIElement from "../BaseUIElement";
import Toggle from "../Input/Toggle";
import {DownloadPanel} from "./DownloadPanel";
import {SubtleButton} from "../Base/SubtleButton";
import Svg from "../../Svg";
import ExportPDF from "../ExportPDF";
import FilteredLayer from "../../Models/FilteredLayer";
import FeaturePipeline from "../../Logic/FeatureSource/FeaturePipeline";
import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig";
import {BBox} from "../../Logic/BBox";
import BaseLayer from "../../Models/BaseLayer";
import Loc from "../../Models/Loc";
import Combine from "../Base/Combine"
import ScrollableFullScreen from "../Base/ScrollableFullScreen"
import Translations from "../i18n/Translations"
import { UIEventSource } from "../../Logic/UIEventSource"
import BaseUIElement from "../BaseUIElement"
import Toggle from "../Input/Toggle"
import { DownloadPanel } from "./DownloadPanel"
import { SubtleButton } from "../Base/SubtleButton"
import Svg from "../../Svg"
import ExportPDF from "../ExportPDF"
import FilteredLayer from "../../Models/FilteredLayer"
import FeaturePipeline from "../../Logic/FeatureSource/FeaturePipeline"
import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig"
import { BBox } from "../../Logic/BBox"
import BaseLayer from "../../Models/BaseLayer"
import Loc from "../../Models/Loc"
interface DownloadState {
interface DownloadState {
filteredLayers: UIEventSource<FilteredLayer[]>
featurePipeline: FeaturePipeline,
layoutToUse: LayoutConfig,
currentBounds: UIEventSource<BBox>,
backgroundLayer:UIEventSource<BaseLayer>,
locationControl: UIEventSource<Loc>,
featureSwitchExportAsPdf: UIEventSource<boolean>,
featureSwitchEnableExport: UIEventSource<boolean>,
featurePipeline: FeaturePipeline
layoutToUse: LayoutConfig
currentBounds: UIEventSource<BBox>
backgroundLayer: UIEventSource<BaseLayer>
locationControl: UIEventSource<Loc>
featureSwitchExportAsPdf: UIEventSource<boolean>
featureSwitchEnableExport: UIEventSource<boolean>
}
export default class AllDownloads extends ScrollableFullScreen {
constructor(isShown: UIEventSource<boolean>,state: {
filteredLayers: UIEventSource<FilteredLayer[]>
featurePipeline: FeaturePipeline,
layoutToUse: LayoutConfig,
currentBounds: UIEventSource<BBox>,
backgroundLayer:UIEventSource<BaseLayer>,
locationControl: UIEventSource<Loc>,
featureSwitchExportAsPdf: UIEventSource<boolean>,
featureSwitchEnableExport: UIEventSource<boolean>,
}) {
super(AllDownloads.GenTitle, () => AllDownloads.GeneratePanel(state), "downloads", isShown);
constructor(
isShown: UIEventSource<boolean>,
state: {
filteredLayers: UIEventSource<FilteredLayer[]>
featurePipeline: FeaturePipeline
layoutToUse: LayoutConfig
currentBounds: UIEventSource<BBox>
backgroundLayer: UIEventSource<BaseLayer>
locationControl: UIEventSource<Loc>
featureSwitchExportAsPdf: UIEventSource<boolean>
featureSwitchEnableExport: UIEventSource<boolean>
}
) {
super(AllDownloads.GenTitle, () => AllDownloads.GeneratePanel(state), "downloads", isShown)
}
private static GenTitle(): BaseUIElement {
return Translations.t.general.download.title
.Clone()
.SetClass("text-2xl break-words font-bold p-2");
.SetClass("text-2xl break-words font-bold p-2")
}
private static GeneratePanel(state: DownloadState): BaseUIElement {
const isExporting = new UIEventSource(false, "Pdf-is-exporting")
const generatePdf = () => {
isExporting.setData(true)
new ExportPDF(
{
freeDivId: "belowmap",
background: state.backgroundLayer,
location: state.locationControl,
features: state.featurePipeline,
layout: state.layoutToUse,
}).isRunning.addCallbackAndRun(isRunning => isExporting.setData(isRunning))
new ExportPDF({
freeDivId: "belowmap",
background: state.backgroundLayer,
location: state.locationControl,
features: state.featurePipeline,
layout: state.layoutToUse,
}).isRunning.addCallbackAndRun((isRunning) => isExporting.setData(isRunning))
}
const loading = Svg.loading_svg().SetClass("animate-rotate");
const loading = Svg.loading_svg().SetClass("animate-rotate")
const dloadTrans = Translations.t.general.download
const icon = new Toggle(loading, Svg.floppy_ui(), isExporting);
const icon = new Toggle(loading, Svg.floppy_ui(), isExporting)
const text = new Toggle(
dloadTrans.exporting.Clone(),
new Combine([
dloadTrans.downloadAsPdf.Clone().SetClass("font-bold"),
dloadTrans.downloadAsPdfHelper.Clone()]
).SetClass("flex flex-col")
dloadTrans.downloadAsPdfHelper.Clone(),
])
.SetClass("flex flex-col")
.onClick(() => {
generatePdf()
}),
isExporting);
isExporting
)
const pdf = new Toggle(
new SubtleButton(
icon,
text),
new SubtleButton(icon, text),
undefined,
state.featureSwitchExportAsPdf
@ -94,6 +92,6 @@ export default class AllDownloads extends ScrollableFullScreen {
state.featureSwitchEnableExport
)
return new Combine([pdf, exportPanel]).SetClass("flex flex-col");
return new Combine([pdf, exportPanel]).SetClass("flex flex-col")
}
}

View file

@ -1,64 +1,85 @@
import Link from "../Base/Link";
import Svg from "../../Svg";
import Combine from "../Base/Combine";
import {UIEventSource} from "../../Logic/UIEventSource";
import UserDetails from "../../Logic/Osm/OsmConnection";
import Constants from "../../Models/Constants";
import Loc from "../../Models/Loc";
import {VariableUiElement} from "../Base/VariableUIElement";
import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig";
import {BBox} from "../../Logic/BBox";
import {Utils} from "../../Utils";
import Link from "../Base/Link"
import Svg from "../../Svg"
import Combine from "../Base/Combine"
import { UIEventSource } from "../../Logic/UIEventSource"
import UserDetails from "../../Logic/Osm/OsmConnection"
import Constants from "../../Models/Constants"
import Loc from "../../Models/Loc"
import { VariableUiElement } from "../Base/VariableUIElement"
import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig"
import { BBox } from "../../Logic/BBox"
import { Utils } from "../../Utils"
/**
* The bottom right attribution panel in the leaflet map
*/
export default class Attribution extends Combine {
constructor(
location: UIEventSource<Loc>,
userDetails: UIEventSource<UserDetails>,
layoutToUse: LayoutConfig,
currentBounds: UIEventSource<BBox>
) {
const mapComplete = new Link(
`Mapcomplete ${Constants.vNumber}`,
"https://github.com/pietervdvn/MapComplete",
true
)
const reportBug = new Link(
Svg.bug_ui().SetClass("small-image"),
"https://github.com/pietervdvn/MapComplete/issues",
true
)
constructor(location: UIEventSource<Loc>,
userDetails: UIEventSource<UserDetails>,
layoutToUse: LayoutConfig,
currentBounds: UIEventSource<BBox>) {
const layoutId = layoutToUse?.id
const stats = new Link(
Svg.statistics_ui().SetClass("small-image"),
Utils.OsmChaLinkFor(31, layoutId),
true
)
const mapComplete = new Link(`Mapcomplete ${Constants.vNumber}`, 'https://github.com/pietervdvn/MapComplete', true);
const reportBug = new Link(Svg.bug_ui().SetClass("small-image"), "https://github.com/pietervdvn/MapComplete/issues", true);
const layoutId = layoutToUse?.id;
const stats = new Link(Svg.statistics_ui().SetClass("small-image"), Utils.OsmChaLinkFor(31, layoutId), true)
const idLink = location.map(location => `https://www.openstreetmap.org/edit?editor=id#map=${location?.zoom ?? 0}/${location?.lat ?? 0}/${location?.lon ?? 0}`)
const idLink = location.map(
(location) =>
`https://www.openstreetmap.org/edit?editor=id#map=${location?.zoom ?? 0}/${
location?.lat ?? 0
}/${location?.lon ?? 0}`
)
const editHere = new Link(Svg.pencil_ui().SetClass("small-image"), idLink, true)
const mapillaryLink = location.map(location => `https://www.mapillary.com/app/?focus=map&lat=${location?.lat ?? 0}&lng=${location?.lon ?? 0}&z=${Math.max((location?.zoom ?? 2) - 1, 1)}`)
const mapillary = new Link(Svg.mapillary_black_ui().SetClass("small-image"), mapillaryLink, true);
const mapillaryLink = location.map(
(location) =>
`https://www.mapillary.com/app/?focus=map&lat=${location?.lat ?? 0}&lng=${
location?.lon ?? 0
}&z=${Math.max((location?.zoom ?? 2) - 1, 1)}`
)
const mapillary = new Link(
Svg.mapillary_black_ui().SetClass("small-image"),
mapillaryLink,
true
)
let editWithJosm = new VariableUiElement(
userDetails.map(userDetails => {
userDetails.map(
(userDetails) => {
if (userDetails.csCount < Constants.userJourney.tagsVisibleAndWikiLinked) {
return undefined;
return undefined
}
const bounds: any = currentBounds.data;
const bounds: any = currentBounds.data
if (bounds === undefined) {
return undefined
}
const top = bounds.getNorth();
const bottom = bounds.getSouth();
const right = bounds.getEast();
const left = bounds.getWest();
const top = bounds.getNorth()
const bottom = bounds.getSouth()
const right = bounds.getEast()
const left = bounds.getWest()
const josmLink = `http://127.0.0.1:8111/load_and_zoom?left=${left}&right=${right}&top=${top}&bottom=${bottom}`
return new Link(Svg.josm_logo_ui().SetClass("small-image"), josmLink, true);
return new Link(Svg.josm_logo_ui().SetClass("small-image"), josmLink, true)
},
[location, currentBounds]
)
)
super([mapComplete, reportBug, stats, editHere, editWithJosm, mapillary]);
super([mapComplete, reportBug, stats, editHere, editWithJosm, mapillary])
this.SetClass("flex")
}
}
}

View file

@ -1,19 +1,16 @@
import {SubtleButton} from "../Base/SubtleButton";
import Svg from "../../Svg";
import Translations from "../i18n/Translations";
import BaseUIElement from "../BaseUIElement";
import { SubtleButton } from "../Base/SubtleButton"
import Svg from "../../Svg"
import Translations from "../i18n/Translations"
import BaseUIElement from "../BaseUIElement"
export default class BackToIndex extends SubtleButton {
constructor(message?: string | BaseUIElement) {
super(
Svg.back_svg().SetStyle("height: 1.5rem;"),
message ?? Translations.t.general.backToMapcomplete,
{
url: "index.html"
url: "index.html",
}
)
}
}
}

View file

@ -1,15 +1,14 @@
import Combine from "../Base/Combine";
import {UIEventSource} from "../../Logic/UIEventSource";
import Loc from "../../Models/Loc";
import Svg from "../../Svg";
import Toggle from "../Input/Toggle";
import BaseLayer from "../../Models/BaseLayer";
import AvailableBaseLayers from "../../Logic/Actors/AvailableBaseLayers";
import BaseUIElement from "../BaseUIElement";
import {GeoOperations} from "../../Logic/GeoOperations";
import Combine from "../Base/Combine"
import { UIEventSource } from "../../Logic/UIEventSource"
import Loc from "../../Models/Loc"
import Svg from "../../Svg"
import Toggle from "../Input/Toggle"
import BaseLayer from "../../Models/BaseLayer"
import AvailableBaseLayers from "../../Logic/Actors/AvailableBaseLayers"
import BaseUIElement from "../BaseUIElement"
import { GeoOperations } from "../../Logic/GeoOperations"
class SingleLayerSelectionButton extends Toggle {
public readonly activate: () => void
/**
@ -24,38 +23,39 @@ class SingleLayerSelectionButton extends Toggle {
constructor(
locationControl: UIEventSource<Loc>,
options: {
currentBackground: UIEventSource<BaseLayer>,
preferredType: string,
preferredLayer?: BaseLayer,
currentBackground: UIEventSource<BaseLayer>
preferredType: string
preferredLayer?: BaseLayer
notAvailable?: () => void
}) {
}
) {
const prefered = options.preferredType
const previousLayer = new UIEventSource(options.preferredLayer)
const unselected = SingleLayerSelectionButton.getIconFor(prefered)
.SetClass("rounded-lg p-1 h-12 w-12 overflow-hidden subtle-background border-invisible")
const unselected = SingleLayerSelectionButton.getIconFor(prefered).SetClass(
"rounded-lg p-1 h-12 w-12 overflow-hidden subtle-background border-invisible"
)
const selected = SingleLayerSelectionButton.getIconFor(prefered)
.SetClass("rounded-lg p-1 h-12 w-12 overflow-hidden subtle-background border-attention-catch")
const selected = SingleLayerSelectionButton.getIconFor(prefered).SetClass(
"rounded-lg p-1 h-12 w-12 overflow-hidden subtle-background border-attention-catch"
)
const available = AvailableBaseLayers
.SelectBestLayerAccordingTo(locationControl, new UIEventSource<string | string[]>(options.preferredType))
const available = AvailableBaseLayers.SelectBestLayerAccordingTo(
locationControl,
new UIEventSource<string | string[]>(options.preferredType)
)
let toggle: BaseUIElement = new Toggle(
selected,
unselected,
options.currentBackground.map(bg => bg.category === options.preferredType)
options.currentBackground.map((bg) => bg.category === options.preferredType)
)
super(
toggle,
undefined,
available.map(av => av.category === options.preferredType)
);
available.map((av) => av.category === options.preferredType)
)
/**
* Checks that the previous layer is still usable on the current location.
@ -85,27 +85,29 @@ class SingleLayerSelectionButton extends Toggle {
options.currentBackground.setData(previousLayer.data)
})
options.currentBackground.addCallbackAndRunD(background => {
options.currentBackground.addCallbackAndRunD((background) => {
if (background.category === options.preferredType) {
previousLayer.setData(background)
}
})
available.addCallbackD(availableLayer => {
available.addCallbackD((availableLayer) => {
// Called whenever a better layer is available
if (previousLayer.data === undefined) {
// PreviousLayer is unset -> we definitively weren't using this category -> no need to switch
return;
return
}
if (options.currentBackground.data?.id !== previousLayer.data?.id) {
// The previously used layer doesn't match the current layer -> no need to switch
return;
return
}
// Is the previous layer still valid? If so, we don't bother to switch
if (previousLayer.data.feature === null || GeoOperations.inside(locationControl.data, previousLayer.data.feature)) {
if (
previousLayer.data.feature === null ||
GeoOperations.inside(locationControl.data, previousLayer.data.feature)
) {
return
}
@ -134,13 +136,12 @@ class SingleLayerSelectionButton extends Toggle {
// Fallback to OSM carto
options.currentBackground.setData(AvailableBaseLayers.osmCarto)
}
return;
return
}
previousLayer.setData(previousLayer.data ?? available.data)
options.currentBackground.setData(previousLayer.data)
}
}
private static getIconFor(type: string) {
@ -158,7 +159,6 @@ class SingleLayerSelectionButton extends Toggle {
}
export default class BackgroundMapSwitch extends Combine {
/**
* Three buttons to easily switch map layers between OSM, aerial and some map.
* @param state
@ -167,14 +167,13 @@ export default class BackgroundMapSwitch extends Combine {
*/
constructor(
state: {
locationControl: UIEventSource<Loc>,
locationControl: UIEventSource<Loc>
backgroundLayer: UIEventSource<BaseLayer>
},
currentBackground: UIEventSource<BaseLayer>,
options?:{
preferredCategory?: string,
options?: {
preferredCategory?: string
allowedCategories?: ("osmbasedmap" | "photo" | "map")[]
}
) {
const allowedCategories = options?.allowedCategories ?? ["osmbasedmap", "photo", "map"]
@ -188,14 +187,12 @@ export default class BackgroundMapSwitch extends Combine {
preferredLayer = previousLayer
}
const button = new SingleLayerSelectionButton(
state.locationControl,
{
preferredType: category,
preferredLayer: preferredLayer,
currentBackground: currentBackground,
notAvailable: activatePrevious
})
const button = new SingleLayerSelectionButton(state.locationControl, {
preferredType: category,
preferredLayer: preferredLayer,
currentBackground: currentBackground,
notAvailable: activatePrevious,
})
// Fall back to the first option: OSM
activatePrevious = activatePrevious ?? button.activate
if (category === options?.preferredCategory) {
@ -209,5 +206,4 @@ export default class BackgroundMapSwitch extends Combine {
super(buttons)
this.SetClass("flex")
}
}
}

View file

@ -1,41 +1,41 @@
import {DropDown} from "../Input/DropDown";
import Translations from "../i18n/Translations";
import State from "../../State";
import BaseLayer from "../../Models/BaseLayer";
import {VariableUiElement} from "../Base/VariableUIElement";
import {Store} from "../../Logic/UIEventSource";
import { DropDown } from "../Input/DropDown"
import Translations from "../i18n/Translations"
import State from "../../State"
import BaseLayer from "../../Models/BaseLayer"
import { VariableUiElement } from "../Base/VariableUIElement"
import { Store } from "../../Logic/UIEventSource"
export default class BackgroundSelector extends VariableUiElement {
constructor(state: {availableBackgroundLayers?:Store<BaseLayer[]>} ) {
const available = state.availableBackgroundLayers?.map(available => {
if(available === undefined){
constructor(state: { availableBackgroundLayers?: Store<BaseLayer[]> }) {
const available = state.availableBackgroundLayers?.map((available) => {
if (available === undefined) {
return undefined
}
const baseLayers: { value: BaseLayer, shown: string }[] = [];
for (const i in available) {
if (!available.hasOwnProperty(i)) {
continue;
}
const layer: BaseLayer = available[i];
baseLayers.push({value: layer, shown: layer.name ?? "id:" + layer.id});
const baseLayers: { value: BaseLayer; shown: string }[] = []
for (const i in available) {
if (!available.hasOwnProperty(i)) {
continue
}
return baseLayers
const layer: BaseLayer = available[i]
baseLayers.push({ value: layer, shown: layer.name ?? "id:" + layer.id })
}
)
return baseLayers
})
super(
available?.map(baseLayers => {
if (baseLayers === undefined || baseLayers.length <= 1) {
return undefined;
}
return new DropDown(Translations.t.general.backgroundMap.Clone(), baseLayers, State.state.backgroundLayer, {
select_class: 'bg-indigo-100 p-1 rounded hover:bg-indigo-200 w-full'
})
available?.map((baseLayers) => {
if (baseLayers === undefined || baseLayers.length <= 1) {
return undefined
}
)
return new DropDown(
Translations.t.general.backgroundMap.Clone(),
baseLayers,
State.state.backgroundLayer,
{
select_class: "bg-indigo-100 p-1 rounded hover:bg-indigo-200 w-full",
}
)
})
)
}
}
}

View file

@ -1,109 +1,128 @@
import Combine from "../Base/Combine";
import Translations from "../i18n/Translations";
import {Store, UIEventSource} from "../../Logic/UIEventSource";
import {FixedUiElement} from "../Base/FixedUiElement";
import Combine from "../Base/Combine"
import Translations from "../i18n/Translations"
import { Store, UIEventSource } from "../../Logic/UIEventSource"
import { FixedUiElement } from "../Base/FixedUiElement"
import * as licenses from "../../assets/generated/license_info.json"
import SmallLicense from "../../Models/smallLicense";
import {Utils} from "../../Utils";
import Link from "../Base/Link";
import {VariableUiElement} from "../Base/VariableUIElement";
import SmallLicense from "../../Models/smallLicense"
import { Utils } from "../../Utils"
import Link from "../Base/Link"
import { VariableUiElement } from "../Base/VariableUIElement"
import * as contributors from "../../assets/contributors.json"
import * as translators from "../../assets/translators.json"
import BaseUIElement from "../BaseUIElement";
import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig";
import Title from "../Base/Title";
import {SubtleButton} from "../Base/SubtleButton";
import Svg from "../../Svg";
import FeaturePipeline from "../../Logic/FeatureSource/FeaturePipeline";
import {BBox} from "../../Logic/BBox";
import Loc from "../../Models/Loc";
import Toggle from "../Input/Toggle";
import {OsmConnection} from "../../Logic/Osm/OsmConnection";
import Constants from "../../Models/Constants";
import ContributorCount from "../../Logic/ContributorCount";
import Img from "../Base/Img";
import {TypedTranslation} from "../i18n/Translation";
import TranslatorsPanel from "./TranslatorsPanel";
import {MapillaryLink} from "./MapillaryLink";
import FullWelcomePaneWithTabs from "./FullWelcomePaneWithTabs";
import BaseUIElement from "../BaseUIElement"
import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig"
import Title from "../Base/Title"
import { SubtleButton } from "../Base/SubtleButton"
import Svg from "../../Svg"
import FeaturePipeline from "../../Logic/FeatureSource/FeaturePipeline"
import { BBox } from "../../Logic/BBox"
import Loc from "../../Models/Loc"
import Toggle from "../Input/Toggle"
import { OsmConnection } from "../../Logic/Osm/OsmConnection"
import Constants from "../../Models/Constants"
import ContributorCount from "../../Logic/ContributorCount"
import Img from "../Base/Img"
import { TypedTranslation } from "../i18n/Translation"
import TranslatorsPanel from "./TranslatorsPanel"
import { MapillaryLink } from "./MapillaryLink"
import FullWelcomePaneWithTabs from "./FullWelcomePaneWithTabs"
export class OpenIdEditor extends VariableUiElement {
constructor(state: { locationControl: UIEventSource<Loc> }, iconStyle?: string, objectId?: string) {
constructor(
state: { locationControl: UIEventSource<Loc> },
iconStyle?: string,
objectId?: string
) {
const t = Translations.t.general.attribution
super(state.locationControl.map(location => {
let elementSelect = "";
if (objectId !== undefined) {
const parts = objectId.split("/")
const tp = parts[0]
if (parts.length === 2 && !isNaN(Number(parts[1])) && (tp === "node" || tp === "way" || tp === "relation")) {
elementSelect = "&" + tp + "=" + parts[1]
super(
state.locationControl.map((location) => {
let elementSelect = ""
if (objectId !== undefined) {
const parts = objectId.split("/")
const tp = parts[0]
if (
parts.length === 2 &&
!isNaN(Number(parts[1])) &&
(tp === "node" || tp === "way" || tp === "relation")
) {
elementSelect = "&" + tp + "=" + parts[1]
}
}
}
const idLink = `https://www.openstreetmap.org/edit?editor=id${elementSelect}#map=${location?.zoom ?? 0}/${location?.lat ?? 0}/${location?.lon ?? 0}`
return new SubtleButton(Svg.pencil_ui().SetStyle(iconStyle), t.editId, {url: idLink, newTab: true})
}));
const idLink = `https://www.openstreetmap.org/edit?editor=id${elementSelect}#map=${
location?.zoom ?? 0
}/${location?.lat ?? 0}/${location?.lon ?? 0}`
return new SubtleButton(Svg.pencil_ui().SetStyle(iconStyle), t.editId, {
url: idLink,
newTab: true,
})
})
)
}
}
export class OpenJosm extends Combine {
constructor(state: { osmConnection: OsmConnection, currentBounds: Store<BBox>, }, iconStyle?: string) {
constructor(
state: { osmConnection: OsmConnection; currentBounds: Store<BBox> },
iconStyle?: string
) {
const t = Translations.t.general.attribution
const josmState = new UIEventSource<string>(undefined)
// Reset after 15s
josmState.stabilized(15000).addCallbackD(_ => josmState.setData(undefined))
josmState.stabilized(15000).addCallbackD((_) => josmState.setData(undefined))
const stateIndication = new VariableUiElement(josmState.map(state => {
if (state === undefined) {
return undefined
}
state = state.toUpperCase()
if (state === "OK") {
return t.josmOpened.SetClass("thanks")
}
return t.josmNotOpened.SetClass("alert")
}));
const stateIndication = new VariableUiElement(
josmState.map((state) => {
if (state === undefined) {
return undefined
}
state = state.toUpperCase()
if (state === "OK") {
return t.josmOpened.SetClass("thanks")
}
return t.josmNotOpened.SetClass("alert")
})
)
const toggle = new Toggle(
new SubtleButton(Svg.josm_logo_ui().SetStyle(iconStyle), t.editJosm).onClick(() => {
const bounds: any = state.currentBounds.data;
const bounds: any = state.currentBounds.data
if (bounds === undefined) {
return undefined
}
const top = bounds.getNorth();
const bottom = bounds.getSouth();
const right = bounds.getEast();
const left = bounds.getWest();
const top = bounds.getNorth()
const bottom = bounds.getSouth()
const right = bounds.getEast()
const left = bounds.getWest()
const josmLink = `http://127.0.0.1:8111/load_and_zoom?left=${left}&right=${right}&top=${top}&bottom=${bottom}`
Utils.download(josmLink).then(answer => josmState.setData(answer.replace(/\n/g, '').trim())).catch(_ => josmState.setData("ERROR"))
}), undefined, state.osmConnection.userDetails.map(ud => ud.loggedIn && ud.csCount >= Constants.userJourney.historyLinkVisible))
super([stateIndication, toggle]);
Utils.download(josmLink)
.then((answer) => josmState.setData(answer.replace(/\n/g, "").trim()))
.catch((_) => josmState.setData("ERROR"))
}),
undefined,
state.osmConnection.userDetails.map(
(ud) => ud.loggedIn && ud.csCount >= Constants.userJourney.historyLinkVisible
)
)
super([stateIndication, toggle])
}
}
/**
* The attribution panel shown on mobile
*/
export default class CopyrightPanel extends Combine {
private static LicenseObject = CopyrightPanel.GenerateLicenses();
private static LicenseObject = CopyrightPanel.GenerateLicenses()
constructor(state: {
layoutToUse: LayoutConfig,
featurePipeline: FeaturePipeline,
currentBounds: Store<BBox>,
locationControl: UIEventSource<Loc>,
osmConnection: OsmConnection,
layoutToUse: LayoutConfig
featurePipeline: FeaturePipeline
currentBounds: Store<BBox>
locationControl: UIEventSource<Loc>
osmConnection: OsmConnection
isTranslator: Store<boolean>
}) {
const t = Translations.t.general.attribution
const layoutToUse = state.layoutToUse
const imgSize = "h-6 w-6"
@ -112,120 +131,134 @@ export default class CopyrightPanel extends Combine {
new SubtleButton(Svg.liberapay_ui(), t.donate, {
url: "https://liberapay.com/pietervdvn/",
newTab: true,
imgSize
imgSize,
}),
new SubtleButton(Svg.bug_ui(), t.openIssueTracker, {
url: "https://github.com/pietervdvn/MapComplete/issues",
newTab: true,
imgSize
imgSize,
}),
new SubtleButton(Svg.statistics_ui(), t.openOsmcha.Subs({theme: state.layoutToUse.title}), {
url: Utils.OsmChaLinkFor(31, state.layoutToUse.id),
newTab: true,
imgSize
}),
new SubtleButton(Svg.mastodon_ui(),
new Combine([t.followOnMastodon.SetClass("font-bold"), t.followBridge]).SetClass("flex flex-col"),
new SubtleButton(
Svg.statistics_ui(),
t.openOsmcha.Subs({ theme: state.layoutToUse.title }),
{
url:"https://en.osm.town/web/notifications",
newTab: true,
imgSize
}),
url: Utils.OsmChaLinkFor(31, state.layoutToUse.id),
newTab: true,
imgSize,
}
),
new SubtleButton(
Svg.mastodon_ui(),
new Combine([t.followOnMastodon.SetClass("font-bold"), t.followBridge]).SetClass(
"flex flex-col"
),
{
url: "https://en.osm.town/web/notifications",
newTab: true,
imgSize,
}
),
new SubtleButton(Svg.twitter_ui(), t.followOnTwitter, {
url:"https://twitter.com/mapcomplete",
url: "https://twitter.com/mapcomplete",
newTab: true,
imgSize
imgSize,
}),
new OpenIdEditor(state, iconStyle),
new MapillaryLink(state, iconStyle),
new OpenJosm(state, iconStyle),
new TranslatorsPanel(state, iconStyle)
new TranslatorsPanel(state, iconStyle),
]
const iconAttributions = layoutToUse.usedImages.map(CopyrightPanel.IconAttribution)
let maintainer: BaseUIElement = undefined
if (layoutToUse.credits !== undefined && layoutToUse.credits !== "") {
maintainer = t.themeBy.Subs({author: layoutToUse.credits})
maintainer = t.themeBy.Subs({ author: layoutToUse.credits })
}
const contributions = new ContributorCount(state).Contributors
const dataContributors = new VariableUiElement(contributions.map(contributions => {
const dataContributors = new VariableUiElement(
contributions.map((contributions) => {
if (contributions === undefined) {
return ""
}
const sorted = Array.from(contributions, ([name, value]) => ({
name,
value
})).filter(x => x.name !== undefined && x.name !== "undefined");
value,
})).filter((x) => x.name !== undefined && x.name !== "undefined")
if (sorted.length === 0) {
return "";
return ""
}
sorted.sort((a, b) => b.value - a.value);
let hiddenCount = 0;
sorted.sort((a, b) => b.value - a.value)
let hiddenCount = 0
if (sorted.length > 10) {
hiddenCount = sorted.length - 10
sorted.splice(10, sorted.length - 10)
}
const links = sorted.map(kv => `<a href="https://openstreetmap.org/user/${kv.name}" target="_blank">${kv.name}</a>`)
const links = sorted.map(
(kv) =>
`<a href="https://openstreetmap.org/user/${kv.name}" target="_blank">${kv.name}</a>`
)
const contribs = links.join(", ")
if (hiddenCount <= 0) {
return t.mapContributionsBy.Subs({
contributors: contribs
contributors: contribs,
})
} else {
return t.mapContributionsByAndHidden.Subs({
contributors: contribs,
hiddenCount: hiddenCount
});
hiddenCount: hiddenCount,
})
}
})
)
}))
super([
new Title(t.attributionTitle),
t.attributionContent,
maintainer,
dataContributors,
CopyrightPanel.CodeContributors(contributors, t.codeContributionsBy),
CopyrightPanel.CodeContributors(translators, t.translatedBy),
new FixedUiElement("MapComplete " + Constants.vNumber).SetClass("font-bold"),
new Combine(actionButtons).SetClass("block w-full link-no-underline"),
new Title(t.iconAttribution.title, 3),
...iconAttributions
].map(e => e?.SetClass("mt-4")));
super(
[
new Title(t.attributionTitle),
t.attributionContent,
maintainer,
dataContributors,
CopyrightPanel.CodeContributors(contributors, t.codeContributionsBy),
CopyrightPanel.CodeContributors(translators, t.translatedBy),
new FixedUiElement("MapComplete " + Constants.vNumber).SetClass("font-bold"),
new Combine(actionButtons).SetClass("block w-full link-no-underline"),
new Title(t.iconAttribution.title, 3),
...iconAttributions,
].map((e) => e?.SetClass("mt-4"))
)
this.SetClass("flex flex-col link-underline overflow-hidden")
this.SetStyle("max-width:100%; width: 40rem; margin-left: 0.75rem; margin-right: 0.5rem")
}
private static CodeContributors(contributors, translation: TypedTranslation<{contributors, hiddenCount}>): BaseUIElement {
const total = contributors.contributors.length;
private static CodeContributors(
contributors,
translation: TypedTranslation<{ contributors; hiddenCount }>
): BaseUIElement {
const total = contributors.contributors.length
let filtered = [...contributors.contributors]
filtered.splice(10, total - 10);
filtered.splice(10, total - 10)
let contribsStr = filtered.map(c => c.contributor).join(", ")
let contribsStr = filtered.map((c) => c.contributor).join(", ")
if (contribsStr === "") {
// Hmm, something went wrong loading the contributors list. Lets show nothing
return undefined;
return undefined
}
return translation.Subs({
contributors: contribsStr,
hiddenCount: total - 10
});
hiddenCount: total - 10,
})
}
private static IconAttribution(iconPath: string): BaseUIElement {
if (iconPath.startsWith("http")) {
try {
iconPath = "." + new URL(iconPath).pathname;
iconPath = "." + new URL(iconPath).pathname
} catch (e) {
console.warn(e)
}
@ -233,10 +266,10 @@ export default class CopyrightPanel extends Combine {
const license: SmallLicense = CopyrightPanel.LicenseObject[iconPath]
if (license == undefined) {
return undefined;
return undefined
}
if (license.license.indexOf("trivial") >= 0) {
return undefined;
return undefined
}
const sources = Utils.NoNull(Utils.NoEmpty(license.sources))
@ -246,25 +279,29 @@ export default class CopyrightPanel extends Combine {
new Combine([
new FixedUiElement(license.authors.join("; ")).SetClass("font-bold"),
license.license,
new Combine([...sources.map(lnk => {
let sourceLinkContent = lnk;
try {
sourceLinkContent = new URL(lnk).hostname
} catch {
console.error("Not a valid URL:", lnk)
}
return new Link(sourceLinkContent, lnk, true).SetClass("mr-2 mb-2");
})]).SetClass("flex flex-wrap")
]).SetClass("flex flex-col").SetStyle("width: calc(100% - 50px - 0.5em); min-width: 12rem;")
new Combine([
...sources.map((lnk) => {
let sourceLinkContent = lnk
try {
sourceLinkContent = new URL(lnk).hostname
} catch {
console.error("Not a valid URL:", lnk)
}
return new Link(sourceLinkContent, lnk, true).SetClass("mr-2 mb-2")
}),
]).SetClass("flex flex-wrap"),
])
.SetClass("flex flex-col")
.SetStyle("width: calc(100% - 50px - 0.5em); min-width: 12rem;"),
]).SetClass("flex flex-wrap border-b border-gray-300 m-2 border-box")
}
private static GenerateLicenses() {
const allLicenses = {}
for (const key in licenses) {
const license: SmallLicense = licenses[key];
const license: SmallLicense = licenses[key]
allLicenses[license.path] = license
}
return allLicenses;
return allLicenses
}
}
}

View file

@ -1,98 +1,105 @@
import {SubtleButton} from "../Base/SubtleButton";
import Svg from "../../Svg";
import Translations from "../i18n/Translations";
import State from "../../State";
import {Utils} from "../../Utils";
import Combine from "../Base/Combine";
import CheckBoxes from "../Input/Checkboxes";
import {GeoOperations} from "../../Logic/GeoOperations";
import Toggle from "../Input/Toggle";
import Title from "../Base/Title";
import FeaturePipeline from "../../Logic/FeatureSource/FeaturePipeline";
import {UIEventSource} from "../../Logic/UIEventSource";
import SimpleMetaTagger from "../../Logic/SimpleMetaTagger";
import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig";
import {BBox} from "../../Logic/BBox";
import FilteredLayer, {FilterState} from "../../Models/FilteredLayer";
import { SubtleButton } from "../Base/SubtleButton"
import Svg from "../../Svg"
import Translations from "../i18n/Translations"
import State from "../../State"
import { Utils } from "../../Utils"
import Combine from "../Base/Combine"
import CheckBoxes from "../Input/Checkboxes"
import { GeoOperations } from "../../Logic/GeoOperations"
import Toggle from "../Input/Toggle"
import Title from "../Base/Title"
import FeaturePipeline from "../../Logic/FeatureSource/FeaturePipeline"
import { UIEventSource } from "../../Logic/UIEventSource"
import SimpleMetaTagger from "../../Logic/SimpleMetaTagger"
import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig"
import { BBox } from "../../Logic/BBox"
import FilteredLayer, { FilterState } from "../../Models/FilteredLayer"
import geojson2svg from "geojson2svg"
import Constants from "../../Models/Constants";
import LayerConfig from "../../Models/ThemeConfig/LayerConfig";
import Constants from "../../Models/Constants"
import LayerConfig from "../../Models/ThemeConfig/LayerConfig"
export class DownloadPanel extends Toggle {
constructor(state: {
filteredLayers: UIEventSource<FilteredLayer[]>
featurePipeline: FeaturePipeline,
layoutToUse: LayoutConfig,
currentBounds: UIEventSource<BBox>,
featurePipeline: FeaturePipeline
layoutToUse: LayoutConfig
currentBounds: UIEventSource<BBox>
}) {
const t = Translations.t.general.download
const name = State.state.layoutToUse.id;
const name = State.state.layoutToUse.id
const includeMetaToggle = new CheckBoxes([t.includeMetaData])
const metaisIncluded = includeMetaToggle.GetValue().map(selected => selected.length > 0)
const metaisIncluded = includeMetaToggle.GetValue().map((selected) => selected.length > 0)
const buttonGeoJson = new SubtleButton(
Svg.floppy_ui(),
new Combine([
t.downloadGeojson.SetClass("font-bold"),
t.downloadGeoJsonHelper,
]).SetClass("flex flex-col")
).OnClickWithLoading(t.exporting, async () => {
const geojson = DownloadPanel.getCleanGeoJson(state, metaisIncluded.data)
Utils.offerContentsAsDownloadableFile(
JSON.stringify(geojson, null, " "),
`MapComplete_${name}_export_${new Date().toISOString().substr(0, 19)}.geojson`,
{
mimetype: "application/vnd.geo+json",
}
)
})
const buttonGeoJson = new SubtleButton(Svg.floppy_ui(),
new Combine([t.downloadGeojson.SetClass("font-bold"),
t.downloadGeoJsonHelper]).SetClass("flex flex-col"))
.OnClickWithLoading(t.exporting, async () => {
const geojson = DownloadPanel.getCleanGeoJson(state, metaisIncluded.data)
Utils.offerContentsAsDownloadableFile(JSON.stringify(geojson, null, " "),
`MapComplete_${name}_export_${new Date().toISOString().substr(0, 19)}.geojson`, {
mimetype: "application/vnd.geo+json"
});
const buttonCSV = new SubtleButton(
Svg.floppy_ui(),
new Combine([t.downloadCSV.SetClass("font-bold"), t.downloadCSVHelper]).SetClass(
"flex flex-col"
)
).OnClickWithLoading(t.exporting, async () => {
const geojson = DownloadPanel.getCleanGeoJson(state, metaisIncluded.data)
const csv = GeoOperations.toCSV(geojson.features)
Utils.offerContentsAsDownloadableFile(
csv,
`MapComplete_${name}_export_${new Date().toISOString().substr(0, 19)}.csv`,
{
mimetype: "text/csv",
}
)
})
const buttonSvg = new SubtleButton(
Svg.floppy_ui(),
new Combine([t.downloadAsSvg.SetClass("font-bold"), t.downloadAsSvgHelper]).SetClass(
"flex flex-col"
)
).OnClickWithLoading(t.exporting, async () => {
const geojson = DownloadPanel.getCleanGeoJsonPerLayer(state, metaisIncluded.data)
const leafletdiv = document.getElementById("leafletDiv")
const csv = DownloadPanel.asSvg(geojson, {
layers: state.filteredLayers.data.map((l) => l.layerDef),
mapExtent: state.currentBounds.data,
width: leafletdiv.offsetWidth,
height: leafletdiv.offsetHeight,
})
Utils.offerContentsAsDownloadableFile(
csv,
`MapComplete_${name}_export_${new Date().toISOString().substr(0, 19)}.svg`,
{
mimetype: "image/svg+xml",
}
)
})
const buttonCSV = new SubtleButton(Svg.floppy_ui(), new Combine(
[t.downloadCSV.SetClass("font-bold"),
t.downloadCSVHelper]).SetClass("flex flex-col"))
.OnClickWithLoading(t.exporting, async () => {
const geojson = DownloadPanel.getCleanGeoJson(state, metaisIncluded.data)
const csv = GeoOperations.toCSV(geojson.features)
const downloadButtons = new Combine([
new Title(t.title),
buttonGeoJson,
buttonCSV,
buttonSvg,
includeMetaToggle,
t.licenseInfo.SetClass("link-underline"),
]).SetClass("w-full flex flex-col border-4 border-gray-300 rounded-3xl p-4")
Utils.offerContentsAsDownloadableFile(csv,
`MapComplete_${name}_export_${new Date().toISOString().substr(0, 19)}.csv`, {
mimetype: "text/csv"
});
})
const buttonSvg = new SubtleButton(Svg.floppy_ui(), new Combine(
[t.downloadAsSvg.SetClass("font-bold"),
t.downloadAsSvgHelper]).SetClass("flex flex-col"))
.OnClickWithLoading(t.exporting, async () => {
const geojson = DownloadPanel.getCleanGeoJsonPerLayer(state, metaisIncluded.data)
const leafletdiv = document.getElementById("leafletDiv")
const csv = DownloadPanel.asSvg(geojson,
{
layers: state.filteredLayers.data.map(l => l.layerDef),
mapExtent: state.currentBounds.data,
width: leafletdiv.offsetWidth,
height: leafletdiv.offsetHeight
})
Utils.offerContentsAsDownloadableFile(csv,
`MapComplete_${name}_export_${new Date().toISOString().substr(0, 19)}.svg`, {
mimetype: "image/svg+xml"
});
})
const downloadButtons = new Combine(
[new Title(t.title),
buttonGeoJson,
buttonCSV,
buttonSvg,
includeMetaToggle,
t.licenseInfo.SetClass("link-underline")])
.SetClass("w-full flex flex-col border-4 border-gray-300 rounded-3xl p-4")
super(
downloadButtons,
t.noDataLoaded,
state.featurePipeline.somethingLoaded)
super(downloadButtons, t.noDataLoaded, state.featurePipeline.somethingLoaded)
}
/**
@ -112,20 +119,21 @@ export class DownloadPanel extends Toggle {
* const perLayer = new Map<string, any[]>([["testlayer", [feature]]])
* DownloadPanel.asSvg(perLayer).replace(/\n/g, "") // => `<svg width="1000px" height="1000px" viewBox="0 0 1000 1000"> <g id="testlayer" inkscape:groupmode="layer" inkscape:label="testlayer"> <path d="M0,27.77777777777778 1000,472.22222222222223" style="fill:none;stroke-width:1" stroke="#ff0000"/> </g></svg>`
*/
public static asSvg(perLayer: Map<string, any[]>,
options?:
{
layers?: LayerConfig[],
width?: 1000 | number,
height?: 1000 | number,
mapExtent?: BBox
unit?: "px" | "mm" | string
}) {
public static asSvg(
perLayer: Map<string, any[]>,
options?: {
layers?: LayerConfig[]
width?: 1000 | number
height?: 1000 | number
mapExtent?: BBox
unit?: "px" | "mm" | string
}
) {
options = options ?? {}
const w = options.width ?? 1000
const h = options.height ?? 1000
const unit = options.unit ?? "px"
const mapExtent = {left: -180, bottom: -90, right: 180, top: 90}
const mapExtent = { left: -180, bottom: -90, right: 180, top: 90 }
if (options.mapExtent !== undefined) {
const bbox = options.mapExtent
mapExtent.left = bbox.minLon
@ -134,51 +142,50 @@ export class DownloadPanel extends Toggle {
mapExtent.top = bbox.maxLat
}
const elements: string [] = []
const elements: string[] = []
for (const layer of Array.from(perLayer.keys())) {
const features = perLayer.get(layer)
if(features.length === 0){
if (features.length === 0) {
continue
}
const layerDef = options?.layers?.find(l => l.id === layer)
const layerDef = options?.layers?.find((l) => l.id === layer)
const rendering = layerDef?.lineRendering[0]
const converter = geojson2svg({
viewportSize: {width: w, height: h},
const converter = geojson2svg({
viewportSize: { width: w, height: h },
mapExtent,
output: 'svg',
attributes:[
output: "svg",
attributes: [
{
property: "style",
type:'static',
value: "fill:none;stroke-width:1"
type: "static",
value: "fill:none;stroke-width:1",
},
{
property: 'properties.stroke',
type:'dynamic',
key: 'stroke'
}
]
});
property: "properties.stroke",
type: "dynamic",
key: "stroke",
},
],
})
for (const feature of features) {
const stroke = rendering?.color?.GetRenderValue(feature.properties)?.txt ?? "#ff0000"
const color = Utils.colorAsHex( Utils.color(stroke))
const stroke =
rendering?.color?.GetRenderValue(feature.properties)?.txt ?? "#ff0000"
const color = Utils.colorAsHex(Utils.color(stroke))
feature.properties.stroke = color
}
const groupPaths: string[] = converter.convert({type: "FeatureCollection", features})
const group = ` <g id="${layer}" inkscape:groupmode="layer" inkscape:label="${layer}">\n` +
groupPaths.map(p => " " + p).join("\n")
+ "\n </g>"
const groupPaths: string[] = converter.convert({ type: "FeatureCollection", features })
const group =
` <g id="${layer}" inkscape:groupmode="layer" inkscape:label="${layer}">\n` +
groupPaths.map((p) => " " + p).join("\n") +
"\n </g>"
elements.push(group)
}
const header = `<svg width="${w}${unit}" height="${h}${unit}" viewBox="0 0 ${w} ${h}">`
return header + "\n" + elements.join("\n") + "\n</svg>"
}
@ -189,48 +196,53 @@ export class DownloadPanel extends Toggle {
* @param includeMetaData
* @private
*/
private static getCleanGeoJson(state: {
featurePipeline: FeaturePipeline,
currentBounds: UIEventSource<BBox>,
filteredLayers: UIEventSource<FilteredLayer[]>
}, includeMetaData: boolean) {
private static getCleanGeoJson(
state: {
featurePipeline: FeaturePipeline
currentBounds: UIEventSource<BBox>
filteredLayers: UIEventSource<FilteredLayer[]>
},
includeMetaData: boolean
) {
const perLayer = DownloadPanel.getCleanGeoJsonPerLayer(state, includeMetaData)
const features = [].concat(...Array.from(perLayer.values()))
return {
type: "FeatureCollection",
features
features,
}
}
private static getCleanGeoJsonPerLayer(state: {
featurePipeline: FeaturePipeline,
currentBounds: UIEventSource<BBox>,
filteredLayers: UIEventSource<FilteredLayer[]>
}, includeMetaData: boolean): Map<string, any[]> /*{layerId --> geojsonFeatures[]}*/ {
const perLayer = new Map<string, any[]>();
const neededLayers = state.filteredLayers.data.map(l => l.layerDef.id)
private static getCleanGeoJsonPerLayer(
state: {
featurePipeline: FeaturePipeline
currentBounds: UIEventSource<BBox>
filteredLayers: UIEventSource<FilteredLayer[]>
},
includeMetaData: boolean
): Map<string, any[]> /*{layerId --> geojsonFeatures[]}*/ {
const perLayer = new Map<string, any[]>()
const neededLayers = state.filteredLayers.data.map((l) => l.layerDef.id)
const bbox = state.currentBounds.data
const featureList = state.featurePipeline.GetAllFeaturesAndMetaWithin(bbox, new Set(neededLayers));
outer : for (const tile of featureList) {
if(Constants.priviliged_layers.indexOf(tile.layer) >= 0){
const featureList = state.featurePipeline.GetAllFeaturesAndMetaWithin(
bbox,
new Set(neededLayers)
)
outer: for (const tile of featureList) {
if (Constants.priviliged_layers.indexOf(tile.layer) >= 0) {
continue
}
const layer = state.filteredLayers.data.find(fl => fl.layerDef.id === tile.layer)
const layer = state.filteredLayers.data.find((fl) => fl.layerDef.id === tile.layer)
if (!perLayer.has(tile.layer)) {
perLayer.set(tile.layer, [])
}
const featureList = perLayer.get(tile.layer)
const filters = layer.appliedFilters.data
for (const feature of tile.features) {
if (!bbox.overlapsWith(BBox.get(feature))) {
continue
}
if (filters !== undefined) {
for (let key of Array.from(filters.keys())) {
const filter: FilterState = filters.get(key)
@ -238,21 +250,21 @@ export class DownloadPanel extends Toggle {
continue
}
if (!filter.currentFilter.matchesProperties(feature.properties)) {
continue outer;
continue outer
}
}
}
const cleaned = {
type: feature.type,
geometry: {...feature.geometry},
properties: {...feature.properties}
geometry: { ...feature.geometry },
properties: { ...feature.properties },
}
if (!includeMetaData) {
for (const key in cleaned.properties) {
if (key === "_lon" || key === "_lat") {
continue;
continue
}
if (key.startsWith("_")) {
delete feature.properties[key]
@ -260,7 +272,11 @@ export class DownloadPanel extends Toggle {
}
}
const datedKeys = [].concat(SimpleMetaTagger.metatags.filter(tagging => tagging.includesDates).map(tagging => tagging.keys))
const datedKeys = [].concat(
SimpleMetaTagger.metatags
.filter((tagging) => tagging.includesDates)
.map((tagging) => tagging.keys)
)
for (const key of datedKeys) {
delete feature.properties[key]
}
@ -270,6 +286,5 @@ export class DownloadPanel extends Toggle {
}
return perLayer
}
}
}

View file

@ -1,37 +1,44 @@
import {UIElement} from "../UIElement";
import BaseUIElement from "../BaseUIElement";
import {UIEventSource} from "../../Logic/UIEventSource";
import ExtraLinkConfig from "../../Models/ThemeConfig/ExtraLinkConfig";
import Img from "../Base/Img";
import {SubtleButton} from "../Base/SubtleButton";
import Toggle from "../Input/Toggle";
import Loc from "../../Models/Loc";
import Locale from "../i18n/Locale";
import {Utils} from "../../Utils";
import Svg from "../../Svg";
import Translations from "../i18n/Translations";
import {Translation} from "../i18n/Translation";
import { UIElement } from "../UIElement"
import BaseUIElement from "../BaseUIElement"
import { UIEventSource } from "../../Logic/UIEventSource"
import ExtraLinkConfig from "../../Models/ThemeConfig/ExtraLinkConfig"
import Img from "../Base/Img"
import { SubtleButton } from "../Base/SubtleButton"
import Toggle from "../Input/Toggle"
import Loc from "../../Models/Loc"
import Locale from "../i18n/Locale"
import { Utils } from "../../Utils"
import Svg from "../../Svg"
import Translations from "../i18n/Translations"
import { Translation } from "../i18n/Translation"
export default class ExtraLinkButton extends UIElement {
private readonly _config: ExtraLinkConfig;
private readonly _config: ExtraLinkConfig
private readonly state: {
layoutToUse: { id: string, title: Translation };
featureSwitchWelcomeMessage: UIEventSource<boolean>, locationControl: UIEventSource<Loc>
};
layoutToUse: { id: string; title: Translation }
featureSwitchWelcomeMessage: UIEventSource<boolean>
locationControl: UIEventSource<Loc>
}
constructor(state: { featureSwitchWelcomeMessage: UIEventSource<boolean>, locationControl: UIEventSource<Loc>, layoutToUse: { id: string, title: Translation } },
config: ExtraLinkConfig) {
super();
this.state = state;
this._config = config;
constructor(
state: {
featureSwitchWelcomeMessage: UIEventSource<boolean>
locationControl: UIEventSource<Loc>
layoutToUse: { id: string; title: Translation }
},
config: ExtraLinkConfig
) {
super()
this.state = state
this._config = config
}
protected InnerRender(): BaseUIElement {
if (this._config === undefined) {
return undefined;
return undefined
}
const c = this._config;
const c = this._config
const isIframe = window !== window.top
@ -46,17 +53,16 @@ export default class ExtraLinkButton extends UIElement {
let link: BaseUIElement
const theme = this.state.layoutToUse?.id ?? ""
const basepath = window.location.host
const href = this.state.locationControl.map(loc => {
const href = this.state.locationControl.map((loc) => {
const subs = {
...loc,
theme: theme,
basepath,
language: Locale.language.data
language: Locale.language.data,
}
return Utils.SubstituteKeys(c.href, subs)
})
let img: BaseUIElement = Svg.pop_out_ui()
if (c.icon !== undefined) {
img = new Img(c.icon).SetClass("h-6")
@ -64,14 +70,16 @@ export default class ExtraLinkButton extends UIElement {
let text: Translation
if (c.text === undefined) {
text = Translations.t.general.screenToSmall.Subs({theme: this.state.layoutToUse.title})
text = Translations.t.general.screenToSmall.Subs({
theme: this.state.layoutToUse.title,
})
} else {
text = c.text.Clone()
}
link = new SubtleButton(img, text, {
url: href,
newTab: c.newTab
newTab: c.newTab,
})
if (c.requirements.has("no-welcome-message")) {
@ -82,7 +90,6 @@ export default class ExtraLinkButton extends UIElement {
link = new Toggle(link, undefined, this.state.featureSwitchWelcomeMessage)
}
return link;
return link
}
}
}

View file

@ -1,18 +1,16 @@
import Combine from "../Base/Combine";
import Combine from "../Base/Combine"
import * as welcome_messages from "../../assets/welcome_message.json"
import BaseUIElement from "../BaseUIElement";
import {FixedUiElement} from "../Base/FixedUiElement";
import MoreScreen from "./MoreScreen";
import BaseUIElement from "../BaseUIElement"
import { FixedUiElement } from "../Base/FixedUiElement"
import MoreScreen from "./MoreScreen"
import * as themeOverview from "../../assets/generated/theme_overview.json"
import Translations from "../i18n/Translations";
import Title from "../Base/Title";
import Translations from "../i18n/Translations"
import Title from "../Base/Title"
export default class FeaturedMessage extends Combine {
constructor() {
const now = new Date()
let welcome_message = undefined;
let welcome_message = undefined
for (const wm of FeaturedMessage.WelcomeMessages()) {
if (wm.start_date >= now) {
continue
@ -24,19 +22,29 @@ export default class FeaturedMessage extends Combine {
if (welcome_message !== undefined) {
console.warn("Multiple applicable messages today:", welcome_message.featured_theme)
}
welcome_message = wm;
welcome_message = wm
}
welcome_message = welcome_message ?? undefined
super([FeaturedMessage.CreateFeaturedBox(welcome_message)]);
super([FeaturedMessage.CreateFeaturedBox(welcome_message)])
}
public static WelcomeMessages(): { start_date: Date, end_date: Date, message: string, featured_theme?: string }[] {
const all_messages: { start_date: Date, end_date: Date, message: string, featured_theme?: string }[] = []
public static WelcomeMessages(): {
start_date: Date
end_date: Date
message: string
featured_theme?: string
}[] {
const all_messages: {
start_date: Date
end_date: Date
message: string
featured_theme?: string
}[] = []
const themesById = new Map<string, { id: string, title: any, shortDescription: any }>();
const themesById = new Map<string, { id: string; title: any; shortDescription: any }>()
for (const theme of themeOverview["default"]) {
themesById.set(theme.id, theme);
themesById.set(theme.id, theme)
}
for (const i in welcome_messages) {
@ -62,32 +70,36 @@ export default class FeaturedMessage extends Combine {
start_date: new Date(wm.start_date),
end_date: new Date(wm.end_date),
message: wm.message,
featured_theme: wm.featured_theme
featured_theme: wm.featured_theme,
})
}
return all_messages
}
public static CreateFeaturedBox(welcome_message: { message: string, featured_theme?: string }): BaseUIElement {
public static CreateFeaturedBox(welcome_message: {
message: string
featured_theme?: string
}): BaseUIElement {
const els: BaseUIElement[] = []
if (welcome_message === undefined) {
return undefined;
return undefined
}
const title = new Title(Translations.t.index.featuredThemeTitle.Clone())
const msg = new FixedUiElement(welcome_message.message).SetClass("link-underline font-lg")
els.push(new Combine([title, msg]).SetClass("m-4"))
if (welcome_message.featured_theme !== undefined) {
const theme = themeOverview["default"].filter(
(th) => th.id === welcome_message.featured_theme
)[0]
const theme = themeOverview["default"].filter(th => th.id === welcome_message.featured_theme)[0];
els.push(MoreScreen.createLinkButton({}, theme)
.SetClass("m-4 self-center md:w-160")
.SetStyle("height: min-content;"))
els.push(
MoreScreen.createLinkButton({}, theme)
.SetClass("m-4 self-center md:w-160")
.SetStyle("height: min-content;")
)
}
return new Combine(els).SetClass("border-2 border-grey-400 rounded-xl flex flex-col md:flex-row");
return new Combine(els).SetClass(
"border-2 border-grey-400 rounded-xl flex flex-col md:flex-row"
)
}
}
}

View file

@ -1,37 +1,39 @@
import {Utils} from "../../Utils";
import {FixedInputElement} from "../Input/FixedInputElement";
import {RadioButton} from "../Input/RadioButton";
import {VariableUiElement} from "../Base/VariableUIElement";
import Toggle, {ClickableToggle} from "../Input/Toggle";
import Combine from "../Base/Combine";
import Translations from "../i18n/Translations";
import {Translation} from "../i18n/Translation";
import Svg from "../../Svg";
import {ImmutableStore, Store, UIEventSource} from "../../Logic/UIEventSource";
import BaseUIElement from "../BaseUIElement";
import FilteredLayer, {FilterState} from "../../Models/FilteredLayer";
import BackgroundSelector from "./BackgroundSelector";
import FilterConfig from "../../Models/ThemeConfig/FilterConfig";
import TilesourceConfig from "../../Models/ThemeConfig/TilesourceConfig";
import {SubstitutedTranslation} from "../SubstitutedTranslation";
import ValidatedTextField from "../Input/ValidatedTextField";
import {QueryParameters} from "../../Logic/Web/QueryParameters";
import {TagUtils} from "../../Logic/Tags/TagUtils";
import {InputElement} from "../Input/InputElement";
import {DropDown} from "../Input/DropDown";
import {FixedUiElement} from "../Base/FixedUiElement";
import BaseLayer from "../../Models/BaseLayer";
import Loc from "../../Models/Loc";
import { Utils } from "../../Utils"
import { FixedInputElement } from "../Input/FixedInputElement"
import { RadioButton } from "../Input/RadioButton"
import { VariableUiElement } from "../Base/VariableUIElement"
import Toggle, { ClickableToggle } from "../Input/Toggle"
import Combine from "../Base/Combine"
import Translations from "../i18n/Translations"
import { Translation } from "../i18n/Translation"
import Svg from "../../Svg"
import { ImmutableStore, Store, UIEventSource } from "../../Logic/UIEventSource"
import BaseUIElement from "../BaseUIElement"
import FilteredLayer, { FilterState } from "../../Models/FilteredLayer"
import BackgroundSelector from "./BackgroundSelector"
import FilterConfig from "../../Models/ThemeConfig/FilterConfig"
import TilesourceConfig from "../../Models/ThemeConfig/TilesourceConfig"
import { SubstitutedTranslation } from "../SubstitutedTranslation"
import ValidatedTextField from "../Input/ValidatedTextField"
import { QueryParameters } from "../../Logic/Web/QueryParameters"
import { TagUtils } from "../../Logic/Tags/TagUtils"
import { InputElement } from "../Input/InputElement"
import { DropDown } from "../Input/DropDown"
import { FixedUiElement } from "../Base/FixedUiElement"
import BaseLayer from "../../Models/BaseLayer"
import Loc from "../../Models/Loc"
export default class FilterView extends VariableUiElement {
constructor(filteredLayer: Store<FilteredLayer[]>,
tileLayers: { config: TilesourceConfig, isDisplayed: UIEventSource<boolean> }[],
state: {
availableBackgroundLayers?: Store<BaseLayer[]>,
featureSwitchBackgroundSelection?: UIEventSource<boolean>,
featureSwitchIsDebugging?: UIEventSource<boolean>,
locationControl?: UIEventSource<Loc>
}) {
constructor(
filteredLayer: Store<FilteredLayer[]>,
tileLayers: { config: TilesourceConfig; isDisplayed: UIEventSource<boolean> }[],
state: {
availableBackgroundLayers?: Store<BaseLayer[]>
featureSwitchBackgroundSelection?: UIEventSource<boolean>
featureSwitchIsDebugging?: UIEventSource<boolean>
locationControl?: UIEventSource<Loc>
}
) {
const backgroundSelector = new Toggle(
new BackgroundSelector(state),
undefined,
@ -39,146 +41,143 @@ export default class FilterView extends VariableUiElement {
)
super(
filteredLayer.map((filteredLayers) => {
// Create the views which toggle layers (and filters them) ...
let elements = filteredLayers
?.map(l => FilterView.createOneFilteredLayerElement(l, state)?.SetClass("filter-panel"))
?.filter(l => l !== undefined)
elements[0].SetClass("first-filter-panel")
// Create the views which toggle layers (and filters them) ...
let elements = filteredLayers
?.map((l) =>
FilterView.createOneFilteredLayerElement(l, state)?.SetClass("filter-panel")
)
?.filter((l) => l !== undefined)
elements[0].SetClass("first-filter-panel")
// ... create views for non-interactive layers ...
elements = elements.concat(tileLayers.map(tl => FilterView.createOverlayToggle(state, tl)))
// ... and add the dropdown to select a different background
return elements.concat(backgroundSelector);
}
)
);
// ... create views for non-interactive layers ...
elements = elements.concat(
tileLayers.map((tl) => FilterView.createOverlayToggle(state, tl))
)
// ... and add the dropdown to select a different background
return elements.concat(backgroundSelector)
})
)
}
private static createOverlayToggle(state: { locationControl?: UIEventSource<Loc> }, config: { config: TilesourceConfig, isDisplayed: UIEventSource<boolean> }) {
private static createOverlayToggle(
state: { locationControl?: UIEventSource<Loc> },
config: { config: TilesourceConfig; isDisplayed: UIEventSource<boolean> }
) {
const iconStyle = "width:1.5rem;height:1.5rem;margin-left:1.25rem;flex-shrink: 0;"
const iconStyle = "width:1.5rem;height:1.5rem;margin-left:1.25rem;flex-shrink: 0;";
const icon = new Combine([Svg.checkbox_filled]).SetStyle(iconStyle)
const iconUnselected = new Combine([Svg.checkbox_empty]).SetStyle(iconStyle)
const name: Translation = config.config.name
const icon = new Combine([Svg.checkbox_filled]).SetStyle(iconStyle);
const iconUnselected = new Combine([Svg.checkbox_empty]).SetStyle(
iconStyle
);
const name: Translation = config.config.name;
const styledNameChecked = name.Clone().SetStyle("font-size:large").SetClass("ml-2")
const styledNameUnChecked = name.Clone().SetStyle("font-size:large").SetClass("ml-2")
const styledNameChecked = name.Clone().SetStyle("font-size:large").SetClass("ml-2");
const styledNameUnChecked = name.Clone().SetStyle("font-size:large").SetClass("ml-2");
const zoomStatus = new Toggle(
undefined,
Translations.t.general.layerSelection.zoomInToSeeThisLayer
.SetClass("alert")
.SetStyle("display: block ruby;width:min-content;"),
state.locationControl?.map((location) => location.zoom >= config.config.minzoom) ??
new ImmutableStore(false)
)
const zoomStatus =
new Toggle(
undefined,
Translations.t.general.layerSelection.zoomInToSeeThisLayer
.SetClass("alert")
.SetStyle("display: block ruby;width:min-content;"),
state.locationControl?.map(location => location.zoom >= config.config.minzoom) ?? new ImmutableStore(false)
)
const style =
"display:flex;align-items:center;padding:0.5rem 0;";
const style = "display:flex;align-items:center;padding:0.5rem 0;"
const layerChecked = new Combine([icon, styledNameChecked, zoomStatus])
.SetStyle(style)
.onClick(() => config.isDisplayed.setData(false));
.onClick(() => config.isDisplayed.setData(false))
const layerNotChecked = new Combine([iconUnselected, styledNameUnChecked])
.SetStyle(style)
.onClick(() => config.isDisplayed.setData(true));
.onClick(() => config.isDisplayed.setData(true))
return new Toggle(
layerChecked,
layerNotChecked,
config.isDisplayed
);
return new Toggle(layerChecked, layerNotChecked, config.isDisplayed)
}
private static createOneFilteredLayerElement(filteredLayer: FilteredLayer,
state: { featureSwitchIsDebugging?: Store<boolean>, locationControl?: Store<Loc> }) {
private static createOneFilteredLayerElement(
filteredLayer: FilteredLayer,
state: { featureSwitchIsDebugging?: Store<boolean>; locationControl?: Store<Loc> }
) {
if (filteredLayer.layerDef.name === undefined) {
// Name is not defined: we hide this one
return new Toggle(
new FixedUiElement(filteredLayer?.layerDef?.id).SetClass("block"),
undefined,
state?.featureSwitchIsDebugging ?? new ImmutableStore(false)
);
)
}
const iconStyle = "width:1.5rem;height:1.5rem;margin-left:1.25rem;flex-shrink: 0;";
const iconStyle = "width:1.5rem;height:1.5rem;margin-left:1.25rem;flex-shrink: 0;"
const icon = new Combine([Svg.checkbox_filled]).SetStyle(iconStyle);
const icon = new Combine([Svg.checkbox_filled]).SetStyle(iconStyle)
const layer = filteredLayer.layerDef
const iconUnselected = new Combine([Svg.checkbox_empty]).SetStyle(
iconStyle
);
const iconUnselected = new Combine([Svg.checkbox_empty]).SetStyle(iconStyle)
const name: Translation = filteredLayer.layerDef.name.Clone()
const styledNameChecked = name.Clone().SetStyle("font-size:large").SetClass("ml-3");
const styledNameChecked = name.Clone().SetStyle("font-size:large").SetClass("ml-3")
const styledNameUnChecked = name.Clone().SetStyle("font-size:large").SetClass("ml-3");
const styledNameUnChecked = name.Clone().SetStyle("font-size:large").SetClass("ml-3")
const zoomStatus =
new Toggle(
undefined,
Translations.t.general.layerSelection.zoomInToSeeThisLayer
.SetClass("alert")
.SetStyle("display: block ruby;width:min-content;"),
state?.locationControl?.map(location => location.zoom >= filteredLayer.layerDef.minzoom) ?? new ImmutableStore(false)
)
const zoomStatus = new Toggle(
undefined,
Translations.t.general.layerSelection.zoomInToSeeThisLayer
.SetClass("alert")
.SetStyle("display: block ruby;width:min-content;"),
state?.locationControl?.map(
(location) => location.zoom >= filteredLayer.layerDef.minzoom
) ?? new ImmutableStore(false)
)
const toggleClasses = "layer-toggle flex flex-wrap items-center pt-2 pb-2 px-0";
const toggleClasses = "layer-toggle flex flex-wrap items-center pt-2 pb-2 px-0"
const layerIcon = layer.defaultIcon()?.SetClass("flex-shrink-0 w-8 h-8 ml-2")
const layerIconUnchecked = layer.defaultIcon()?.SetClass("flex-shrink-0 opacity-50 w-8 h-8 ml-2")
const layerIconUnchecked = layer
.defaultIcon()
?.SetClass("flex-shrink-0 opacity-50 w-8 h-8 ml-2")
const layerChecked = new Combine([icon, layerIcon, styledNameChecked, zoomStatus])
.SetClass(toggleClasses)
.onClick(() => filteredLayer.isDisplayed.setData(false));
.onClick(() => filteredLayer.isDisplayed.setData(false))
const layerNotChecked = new Combine([iconUnselected, layerIconUnchecked, styledNameUnChecked])
const layerNotChecked = new Combine([
iconUnselected,
layerIconUnchecked,
styledNameUnChecked,
])
.SetClass(toggleClasses)
.onClick(() => filteredLayer.isDisplayed.setData(true));
.onClick(() => filteredLayer.isDisplayed.setData(true))
const filterPanel: BaseUIElement = new LayerFilterPanel(state, filteredLayer)
return new Toggle(
new Combine([layerChecked, filterPanel]),
layerNotChecked,
filteredLayer.isDisplayed
);
)
}
}
export class LayerFilterPanel extends Combine {
public constructor(state: any, flayer: FilteredLayer) {
const layer = flayer.layerDef
if (layer.filters.length === 0) {
return undefined;
super([])
return undefined
}
const toShow: BaseUIElement [] = []
const toShow: BaseUIElement[] = []
for (const filter of layer.filters) {
const [ui, actualTags] = LayerFilterPanel.createFilter(state, filter)
ui.SetClass("mt-1")
toShow.push(ui)
actualTags.addCallbackAndRun(tagsToFilterFor => {
actualTags.addCallbackAndRun((tagsToFilterFor) => {
flayer.appliedFilters.data.set(filter.id, tagsToFilterFor)
flayer.appliedFilters.ping()
})
flayer.appliedFilters.map(dict => dict.get(filter.id))
.addCallbackAndRun(filters => actualTags.setData(filters))
flayer.appliedFilters
.map((dict) => dict.get(filter.id))
.addCallbackAndRun((filters) => actualTags.setData(filters))
}
super(toShow)
@ -186,37 +185,53 @@ export class LayerFilterPanel extends Combine {
}
// Filter which uses one or more textfields
private static createFilterWithFields(state: any, filterConfig: FilterConfig): [BaseUIElement, UIEventSource<FilterState>] {
private static createFilterWithFields(
state: any,
filterConfig: FilterConfig
): [BaseUIElement, UIEventSource<FilterState>] {
const filter = filterConfig.options[0]
const mappings = new Map<string, BaseUIElement>()
let allValid: Store<boolean> = new ImmutableStore(true)
var allFields: InputElement<string>[] = []
const properties = new UIEventSource<any>({})
for (const {name, type} of filter.fields) {
const value = QueryParameters.GetQueryParameter("filter-" + filterConfig.id + "-" + name, "", "Value for filter " + filterConfig.id)
const field = ValidatedTextField.ForType(type).ConstructInputElement({
value
}).SetClass("inline-block")
for (const { name, type } of filter.fields) {
const value = QueryParameters.GetQueryParameter(
"filter-" + filterConfig.id + "-" + name,
"",
"Value for filter " + filterConfig.id
)
const field = ValidatedTextField.ForType(type)
.ConstructInputElement({
value,
})
.SetClass("inline-block")
mappings.set(name, field)
const stable = value.stabilized(250)
stable.addCallbackAndRunD(v => {
properties.data[name] = v.toLowerCase();
stable.addCallbackAndRunD((v) => {
properties.data[name] = v.toLowerCase()
properties.ping()
})
allFields.push(field)
allValid = allValid.map(previous => previous && field.IsValid(stable.data) && stable.data !== "", [stable])
allValid = allValid.map(
(previous) => previous && field.IsValid(stable.data) && stable.data !== "",
[stable]
)
}
const tr = new SubstitutedTranslation(filter.question, new UIEventSource<any>({id: filterConfig.id}), state, mappings)
const trigger: Store<FilterState> = allValid.map(isValid => {
if (!isValid) {
return undefined
}
const props = properties.data
// Replace all the field occurences in the tags...
const tagsSpec = Utils.WalkJson(filter.originalTagsSpec,
v => {
const tr = new SubstitutedTranslation(
filter.question,
new UIEventSource<any>({ id: filterConfig.id }),
state,
mappings
)
const trigger: Store<FilterState> = allValid.map(
(isValid) => {
if (!isValid) {
return undefined
}
const props = properties.data
// Replace all the field occurences in the tags...
const tagsSpec = Utils.WalkJson(filter.originalTagsSpec, (v) => {
if (typeof v !== "string") {
return v
}
@ -226,57 +241,72 @@ export class LayerFilterPanel extends Combine {
}
return v
})
const tagsFilter = TagUtils.Tag(tagsSpec)
return {
currentFilter: tagsFilter,
state: JSON.stringify(props),
}
)
const tagsFilter = TagUtils.Tag(tagsSpec)
return {
currentFilter: tagsFilter,
state: JSON.stringify(props)
}
}, [properties])
},
[properties]
)
const settableFilter = new UIEventSource<FilterState>(undefined)
trigger.addCallbackAndRun(state => settableFilter.setData(state))
settableFilter.addCallback(state => {
trigger.addCallbackAndRun((state) => settableFilter.setData(state))
settableFilter.addCallback((state) => {
if (state === undefined) {
// still initializing
return
}
if (state.currentFilter === undefined) {
allFields.forEach(f => f.GetValue().setData(undefined));
allFields.forEach((f) => f.GetValue().setData(undefined))
}
})
return [tr, settableFilter];
return [tr, settableFilter]
}
private static createCheckboxFilter(filterConfig: FilterConfig): [BaseUIElement, UIEventSource<FilterState>] {
let option = filterConfig.options[0];
private static createCheckboxFilter(
filterConfig: FilterConfig
): [BaseUIElement, UIEventSource<FilterState>] {
let option = filterConfig.options[0]
const icon = Svg.checkbox_filled_svg().SetClass("block mr-2 w-6");
const iconUnselected = Svg.checkbox_empty_svg().SetClass("block mr-2 w-6");
const icon = Svg.checkbox_filled_svg().SetClass("block mr-2 w-6")
const iconUnselected = Svg.checkbox_empty_svg().SetClass("block mr-2 w-6")
const toggle = new ClickableToggle(
new Combine([icon, option.question.Clone().SetClass("block")]).SetClass("flex"),
new Combine([iconUnselected, option.question.Clone().SetClass("block")]).SetClass("flex")
new Combine([iconUnselected, option.question.Clone().SetClass("block")]).SetClass(
"flex"
)
)
.ToggleOnClick()
.SetClass("block m-1")
return [toggle, toggle.isEnabled.sync(enabled => enabled ? {
currentFilter: option.osmTags,
state: "true"
} : undefined, [],
f => f !== undefined)
return [
toggle,
toggle.isEnabled.sync(
(enabled) =>
enabled
? {
currentFilter: option.osmTags,
state: "true",
}
: undefined,
[],
(f) => f !== undefined
),
]
}
private static createMultiFilter(filterConfig: FilterConfig): [BaseUIElement, UIEventSource<FilterState>] {
let options = filterConfig.options;
private static createMultiFilter(
filterConfig: FilterConfig
): [BaseUIElement, UIEventSource<FilterState>] {
let options = filterConfig.options
const values: FilterState[] = options.map((f, i) => ({
currentFilter: f.osmTags, state: i
currentFilter: f.osmTags,
state: i,
}))
let filterPicker: InputElement<number>
@ -287,36 +317,43 @@ export class LayerFilterPanel extends Combine {
new FixedInputElement(option.question.Clone().SetClass("block"), i)
),
{
dontStyle: true
dontStyle: true,
}
);
)
} else {
filterPicker = new DropDown("", options.map((option, i) => ({
value: i, shown: option.question.Clone()
})))
filterPicker = new DropDown(
"",
options.map((option, i) => ({
value: i,
shown: option.question.Clone(),
}))
)
}
return [filterPicker,
return [
filterPicker,
filterPicker.GetValue().sync(
i => values[i],
(i) => values[i],
[],
selected => {
(selected) => {
const v = selected?.state
if (v === undefined || typeof v === "string") {
return undefined
}
return v
}
)]
),
]
}
private static createFilter(state: {}, filterConfig: FilterConfig): [BaseUIElement, UIEventSource<FilterState>] {
private static createFilter(
state: {},
filterConfig: FilterConfig
): [BaseUIElement, UIEventSource<FilterState>] {
if (filterConfig.options[0].fields.length > 0) {
return LayerFilterPanel.createFilterWithFields(state, filterConfig)
}
if (filterConfig.options.length === 1) {
return LayerFilterPanel.createCheckboxFilter(filterConfig)
}

View file

@ -1,43 +1,44 @@
import ThemeIntroductionPanel from "./ThemeIntroductionPanel";
import Svg from "../../Svg";
import Translations from "../i18n/Translations";
import ShareScreen from "./ShareScreen";
import MoreScreen from "./MoreScreen";
import Constants from "../../Models/Constants";
import Combine from "../Base/Combine";
import {TabbedComponent} from "../Base/TabbedComponent";
import {UIEventSource} from "../../Logic/UIEventSource";
import UserDetails, {OsmConnection} from "../../Logic/Osm/OsmConnection";
import ScrollableFullScreen from "../Base/ScrollableFullScreen";
import BaseUIElement from "../BaseUIElement";
import Toggle from "../Input/Toggle";
import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig";
import {Utils} from "../../Utils";
import UserRelatedState from "../../Logic/State/UserRelatedState";
import Loc from "../../Models/Loc";
import BaseLayer from "../../Models/BaseLayer";
import FilteredLayer from "../../Models/FilteredLayer";
import CopyrightPanel from "./CopyrightPanel";
import FeaturePipeline from "../../Logic/FeatureSource/FeaturePipeline";
import PrivacyPolicy from "./PrivacyPolicy";
import ThemeIntroductionPanel from "./ThemeIntroductionPanel"
import Svg from "../../Svg"
import Translations from "../i18n/Translations"
import ShareScreen from "./ShareScreen"
import MoreScreen from "./MoreScreen"
import Constants from "../../Models/Constants"
import Combine from "../Base/Combine"
import { TabbedComponent } from "../Base/TabbedComponent"
import { UIEventSource } from "../../Logic/UIEventSource"
import UserDetails, { OsmConnection } from "../../Logic/Osm/OsmConnection"
import ScrollableFullScreen from "../Base/ScrollableFullScreen"
import BaseUIElement from "../BaseUIElement"
import Toggle from "../Input/Toggle"
import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig"
import { Utils } from "../../Utils"
import UserRelatedState from "../../Logic/State/UserRelatedState"
import Loc from "../../Models/Loc"
import BaseLayer from "../../Models/BaseLayer"
import FilteredLayer from "../../Models/FilteredLayer"
import CopyrightPanel from "./CopyrightPanel"
import FeaturePipeline from "../../Logic/FeatureSource/FeaturePipeline"
import PrivacyPolicy from "./PrivacyPolicy"
export default class FullWelcomePaneWithTabs extends ScrollableFullScreen {
public static MoreThemesTabIndex = 1
public static MoreThemesTabIndex = 1;
constructor(isShown: UIEventSource<boolean>,
currentTab: UIEventSource<number>,
state: {
layoutToUse: LayoutConfig,
osmConnection: OsmConnection,
featureSwitchShareScreen: UIEventSource<boolean>,
featureSwitchMoreQuests: UIEventSource<boolean>,
locationControl: UIEventSource<Loc>,
featurePipeline: FeaturePipeline,
backgroundLayer: UIEventSource<BaseLayer>,
filteredLayers: UIEventSource<FilteredLayer[]>
} & UserRelatedState) {
const layoutToUse = state.layoutToUse;
constructor(
isShown: UIEventSource<boolean>,
currentTab: UIEventSource<number>,
state: {
layoutToUse: LayoutConfig
osmConnection: OsmConnection
featureSwitchShareScreen: UIEventSource<boolean>
featureSwitchMoreQuests: UIEventSource<boolean>
locationControl: UIEventSource<Loc>
featurePipeline: FeaturePipeline
backgroundLayer: UIEventSource<BaseLayer>
filteredLayers: UIEventSource<FilteredLayer[]>
} & UserRelatedState
) {
const layoutToUse = state.layoutToUse
super(
() => layoutToUse.title.Clone(),
() => FullWelcomePaneWithTabs.GenerateContents(state, currentTab, isShown),
@ -46,83 +47,99 @@ export default class FullWelcomePaneWithTabs extends ScrollableFullScreen {
)
}
private static ConstructBaseTabs(state: { layoutToUse: LayoutConfig; osmConnection: OsmConnection; featureSwitchShareScreen: UIEventSource<boolean>; featureSwitchMoreQuests: UIEventSource<boolean>; featurePipeline: FeaturePipeline; locationControl: UIEventSource<Loc>; backgroundLayer: UIEventSource<BaseLayer>; filteredLayers: UIEventSource<FilteredLayer[]> } & UserRelatedState,
isShown: UIEventSource<boolean>, currentTab: UIEventSource<number>):
{ header: string | BaseUIElement; content: BaseUIElement }[] {
const tabs: { header: string | BaseUIElement, content: BaseUIElement }[] = [
{header: `<img src='${state.layoutToUse.icon}'>`, content: new ThemeIntroductionPanel(isShown, currentTab, state)},
private static ConstructBaseTabs(
state: {
layoutToUse: LayoutConfig
osmConnection: OsmConnection
featureSwitchShareScreen: UIEventSource<boolean>
featureSwitchMoreQuests: UIEventSource<boolean>
featurePipeline: FeaturePipeline
locationControl: UIEventSource<Loc>
backgroundLayer: UIEventSource<BaseLayer>
filteredLayers: UIEventSource<FilteredLayer[]>
} & UserRelatedState,
isShown: UIEventSource<boolean>,
currentTab: UIEventSource<number>
): { header: string | BaseUIElement; content: BaseUIElement }[] {
const tabs: { header: string | BaseUIElement; content: BaseUIElement }[] = [
{
header: `<img src='${state.layoutToUse.icon}'>`,
content: new ThemeIntroductionPanel(isShown, currentTab, state),
},
]
if (state.featureSwitchMoreQuests.data) {
tabs.push({
header: Svg.add_img,
content:
new Combine([
Translations.t.general.morescreen.intro,
new MoreScreen(state)
]).SetClass("flex flex-col")
});
content: new Combine([
Translations.t.general.morescreen.intro,
new MoreScreen(state),
]).SetClass("flex flex-col"),
})
}
if (state.featureSwitchShareScreen.data) {
tabs.push({header: Svg.share_img, content: new ShareScreen(state)});
tabs.push({ header: Svg.share_img, content: new ShareScreen(state) })
}
const copyright = {
header: Svg.copyright_svg(),
content:
new Combine(
[
Translations.t.general.openStreetMapIntro.SetClass("link-underline"),
new CopyrightPanel(state)
]
)
content: new Combine([
Translations.t.general.openStreetMapIntro.SetClass("link-underline"),
new CopyrightPanel(state),
]),
}
tabs.push(copyright)
const privacy = {
header: Svg.eye_svg(),
content: new PrivacyPolicy()
content: new PrivacyPolicy(),
}
tabs.push(privacy)
return tabs;
return tabs
}
private static GenerateContents(state: {
layoutToUse: LayoutConfig,
osmConnection: OsmConnection,
featureSwitchShareScreen: UIEventSource<boolean>,
featureSwitchMoreQuests: UIEventSource<boolean>,
featurePipeline: FeaturePipeline,
locationControl: UIEventSource<Loc>, backgroundLayer: UIEventSource<BaseLayer>, filteredLayers: UIEventSource<FilteredLayer[]>
} & UserRelatedState, currentTab: UIEventSource<number>, isShown: UIEventSource<boolean>) {
private static GenerateContents(
state: {
layoutToUse: LayoutConfig
osmConnection: OsmConnection
featureSwitchShareScreen: UIEventSource<boolean>
featureSwitchMoreQuests: UIEventSource<boolean>
featurePipeline: FeaturePipeline
locationControl: UIEventSource<Loc>
backgroundLayer: UIEventSource<BaseLayer>
filteredLayers: UIEventSource<FilteredLayer[]>
} & UserRelatedState,
currentTab: UIEventSource<number>,
isShown: UIEventSource<boolean>
) {
const tabs = FullWelcomePaneWithTabs.ConstructBaseTabs(state, isShown, currentTab)
const tabsWithAboutMc = [...FullWelcomePaneWithTabs.ConstructBaseTabs(state, isShown, currentTab)]
const tabsWithAboutMc = [
...FullWelcomePaneWithTabs.ConstructBaseTabs(state, isShown, currentTab),
]
tabsWithAboutMc.push({
header: Svg.help,
content: new Combine([Translations.t.general.aboutMapcomplete
.Subs({"osmcha_link": Utils.OsmChaLinkFor(7)}), "<br/>Version " + Constants.vNumber])
.SetClass("link-underline")
}
);
header: Svg.help,
content: new Combine([
Translations.t.general.aboutMapcomplete.Subs({
osmcha_link: Utils.OsmChaLinkFor(7),
}),
"<br/>Version " + Constants.vNumber,
]).SetClass("link-underline"),
})
tabs.forEach(c => c.content.SetClass("p-4"))
tabsWithAboutMc.forEach(c => c.content.SetClass("p-4"))
tabs.forEach((c) => c.content.SetClass("p-4"))
tabsWithAboutMc.forEach((c) => c.content.SetClass("p-4"))
return new Toggle(
new TabbedComponent(tabsWithAboutMc, currentTab),
new TabbedComponent(tabs, currentTab),
state.osmConnection.userDetails.map((userdetails: UserDetails) =>
userdetails.loggedIn &&
userdetails.csCount >= Constants.userJourney.mapCompleteHelpUnlock)
state.osmConnection.userDetails.map(
(userdetails: UserDetails) =>
userdetails.loggedIn &&
userdetails.csCount >= Constants.userJourney.mapCompleteHelpUnlock
)
)
}
}
}

View file

@ -1,14 +1,13 @@
import {VariableUiElement} from "../Base/VariableUIElement";
import {Store, UIEventSource} from "../../Logic/UIEventSource";
import Table from "../Base/Table";
import Combine from "../Base/Combine";
import {FixedUiElement} from "../Base/FixedUiElement";
import {Utils} from "../../Utils";
import BaseUIElement from "../BaseUIElement";
import Svg from "../../Svg";
import { VariableUiElement } from "../Base/VariableUIElement"
import { Store, UIEventSource } from "../../Logic/UIEventSource"
import Table from "../Base/Table"
import Combine from "../Base/Combine"
import { FixedUiElement } from "../Base/FixedUiElement"
import { Utils } from "../../Utils"
import BaseUIElement from "../BaseUIElement"
import Svg from "../../Svg"
export default class Histogram<T> extends VariableUiElement {
private static defaultPalette = [
"#ff5858",
"#ffad48",
@ -16,29 +15,35 @@ export default class Histogram<T> extends VariableUiElement {
"#56bd56",
"#63a9ff",
"#9d62d9",
"#fa61fa"
"#fa61fa",
]
constructor(values: Store<string[]>,
title: string | BaseUIElement,
countTitle: string | BaseUIElement,
options?: {
assignColor?: (t0: string) => string,
sortMode?: "name" | "name-rev" | "count" | "count-rev"
}
constructor(
values: Store<string[]>,
title: string | BaseUIElement,
countTitle: string | BaseUIElement,
options?: {
assignColor?: (t0: string) => string
sortMode?: "name" | "name-rev" | "count" | "count-rev"
}
) {
const sortMode = new UIEventSource<"name" | "name-rev" | "count" | "count-rev">(options?.sortMode ?? "name")
const sortName = new VariableUiElement(sortMode.map(m => {
switch (m) {
case "name":
return Svg.up_svg()
case "name-rev":
return Svg.up_svg().SetStyle("transform: rotate(180deg)")
default:
return Svg.circle_svg()
}
}))
const titleHeader = new Combine([sortName.SetClass("w-4 mr-2"), title]).SetClass("flex")
const sortMode = new UIEventSource<"name" | "name-rev" | "count" | "count-rev">(
options?.sortMode ?? "name"
)
const sortName = new VariableUiElement(
sortMode.map((m) => {
switch (m) {
case "name":
return Svg.up_svg()
case "name-rev":
return Svg.up_svg().SetStyle("transform: rotate(180deg)")
default:
return Svg.circle_svg()
}
})
)
const titleHeader = new Combine([sortName.SetClass("w-4 mr-2"), title])
.SetClass("flex")
.onClick(() => {
if (sortMode.data === "name") {
sortMode.setData("name-rev")
@ -47,91 +52,103 @@ export default class Histogram<T> extends VariableUiElement {
}
})
const sortCount = new VariableUiElement(sortMode.map(m => {
switch (m) {
case "count":
return Svg.up_svg()
case "count-rev":
return Svg.up_svg().SetStyle("transform: rotate(180deg)")
default:
return Svg.circle_svg()
}
}))
const countHeader = new Combine([sortCount.SetClass("w-4 mr-2"), countTitle]).SetClass("flex").onClick(() => {
if (sortMode.data === "count-rev") {
sortMode.setData("count")
} else {
sortMode.setData("count-rev")
}
})
const header = [
titleHeader,
countHeader
]
super(values.map(values => {
if (values === undefined) {
return undefined;
}
values = Utils.NoNull(values)
const counts = new Map<string, number>()
for (const value of values) {
const c = counts.get(value) ?? 0;
counts.set(value, c + 1);
}
const keys = Array.from(counts.keys());
switch (sortMode.data) {
case "name":
keys.sort()
break;
case "name-rev":
keys.sort().reverse(/*Copy of array, inplace reverse if fine*/)
break;
case "count":
keys.sort((k0, k1) => counts.get(k0) - counts.get(k1))
break;
case "count-rev":
keys.sort((k0, k1) => counts.get(k1) - counts.get(k0))
break;
}
const max = Math.max(...Array.from(counts.values()))
const fallbackColor = (keyValue: string) => {
const index = keys.indexOf(keyValue)
return Histogram.defaultPalette[index % Histogram.defaultPalette.length]
};
let actualAssignColor = undefined;
if (options?.assignColor === undefined) {
actualAssignColor = fallbackColor;
} else {
actualAssignColor = (keyValue: string) => {
return options.assignColor(keyValue) ?? fallbackColor(keyValue)
const sortCount = new VariableUiElement(
sortMode.map((m) => {
switch (m) {
case "count":
return Svg.up_svg()
case "count-rev":
return Svg.up_svg().SetStyle("transform: rotate(180deg)")
default:
return Svg.circle_svg()
}
}
})
)
return new Table(
header,
keys.map(key => [
key,
new Combine([
new Combine([new FixedUiElement("" + counts.get(key)).SetClass("font-bold rounded-full block")])
.SetClass("flex justify-center rounded border border-black")
.SetStyle(`background: ${actualAssignColor(key)}; width: ${100 * counts.get(key) / max}%`)
]).SetClass("block w-full")
]),{
contentStyle:keys.map(_ => ["width: 20%"])
const countHeader = new Combine([sortCount.SetClass("w-4 mr-2"), countTitle])
.SetClass("flex")
.onClick(() => {
if (sortMode.data === "count-rev") {
sortMode.setData("count")
} else {
sortMode.setData("count-rev")
}
).SetClass("w-full zebra-table");
}, [sortMode]));
})
const header = [titleHeader, countHeader]
super(
values.map(
(values) => {
if (values === undefined) {
return undefined
}
values = Utils.NoNull(values)
const counts = new Map<string, number>()
for (const value of values) {
const c = counts.get(value) ?? 0
counts.set(value, c + 1)
}
const keys = Array.from(counts.keys())
switch (sortMode.data) {
case "name":
keys.sort()
break
case "name-rev":
keys.sort().reverse(/*Copy of array, inplace reverse if fine*/)
break
case "count":
keys.sort((k0, k1) => counts.get(k0) - counts.get(k1))
break
case "count-rev":
keys.sort((k0, k1) => counts.get(k1) - counts.get(k0))
break
}
const max = Math.max(...Array.from(counts.values()))
const fallbackColor = (keyValue: string) => {
const index = keys.indexOf(keyValue)
return Histogram.defaultPalette[index % Histogram.defaultPalette.length]
}
let actualAssignColor = undefined
if (options?.assignColor === undefined) {
actualAssignColor = fallbackColor
} else {
actualAssignColor = (keyValue: string) => {
return options.assignColor(keyValue) ?? fallbackColor(keyValue)
}
}
return new Table(
header,
keys.map((key) => [
key,
new Combine([
new Combine([
new FixedUiElement("" + counts.get(key)).SetClass(
"font-bold rounded-full block"
),
])
.SetClass("flex justify-center rounded border border-black")
.SetStyle(
`background: ${actualAssignColor(key)}; width: ${
(100 * counts.get(key)) / max
}%`
),
]).SetClass("block w-full"),
]),
{
contentStyle: keys.map((_) => ["width: 20%"]),
}
).SetClass("w-full zebra-table")
},
[sortMode]
)
)
}
}
}

View file

@ -1,27 +1,29 @@
import Combine from "../Base/Combine";
import Translations from "../i18n/Translations";
import {FixedUiElement} from "../Base/FixedUiElement";
import Combine from "../Base/Combine"
import Translations from "../i18n/Translations"
import { FixedUiElement } from "../Base/FixedUiElement"
export default class IndexText extends Combine {
constructor() {
super([
new FixedUiElement(`<img class="w-12 h-12 sm:h-24 sm:w-24" src="./assets/svg/logo.svg" alt="MapComplete Logo">`)
.SetClass("flex-none m-3"),
new FixedUiElement(
`<img class="w-12 h-12 sm:h-24 sm:w-24" src="./assets/svg/logo.svg" alt="MapComplete Logo">`
).SetClass("flex-none m-3"),
new Combine([
Translations.t.index.title
.SetClass("text-2xl tracking-tight font-extrabold text-gray-900 sm:text-5xl md:text-6xl block text-gray-800 xl:inline"),
Translations.t.index.title.SetClass(
"text-2xl tracking-tight font-extrabold text-gray-900 sm:text-5xl md:text-6xl block text-gray-800 xl:inline"
),
Translations.t.index.intro.SetClass(
"mt-3 text-base font-semibold text-gray-500 sm:mt-5 sm:text-lg sm:max-w-xl sm:mx-auto md:mt-5 md:text-xl lg:mx-0"),
"mt-3 text-base font-semibold text-gray-500 sm:mt-5 sm:text-lg sm:max-w-xl sm:mx-auto md:mt-5 md:text-xl lg:mx-0"
),
Translations.t.index.pickTheme.SetClass("mt-3 text-base text-green-600 sm:mt-5 sm:text-lg sm:max-w-xl sm:mx-auto md:mt-5 md:text-xl lg:mx-0")
Translations.t.index.pickTheme.SetClass(
"mt-3 text-base text-green-600 sm:mt-5 sm:text-lg sm:max-w-xl sm:mx-auto md:mt-5 md:text-xl lg:mx-0"
),
]).SetClass("flex flex-col sm:text-center lg:text-left m-1 mt-2 md:m-2 md:mt-4"),
])
]).SetClass("flex flex-col sm:text-center lg:text-left m-1 mt-2 md:m-2 md:mt-4")
]);
this.SetClass("flex flex-row");
this.SetClass("flex flex-row")
}
}
}

View file

@ -1,93 +1,102 @@
import Combine from "../Base/Combine";
import ScrollableFullScreen from "../Base/ScrollableFullScreen";
import Translations from "../i18n/Translations";
import Toggle from "../Input/Toggle";
import MapControlButton from "../MapControlButton";
import Svg from "../../Svg";
import AllDownloads from "./AllDownloads";
import FilterView from "./FilterView";
import {Store, UIEventSource} from "../../Logic/UIEventSource";
import BackgroundMapSwitch from "./BackgroundMapSwitch";
import Lazy from "../Base/Lazy";
import {VariableUiElement} from "../Base/VariableUIElement";
import FeatureInfoBox from "../Popup/FeatureInfoBox";
import CopyrightPanel from "./CopyrightPanel";
import FeaturePipelineState from "../../Logic/State/FeaturePipelineState";
import Combine from "../Base/Combine"
import ScrollableFullScreen from "../Base/ScrollableFullScreen"
import Translations from "../i18n/Translations"
import Toggle from "../Input/Toggle"
import MapControlButton from "../MapControlButton"
import Svg from "../../Svg"
import AllDownloads from "./AllDownloads"
import FilterView from "./FilterView"
import { Store, UIEventSource } from "../../Logic/UIEventSource"
import BackgroundMapSwitch from "./BackgroundMapSwitch"
import Lazy from "../Base/Lazy"
import { VariableUiElement } from "../Base/VariableUIElement"
import FeatureInfoBox from "../Popup/FeatureInfoBox"
import CopyrightPanel from "./CopyrightPanel"
import FeaturePipelineState from "../../Logic/State/FeaturePipelineState"
export default class LeftControls extends Combine {
constructor(state: FeaturePipelineState,
guiState: {
currentViewControlIsOpened: UIEventSource<boolean>;
downloadControlIsOpened: UIEventSource<boolean>,
filterViewIsOpened: UIEventSource<boolean>,
copyrightViewIsOpened: UIEventSource<boolean>
}) {
constructor(
state: FeaturePipelineState,
guiState: {
currentViewControlIsOpened: UIEventSource<boolean>
downloadControlIsOpened: UIEventSource<boolean>
filterViewIsOpened: UIEventSource<boolean>
copyrightViewIsOpened: UIEventSource<boolean>
}
) {
const currentViewFL = state.currentView?.layer
const currentViewAction = new Toggle(
new Lazy(() => {
const feature: Store<any> = state.currentView.features.map(ffs => ffs[0]?.feature)
const icon = new VariableUiElement(feature.map(feature => {
const defaultIcon = Svg.checkbox_empty_svg()
if (feature === undefined) {
return defaultIcon;
}
const tags = {...feature.properties, button: "yes"}
const elem = currentViewFL.layerDef.mapRendering[0]?.GetSimpleIcon(new UIEventSource(tags));
if (elem === undefined) {
return defaultIcon
}
return elem
})).SetClass("inline-block w-full h-full")
const featureBox = new VariableUiElement(feature.map(feature => {
if (feature === undefined) {
return undefined
}
return new Lazy(() => {
const tagsSource = state.allElements.getEventSourceById(feature.properties.id)
return new FeatureInfoBox(tagsSource, currentViewFL.layerDef, state, {
hashToShow: "currentview",
isShown: guiState.currentViewControlIsOpened
})
.SetClass("md:floating-element-width")
const feature: Store<any> = state.currentView.features.map((ffs) => ffs[0]?.feature)
const icon = new VariableUiElement(
feature.map((feature) => {
const defaultIcon = Svg.checkbox_empty_svg()
if (feature === undefined) {
return defaultIcon
}
const tags = { ...feature.properties, button: "yes" }
const elem = currentViewFL.layerDef.mapRendering[0]?.GetSimpleIcon(
new UIEventSource(tags)
)
if (elem === undefined) {
return defaultIcon
}
return elem
})
})).SetStyle("width: 40rem").SetClass("block")
).SetClass("inline-block w-full h-full")
const featureBox = new VariableUiElement(
feature.map((feature) => {
if (feature === undefined) {
return undefined
}
return new Lazy(() => {
const tagsSource = state.allElements.getEventSourceById(
feature.properties.id
)
return new FeatureInfoBox(tagsSource, currentViewFL.layerDef, state, {
hashToShow: "currentview",
isShown: guiState.currentViewControlIsOpened,
}).SetClass("md:floating-element-width")
})
})
)
.SetStyle("width: 40rem")
.SetClass("block")
return new Toggle(
featureBox,
new MapControlButton(icon),
guiState.currentViewControlIsOpened
)
}).onClick(() => {
guiState.currentViewControlIsOpened.setData(true)
}),
undefined,
new UIEventSource<boolean>(currentViewFL !== undefined && currentViewFL?.layerDef?.tagRenderings !== null)
new UIEventSource<boolean>(
currentViewFL !== undefined && currentViewFL?.layerDef?.tagRenderings !== null
)
)
const toggledDownload = new Toggle(
new AllDownloads(
guiState.downloadControlIsOpened,
state
).SetClass("block p-1 rounded-full md:floating-element-width"),
new MapControlButton(Svg.download_svg())
.onClick(() => guiState.downloadControlIsOpened.setData(true)),
new AllDownloads(guiState.downloadControlIsOpened, state).SetClass(
"block p-1 rounded-full md:floating-element-width"
),
new MapControlButton(Svg.download_svg()).onClick(() =>
guiState.downloadControlIsOpened.setData(true)
),
guiState.downloadControlIsOpened
)
const downloadButtonn = new Toggle(
toggledDownload,
undefined,
state.featureSwitchEnableExport.map(downloadEnabled => downloadEnabled || state.featureSwitchExportAsPdf.data,
[state.featureSwitchExportAsPdf])
);
state.featureSwitchEnableExport.map(
(downloadEnabled) => downloadEnabled || state.featureSwitchExportAsPdf.data,
[state.featureSwitchExportAsPdf]
)
)
const toggledFilter = new Toggle(
new ScrollableFullScreen(
@ -99,16 +108,13 @@ export default class LeftControls extends Combine {
"filters",
guiState.filterViewIsOpened
).SetClass("rounded-lg md:floating-element-width"),
new MapControlButton(Svg.layers_svg())
.onClick(() => guiState.filterViewIsOpened.setData(true)),
new MapControlButton(Svg.layers_svg()).onClick(() =>
guiState.filterViewIsOpened.setData(true)
),
guiState.filterViewIsOpened
)
const filterButton = new Toggle(
toggledFilter,
undefined,
state.featureSwitchFilter
);
const filterButton = new Toggle(toggledFilter, undefined, state.featureSwitchFilter)
const mapSwitch = new Toggle(
new BackgroundMapSwitch(state, state.backgroundLayer),
@ -119,32 +125,26 @@ export default class LeftControls extends Combine {
// If the welcomeMessage is disabled, the copyright is hidden (as that is where the copyright is located
const copyright = new Toggle(
undefined,
new Lazy(() =>
new Toggle(
new ScrollableFullScreen(
() => Translations.t.general.attribution.attributionTitle,
() => new CopyrightPanel(state),
"copyright",
new Lazy(
() =>
new Toggle(
new ScrollableFullScreen(
() => Translations.t.general.attribution.attributionTitle,
() => new CopyrightPanel(state),
"copyright",
guiState.copyrightViewIsOpened
),
new MapControlButton(Svg.copyright_svg()).onClick(() =>
guiState.copyrightViewIsOpened.setData(true)
),
guiState.copyrightViewIsOpened
),
new MapControlButton(Svg.copyright_svg()).onClick(() => guiState.copyrightViewIsOpened.setData(true)),
guiState.copyrightViewIsOpened
)
)
),
state.featureSwitchWelcomeMessage
)
super([
currentViewAction,
filterButton,
downloadButtonn,
copyright,
mapSwitch
])
super([currentViewAction, filterButton, downloadButtonn, copyright, mapSwitch])
this.SetClass("flex flex-col")
}
}
}

View file

@ -1,36 +1,36 @@
import FloorLevelInputElement from "../Input/FloorLevelInputElement";
import MapState, {GlobalFilter} from "../../Logic/State/MapState";
import {TagsFilter} from "../../Logic/Tags/TagsFilter";
import {RegexTag} from "../../Logic/Tags/RegexTag";
import {Or} from "../../Logic/Tags/Or";
import {Tag} from "../../Logic/Tags/Tag";
import Translations from "../i18n/Translations";
import Combine from "../Base/Combine";
import {OsmFeature} from "../../Models/OsmFeature";
import {BBox} from "../../Logic/BBox";
import {TagUtils} from "../../Logic/Tags/TagUtils";
import FeaturePipeline from "../../Logic/FeatureSource/FeaturePipeline";
import {Store} from "../../Logic/UIEventSource";
import FloorLevelInputElement from "../Input/FloorLevelInputElement"
import MapState, { GlobalFilter } from "../../Logic/State/MapState"
import { TagsFilter } from "../../Logic/Tags/TagsFilter"
import { RegexTag } from "../../Logic/Tags/RegexTag"
import { Or } from "../../Logic/Tags/Or"
import { Tag } from "../../Logic/Tags/Tag"
import Translations from "../i18n/Translations"
import Combine from "../Base/Combine"
import { OsmFeature } from "../../Models/OsmFeature"
import { BBox } from "../../Logic/BBox"
import { TagUtils } from "../../Logic/Tags/TagUtils"
import FeaturePipeline from "../../Logic/FeatureSource/FeaturePipeline"
import { Store } from "../../Logic/UIEventSource"
/***
* The element responsible for the level input element and picking the right level, showing and hiding at the right time, ...
*/
export default class LevelSelector extends Combine {
constructor(state: MapState & { featurePipeline: FeaturePipeline }) {
const levelsInView : Store< Record<string, number>> = state.currentBounds.map(bbox => {
const levelsInView: Store<Record<string, number>> = state.currentBounds.map((bbox) => {
if (bbox === undefined) {
return {}
}
const allElementsUnfiltered: OsmFeature[] = [].concat(...state.featurePipeline.GetAllFeaturesAndMetaWithin(bbox).map(ff => ff.features))
const allElements = allElementsUnfiltered.filter(f => BBox.get(f).overlapsWith(bbox))
const allLevelsRaw: string[] = allElements.map(f => f.properties["level"])
const levels : Record<string, number> = {"0": 0}
const allElementsUnfiltered: OsmFeature[] = [].concat(
...state.featurePipeline.GetAllFeaturesAndMetaWithin(bbox).map((ff) => ff.features)
)
const allElements = allElementsUnfiltered.filter((f) => BBox.get(f).overlapsWith(bbox))
const allLevelsRaw: string[] = allElements.map((f) => f.properties["level"])
const levels: Record<string, number> = { "0": 0 }
for (const levelDescription of allLevelsRaw) {
if(levelDescription === undefined){
levels["0"] ++
if (levelDescription === undefined) {
levels["0"]++
}
for (const level of TagUtils.LevelsParser(levelDescription)) {
levels[level] = (levels[level] ?? 0) + 1
@ -46,61 +46,66 @@ export default class LevelSelector extends Combine {
filter: {
currentFilter: undefined,
state: undefined,
},
id: "level",
onNewPoint: undefined
onNewPoint: undefined,
})
const isShown = levelsInView.map(levelsInView => {
const isShown = levelsInView.map(
(levelsInView) => {
if (state.locationControl.data.zoom <= 16) {
return false;
return false
}
if (Object.keys(levelsInView).length == 1) {
return false;
return false
}
return true;
return true
},
[state.locationControl])
[state.locationControl]
)
function setLevelFilter() {
console.log("Updating levels filter to ", levelSelect.GetValue().data, " is shown:", isShown.data)
const filter: GlobalFilter = state.globalFilters.data.find(gf => gf.id === "level")
console.log(
"Updating levels filter to ",
levelSelect.GetValue().data,
" is shown:",
isShown.data
)
const filter: GlobalFilter = state.globalFilters.data.find((gf) => gf.id === "level")
if (!isShown.data) {
filter.filter = {
state: "*",
currentFilter: undefined,
}
filter.onNewPoint = undefined
state.globalFilters.ping();
state.globalFilters.ping()
return
}
const l = levelSelect.GetValue().data
if(l === undefined){
if (l === undefined) {
return
}
let neededLevel: TagsFilter = new RegexTag("level", new RegExp("(^|;)" + l + "(;|$)"));
let neededLevel: TagsFilter = new RegexTag("level", new RegExp("(^|;)" + l + "(;|$)"))
if (l === "0") {
neededLevel = new Or([neededLevel, new Tag("level", "")])
}
filter.filter = {
state: l,
currentFilter: neededLevel
currentFilter: neededLevel,
}
const t = Translations.t.general.levelSelection
filter.onNewPoint = {
confirmAddNew: t.confirmLevel.PartialSubs({level: l}),
safetyCheck: t.addNewOnLevel.Subs({level: l}),
tags: [new Tag("level", l)]
confirmAddNew: t.confirmLevel.PartialSubs({ level: l }),
safetyCheck: t.addNewOnLevel.Subs({ level: l }),
tags: [new Tag("level", l)],
}
state.globalFilters.ping();
return;
state.globalFilters.ping()
return
}
isShown.addCallbackAndRun(shown => {
isShown.addCallbackAndRun((shown) => {
console.log("Is level selector shown?", shown)
setLevelFilter()
if (shown) {
@ -110,31 +115,36 @@ export default class LevelSelector extends Combine {
}
})
levelsInView.addCallbackAndRun(levels => {
if(!isShown.data){
levelsInView.addCallbackAndRun((levels) => {
if (!isShown.data) {
return
}
const value = levelSelect.GetValue()
if (!(levels[value.data] === undefined || levels[value.data] === 0)) {
return;
return
}
// Nothing in view. Lets switch to a different level (the level with the most features)
let mostElements = 0
let mostElementsLevel = undefined
for (const level in levels) {
const count = levels[level]
if(mostElementsLevel === undefined || mostElements < count){
if (mostElementsLevel === undefined || mostElements < count) {
mostElementsLevel = level
mostElements = count
}
}
console.log("Force switching to a different level:", mostElementsLevel,"as it has",mostElements,"elements on that floor",levels,"(old level: "+value.data+")")
value.setData(mostElementsLevel )
console.log(
"Force switching to a different level:",
mostElementsLevel,
"as it has",
mostElements,
"elements on that floor",
levels,
"(old level: " + value.data + ")"
)
value.setData(mostElementsLevel)
})
levelSelect.GetValue().addCallback(_ => setLevelFilter())
levelSelect.GetValue().addCallback((_) => setLevelFilter())
super([levelSelect])
}
}
}

View file

@ -1,32 +1,33 @@
import {DropDown} from "../Input/DropDown";
import Translations from "../i18n/Translations";
import {UIEventSource} from "../../Logic/UIEventSource";
import {OsmConnection} from "../../Logic/Osm/OsmConnection";
import {Translation} from "../i18n/Translation";
import { DropDown } from "../Input/DropDown"
import Translations from "../i18n/Translations"
import { UIEventSource } from "../../Logic/UIEventSource"
import { OsmConnection } from "../../Logic/Osm/OsmConnection"
import { Translation } from "../i18n/Translation"
export default class LicensePicker extends DropDown<string> {
private static readonly cc0 = "CC0"
private static readonly ccbysa = "CC-BY-SA 4.0"
private static readonly ccby = "CC-BY 4.0"
constructor(state: { osmConnection: OsmConnection }) {
super(Translations.t.image.willBePublished.Clone(),
super(
Translations.t.image.willBePublished.Clone(),
[
{value: LicensePicker.cc0, shown: Translations.t.image.cco.Clone()},
{value: LicensePicker.ccbysa, shown: Translations.t.image.ccbs.Clone()},
{value: LicensePicker.ccby, shown: Translations.t.image.ccb.Clone()}
{ value: LicensePicker.cc0, shown: Translations.t.image.cco.Clone() },
{ value: LicensePicker.ccbysa, shown: Translations.t.image.ccbs.Clone() },
{ value: LicensePicker.ccby, shown: Translations.t.image.ccb.Clone() },
],
state?.osmConnection?.GetPreference("pictures-license") ?? new UIEventSource<string>("CC0"),
state?.osmConnection?.GetPreference("pictures-license") ??
new UIEventSource<string>("CC0"),
{
select_class:"w-min bg-indigo-100 p-1 rounded hover:bg-indigo-200"
select_class: "w-min bg-indigo-100 p-1 rounded hover:bg-indigo-200",
}
)
this.SetClass("flex flex-col sm:flex-row").SetStyle("float:left");
this.SetClass("flex flex-col sm:flex-row").SetStyle("float:left")
}
public static LicenseExplanations(): Map<string, Translation> {
let dict = new Map<string, Translation>();
let dict = new Map<string, Translation>()
dict.set(LicensePicker.cc0, Translations.t.image.ccoExplanation)
dict.set(LicensePicker.ccby, Translations.t.image.ccbExplanation)
@ -34,5 +35,4 @@ export default class LicensePicker extends DropDown<string> {
return dict
}
}
}

View file

@ -1,24 +1,29 @@
import {VariableUiElement} from "../Base/VariableUIElement";
import {UIEventSource} from "../../Logic/UIEventSource";
import Loc from "../../Models/Loc";
import Translations from "../i18n/Translations";
import {SubtleButton} from "../Base/SubtleButton";
import Svg from "../../Svg";
import Combine from "../Base/Combine";
import Title from "../Base/Title";
import { VariableUiElement } from "../Base/VariableUIElement"
import { UIEventSource } from "../../Logic/UIEventSource"
import Loc from "../../Models/Loc"
import Translations from "../i18n/Translations"
import { SubtleButton } from "../Base/SubtleButton"
import Svg from "../../Svg"
import Combine from "../Base/Combine"
import Title from "../Base/Title"
export class MapillaryLink extends VariableUiElement {
constructor(state: { locationControl: UIEventSource<Loc> }, iconStyle?: string) {
const t = Translations.t.general.attribution
super(state.locationControl.map(location => {
const mapillaryLink = `https://www.mapillary.com/app/?focus=map&lat=${location?.lat ?? 0}&lng=${location?.lon ?? 0}&z=${Math.max((location?.zoom ?? 2) - 1, 1)}`
return new SubtleButton(Svg.mapillary_black_ui().SetStyle(iconStyle),
new Combine([
t.openMapillary.SetClass("font-bold"),
t.mapillaryHelp]), {
url: mapillaryLink,
newTab: true
}).SetClass("flex flex-col link-no-underline")
}))
super(
state.locationControl.map((location) => {
const mapillaryLink = `https://www.mapillary.com/app/?focus=map&lat=${
location?.lat ?? 0
}&lng=${location?.lon ?? 0}&z=${Math.max((location?.zoom ?? 2) - 1, 1)}`
return new SubtleButton(
Svg.mapillary_black_ui().SetStyle(iconStyle),
new Combine([t.openMapillary.SetClass("font-bold"), t.mapillaryHelp]),
{
url: mapillaryLink,
newTab: true,
}
).SetClass("flex flex-col link-no-underline")
})
)
}
}
}

View file

@ -1,82 +1,90 @@
import {VariableUiElement} from "../Base/VariableUIElement";
import Svg from "../../Svg";
import Combine from "../Base/Combine";
import {SubtleButton} from "../Base/SubtleButton";
import Translations from "../i18n/Translations";
import { VariableUiElement } from "../Base/VariableUIElement"
import Svg from "../../Svg"
import Combine from "../Base/Combine"
import { SubtleButton } from "../Base/SubtleButton"
import Translations from "../i18n/Translations"
import * as personal from "../../assets/themes/personal/personal.json"
import Constants from "../../Models/Constants";
import BaseUIElement from "../BaseUIElement";
import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig";
import {ImmutableStore, Store, Stores, UIEventSource} from "../../Logic/UIEventSource";
import Loc from "../../Models/Loc";
import {OsmConnection} from "../../Logic/Osm/OsmConnection";
import UserRelatedState from "../../Logic/State/UserRelatedState";
import Toggle from "../Input/Toggle";
import {Utils} from "../../Utils";
import Title from "../Base/Title";
import Constants from "../../Models/Constants"
import BaseUIElement from "../BaseUIElement"
import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig"
import { ImmutableStore, Store, Stores, UIEventSource } from "../../Logic/UIEventSource"
import Loc from "../../Models/Loc"
import { OsmConnection } from "../../Logic/Osm/OsmConnection"
import UserRelatedState from "../../Logic/State/UserRelatedState"
import Toggle from "../Input/Toggle"
import { Utils } from "../../Utils"
import Title from "../Base/Title"
import * as themeOverview from "../../assets/generated/theme_overview.json"
import {Translation} from "../i18n/Translation";
import {TextField} from "../Input/TextField";
import FilteredCombine from "../Base/FilteredCombine";
import Locale from "../i18n/Locale";
import { Translation } from "../i18n/Translation"
import { TextField } from "../Input/TextField"
import FilteredCombine from "../Base/FilteredCombine"
import Locale from "../i18n/Locale"
export default class MoreScreen extends Combine {
private static readonly officialThemes: {
id: string,
icon: string,
title: any,
shortDescription: any,
definition?: any,
mustHaveLanguage?: boolean,
hideFromOverview: boolean,
id: string
icon: string
title: any
shortDescription: any
definition?: any
mustHaveLanguage?: boolean
hideFromOverview: boolean
keywors?: any[]
}[] = themeOverview["default"];
constructor(state: UserRelatedState & {
locationControl?: UIEventSource<Loc>,
layoutToUse?: LayoutConfig
}, onMainScreen: boolean = false) {
const tr = Translations.t.general.morescreen;
}[] = themeOverview["default"]
constructor(
state: UserRelatedState & {
locationControl?: UIEventSource<Loc>
layoutToUse?: LayoutConfig
},
onMainScreen: boolean = false
) {
const tr = Translations.t.general.morescreen
let themeButtonStyle = ""
let themeListStyle = ""
if (onMainScreen) {
themeButtonStyle = "h-32 min-h-32 max-h-32 overflow-ellipsis overflow-hidden"
themeListStyle = "md:grid md:grid-flow-row md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-g4 gap-4"
themeButtonStyle = "h-32 min-h-32 max-h-32 text-ellipsis overflow-hidden"
themeListStyle =
"md:grid md:grid-flow-row md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-g4 gap-4"
}
const search = new TextField({
placeholder: tr.searchForATheme,
})
search.enterPressed.addCallbackD(searchTerm => {
search.enterPressed.addCallbackD((searchTerm) => {
searchTerm = searchTerm.toLowerCase()
if(searchTerm === "personal"){
window.location.href = MoreScreen.createUrlFor({id: "personal"}, false, state).data
if (searchTerm === "personal") {
window.location.href = MoreScreen.createUrlFor(
{ id: "personal" },
false,
state
).data
}
if(searchTerm === "bugs" || searchTerm === "issues") {
if (searchTerm === "bugs" || searchTerm === "issues") {
window.location.href = "https://github.com/pietervdvn/MapComplete/issues"
}
if(searchTerm === "source") {
if (searchTerm === "source") {
window.location.href = "https://github.com/pietervdvn/MapComplete"
}
if(searchTerm === "docs") {
if (searchTerm === "docs") {
window.location.href = "https://github.com/pietervdvn/MapComplete/tree/develop/Docs"
}
if(searchTerm === "osmcha" || searchTerm === "stats"){
if (searchTerm === "osmcha" || searchTerm === "stats") {
window.location.href = Utils.OsmChaLinkFor(7)
}
// Enter pressed -> search the first _official_ matchin theme and open it
const publicTheme = MoreScreen.officialThemes.find(th =>
th.hideFromOverview == false &&
th.id !== "personal" &&
MoreScreen.MatchesLayoutFunc(th)(searchTerm))
const publicTheme = MoreScreen.officialThemes.find(
(th) =>
th.hideFromOverview == false &&
th.id !== "personal" &&
MoreScreen.MatchesLayoutFunc(th)(searchTerm)
)
if (publicTheme !== undefined) {
window.location.href = MoreScreen.createUrlFor(publicTheme, false, state).data
}
const hiddenTheme = MoreScreen.officialThemes.find(th =>
th.id !== "personal" &&
MoreScreen.MatchesLayoutFunc(th)(searchTerm))
const hiddenTheme = MoreScreen.officialThemes.find(
(th) => th.id !== "personal" && MoreScreen.MatchesLayoutFunc(th)(searchTerm)
)
if (hiddenTheme !== undefined) {
window.location.href = MoreScreen.createUrlFor(hiddenTheme, false, state).data
}
@ -88,53 +96,71 @@ export default class MoreScreen extends Combine {
document.addEventListener("keydown", function (event) {
if (event.ctrlKey && event.code === "KeyF") {
search.focus()
event.preventDefault();
event.preventDefault()
}
});
const searchBar = new Combine([Svg.search_svg().SetClass("w-8"), search.SetClass("mr-4 w-full")])
.SetClass("flex rounded-full border-2 border-black items-center my-2 w-1/2")
})
const searchBar = new Combine([
Svg.search_svg().SetClass("w-8"),
search.SetClass("mr-4 w-full"),
]).SetClass("flex rounded-full border-2 border-black items-center my-2 w-1/2")
super([
new Combine([searchBar]).SetClass("flex justify-center"),
MoreScreen.createOfficialThemesList(state, themeButtonStyle, themeListStyle, search.GetValue()),
MoreScreen.createPreviouslyVistedHiddenList(state, themeButtonStyle, themeListStyle, search.GetValue()),
MoreScreen.createUnofficialThemeList(themeButtonStyle, state, themeListStyle, search.GetValue()),
tr.streetcomplete.Clone().SetClass("block text-base mx-10 my-3 mb-10")
]);
MoreScreen.createOfficialThemesList(
state,
themeButtonStyle,
themeListStyle,
search.GetValue()
),
MoreScreen.createPreviouslyVistedHiddenList(
state,
themeButtonStyle,
themeListStyle,
search.GetValue()
),
MoreScreen.createUnofficialThemeList(
themeButtonStyle,
state,
themeListStyle,
search.GetValue()
),
tr.streetcomplete.Clone().SetClass("block text-base mx-10 my-3 mb-10"),
])
}
private static NothingFound(search: UIEventSource<string>): BaseUIElement {
const t = Translations.t.general.morescreen;
const t = Translations.t.general.morescreen
return new Combine([
new Title(t.noMatchingThemes, 5).SetClass("w-max font-bold"),
new SubtleButton(Svg.search_disable_ui(), t.noSearch, {imgSize: "h-6"}).SetClass("h-12 w-max")
.onClick(() => search.setData(""))
new SubtleButton(Svg.search_disable_ui(), t.noSearch, { imgSize: "h-6" })
.SetClass("h-12 w-max")
.onClick(() => search.setData("")),
]).SetClass("flex flex-col items-center w-full")
}
private static createUrlFor(layout: { id: string, definition?: string },
isCustom: boolean,
state?: { locationControl?: UIEventSource<{ lat, lon, zoom }>, layoutToUse?: { id } }
private static createUrlFor(
layout: { id: string; definition?: string },
isCustom: boolean,
state?: { locationControl?: UIEventSource<{ lat; lon; zoom }>; layoutToUse?: { id } }
): Store<string> {
if (layout === undefined) {
return undefined;
return undefined
}
if (layout.id === undefined) {
console.error("ID is undefined for layout", layout);
return undefined;
console.error("ID is undefined for layout", layout)
return undefined
}
if (layout.id === state?.layoutToUse?.id) {
return undefined;
return undefined
}
const currentLocation = state?.locationControl;
const currentLocation = state?.locationControl
let path = window.location.pathname;
let path = window.location.pathname
// Path starts with a '/' and contains everything, e.g. '/dir/dir/page.html'
path = path.substr(0, path.lastIndexOf("/"));
path = path.substr(0, path.lastIndexOf("/"))
// Path will now contain '/dir/dir', or empty string in case of nothing
if (path === "") {
path = "."
@ -154,18 +180,19 @@ export default class MoreScreen extends Combine {
hash = "#" + btoa(JSON.stringify(layout.definition))
}
return currentLocation?.map(currentLocation => {
const params = [
["z", currentLocation?.zoom],
["lat", currentLocation?.lat],
["lon", currentLocation?.lon]
].filter(part => part[1] !== undefined)
.map(part => part[0] + "=" + part[1])
.join("&")
return `${linkPrefix}${params}${hash}`;
}) ?? new ImmutableStore<string>(`${linkPrefix}`)
return (
currentLocation?.map((currentLocation) => {
const params = [
["z", currentLocation?.zoom],
["lat", currentLocation?.lat],
["lon", currentLocation?.lon],
]
.filter((part) => part[1] !== undefined)
.map((part) => part[0] + "=" + part[1])
.join("&")
return `${linkPrefix}${params}${hash}`
}) ?? new ImmutableStore<string>(`${linkPrefix}`)
)
}
/**
@ -174,135 +201,157 @@ export default class MoreScreen extends Combine {
*/
public static createLinkButton(
state: {
locationControl?: UIEventSource<Loc>,
locationControl?: UIEventSource<Loc>
layoutToUse?: LayoutConfig
}, layout: {
id: string,
icon: string,
title: any,
shortDescription: any,
definition?: any,
},
layout: {
id: string
icon: string
title: any
shortDescription: any
definition?: any
mustHaveLanguage?: boolean
}, isCustom: boolean = false
):
BaseUIElement {
},
isCustom: boolean = false
): BaseUIElement {
const url = MoreScreen.createUrlFor(layout, isCustom, state)
let content = new Combine([
new Translation(layout.title, !isCustom && !layout.mustHaveLanguage ? "themes:" + layout.id + ".title" : undefined),
new Translation(
layout.title,
!isCustom && !layout.mustHaveLanguage ? "themes:" + layout.id + ".title" : undefined
),
new Translation(layout.shortDescription)?.SetClass("subtle") ?? "",
]).SetClass("overflow-hidden flex flex-col")
if(state.layoutToUse === undefined){
if (state.layoutToUse === undefined) {
// Currently on the index screen: we style the buttons equally large
content = new Combine([content]).SetClass("flex flex-col justify-center h-24")
}
return new SubtleButton(layout.icon, content, {url, newTab: false});
return new SubtleButton(layout.icon, content, { url, newTab: false })
}
public static CreateProffessionalSerivesButton() {
const t = Translations.t.professional.indexPage;
const t = Translations.t.professional.indexPage
return new Combine([
new Title(t.hook, 4),
t.hookMore,
new SubtleButton(undefined, t.button, {url: "./professional.html"}),
new SubtleButton(undefined, t.button, { url: "./professional.html" }),
]).SetClass("flex flex-col border border-gray-300 p-2 rounded-lg")
}
private static createUnofficialThemeList(buttonClass: string, state: UserRelatedState, themeListClasses: string, search: UIEventSource<string>): BaseUIElement {
private static createUnofficialThemeList(
buttonClass: string,
state: UserRelatedState,
themeListClasses: string,
search: UIEventSource<string>
): BaseUIElement {
var currentIds: Store<string[]> = state.installedUserThemes
var stableIds = Stores.ListStabilized<string>(currentIds)
return new VariableUiElement(
stableIds.map(ids => {
const allThemes: { element: BaseUIElement, predicate?: (s: string) => boolean }[] = []
stableIds.map((ids) => {
const allThemes: { element: BaseUIElement; predicate?: (s: string) => boolean }[] =
[]
for (const id of ids) {
const themeInfo = state.GetUnofficialTheme(id)
if(themeInfo === undefined){
if (themeInfo === undefined) {
continue
}
const link = MoreScreen.createLinkButton(state, themeInfo, true)
if (link !== undefined) {
allThemes.push({
element: link.SetClass(buttonClass),
predicate: s => id.toLowerCase().indexOf(s) >= 0
predicate: (s) => id.toLowerCase().indexOf(s) >= 0,
})
}
}
if (allThemes.length <= 0) {
return undefined;
return undefined
}
return new Combine([
Translations.t.general.customThemeIntro,
new FilteredCombine(allThemes, search, {
innerClasses: themeListClasses,
onEmpty: MoreScreen.NothingFound(search)
})
]);
}));
onEmpty: MoreScreen.NothingFound(search),
}),
])
})
)
}
private static createPreviouslyVistedHiddenList(state: UserRelatedState, buttonClass: string, themeListStyle: string, search: UIEventSource<string>): BaseUIElement {
private static createPreviouslyVistedHiddenList(
state: UserRelatedState,
buttonClass: string,
themeListStyle: string,
search: UIEventSource<string>
): BaseUIElement {
const t = Translations.t.general.morescreen
const prefix = "mapcomplete-hidden-theme-"
const hiddenThemes = themeOverview["default"].filter(layout => layout.hideFromOverview)
const hiddenThemes = themeOverview["default"].filter((layout) => layout.hideFromOverview)
const hiddenTotal = hiddenThemes.length
return new Toggle(
new VariableUiElement(
state.osmConnection.preferencesHandler.preferences.map(allPreferences => {
const knownThemes: Set<string> = new Set(Utils.NoNull(Object.keys(allPreferences)
.filter(key => key.startsWith(prefix))
.map(key => key.substring(prefix.length, key.length - "-enabled".length))));
state.osmConnection.preferencesHandler.preferences.map((allPreferences) => {
const knownThemes: Set<string> = new Set(
Utils.NoNull(
Object.keys(allPreferences)
.filter((key) => key.startsWith(prefix))
.map((key) =>
key.substring(prefix.length, key.length - "-enabled".length)
)
)
)
if (knownThemes.size === 0) {
return undefined
}
const knownThemeDescriptions = hiddenThemes.filter(theme => knownThemes.has(theme.id))
.map(theme => ({
element: MoreScreen.createLinkButton(state, theme)?.SetClass(buttonClass),
predicate: MoreScreen.MatchesLayoutFunc(theme)
}));
const knownThemeDescriptions = hiddenThemes
.filter((theme) => knownThemes.has(theme.id))
.map((theme) => ({
element: MoreScreen.createLinkButton(state, theme)?.SetClass(
buttonClass
),
predicate: MoreScreen.MatchesLayoutFunc(theme),
}))
const knownLayouts = new FilteredCombine(knownThemeDescriptions,
search,
{
innerClasses: themeListStyle,
onEmpty: MoreScreen.NothingFound(search)
}
)
const knownLayouts = new FilteredCombine(knownThemeDescriptions, search, {
innerClasses: themeListStyle,
onEmpty: MoreScreen.NothingFound(search),
})
return new Combine([
new Title(t.previouslyHiddenTitle),
t.hiddenExplanation.Subs({
hidden_discovered: "" + knownThemes.size,
total_hidden: "" + hiddenTotal
total_hidden: "" + hiddenTotal,
}),
knownLayouts
knownLayouts,
])
})
).SetClass("flex flex-col"),
undefined,
state.osmConnection.isLoggedIn
)
}
private static MatchesLayoutFunc(layout: {
id: string,
title: any,
shortDescription: any,
id: string
title: any
shortDescription: any
keywords?: any[]
}): ((search: string) => boolean) {
}): (search: string) => boolean {
return (search: string) => {
search = search.toLocaleLowerCase()
if (layout.id.toLowerCase().indexOf(search) >= 0) {
return true;
return true
}
const entitiesToSearch = [layout.shortDescription, layout.title, ...(layout.keywords ?? [])]
const entitiesToSearch = [
layout.shortDescription,
layout.title,
...(layout.keywords ?? []),
]
for (const entity of entitiesToSearch) {
if (entity === undefined) {
continue
@ -313,73 +362,73 @@ export default class MoreScreen extends Combine {
}
}
return false;
return false
}
}
private static createOfficialThemesList(state: { osmConnection: OsmConnection, locationControl?: UIEventSource<Loc> }, buttonClass: string, themeListStyle: string, search: UIEventSource<string>): BaseUIElement {
private static createOfficialThemesList(
state: { osmConnection: OsmConnection; locationControl?: UIEventSource<Loc> },
buttonClass: string,
themeListStyle: string,
search: UIEventSource<string>
): BaseUIElement {
let buttons: { element: BaseUIElement; predicate?: (s: string) => boolean }[] =
MoreScreen.officialThemes.map((layout) => {
if (layout === undefined) {
console.trace("Layout is undefined")
return undefined
}
if (layout.hideFromOverview) {
return undefined
}
const button = MoreScreen.createLinkButton(state, layout)?.SetClass(buttonClass)
if (layout.id === personal.id) {
const element = new VariableUiElement(
state.osmConnection.userDetails
.map((userdetails) => userdetails.csCount)
.map((csCount) => {
if (csCount < Constants.userJourney.personalLayoutUnlock) {
return undefined
} else {
return button
}
})
)
return { element }
}
return { element: button, predicate: MoreScreen.MatchesLayoutFunc(layout) }
})
let buttons: { element: BaseUIElement, predicate?: (s: string) => boolean }[] = MoreScreen.officialThemes.map((layout) => {
if (layout === undefined) {
console.trace("Layout is undefined")
return undefined
}
if (layout.hideFromOverview) {
return undefined;
}
const button = MoreScreen.createLinkButton(state, layout)?.SetClass(buttonClass);
if (layout.id === personal.id) {
const element = new VariableUiElement(
state.osmConnection.userDetails.map(userdetails => userdetails.csCount)
.map(csCount => {
if (csCount < Constants.userJourney.personalLayoutUnlock) {
return undefined
} else {
return button
}
})
)
return {element}
}
return {element: button, predicate: MoreScreen.MatchesLayoutFunc(layout)};
})
const professional = MoreScreen.CreateProffessionalSerivesButton();
const professional = MoreScreen.CreateProffessionalSerivesButton()
const customGeneratorLink = MoreScreen.createCustomGeneratorButton(state)
buttons.splice(0, 0,
{element: customGeneratorLink},
{element: professional});
buttons.splice(0, 0, { element: customGeneratorLink }, { element: professional })
return new FilteredCombine(buttons, search, {
innerClasses: themeListStyle,
onEmpty: MoreScreen.NothingFound(search)
});
onEmpty: MoreScreen.NothingFound(search),
})
}
/*
* Returns either a link to the issue tracker or a link to the custom generator, depending on the achieved number of changesets
* */
private static createCustomGeneratorButton(state: { osmConnection: OsmConnection }): VariableUiElement {
const tr = Translations.t.general.morescreen;
* Returns either a link to the issue tracker or a link to the custom generator, depending on the achieved number of changesets
* */
private static createCustomGeneratorButton(state: {
osmConnection: OsmConnection
}): VariableUiElement {
const tr = Translations.t.general.morescreen
return new VariableUiElement(
state.osmConnection.userDetails.map(userDetails => {
state.osmConnection.userDetails.map((userDetails) => {
if (userDetails.csCount < Constants.userJourney.themeGeneratorReadOnlyUnlock) {
return new SubtleButton(null, tr.requestATheme.Clone(), {
url: "https://github.com/pietervdvn/MapComplete/issues",
newTab: true
});
newTab: true,
})
}
return new SubtleButton(Svg.pencil_ui(), tr.createYourOwnTheme.Clone(), {
url: "https://pietervdvn.github.io/mc/legacy/070/customGenerator.html",
newTab: false
});
newTab: false,
})
})
)
}
}
}

View file

@ -0,0 +1,347 @@
import Combine from "../Base/Combine";
import {FlowPanelFactory, FlowStep} from "../ImportFlow/FlowStep";
import {ImmutableStore, Store, UIEventSource} from "../../Logic/UIEventSource";
import {InputElement} from "../Input/InputElement";
import {SvgToPdf, SvgToPdfOptions} from "../../Utils/svgToPdf";
import {FixedInputElement} from "../Input/FixedInputElement";
import {FixedUiElement} from "../Base/FixedUiElement";
import FileSelectorButton from "../Input/FileSelectorButton";
import InputElementMap from "../Input/InputElementMap";
import {RadioButton} from "../Input/RadioButton";
import {Utils} from "../../Utils";
import {VariableUiElement} from "../Base/VariableUIElement";
import Loading from "../Base/Loading";
import BaseUIElement from "../BaseUIElement";
import Img from "../Base/Img";
import Title from "../Base/Title";
import {CheckBox} from "../Input/Checkboxes";
import Minimap from "../Base/Minimap";
import SearchAndGo from "./SearchAndGo";
import Toggle from "../Input/Toggle";
import List from "../Base/List";
import LeftIndex from "../Base/LeftIndex";
import Constants from "../../Models/Constants";
import Toggleable from "../Base/Toggleable";
import Lazy from "../Base/Lazy";
import LinkToWeblate from "../Base/LinkToWeblate";
import Link from "../Base/Link";
import {SearchablePillsSelector} from "../Input/SearchableMappingsSelector";
import * as languages from "../../assets/language_translations.json"
import {Translation} from "../i18n/Translation";
class SelectTemplate extends Combine implements FlowStep<{ title: string, pages: string[] }> {
readonly IsValid: Store<boolean>;
readonly Value: Store<{ title: string, pages: string[] }>;
constructor() {
const elements: InputElement<{ templateName: string, pages: string[] }>[] = []
for (const templateName in SvgToPdf.templates) {
const template = SvgToPdf.templates[templateName]
elements.push(new FixedInputElement(
new Combine([new FixedUiElement(templateName).SetClass("font-bold pr-2"),
template.description
])
, new UIEventSource({templateName, pages: template.pages})))
}
const file = new FileSelectorButton(new FixedUiElement("Select an svg image which acts as template"), {
acceptType: "image/svg+xml",
allowMultiple: true
})
const fileMapped = new InputElementMap<FileList, { templateName: string, pages: string[], fromFile: true }>(file, (x0, x1) => x0 === x1,
(filelist) => {
if (filelist === undefined) {
return undefined;
}
const pages = []
let templateName: string = undefined;
for (const file of Array.from(filelist)) {
if (templateName == undefined) {
templateName = file.name.substring(file.name.lastIndexOf("/") + 1)
templateName = templateName.substring(0, templateName.lastIndexOf("."))
}
pages.push(file.text())
}
return {
templateName,
pages,
fromFile: true
}
},
_ => undefined
)
elements.push(fileMapped)
const radio = new RadioButton(elements, {selectFirstAsDefault: true})
const loaded: Store<{ success: { title: string, pages: string[] } } | { error: any }> = radio.GetValue().bind(template => {
if (template === undefined) {
return undefined
}
if (template["fromFile"]) {
return UIEventSource.FromPromiseWithErr(Promise.all(template.pages).then(pages => ({
title: template.templateName,
pages
})))
}
const urls = template.pages.map(p => SelectTemplate.ToUrl(p))
const dloadAll: Promise<{ title: string, pages: string[] }> = Promise.all(urls.map(url => Utils.download(url))).then(pages => ({
pages,
title: template.templateName
}))
return UIEventSource.FromPromiseWithErr(dloadAll)
})
const preview = new VariableUiElement(
loaded.map(pages => {
if (pages === undefined) {
return new Loading()
}
if (pages["error"] !== undefined) {
return new FixedUiElement("Loading preview failed: " + pages["err"]).SetClass("alert")
}
const svgs = pages["success"].pages
if (svgs.length === 0) {
return new FixedUiElement("No pages loaded...").SetClass("alert")
}
const els: BaseUIElement[] = []
for (const pageSrc of svgs) {
const el = new Img(pageSrc, true)
.SetClass("w-96 m-2 border-black border-2")
els.push(el)
}
return new Combine(els).SetClass("flex border border-subtle rounded-xl");
})
)
super([
new Title("Select template"),
radio,
new Title("Preview"),
preview
]);
this.Value = loaded.map(l => l === undefined ? undefined : l["success"])
this.IsValid = this.Value.map(v => v !== undefined)
}
public static ToUrl(spec: string) {
if (spec.startsWith("http")) {
return spec
}
let path = window.location.pathname
path = path.substring(0, path.lastIndexOf("/"))
return window.location.protocol + "//" + window.location.host + path + "/" + spec
}
}
class SelectPdfOptions extends Combine implements FlowStep<{ title: string, pages: string[], options: SvgToPdfOptions }> {
readonly IsValid: Store<boolean>;
readonly Value: Store<{ title: string, pages: string[], options: SvgToPdfOptions }>;
constructor(title: string, pages: string[], getFreeDiv: () => string) {
const dummy = new CheckBox("Don't add data to the map (to quickly preview the PDF)", false)
const overrideMapLocation = new CheckBox("Override map location: use a selected location instead of the location set in the template", false)
const locationInput = Minimap.createMiniMap().SetClass("block w-full")
const searchField = new SearchAndGo({leafletMap: locationInput.leafletMap})
const selectLocation =
new Combine([
new Toggle(new Combine([new Title("Select override location"), searchField]).SetClass("flex"), undefined, overrideMapLocation.GetValue()),
new Toggle(locationInput.SetStyle("height: 20rem"), undefined, overrideMapLocation.GetValue()).SetStyle("height: 20rem")
]).SetClass("block").SetStyle("height: 25rem")
super([new Title("Select options"),
dummy,
overrideMapLocation,
selectLocation
]);
this.Value = dummy.GetValue().map((disableMaps) => {
return {
pages,
title,
options: <SvgToPdfOptions>{
disableMaps,
getFreeDiv,
overrideLocation: overrideMapLocation.GetValue().data ? locationInput.location.data : undefined
}
}
}, [overrideMapLocation.GetValue(), locationInput.location])
this.IsValid = new ImmutableStore(true)
}
}
class PreparePdf extends Combine implements FlowStep<{ svgToPdf: SvgToPdf, languages: string[] }> {
readonly IsValid: Store<boolean>;
readonly Value: Store<{ svgToPdf: SvgToPdf, languages: string[] }>;
constructor(title: string, pages: string[], options: SvgToPdfOptions) {
const svgToPdf = new SvgToPdf(title, pages, options)
const languageOptions = [
new FixedInputElement("Nederlands", "nl"),
new FixedInputElement("English", "en")
]
const langs: string[] = Array.from(Object.keys(languages["default"] ?? languages))
console.log("Available languages are:", langs)
const languageSelector = new SearchablePillsSelector(
langs.map(l => ({
show: new Translation(languages[l]),
value: l,
mainTerm: languages[l]
})), {
mode: "select-many"
}
)
const isPrepared = UIEventSource.FromPromiseWithErr(svgToPdf.Prepare())
super([
new Title("Select languages..."),
languageSelector,
new Toggle(
new Loading("Preparing maps..."),
undefined,
isPrepared.map(p => p === undefined)
)
]);
this.Value = isPrepared.map(isPrepped => {
if (isPrepped === undefined) {
return undefined
}
if (isPrepped["success"] !== undefined) {
const svgToPdf = isPrepped["success"]
const langs = languageSelector.GetValue().data
console.log("Languages are", langs)
if (langs.length === 0) {
return undefined
}
return {svgToPdf, languages: langs}
}
return undefined;
}, [languageSelector.GetValue()])
this.IsValid = this.Value.map(v => v !== undefined)
}
}
class InspectStrings extends Toggle implements FlowStep<{ svgToPdf: SvgToPdf, languages: string[] }> {
readonly IsValid: Store<boolean>;
readonly Value: Store<{ svgToPdf: SvgToPdf; languages: string[] }>;
constructor(svgToPdf: SvgToPdf, languages: string[]) {
const didLoadLanguages = UIEventSource.FromPromiseWithErr(svgToPdf.PrepareLanguages(languages)).map(l => l !== undefined && l["success"] !== undefined)
super(new Combine([
new Title("Inspect translation strings"),
...languages.map(l => new Lazy(() => InspectStrings.createOverviewPanel(svgToPdf, l)))
]),
new Loading(),
didLoadLanguages
);
this.Value = new ImmutableStore({svgToPdf, languages})
this.IsValid = didLoadLanguages
}
private static createOverviewPanel(svgToPdf: SvgToPdf, language: string): BaseUIElement {
const elements: BaseUIElement[] = []
let foundTranslations = 0
const allKeys = Array.from(svgToPdf.translationKeys())
for (const translationKey of allKeys) {
let spec = translationKey
if (translationKey.startsWith("layer.")) {
spec = "layers:" + translationKey.substring(6)
} else {
spec = "core:" + translationKey
}
const translated = svgToPdf.getTranslation("$" + translationKey, language, true)
if (translated) {
foundTranslations++
}
const linkToWeblate = new Link(spec, LinkToWeblate.hrefToWeblate(language, spec), true).SetClass("font-bold link-underline")
elements.push(new Combine([
linkToWeblate,
"&nbsp;",
translated ?? new FixedUiElement("No translation found!").SetClass("alert")
]))
}
return new Toggleable(
new Title("Translations for " + language),
new Combine([
`${foundTranslations}/${allKeys.length} of translations are found (${Math.floor(100 * foundTranslations / allKeys.length)}%)`,
"The following keys are used:",
new List(elements)
]),
{closeOnClick: false, height: "15rem"})
}
}
class SavePdf extends Combine {
constructor(svgToPdf: SvgToPdf, languages: string[]) {
super([
new Title("Generating your pdfs..."),
new List(languages.map(lng => new Toggle(
lng + " is done!",
new Loading("Creating pdf for " + lng),
UIEventSource.FromPromiseWithErr(svgToPdf.ConvertSvg(lng).then(() => true))
.map(x => x !== undefined && x["success"] === true)
)))
]);
}
}
export class PdfExportGui extends LeftIndex {
constructor(freeDivId: string) {
let i = 0
const createDiv = (): string => {
const div = document.createElement("div")
div.id = "freediv-" + (i++)
document.getElementById(freeDivId).append(div)
return div.id
}
Constants.defaultOverpassUrls.splice(0, 1)
const {flow, furthestStep, titles} = FlowPanelFactory.start(
new Title("Select template"), new SelectTemplate()
).then(new Title("Select options"), ({title, pages}) => new SelectPdfOptions(title, pages, createDiv))
.then("Generate maps...", ({title, pages, options}) => new PreparePdf(title, pages, options))
.then("Inspect translations", (({svgToPdf, languages}) => new InspectStrings(svgToPdf, languages)))
.finish("Generating...", ({svgToPdf, languages}) => new SavePdf(svgToPdf, languages))
const toc = new List(
titles.map(
(title, i) =>
new VariableUiElement(
furthestStep.map((currentStep) => {
if (i > currentStep) {
return new Combine([title]).SetClass("subtle")
}
if (i == currentStep) {
return new Combine([title]).SetClass("font-bold")
}
if (i < currentStep) {
return title
}
})
)
),
true
)
const leftContents: BaseUIElement[] = [
toc
].map((el) => el?.SetClass("pl-4"))
super(leftContents, flow)
}
}

View file

@ -1,17 +1,16 @@
import {VariableUiElement} from "../Base/VariableUIElement";
import {Store, UIEventSource} from "../../Logic/UIEventSource";
import PlantNet from "../../Logic/Web/PlantNet";
import Loading from "../Base/Loading";
import Wikidata from "../../Logic/Web/Wikidata";
import WikidataPreviewBox from "../Wikipedia/WikidataPreviewBox";
import {Button} from "../Base/Button";
import Combine from "../Base/Combine";
import Title from "../Base/Title";
import WikipediaBox from "../Wikipedia/WikipediaBox";
import Translations from "../i18n/Translations";
import List from "../Base/List";
import Svg from "../../Svg";
import { VariableUiElement } from "../Base/VariableUIElement"
import { Store, UIEventSource } from "../../Logic/UIEventSource"
import PlantNet from "../../Logic/Web/PlantNet"
import Loading from "../Base/Loading"
import Wikidata from "../../Logic/Web/Wikidata"
import WikidataPreviewBox from "../Wikipedia/WikidataPreviewBox"
import { Button } from "../Base/Button"
import Combine from "../Base/Combine"
import Title from "../Base/Title"
import WikipediaBox from "../Wikipedia/WikipediaBox"
import Translations from "../i18n/Translations"
import List from "../Base/List"
import Svg from "../../Svg"
export default class PlantNetSpeciesSearch extends VariableUiElement {
/***
@ -23,99 +22,116 @@ export default class PlantNetSpeciesSearch extends VariableUiElement {
const t = Translations.t.plantDetection
super(
images
.bind(images => {
.bind((images) => {
if (images.length === 0) {
return null
}
return UIEventSource.FromPromiseWithErr(PlantNet.query(images.slice(0,5)));
return UIEventSource.FromPromiseWithErr(PlantNet.query(images.slice(0, 5)))
})
.map(result => {
.map((result) => {
if (images.data.length === 0) {
return new Combine([t.takeImages, t.howTo.intro, new List(
[
t.howTo.li0,
t.howTo.li1,
t.howTo.li2,
t.howTo.li3
]
)]).SetClass("flex flex-col")
return new Combine([
t.takeImages,
t.howTo.intro,
new List([t.howTo.li0, t.howTo.li1, t.howTo.li2, t.howTo.li3]),
]).SetClass("flex flex-col")
}
if (result === undefined) {
return new Loading(t.querying.Subs(images.data))
}
if (result === undefined) {
return new Loading(t.querying.Subs(images.data))
}
if (result["error"] !== undefined) {
return t.error.Subs(<any>result).SetClass("alert")
}
console.log(result)
const success = result["success"]
const selectedSpecies = new UIEventSource<string>(undefined)
const speciesInformation = success.results
.filter(species => species.score >= 0.005)
.map(species => {
const wikidata = UIEventSource.FromPromise(Wikidata.Sparql<{ species }>(["?species", "?speciesLabel"],
["?species wdt:P846 \"" + species.gbif.id + "\""]));
if (result["error"] !== undefined) {
return t.error.Subs(<any>result).SetClass("alert")
}
console.log(result)
const success = result["success"]
const confirmButton = new Button(t.seeInfo, async() => {
await selectedSpecies.setData(wikidata.data[0].species?.value)
}).SetClass("btn")
const selectedSpecies = new UIEventSource<string>(undefined)
const speciesInformation = success.results
.filter((species) => species.score >= 0.005)
.map((species) => {
const wikidata = UIEventSource.FromPromise(
Wikidata.Sparql<{ species }>(
["?species", "?speciesLabel"],
['?species wdt:P846 "' + species.gbif.id + '"']
)
)
const match = t.matchPercentage.Subs({match: Math.round(species.score * 100)}).SetClass("font-bold")
const confirmButton = new Button(t.seeInfo, async () => {
await selectedSpecies.setData(wikidata.data[0].species?.value)
}).SetClass("btn")
const extraItems = new Combine([match, confirmButton]).SetClass("flex flex-col")
const match = t.matchPercentage
.Subs({ match: Math.round(species.score * 100) })
.SetClass("font-bold")
return new WikidataPreviewBox(wikidata.map(wd => wd == undefined ? undefined : wd[0]?.species?.value),
{
whileLoading: new Loading(
t.loadingWikidata.Subs({species: species.species.scientificNameWithoutAuthor})),
extraItems: [new Combine([extraItems])],
const extraItems = new Combine([match, confirmButton]).SetClass(
"flex flex-col"
)
imageStyle: "max-width: 8rem; width: unset; height: 8rem"
return new WikidataPreviewBox(
wikidata.map((wd) =>
wd == undefined ? undefined : wd[0]?.species?.value
),
{
whileLoading: new Loading(
t.loadingWikidata.Subs({
species: species.species.scientificNameWithoutAuthor,
})
.SetClass("border-2 border-subtle rounded-xl block mb-2")
),
extraItems: [new Combine([extraItems])],
imageStyle: "max-width: 8rem; width: unset; height: 8rem",
}
);
const plantOverview = new Combine([
new Title(t.overviewTitle),
t.overviewIntro,
t.overviewVerify.SetClass("font-bold"),
...speciesInformation]).SetClass("flex flex-col")
).SetClass("border-2 border-subtle rounded-xl block mb-2")
})
const plantOverview = new Combine([
new Title(t.overviewTitle),
t.overviewIntro,
t.overviewVerify.SetClass("font-bold"),
...speciesInformation,
]).SetClass("flex flex-col")
return new VariableUiElement(selectedSpecies.map(wikidataSpecies => {
return new VariableUiElement(
selectedSpecies.map((wikidataSpecies) => {
if (wikidataSpecies === undefined) {
return plantOverview
}
const buttons = new Combine([
new Button(
new Combine([
Svg.back_svg().SetClass("w-6 mr-1 bg-white rounded-full p-1"),
t.back]).SetClass("flex"),
new Combine([
Svg.back_svg().SetClass(
"w-6 mr-1 bg-white rounded-full p-1"
),
t.back,
]).SetClass("flex"),
() => {
selectedSpecies.setData(undefined)
}).SetClass("btn btn-secondary"),
selectedSpecies.setData(undefined)
}
).SetClass("btn btn-secondary"),
new Button(
new Combine([Svg.confirm_svg().SetClass("w-6 mr-1"), t.confirm]).SetClass("flex")
, () => {
onConfirm(wikidataSpecies)
}).SetClass("btn"),
]).SetClass("flex justify-between");
new Combine([
Svg.confirm_svg().SetClass("w-6 mr-1"),
t.confirm,
]).SetClass("flex"),
() => {
onConfirm(wikidataSpecies)
}
).SetClass("btn"),
]).SetClass("flex justify-between")
return new Combine([
new WikipediaBox([wikidataSpecies], {
firstParagraphOnly: false,
noImages: false,
addHeader: false
addHeader: false,
}).SetClass("h-96"),
buttons
buttons,
]).SetClass("flex flex-col self-end")
}))
}
))
})
)
})
)
}
}
}

View file

@ -1,6 +1,6 @@
import Combine from "../Base/Combine";
import Translations from "../i18n/Translations";
import Title from "../Base/Title";
import Combine from "../Base/Combine"
import Translations from "../i18n/Translations"
import Title from "../Base/Title"
export default class PrivacyPolicy extends Combine {
constructor() {
@ -19,8 +19,7 @@ export default class PrivacyPolicy extends Combine {
t.miscCookies,
new Title(t.whileYoureHere),
t.surveillance,
]);
])
this.SetClass("link-underline")
}
}
}

View file

@ -1,52 +1,42 @@
import Combine from "../Base/Combine";
import Toggle from "../Input/Toggle";
import MapControlButton from "../MapControlButton";
import GeoLocationHandler from "../../Logic/Actors/GeoLocationHandler";
import Svg from "../../Svg";
import MapState from "../../Logic/State/MapState";
import FeaturePipeline from "../../Logic/FeatureSource/FeaturePipeline";
import {Utils} from "../../Utils";
import {TagUtils} from "../../Logic/Tags/TagUtils";
import {BBox} from "../../Logic/BBox";
import {OsmFeature} from "../../Models/OsmFeature";
import LevelSelector from "./LevelSelector";
import Combine from "../Base/Combine"
import Toggle from "../Input/Toggle"
import MapControlButton from "../MapControlButton"
import GeoLocationHandler from "../../Logic/Actors/GeoLocationHandler"
import Svg from "../../Svg"
import MapState from "../../Logic/State/MapState"
import FeaturePipeline from "../../Logic/FeatureSource/FeaturePipeline"
import { Utils } from "../../Utils"
import { TagUtils } from "../../Logic/Tags/TagUtils"
import { BBox } from "../../Logic/BBox"
import { OsmFeature } from "../../Models/OsmFeature"
import LevelSelector from "./LevelSelector"
export default class RightControls extends Combine {
constructor(state: MapState & { featurePipeline: FeaturePipeline }) {
const geolocatioHandler = new GeoLocationHandler(
state
)
const geolocatioHandler = new GeoLocationHandler(state)
const geolocationButton = new Toggle(
new MapControlButton(
geolocatioHandler
, {
dontStyle: true
}
),
new MapControlButton(geolocatioHandler, {
dontStyle: true,
}),
undefined,
state.featureSwitchGeolocation
);
)
const plus = new MapControlButton(
Svg.plus_svg()
).onClick(() => {
state.locationControl.data.zoom++;
state.locationControl.ping();
});
const plus = new MapControlButton(Svg.plus_svg()).onClick(() => {
state.locationControl.data.zoom++
state.locationControl.ping()
})
const min = new MapControlButton(
Svg.min_svg()
).onClick(() => {
state.locationControl.data.zoom--;
state.locationControl.ping();
});
const min = new MapControlButton(Svg.min_svg()).onClick(() => {
state.locationControl.data.zoom--
state.locationControl.ping()
})
const levelSelector = new LevelSelector(state);
super([levelSelector, plus, min, geolocationButton].map(el => el.SetClass("m-0.5 md:m-1")))
const levelSelector = new LevelSelector(state)
super(
[levelSelector, plus, min, geolocationButton].map((el) => el.SetClass("m-0.5 md:m-1"))
)
this.SetClass("flex flex-col items-center")
}
}
}

View file

@ -1,28 +1,24 @@
import {UIEventSource} from "../../Logic/UIEventSource";
import {Translation} from "../i18n/Translation";
import {VariableUiElement} from "../Base/VariableUIElement";
import Svg from "../../Svg";
import {TextField} from "../Input/TextField";
import {Geocoding} from "../../Logic/Osm/Geocoding";
import Translations from "../i18n/Translations";
import Hash from "../../Logic/Web/Hash";
import Combine from "../Base/Combine";
import Locale from "../i18n/Locale";
import { UIEventSource } from "../../Logic/UIEventSource"
import { Translation } from "../i18n/Translation"
import { VariableUiElement } from "../Base/VariableUIElement"
import Svg from "../../Svg"
import { TextField } from "../Input/TextField"
import { Geocoding } from "../../Logic/Osm/Geocoding"
import Translations from "../i18n/Translations"
import Hash from "../../Logic/Web/Hash"
import Combine from "../Base/Combine"
import Locale from "../i18n/Locale"
export default class SearchAndGo extends Combine {
constructor(state: {
leafletMap: UIEventSource<any>,
selectedElement: UIEventSource<any>
}) {
const goButton = Svg.search_ui().SetClass(
"w-8 h-8 full-rounded border-black float-right"
);
constructor(state: { leafletMap: UIEventSource<any>; selectedElement?: UIEventSource<any> }) {
const goButton = Svg.search_ui().SetClass("w-8 h-8 full-rounded border-black float-right")
const placeholder = new UIEventSource<Translation>(
Translations.t.general.search.search
);
const placeholder = new UIEventSource<Translation>(Translations.t.general.search.search)
const searchField = new TextField({
placeholder: placeholder.map(tr => tr.textFor(Locale.language.data), [Locale.language]),
placeholder: placeholder.map(
(tr) => tr.textFor(Locale.language.data),
[Locale.language]
),
value: new UIEventSource<string>(""),
inputStyle:
" background: transparent;\n" +
@ -32,53 +28,52 @@ export default class SearchAndGo extends Combine {
" height: 100%;\n" +
" box-sizing: border-box;\n" +
" color: var(--foreground-color);",
});
})
searchField.SetClass("relative float-left mt-0 ml-2");
searchField.SetStyle("width: calc(100% - 3em);height: 100%");
searchField.SetClass("relative float-left mt-0 ml-2")
searchField.SetStyle("width: calc(100% - 3em);height: 100%")
super([searchField, goButton]);
super([searchField, goButton])
this.SetClass("block h-8");
this.SetClass("block h-8")
this.SetStyle(
"background: var(--background-color); color: var(--foreground-color); pointer-evetns:all;"
);
)
// Triggered by 'enter' or onclick
async function runSearch() {
const searchString = searchField.GetValue().data;
const searchString = searchField.GetValue().data
if (searchString === undefined || searchString === "") {
return;
return
}
searchField.GetValue().setData("");
placeholder.setData(Translations.t.general.search.searching);
searchField.GetValue().setData("")
placeholder.setData(Translations.t.general.search.searching)
try {
const result = await Geocoding.Search(searchString)
const result = await Geocoding.Search(searchString);
console.log("Search result", result);
console.log("Search result", result)
if (result.length == 0) {
placeholder.setData(Translations.t.general.search.nothing);
return;
placeholder.setData(Translations.t.general.search.nothing)
return
}
const poi = result[0];
const bb = poi.boundingbox;
const poi = result[0]
const bb = poi.boundingbox
const bounds: [[number, number], [number, number]] = [
[bb[0], bb[2]],
[bb[1], bb[3]],
];
state.selectedElement.setData(undefined);
Hash.hash.setData(poi.osm_type + "/" + poi.osm_id);
state.leafletMap.data.fitBounds(bounds);
]
state.selectedElement?.setData(undefined)
Hash.hash.setData(poi.osm_type + "/" + poi.osm_id)
state.leafletMap.data.fitBounds(bounds)
placeholder.setData(Translations.t.general.search.search)
}catch(e){
searchField.GetValue().setData("");
placeholder.setData(Translations.t.general.search.error);
} catch (e) {
searchField.GetValue().setData("")
placeholder.setData(Translations.t.general.search.error)
}
}
searchField.enterPressed.addCallback(runSearch);
goButton.onClick(runSearch);
searchField.enterPressed.addCallback(runSearch)
goButton.onClick(runSearch)
}
}

View file

@ -1,17 +1,20 @@
import BaseUIElement from "../BaseUIElement";
import BaseUIElement from "../BaseUIElement"
export default class ShareButton extends BaseUIElement {
private _embedded: BaseUIElement;
private _shareData: () => { text: string; title: string; url: string };
private _embedded: BaseUIElement
private _shareData: () => { text: string; title: string; url: string }
constructor(embedded: BaseUIElement, generateShareData: () => {
text: string,
title: string,
url: string
}) {
super();
this._embedded = embedded;
this._shareData = generateShareData;
constructor(
embedded: BaseUIElement,
generateShareData: () => {
text: string
title: string
url: string
}
) {
super()
this._embedded = embedded
this._shareData = generateShareData
this.SetClass("share-button")
}
@ -20,21 +23,21 @@ export default class ShareButton extends BaseUIElement {
e.type = "button"
e.appendChild(this._embedded.ConstructElement())
e.addEventListener('click', () => {
e.addEventListener("click", () => {
if (navigator.share) {
navigator.share(this._shareData()).then(() => {
console.log('Thanks for sharing!');
})
.catch(err => {
console.log(`Couldn't share because of`, err.message);
});
navigator
.share(this._shareData())
.then(() => {
console.log("Thanks for sharing!")
})
.catch((err) => {
console.log(`Couldn't share because of`, err.message)
})
} else {
console.log('web share not supported');
console.log("web share not supported")
}
});
})
return e;
return e
}
}
}

View file

@ -1,116 +1,138 @@
import {VariableUiElement} from "../Base/VariableUIElement";
import {Translation} from "../i18n/Translation";
import Svg from "../../Svg";
import Combine from "../Base/Combine";
import {Store, UIEventSource} from "../../Logic/UIEventSource";
import {Utils} from "../../Utils";
import Translations from "../i18n/Translations";
import BaseUIElement from "../BaseUIElement";
import LayerConfig from "../../Models/ThemeConfig/LayerConfig";
import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig";
import Loc from "../../Models/Loc";
import BaseLayer from "../../Models/BaseLayer";
import FilteredLayer from "../../Models/FilteredLayer";
import {InputElement} from "../Input/InputElement";
import {CheckBox} from "../Input/Checkboxes";
import {SubtleButton} from "../Base/SubtleButton";
import LZString from "lz-string";
import { VariableUiElement } from "../Base/VariableUIElement"
import { Translation } from "../i18n/Translation"
import Svg from "../../Svg"
import Combine from "../Base/Combine"
import { Store, UIEventSource } from "../../Logic/UIEventSource"
import { Utils } from "../../Utils"
import Translations from "../i18n/Translations"
import BaseUIElement from "../BaseUIElement"
import LayerConfig from "../../Models/ThemeConfig/LayerConfig"
import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig"
import Loc from "../../Models/Loc"
import BaseLayer from "../../Models/BaseLayer"
import FilteredLayer from "../../Models/FilteredLayer"
import { InputElement } from "../Input/InputElement"
import { CheckBox } from "../Input/Checkboxes"
import { SubtleButton } from "../Base/SubtleButton"
import LZString from "lz-string"
export default class ShareScreen extends Combine {
constructor(state: { layoutToUse: LayoutConfig, locationControl: UIEventSource<Loc>, backgroundLayer: UIEventSource<BaseLayer>, filteredLayers: UIEventSource<FilteredLayer[]> }) {
const layout = state?.layoutToUse;
const tr = Translations.t.general.sharescreen;
constructor(state: {
layoutToUse: LayoutConfig
locationControl: UIEventSource<Loc>
backgroundLayer: UIEventSource<BaseLayer>
filteredLayers: UIEventSource<FilteredLayer[]>
}) {
const layout = state?.layoutToUse
const tr = Translations.t.general.sharescreen
const optionCheckboxes: InputElement<boolean>[] = []
const optionParts: (Store<string>)[] = [];
const optionParts: Store<string>[] = []
const includeLocation = new CheckBox(tr.fsIncludeCurrentLocation, true)
optionCheckboxes.push(includeLocation);
optionCheckboxes.push(includeLocation)
const currentLocation = state.locationControl;
const currentLocation = state.locationControl
optionParts.push(includeLocation.GetValue().map((includeL) => {
if (currentLocation === undefined) {
return null;
}
if (includeL) {
return [["z", currentLocation.data?.zoom], ["lat", currentLocation.data?.lat], ["lon", currentLocation.data?.lon]]
.filter(p => p[1] !== undefined)
.map(p => p[0] + "=" + p[1])
.join("&")
} else {
return null;
}
optionParts.push(
includeLocation.GetValue().map(
(includeL) => {
if (currentLocation === undefined) {
return null
}
if (includeL) {
return [
["z", currentLocation.data?.zoom],
["lat", currentLocation.data?.lat],
["lon", currentLocation.data?.lon],
]
.filter((p) => p[1] !== undefined)
.map((p) => p[0] + "=" + p[1])
.join("&")
} else {
return null
}
},
[currentLocation]
)
)
}, [currentLocation]));
function fLayerToParam(flayer: { isDisplayed: UIEventSource<boolean>, layerDef: LayerConfig }) {
function fLayerToParam(flayer: {
isDisplayed: UIEventSource<boolean>
layerDef: LayerConfig
}) {
if (flayer.isDisplayed.data) {
return null; // Being displayed is the default
return null // Being displayed is the default
}
return "layer-" + flayer.layerDef.id + "=" + flayer.isDisplayed.data
}
const currentLayer: UIEventSource<{ id: string, name: string, layer: any }> = state.backgroundLayer;
const currentBackground = new VariableUiElement(currentLayer.map(layer => {
return tr.fsIncludeCurrentBackgroundMap.Subs({name: layer?.name ?? ""});
}));
const currentLayer: UIEventSource<{ id: string; name: string; layer: any }> =
state.backgroundLayer
const currentBackground = new VariableUiElement(
currentLayer.map((layer) => {
return tr.fsIncludeCurrentBackgroundMap.Subs({ name: layer?.name ?? "" })
})
)
const includeCurrentBackground = new CheckBox(currentBackground, true)
optionCheckboxes.push(includeCurrentBackground);
optionParts.push(includeCurrentBackground.GetValue().map((includeBG) => {
if (includeBG) {
return "background=" + currentLayer.data.id
} else {
return null
}
}, [currentLayer]));
optionCheckboxes.push(includeCurrentBackground)
optionParts.push(
includeCurrentBackground.GetValue().map(
(includeBG) => {
if (includeBG) {
return "background=" + currentLayer.data.id
} else {
return null
}
},
[currentLayer]
)
)
const includeLayerChoices = new CheckBox(tr.fsIncludeCurrentLayers, true)
optionCheckboxes.push(includeLayerChoices);
optionParts.push(includeLayerChoices.GetValue().map((includeLayerSelection) => {
if (includeLayerSelection) {
return Utils.NoNull(state.filteredLayers.data.map(fLayerToParam)).join("&")
} else {
return null
}
}, state.filteredLayers.data.map((flayer) => flayer.isDisplayed)));
optionCheckboxes.push(includeLayerChoices)
optionParts.push(
includeLayerChoices.GetValue().map(
(includeLayerSelection) => {
if (includeLayerSelection) {
return Utils.NoNull(state.filteredLayers.data.map(fLayerToParam)).join("&")
} else {
return null
}
},
state.filteredLayers.data.map((flayer) => flayer.isDisplayed)
)
)
const switches = [
{urlName: "fs-userbadge", human: tr.fsUserbadge},
{urlName: "fs-search", human: tr.fsSearch},
{urlName: "fs-welcome-message", human: tr.fsWelcomeMessage},
{urlName: "fs-layers", human: tr.fsLayers},
{urlName: "layer-control-toggle", human: tr.fsLayerControlToggle, reverse: true},
{urlName: "fs-add-new", human: tr.fsAddNew},
{urlName: "fs-geolocation", human: tr.fsGeolocation},
{ urlName: "fs-userbadge", human: tr.fsUserbadge },
{ urlName: "fs-search", human: tr.fsSearch },
{ urlName: "fs-welcome-message", human: tr.fsWelcomeMessage },
{ urlName: "fs-layers", human: tr.fsLayers },
{ urlName: "layer-control-toggle", human: tr.fsLayerControlToggle, reverse: true },
{ urlName: "fs-add-new", human: tr.fsAddNew },
{ urlName: "fs-geolocation", human: tr.fsGeolocation },
]
for (const swtch of switches) {
const checkbox = new CheckBox(Translations.W(swtch.human), !swtch.reverse)
optionCheckboxes.push(checkbox);
optionParts.push(checkbox.GetValue().map((isEn) => {
if (isEn) {
if (swtch.reverse) {
return `${swtch.urlName}=true`
optionCheckboxes.push(checkbox)
optionParts.push(
checkbox.GetValue().map((isEn) => {
if (isEn) {
if (swtch.reverse) {
return `${swtch.urlName}=true`
}
return null
} else {
if (swtch.reverse) {
return null
}
return `${swtch.urlName}=false`
}
return null;
} else {
if (swtch.reverse) {
return null;
}
return `${swtch.urlName}=false`
}
}))
})
)
}
if (layout.definitionRaw !== undefined) {
@ -119,10 +141,9 @@ export default class ShareScreen extends Combine {
const options = new Combine(optionCheckboxes).SetClass("flex flex-col")
const url = (currentLocation ?? new UIEventSource(undefined)).map(() => {
const host = window.location.host;
let path = window.location.pathname;
path = path.substr(0, path.lastIndexOf("/"));
const host = window.location.host
let path = window.location.pathname
path = path.substr(0, path.lastIndexOf("/"))
let id = layout.id.toLowerCase()
if (layout.definitionRaw !== undefined) {
id = "theme.html"
@ -133,28 +154,32 @@ export default class ShareScreen extends Combine {
if (layout.definedAtUrl === undefined && layout.definitionRaw !== undefined) {
hash = "#" + LZString.compressToBase64(Utils.MinifyJSON(layout.definitionRaw))
}
const parts = Utils.NoEmpty(Utils.NoNull(optionParts.map((eventSource) => eventSource.data)));
const parts = Utils.NoEmpty(
Utils.NoNull(optionParts.map((eventSource) => eventSource.data))
)
if (parts.length === 0) {
return literalText + hash;
return literalText + hash
}
return literalText + "?" + parts.join("&") + hash;
}, optionParts);
return literalText + "?" + parts.join("&") + hash
}, optionParts)
const iframeCode = new VariableUiElement(
url.map((url) => {
return `<span class='literal-code iframe-code-block'>
&lt;iframe src="${url}" allow="geolocation" width="100%" height="100%" style="min-width: 250px; min-height: 250px" title="${layout.title?.txt ?? "MapComplete"} with MapComplete"&gt;&lt;/iframe&gt
&lt;iframe src="${url}" allow="geolocation" width="100%" height="100%" style="min-width: 250px; min-height: 250px" title="${
layout.title?.txt ?? "MapComplete"
} with MapComplete"&gt;&lt;/iframe&gt
</span>`
})
);
)
const linkStatus = new UIEventSource<string | Translation>("");
const linkStatus = new UIEventSource<string | Translation>("")
const link = new VariableUiElement(
url.map((url) => `<input type="text" value=" ${url}" id="code-link--copyable" style="width:90%">`)
url.map(
(url) =>
`<input type="text" value=" ${url}" id="code-link--copyable" style="width:90%">`
)
).onClick(async () => {
const shareData = {
title: Translations.W(layout.title)?.ConstructElement().textContent ?? "",
text: Translations.W(layout.description)?.ConstructElement().textContent ?? "",
@ -162,57 +187,67 @@ export default class ShareScreen extends Combine {
}
function rejected() {
const copyText = document.getElementById("code-link--copyable");
const copyText = document.getElementById("code-link--copyable")
// @ts-ignore
copyText.select();
copyText.select()
// @ts-ignore
copyText.setSelectionRange(0, 99999); /*For mobile devices*/
copyText.setSelectionRange(0, 99999) /*For mobile devices*/
document.execCommand("copy");
const copied = tr.copiedToClipboard.Clone();
document.execCommand("copy")
const copied = tr.copiedToClipboard.Clone()
copied.SetClass("thanks")
linkStatus.setData(copied);
linkStatus.setData(copied)
}
try {
navigator.share(shareData)
navigator
.share(shareData)
.then(() => {
const thx = tr.thanksForSharing.Clone();
thx.SetClass("thanks");
linkStatus.setData(thx);
const thx = tr.thanksForSharing.Clone()
thx.SetClass("thanks")
linkStatus.setData(thx)
}, rejected)
.catch(rejected)
} catch (err) {
rejected();
rejected()
}
})
});
let downloadThemeConfig: BaseUIElement = undefined;
let downloadThemeConfig: BaseUIElement = undefined
if (layout.definitionRaw !== undefined) {
const downloadThemeConfigAsJson = new SubtleButton(Svg.download_svg(), new Combine([
tr.downloadCustomTheme,
tr.downloadCustomThemeHelp.SetClass("subtle")
]).onClick(() => {
Utils.offerContentsAsDownloadableFile(layout.definitionRaw, layout.id + ".mapcomplete-theme-definition.json", {
mimetype: "application/json"
})
})
.SetClass("flex flex-col"))
const downloadThemeConfigAsJson = new SubtleButton(
Svg.download_svg(),
new Combine([tr.downloadCustomTheme, tr.downloadCustomThemeHelp.SetClass("subtle")])
.onClick(() => {
Utils.offerContentsAsDownloadableFile(
layout.definitionRaw,
layout.id + ".mapcomplete-theme-definition.json",
{
mimetype: "application/json",
}
)
})
.SetClass("flex flex-col")
)
let editThemeConfig: BaseUIElement = undefined
if (layout.definedAtUrl === undefined) {
const patchedDefinition = JSON.parse(layout.definitionRaw)
patchedDefinition["language"] = Object.keys(patchedDefinition.title)
editThemeConfig = new SubtleButton(Svg.pencil_svg(), "Edit this theme on the custom theme generator",
editThemeConfig = new SubtleButton(
Svg.pencil_svg(),
"Edit this theme on the custom theme generator",
{
url: `https://pietervdvn.github.io/mc/legacy/070/customGenerator.html#${btoa(JSON.stringify(patchedDefinition))}`
url: `https://pietervdvn.github.io/mc/legacy/070/customGenerator.html#${btoa(
JSON.stringify(patchedDefinition)
)}`,
}
)
}
downloadThemeConfig = new Combine([downloadThemeConfigAsJson, editThemeConfig]).SetClass("flex flex-col")
downloadThemeConfig = new Combine([
downloadThemeConfigAsJson,
editThemeConfig,
]).SetClass("flex flex-col")
}
super([
@ -226,7 +261,5 @@ export default class ShareScreen extends Combine {
iframeCode,
])
this.SetClass("flex flex-col link-underline")
}
}
}

View file

@ -1,147 +1,163 @@
/**
* Asks to add a feature at the last clicked location, at least if zoom is sufficient
*/
import {UIEventSource} from "../../Logic/UIEventSource";
import Svg from "../../Svg";
import {SubtleButton} from "../Base/SubtleButton";
import Combine from "../Base/Combine";
import Translations from "../i18n/Translations";
import Constants from "../../Models/Constants";
import {TagUtils} from "../../Logic/Tags/TagUtils";
import BaseUIElement from "../BaseUIElement";
import {VariableUiElement} from "../Base/VariableUIElement";
import Toggle from "../Input/Toggle";
import UserDetails, {OsmConnection} from "../../Logic/Osm/OsmConnection";
import CreateNewNodeAction from "../../Logic/Osm/Actions/CreateNewNodeAction";
import {OsmObject, OsmWay} from "../../Logic/Osm/OsmObject";
import PresetConfig from "../../Models/ThemeConfig/PresetConfig";
import FilteredLayer from "../../Models/FilteredLayer";
import Loc from "../../Models/Loc";
import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig";
import {Changes} from "../../Logic/Osm/Changes";
import FeaturePipeline from "../../Logic/FeatureSource/FeaturePipeline";
import {ElementStorage} from "../../Logic/ElementStorage";
import ConfirmLocationOfPoint from "../NewPoint/ConfirmLocationOfPoint";
import BaseLayer from "../../Models/BaseLayer";
import Loading from "../Base/Loading";
import Hash from "../../Logic/Web/Hash";
import {GlobalFilter} from "../../Logic/State/MapState";
import { UIEventSource } from "../../Logic/UIEventSource"
import Svg from "../../Svg"
import { SubtleButton } from "../Base/SubtleButton"
import Combine from "../Base/Combine"
import Translations from "../i18n/Translations"
import Constants from "../../Models/Constants"
import { TagUtils } from "../../Logic/Tags/TagUtils"
import BaseUIElement from "../BaseUIElement"
import { VariableUiElement } from "../Base/VariableUIElement"
import Toggle from "../Input/Toggle"
import UserDetails, { OsmConnection } from "../../Logic/Osm/OsmConnection"
import CreateNewNodeAction from "../../Logic/Osm/Actions/CreateNewNodeAction"
import { OsmObject, OsmWay } from "../../Logic/Osm/OsmObject"
import PresetConfig from "../../Models/ThemeConfig/PresetConfig"
import FilteredLayer from "../../Models/FilteredLayer"
import Loc from "../../Models/Loc"
import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig"
import { Changes } from "../../Logic/Osm/Changes"
import FeaturePipeline from "../../Logic/FeatureSource/FeaturePipeline"
import { ElementStorage } from "../../Logic/ElementStorage"
import ConfirmLocationOfPoint from "../NewPoint/ConfirmLocationOfPoint"
import BaseLayer from "../../Models/BaseLayer"
import Loading from "../Base/Loading"
import Hash from "../../Logic/Web/Hash"
import { GlobalFilter } from "../../Logic/State/MapState"
import {WayId} from "../../Models/OsmFeature";
/*
* The SimpleAddUI is a single panel, which can have multiple states:
* - A list of presets which can be added by the user
* - A 'confirm-selection' button (or alternatively: please enable the layer)
* - A 'something is wrong - please soom in further'
* - A 'read your unread messages before adding a point'
* The SimpleAddUI is a single panel, which can have multiple states:
* - A list of presets which can be added by the user
* - A 'confirm-selection' button (or alternatively: please enable the layer)
* - A 'something is wrong - please soom in further'
* - A 'read your unread messages before adding a point'
*/
export interface PresetInfo extends PresetConfig {
name: string | BaseUIElement,
icon: () => BaseUIElement,
layerToAddTo: FilteredLayer,
name: string | BaseUIElement
icon: () => BaseUIElement
layerToAddTo: FilteredLayer
boundsFactor?: 0.25 | number
}
export default class SimpleAddUI extends Toggle {
/**
*
*
* @param isShown
* @param resetScrollSignal
* @param filterViewIsOpened
* @param state
* @param takeLocationFrom: defaults to state.lastClickLocation. Take this location to add the new point around
*/
constructor(isShown: UIEventSource<boolean>,
resetScrollSignal: UIEventSource<void>,
filterViewIsOpened: UIEventSource<boolean>,
state: {
featureSwitchIsTesting: UIEventSource<boolean>,
layoutToUse: LayoutConfig,
osmConnection: OsmConnection,
changes: Changes,
allElements: ElementStorage,
LastClickLocation: UIEventSource<{ lat: number, lon: number }>,
featurePipeline: FeaturePipeline,
selectedElement: UIEventSource<any>,
locationControl: UIEventSource<Loc>,
filteredLayers: UIEventSource<FilteredLayer[]>,
featureSwitchFilter: UIEventSource<boolean>,
backgroundLayer: UIEventSource<BaseLayer>,
globalFilters: UIEventSource<GlobalFilter[]>
},
takeLocationFrom?: UIEventSource<{lat: number, lon: number}>
constructor(
isShown: UIEventSource<boolean>,
resetScrollSignal: UIEventSource<void>,
filterViewIsOpened: UIEventSource<boolean>,
state: {
featureSwitchIsTesting: UIEventSource<boolean>
layoutToUse: LayoutConfig
osmConnection: OsmConnection
changes: Changes
allElements: ElementStorage
LastClickLocation: UIEventSource<{ lat: number; lon: number }>
featurePipeline: FeaturePipeline
selectedElement: UIEventSource<any>
locationControl: UIEventSource<Loc>
filteredLayers: UIEventSource<FilteredLayer[]>
featureSwitchFilter: UIEventSource<boolean>
backgroundLayer: UIEventSource<BaseLayer>
globalFilters: UIEventSource<GlobalFilter[]>
},
takeLocationFrom?: UIEventSource<{ lat: number; lon: number }>
) {
const loginButton = new SubtleButton(Svg.osm_logo_ui(), Translations.t.general.add.pleaseLogin.Clone())
.onClick(() => state.osmConnection.AttemptLogin());
const loginButton = new SubtleButton(
Svg.osm_logo_ui(),
Translations.t.general.add.pleaseLogin.Clone()
).onClick(() => state.osmConnection.AttemptLogin())
const readYourMessages = new Combine([
Translations.t.general.readYourMessages.Clone().SetClass("alert"),
new SubtleButton(Svg.envelope_ui(),
Translations.t.general.goToInbox, {url: "https://www.openstreetmap.org/messages/inbox", newTab: false})
]);
new SubtleButton(Svg.envelope_ui(), Translations.t.general.goToInbox, {
url: "https://www.openstreetmap.org/messages/inbox",
newTab: false,
}),
])
takeLocationFrom = takeLocationFrom ?? state.LastClickLocation
const selectedPreset = new UIEventSource<PresetInfo>(undefined);
selectedPreset.addCallback(_ => {
resetScrollSignal.ping();
const selectedPreset = new UIEventSource<PresetInfo>(undefined)
selectedPreset.addCallback((_) => {
resetScrollSignal.ping()
})
isShown.addCallback(_ => selectedPreset.setData(undefined)) // Clear preset selection when the UI is closed/opened
takeLocationFrom.addCallback(_ => selectedPreset.setData(undefined))
isShown.addCallback((_) => selectedPreset.setData(undefined)) // Clear preset selection when the UI is closed/opened
takeLocationFrom.addCallback((_) => selectedPreset.setData(undefined))
const presetsOverview = SimpleAddUI.CreateAllPresetsPanel(selectedPreset, state)
async function createNewPoint(tags: any[], location: { lat: number, lon: number }, snapOntoWay?: OsmWay) : Promise<void>{
async function createNewPoint(
tags: any[],
location: { lat: number; lon: number },
snapOntoWay?: OsmWay
): Promise<void> {
const newElementAction = new CreateNewNodeAction(tags, location.lat, location.lon, {
theme: state.layoutToUse?.id ?? "unkown",
changeType: "create",
snapOnto: snapOntoWay
snapOnto: snapOntoWay,
})
await state.changes.applyAction(newElementAction)
selectedPreset.setData(undefined)
isShown.setData(false)
state.selectedElement.setData(state.allElements.ContainingFeatures.get(
newElementAction.newElementId
))
state.selectedElement.setData(
state.allElements.ContainingFeatures.get(newElementAction.newElementId)
)
Hash.hash.setData(newElementAction.newElementId)
}
const addUi = new VariableUiElement(
selectedPreset.map(preset => {
if (preset === undefined) {
return presetsOverview
}
function confirm(tags: any[], location: { lat: number, lon: number }, snapOntoWayId?: string) {
if (snapOntoWayId === undefined) {
createNewPoint(tags, location, undefined)
} else {
OsmObject.DownloadObject(snapOntoWayId).addCallbackAndRunD(way => {
createNewPoint(tags, location, <OsmWay>way)
return true;
})
}
}
function cancel() {
selectedPreset.setData(undefined)
}
const message = Translations.t.general.add.addNew.Subs({category: preset.name}, preset.name["context"]);
return new ConfirmLocationOfPoint(state, filterViewIsOpened, preset,
message,
takeLocationFrom.data,
confirm,
cancel,
() => {
isShown.setData(false)
})
selectedPreset.map((preset) => {
if (preset === undefined) {
return presetsOverview
}
))
function confirm(
tags: any[],
location: { lat: number; lon: number },
snapOntoWayId?: WayId
) {
if (snapOntoWayId === undefined) {
createNewPoint(tags, location, undefined)
} else {
OsmObject.DownloadObject(snapOntoWayId).addCallbackAndRunD((way) => {
createNewPoint(tags, location, way)
return true
})
}
}
function cancel() {
selectedPreset.setData(undefined)
}
const message = Translations.t.general.add.addNew.Subs(
{ category: preset.name },
preset.name["context"]
)
return new ConfirmLocationOfPoint(
state,
filterViewIsOpened,
preset,
message,
takeLocationFrom.data,
confirm,
cancel,
() => {
isShown.setData(false)
}
)
})
)
super(
new Toggle(
@ -152,114 +168,136 @@ export default class SimpleAddUI extends Toggle {
state.featurePipeline.runningQuery
),
Translations.t.general.add.zoomInFurther.Clone().SetClass("alert"),
state.locationControl.map(loc => loc.zoom >= Constants.userJourney.minZoomLevelToAddNewPoints)
state.locationControl.map(
(loc) => loc.zoom >= Constants.userJourney.minZoomLevelToAddNewPoints
)
),
readYourMessages,
state.osmConnection.userDetails.map((userdetails: UserDetails) =>
userdetails.csCount >= Constants.userJourney.addNewPointWithUnreadMessagesUnlock ||
userdetails.unreadMessages == 0)
state.osmConnection.userDetails.map(
(userdetails: UserDetails) =>
userdetails.csCount >=
Constants.userJourney.addNewPointWithUnreadMessagesUnlock ||
userdetails.unreadMessages == 0
)
),
loginButton,
state.osmConnection.isLoggedIn
)
}
public static CreateTagInfoFor(preset: PresetInfo, osmConnection: OsmConnection, optionallyLinkToWiki = true) {
const csCount = osmConnection.userDetails.data.csCount;
public static CreateTagInfoFor(
preset: PresetInfo,
osmConnection: OsmConnection,
optionallyLinkToWiki = true
) {
const csCount = osmConnection.userDetails.data.csCount
return new Toggle(
Translations.t.general.add.presetInfo.Subs({
tags: preset.tags.map(t => t.asHumanString(optionallyLinkToWiki && csCount > Constants.userJourney.tagsVisibleAndWikiLinked, true)).join("&"),
}).SetStyle("word-break: break-all"),
Translations.t.general.add.presetInfo
.Subs({
tags: preset.tags
.map((t) =>
t.asHumanString(
optionallyLinkToWiki &&
csCount > Constants.userJourney.tagsVisibleAndWikiLinked,
true
)
)
.join("&"),
})
.SetStyle("word-break: break-all"),
undefined,
osmConnection.userDetails.map(userdetails => userdetails.csCount >= Constants.userJourney.tagsVisibleAt)
);
osmConnection.userDetails.map(
(userdetails) => userdetails.csCount >= Constants.userJourney.tagsVisibleAt
)
)
}
private static CreateAllPresetsPanel(selectedPreset: UIEventSource<PresetInfo>,
state: {
featureSwitchIsTesting: UIEventSource<boolean>;
filteredLayers: UIEventSource<FilteredLayer[]>,
featureSwitchFilter: UIEventSource<boolean>,
osmConnection: OsmConnection
}): BaseUIElement {
private static CreateAllPresetsPanel(
selectedPreset: UIEventSource<PresetInfo>,
state: {
featureSwitchIsTesting: UIEventSource<boolean>
filteredLayers: UIEventSource<FilteredLayer[]>
featureSwitchFilter: UIEventSource<boolean>
osmConnection: OsmConnection
}
): BaseUIElement {
const presetButtons = SimpleAddUI.CreatePresetButtons(state, selectedPreset)
let intro: BaseUIElement = Translations.t.general.add.intro;
let intro: BaseUIElement = Translations.t.general.add.intro
let testMode: BaseUIElement = new Toggle(Translations.t.general.testing.SetClass("alert"),
let testMode: BaseUIElement = new Toggle(
Translations.t.general.testing.SetClass("alert"),
undefined,
state.featureSwitchIsTesting);
state.featureSwitchIsTesting
)
return new Combine([intro, testMode, presetButtons]).SetClass("flex flex-col")
}
private static CreatePresetSelectButton(preset: PresetInfo) {
const title = Translations.t.general.add.addNew.Subs({
category: preset.name
}, preset.name["context"])
const title = Translations.t.general.add.addNew.Subs(
{
category: preset.name,
},
preset.name["context"]
)
return new SubtleButton(
preset.icon(),
new Combine([
title.SetClass("font-bold"),
preset.description?.FirstSentence()
preset.description?.FirstSentence(),
]).SetClass("flex flex-col")
)
}
/*
* Generates the list with all the buttons.*/
* Generates the list with all the buttons.*/
private static CreatePresetButtons(
state: {
filteredLayers: UIEventSource<FilteredLayer[]>,
featureSwitchFilter: UIEventSource<boolean>,
filteredLayers: UIEventSource<FilteredLayer[]>
featureSwitchFilter: UIEventSource<boolean>
osmConnection: OsmConnection
},
selectedPreset: UIEventSource<PresetInfo>): BaseUIElement {
const allButtons = [];
selectedPreset: UIEventSource<PresetInfo>
): BaseUIElement {
const allButtons = []
for (const layer of state.filteredLayers.data) {
if (layer.isDisplayed.data === false) {
// The layer is not displayed...
if(!state.featureSwitchFilter.data){
if (!state.featureSwitchFilter.data) {
// ...and we cannot enable the layer control -> we skip, as these presets can never be shown anyway
continue;
continue
}
if (layer.layerDef.name === undefined) {
// this layer can never be toggled on in any case, so we skip the presets
continue;
continue
}
}
const presets = layer.layerDef.presets;
const presets = layer.layerDef.presets
for (const preset of presets) {
const tags = TagUtils.KVtoProperties(preset.tags ?? []);
let icon: () => BaseUIElement = () => layer.layerDef.mapRendering[0].GenerateLeafletStyle(new UIEventSource<any>(tags), false).html
.SetClass("w-12 h-12 block relative");
const tags = TagUtils.KVtoProperties(preset.tags ?? [])
let icon: () => BaseUIElement = () =>
layer.layerDef.mapRendering[0]
.GenerateLeafletStyle(new UIEventSource<any>(tags), false)
.html.SetClass("w-12 h-12 block relative")
const presetInfo: PresetInfo = {
layerToAddTo: layer,
name: preset.title,
title: preset.title,
icon: icon,
preciseInput: preset.preciseInput,
...preset
...preset,
}
const button = SimpleAddUI.CreatePresetSelectButton(presetInfo);
const button = SimpleAddUI.CreatePresetSelectButton(presetInfo)
button.onClick(() => {
selectedPreset.setData(presetInfo)
})
allButtons.push(button);
allButtons.push(button)
}
}
return new Combine(allButtons).SetClass("flex flex-col");
return new Combine(allButtons).SetClass("flex flex-col")
}
}
}

View file

@ -1,51 +1,68 @@
import {VariableUiElement} from "../Base/VariableUIElement";
import Loading from "../Base/Loading";
import Title from "../Base/Title";
import TagRenderingChart from "./TagRenderingChart";
import Combine from "../Base/Combine";
import Locale from "../i18n/Locale";
import {UIEventSource} from "../../Logic/UIEventSource";
import {OsmFeature} from "../../Models/OsmFeature";
import LayerConfig from "../../Models/ThemeConfig/LayerConfig";
import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig";
import { VariableUiElement } from "../Base/VariableUIElement"
import Loading from "../Base/Loading"
import Title from "../Base/Title"
import TagRenderingChart from "./TagRenderingChart"
import Combine from "../Base/Combine"
import Locale from "../i18n/Locale"
import { UIEventSource } from "../../Logic/UIEventSource"
import { OsmFeature } from "../../Models/OsmFeature"
import LayerConfig from "../../Models/ThemeConfig/LayerConfig"
import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig"
export default class StatisticsPanel extends VariableUiElement {
constructor(elementsInview: UIEventSource<{ element: OsmFeature, layer: LayerConfig }[]>, state: {
layoutToUse: LayoutConfig
}) {
super(elementsInview.stabilized(1000).map(features => {
if (features === undefined) {
return new Loading("Loading data")
}
if (features.length === 0) {
return "No elements in view"
}
const els = []
for (const layer of state.layoutToUse.layers) {
if(layer.name === undefined){
continue
}
const featuresForLayer = features.filter(f => f.layer === layer).map(f => f.element)
if(featuresForLayer.length === 0){
continue
}
els.push(new Title(layer.name.Clone(), 1).SetClass("mt-8"))
const layerStats = []
for (const tagRendering of (layer?.tagRenderings ?? [])) {
const chart = new TagRenderingChart(featuresForLayer, tagRendering, {
chartclasses: "w-full",
chartstyle: "height: 60rem",
includeTitle: false
})
const title = new Title(tagRendering.question?.Clone() ?? tagRendering.id, 4).SetClass("mt-8")
if(!chart.HasClass("hidden")){
layerStats.push(new Combine([title, chart]).SetClass("flex flex-col w-full lg:w-1/3"))
constructor(
elementsInview: UIEventSource<{ element: OsmFeature; layer: LayerConfig }[]>,
state: {
layoutToUse: LayoutConfig
}
) {
super(
elementsInview.stabilized(1000).map(
(features) => {
if (features === undefined) {
return new Loading("Loading data")
}
}
els.push(new Combine(layerStats).SetClass("flex flex-wrap"))
}
return new Combine(els)
}, [Locale.language]));
if (features.length === 0) {
return "No elements in view"
}
const els = []
for (const layer of state.layoutToUse.layers) {
if (layer.name === undefined) {
continue
}
const featuresForLayer = features
.filter((f) => f.layer === layer)
.map((f) => f.element)
if (featuresForLayer.length === 0) {
continue
}
els.push(new Title(layer.name.Clone(), 1).SetClass("mt-8"))
const layerStats = []
for (const tagRendering of layer?.tagRenderings ?? []) {
const chart = new TagRenderingChart(featuresForLayer, tagRendering, {
chartclasses: "w-full",
chartstyle: "height: 60rem",
includeTitle: false,
})
const title = new Title(
tagRendering.question?.Clone() ?? tagRendering.id,
4
).SetClass("mt-8")
if (!chart.HasClass("hidden")) {
layerStats.push(
new Combine([title, chart]).SetClass(
"flex flex-col w-full lg:w-1/3"
)
)
}
}
els.push(new Combine(layerStats).SetClass("flex flex-wrap"))
}
return new Combine(els)
},
[Locale.language]
)
)
}
}
}

View file

@ -1,62 +1,66 @@
import ChartJs from "../Base/ChartJs";
import TagRenderingConfig from "../../Models/ThemeConfig/TagRenderingConfig";
import {ChartConfiguration} from 'chart.js';
import Combine from "../Base/Combine";
import {TagUtils} from "../../Logic/Tags/TagUtils";
import {Utils} from "../../Utils";
import {OsmFeature} from "../../Models/OsmFeature";
import ChartJs from "../Base/ChartJs"
import TagRenderingConfig from "../../Models/ThemeConfig/TagRenderingConfig"
import { ChartConfiguration } from "chart.js"
import Combine from "../Base/Combine"
import { TagUtils } from "../../Logic/Tags/TagUtils"
import { Utils } from "../../Utils"
import { OsmFeature } from "../../Models/OsmFeature"
export interface TagRenderingChartOptions {
groupToOtherCutoff?: 3 | number,
groupToOtherCutoff?: 3 | number
sort?: boolean
}
export class StackedRenderingChart extends ChartJs {
constructor(tr: TagRenderingConfig, features: (OsmFeature & { properties: { date: string } })[], options?: {
period: "day" | "month",
groupToOtherCutoff?: 3 | number
}) {
const {labels, data} = TagRenderingChart.extractDataAndLabels(tr, features, {
constructor(
tr: TagRenderingConfig,
features: (OsmFeature & { properties: { date: string } })[],
options?: {
period: "day" | "month"
groupToOtherCutoff?: 3 | number
}
) {
const { labels, data } = TagRenderingChart.extractDataAndLabels(tr, features, {
sort: true,
groupToOtherCutoff: options?.groupToOtherCutoff
groupToOtherCutoff: options?.groupToOtherCutoff,
})
if (labels === undefined || data === undefined) {
throw ("No labels or data given...")
console.error("Could not extract data and labels for ", tr, " with features", features)
throw "No labels or data given..."
}
// labels: ["cyclofix", "buurtnatuur", ...]; data : [ ["cyclofix-changeset", "cyclofix-changeset", ...], ["buurtnatuur-cs", "buurtnatuur-cs"], ... ]
for (let i = labels.length; i >= 0; i--) {
if (data[i]?.length != 0) {
continue
}
data.splice(i, 1)
labels.splice(i, 1)
}
const datasets: { label: string /*themename*/, data: number[]/*counts per day*/, backgroundColor: string }[] = []
const datasets: {
label: string /*themename*/
data: number[] /*counts per day*/
backgroundColor: string
}[] = []
const allDays = StackedRenderingChart.getAllDays(features)
let trimmedDays = allDays.map(d => d.substr(0, d.indexOf("T")))
let trimmedDays = allDays.map((d) => d.substr(0, 10))
if (options?.period === "month") {
trimmedDays = trimmedDays.map(d => d.substr(0, 7))
trimmedDays = trimmedDays.map((d) => d.substr(0, 7))
}
trimmedDays = Utils.Dedup(trimmedDays)
for (let i = 0; i < labels.length; i++) {
const label = labels[i];
const label = labels[i]
const changesetsForTheme = data[i]
const perDay: Record<string, OsmFeature[]> = {}
for (const changeset of changesetsForTheme) {
const csDate = new Date(changeset.properties.date)
Utils.SetMidnight(csDate)
let str = csDate.toISOString();
let str = csDate.toISOString()
str = str.substr(0, 10)
if (options?.period === "month") {
csDate.setUTCDate(1)
str = csDate.toISOString().substr(0, 7);
str = str.substr(0, 7)
}
if (perDay[str] === undefined) {
perDay[str] = [changeset]
@ -67,10 +71,11 @@ export class StackedRenderingChart extends ChartJs {
const countsPerDay: number[] = []
for (let i = 0; i < trimmedDays.length; i++) {
const day = trimmedDays[i];
const day = trimmedDays[i]
countsPerDay[i] = perDay[day]?.length ?? 0
}
let backgroundColor = TagRenderingChart.borderColors[i % TagRenderingChart.borderColors.length]
let backgroundColor =
TagRenderingChart.borderColors[i % TagRenderingChart.borderColors.length]
if (label === "Unknown") {
backgroundColor = TagRenderingChart.unkownBorderColor
}
@ -80,47 +85,44 @@ export class StackedRenderingChart extends ChartJs {
datasets.push({
data: countsPerDay,
backgroundColor,
label
label,
})
}
const perDayData = {
labels: trimmedDays,
datasets
datasets,
}
const config = <ChartConfiguration>{
type: 'bar',
type: "bar",
data: perDayData,
options: {
responsive: true,
legend: {
display: false
display: false,
},
scales: {
x: {
stacked: true,
},
y: {
stacked: true
}
}
}
stacked: true,
},
},
},
}
super(config)
}
public static getAllDays(features: (OsmFeature & { properties: { date: string } })[]): string[] {
public static getAllDays(
features: (OsmFeature & { properties: { date: string } })[]
): string[] {
let earliest: Date = undefined
let latest: Date = undefined;
let allDates = new Set<string>();
let latest: Date = undefined
let allDates = new Set<string>()
features.forEach((value, key) => {
const d = new Date(value.properties.date);
const d = new Date(value.properties.date)
Utils.SetMidnight(d)
if (earliest === undefined) {
@ -147,60 +149,72 @@ export class StackedRenderingChart extends ChartJs {
}
export default class TagRenderingChart extends Combine {
public static readonly unkownColor = "rgba(128, 128, 128, 0.2)"
public static readonly unkownBorderColor = "rgba(128, 128, 128, 0.2)"
public static readonly unkownColor = 'rgba(128, 128, 128, 0.2)'
public static readonly unkownBorderColor = 'rgba(128, 128, 128, 0.2)'
public static readonly otherColor = 'rgba(128, 128, 128, 0.2)'
public static readonly otherBorderColor = 'rgba(128, 128, 255)'
public static readonly notApplicableColor = 'rgba(128, 128, 128, 0.2)'
public static readonly notApplicableBorderColor = 'rgba(255, 0, 0)'
public static readonly otherColor = "rgba(128, 128, 128, 0.2)"
public static readonly otherBorderColor = "rgba(128, 128, 255)"
public static readonly notApplicableColor = "rgba(128, 128, 128, 0.2)"
public static readonly notApplicableBorderColor = "rgba(255, 0, 0)"
public static readonly backgroundColors = [
'rgba(255, 99, 132, 0.2)',
'rgba(54, 162, 235, 0.2)',
'rgba(255, 206, 86, 0.2)',
'rgba(75, 192, 192, 0.2)',
'rgba(153, 102, 255, 0.2)',
'rgba(255, 159, 64, 0.2)'
"rgba(255, 99, 132, 0.2)",
"rgba(54, 162, 235, 0.2)",
"rgba(255, 206, 86, 0.2)",
"rgba(75, 192, 192, 0.2)",
"rgba(153, 102, 255, 0.2)",
"rgba(255, 159, 64, 0.2)",
]
public static readonly borderColors = [
'rgba(255, 99, 132, 1)',
'rgba(54, 162, 235, 1)',
'rgba(255, 206, 86, 1)',
'rgba(75, 192, 192, 1)',
'rgba(153, 102, 255, 1)',
'rgba(255, 159, 64, 1)'
"rgba(255, 99, 132, 1)",
"rgba(54, 162, 235, 1)",
"rgba(255, 206, 86, 1)",
"rgba(75, 192, 192, 1)",
"rgba(153, 102, 255, 1)",
"rgba(255, 159, 64, 1)",
]
/**
* Creates a chart about this tagRendering for the given data
*/
constructor(features: { properties: Record<string, string> }[], tagRendering: TagRenderingConfig, options?: TagRenderingChartOptions & {
chartclasses?: string,
chartstyle?: string,
includeTitle?: boolean,
chartType?: "pie" | "bar" | "doughnut"
}) {
constructor(
features: { properties: Record<string, string> }[],
tagRendering: TagRenderingConfig,
options?: TagRenderingChartOptions & {
chartclasses?: string
chartstyle?: string
includeTitle?: boolean
chartType?: "pie" | "bar" | "doughnut"
}
) {
if (tagRendering.mappings?.length === 0 && tagRendering.freeform?.key === undefined) {
super([])
this.SetClass("hidden")
return;
return
}
const {labels, data} = TagRenderingChart.extractDataAndLabels(tagRendering, features, options)
const { labels, data } = TagRenderingChart.extractDataAndLabels(
tagRendering,
features,
options
)
if (labels === undefined || data === undefined) {
super([])
this.SetClass("hidden")
return
}
const borderColor = [TagRenderingChart.unkownBorderColor, TagRenderingChart.otherBorderColor, TagRenderingChart.notApplicableBorderColor]
const backgroundColor = [TagRenderingChart.unkownColor, TagRenderingChart.otherColor, TagRenderingChart.notApplicableColor]
const borderColor = [
TagRenderingChart.unkownBorderColor,
TagRenderingChart.otherBorderColor,
TagRenderingChart.notApplicableBorderColor,
]
const backgroundColor = [
TagRenderingChart.unkownColor,
TagRenderingChart.otherColor,
TagRenderingChart.notApplicableColor,
]
while (borderColor.length < data.length) {
borderColor.push(...TagRenderingChart.borderColors)
@ -216,80 +230,87 @@ export default class TagRenderingChart extends Combine {
}
}
let barchartMode = tagRendering.multiAnswer;
let barchartMode = tagRendering.multiAnswer
if (labels.length > 9) {
barchartMode = true;
barchartMode = true
}
const config = <ChartConfiguration>{
type: options.chartType ?? (barchartMode ? 'bar' : 'doughnut'),
type: options.chartType ?? (barchartMode ? "bar" : "doughnut"),
data: {
labels,
datasets: [{
data: data.map(l => l.length),
backgroundColor,
borderColor,
borderWidth: 1,
label: undefined
}]
datasets: [
{
data: data.map((l) => l.length),
backgroundColor,
borderColor,
borderWidth: 1,
label: undefined,
},
],
},
options: {
plugins: {
legend: {
display: !barchartMode
}
}
}
display: !barchartMode,
},
},
},
}
const chart = new ChartJs(config).SetClass(options?.chartclasses ?? "w-32 h-32");
const chart = new ChartJs(config).SetClass(options?.chartclasses ?? "w-32 h-32")
if (options.chartstyle !== undefined) {
chart.SetStyle(options.chartstyle)
}
super([
options?.includeTitle ? (tagRendering.question.Clone() ?? tagRendering.id) : undefined,
chart])
options?.includeTitle ? tagRendering.question.Clone() ?? tagRendering.id : undefined,
chart,
])
this.SetClass("block")
}
public static extractDataAndLabels<T extends { properties: Record<string, string> }>(tagRendering: TagRenderingConfig, features: T[], options?: TagRenderingChartOptions): { labels: string[], data: T[][] } {
public static extractDataAndLabels<T extends { properties: Record<string, string> }>(
tagRendering: TagRenderingConfig,
features: T[],
options?: TagRenderingChartOptions
): { labels: string[]; data: T[][] } {
const mappings = tagRendering.mappings ?? []
options = options ?? {}
let unknownCount: T[] = [];
const categoryCounts: T[][] = mappings.map(_ => [])
let unknownCount: T[] = []
const categoryCounts: T[][] = mappings.map((_) => [])
const otherCounts: Record<string, T[]> = {}
let notApplicable: T[] = [];
let notApplicable: T[] = []
for (const feature of features) {
const props = feature.properties
if (tagRendering.condition !== undefined && !tagRendering.condition.matchesProperties(props)) {
notApplicable.push(feature);
continue;
if (
tagRendering.condition !== undefined &&
!tagRendering.condition.matchesProperties(props)
) {
notApplicable.push(feature)
continue
}
if (!tagRendering.IsKnown(props)) {
unknownCount.push(feature);
continue;
unknownCount.push(feature)
continue
}
let foundMatchingMapping = false;
let foundMatchingMapping = false
if (!tagRendering.multiAnswer) {
for (let i = 0; i < mappings.length; i++) {
const mapping = mappings[i];
const mapping = mappings[i]
if (mapping.if.matchesProperties(props)) {
categoryCounts[i].push(feature)
foundMatchingMapping = true
break;
break
}
}
} else {
for (let i = 0; i < mappings.length; i++) {
const mapping = mappings[i];
const mapping = mappings[i]
if (TagUtils.MatchesMultiAnswer(mapping.if, props)) {
categoryCounts[i].push(feature)
foundMatchingMapping = true
@ -297,9 +318,12 @@ export default class TagRenderingChart extends Combine {
}
}
if (!foundMatchingMapping) {
if (tagRendering.freeform?.key !== undefined && props[tagRendering.freeform.key] !== undefined) {
if (
tagRendering.freeform?.key !== undefined &&
props[tagRendering.freeform.key] !== undefined
) {
const otherValue = props[tagRendering.freeform.key]
otherCounts[otherValue] = (otherCounts[otherValue] ?? [])
otherCounts[otherValue] = otherCounts[otherValue] ?? []
otherCounts[otherValue].push(feature)
} else {
unknownCount.push(feature)
@ -309,15 +333,15 @@ export default class TagRenderingChart extends Combine {
if (unknownCount.length + notApplicable.length === features.length) {
console.log("Returning no label nor data: all features are unkown or notApplicable")
return {labels: undefined, data: undefined}
return { labels: undefined, data: undefined }
}
let otherGrouped: T[] = [];
let otherGrouped: T[] = []
const otherLabels: string[] = []
const otherData: T[][] = []
const sortedOtherCounts: [string, T[]][] = []
for (const v in otherCounts) {
sortedOtherCounts.push([v, otherCounts[v]]);
sortedOtherCounts.push([v, otherCounts[v]])
}
if (options?.sort) {
sortedOtherCounts.sort((a, b) => b[1].length - a[1].length)
@ -327,15 +351,25 @@ export default class TagRenderingChart extends Combine {
otherLabels.push(v)
otherData.push(otherCounts[v])
} else {
otherGrouped.push(...count);
otherGrouped.push(...count)
}
}
const labels = [
"Unknown",
"Other",
"Not applicable",
...(mappings?.map((m) => m.then.txt) ?? []),
...otherLabels,
]
const data: T[][] = [
unknownCount,
otherGrouped,
notApplicable,
...categoryCounts,
...otherData,
]
const labels = ["Unknown", "Other", "Not applicable", ...mappings?.map(m => m.then.txt) ?? [], ...otherLabels]
const data: T[][] = [unknownCount, otherGrouped, notApplicable, ...categoryCounts, ...otherData]
return {labels, data}
return { labels, data }
}
}
}

View file

@ -1,18 +1,27 @@
import Combine from "../Base/Combine";
import LanguagePicker from "../LanguagePicker";
import Translations from "../i18n/Translations";
import Toggle from "../Input/Toggle";
import {SubtleButton} from "../Base/SubtleButton";
import {UIEventSource} from "../../Logic/UIEventSource";
import {LoginToggle} from "../Popup/LoginButton";
import Svg from "../../Svg";
import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig";
import {OsmConnection} from "../../Logic/Osm/OsmConnection";
import FullWelcomePaneWithTabs from "./FullWelcomePaneWithTabs";
import Combine from "../Base/Combine"
import LanguagePicker from "../LanguagePicker"
import Translations from "../i18n/Translations"
import Toggle from "../Input/Toggle"
import { SubtleButton } from "../Base/SubtleButton"
import { UIEventSource } from "../../Logic/UIEventSource"
import { LoginToggle } from "../Popup/LoginButton"
import Svg from "../../Svg"
import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig"
import { OsmConnection } from "../../Logic/Osm/OsmConnection"
import FullWelcomePaneWithTabs from "./FullWelcomePaneWithTabs"
export default class ThemeIntroductionPanel extends Combine {
constructor(isShown: UIEventSource<boolean>, currentTab: UIEventSource<number>, state: { featureSwitchMoreQuests: UIEventSource<boolean>; featureSwitchAddNew: UIEventSource<boolean>; featureSwitchUserbadge: UIEventSource<boolean>; layoutToUse: LayoutConfig; osmConnection: OsmConnection }) {
constructor(
isShown: UIEventSource<boolean>,
currentTab: UIEventSource<number>,
state: {
featureSwitchMoreQuests: UIEventSource<boolean>
featureSwitchAddNew: UIEventSource<boolean>
featureSwitchUserbadge: UIEventSource<boolean>
layoutToUse: LayoutConfig
osmConnection: OsmConnection
}
) {
const t = Translations.t.general
const layout = state.layoutToUse
@ -21,48 +30,56 @@ export default class ThemeIntroductionPanel extends Combine {
const toTheMap = new SubtleButton(
undefined,
t.openTheMap.Clone().SetClass("text-xl font-bold w-full text-center")
).onClick(() => {
isShown.setData(false)
}).SetClass("only-on-mobile")
)
.onClick(() => {
isShown.setData(false)
})
.SetClass("only-on-mobile")
const loginStatus =
new Toggle(
new LoginToggle(
undefined,
new Combine([Translations.t.general.loginWithOpenStreetMap.SetClass("text-xl font-bold"),
Translations.t.general.loginOnlyNeededToEdit.Clone().SetClass("font-bold")]
).SetClass("flex flex-col"),
state
),
const loginStatus = new Toggle(
new LoginToggle(
undefined,
state.featureSwitchUserbadge
)
new Combine([
Translations.t.general.loginWithOpenStreetMap.SetClass("text-xl font-bold"),
Translations.t.general.loginOnlyNeededToEdit.Clone().SetClass("font-bold"),
]).SetClass("flex flex-col"),
state
),
undefined,
state.featureSwitchUserbadge
)
const hasPresets = layout.layers.some(l => l.presets?.length > 0)
const hasPresets = layout.layers.some((l) => l.presets?.length > 0)
super([
layout.description.Clone().SetClass("blcok mb-4"),
new Combine([
t.welcomeExplanation.general,
hasPresets ? Toggle.If( state.featureSwitchAddNew, () => t.welcomeExplanation.addNew) : undefined,
hasPresets
? Toggle.If(state.featureSwitchAddNew, () => t.welcomeExplanation.addNew)
: undefined,
]).SetClass("flex flex-col mt-2"),
toTheMap,
loginStatus.SetClass("block"),
layout.descriptionTail?.Clone().SetClass("block mt-4"),
languagePicker?.SetClass("block mt-4"),
Toggle.If(state.featureSwitchMoreQuests,
() => new Combine([
Toggle.If(state.featureSwitchMoreQuests, () =>
new Combine([
t.welcomeExplanation.browseOtherThemesIntro,
new SubtleButton(Svg.add_ui().SetClass("h-6"),t.welcomeExplanation.browseMoreMaps )
.onClick(() => currentTab.setData(FullWelcomePaneWithTabs.MoreThemesTabIndex))
.SetClass("h-12")
]).SetClass("flex flex-col mt-6")),
...layout.CustomCodeSnippets()
new SubtleButton(
Svg.add_ui().SetClass("h-6"),
t.welcomeExplanation.browseMoreMaps
)
.onClick(() =>
currentTab.setData(FullWelcomePaneWithTabs.MoreThemesTabIndex)
)
.SetClass("h-12"),
]).SetClass("flex flex-col mt-6")
),
...layout.CustomCodeSnippets(),
])
this.SetClass("link-underline")

View file

@ -1,28 +1,29 @@
import Toggle from "../Input/Toggle";
import Lazy from "../Base/Lazy";
import {Utils} from "../../Utils";
import Translations from "../i18n/Translations";
import Combine from "../Base/Combine";
import Locale from "../i18n/Locale";
import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig";
import {Translation} from "../i18n/Translation";
import {VariableUiElement} from "../Base/VariableUIElement";
import Link from "../Base/Link";
import LinkToWeblate from "../Base/LinkToWeblate";
import Toggleable from "../Base/Toggleable";
import Title from "../Base/Title";
import {Store, UIEventSource} from "../../Logic/UIEventSource";
import {SubtleButton} from "../Base/SubtleButton";
import Svg from "../../Svg";
import Toggle from "../Input/Toggle"
import Lazy from "../Base/Lazy"
import { Utils } from "../../Utils"
import Translations from "../i18n/Translations"
import Combine from "../Base/Combine"
import Locale from "../i18n/Locale"
import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig"
import { Translation } from "../i18n/Translation"
import { VariableUiElement } from "../Base/VariableUIElement"
import Link from "../Base/Link"
import LinkToWeblate from "../Base/LinkToWeblate"
import Toggleable from "../Base/Toggleable"
import Title from "../Base/Title"
import { Store } from "../../Logic/UIEventSource"
import { SubtleButton } from "../Base/SubtleButton"
import Svg from "../../Svg"
import * as native_languages from "../../assets/language_native.json"
import * as used_languages from "../../assets/generated/used_languages.json"
import BaseUIElement from "../BaseUIElement";
import BaseUIElement from "../BaseUIElement"
class TranslatorsPanelContent extends Combine {
constructor(layout: LayoutConfig, isTranslator: Store<boolean>) {
const t = Translations.t.translations
const {completeness, untranslated, total} = TranslatorsPanel.MissingTranslationsFor(layout)
const { completeness, untranslated, total } =
TranslatorsPanel.MissingTranslationsFor(layout)
const seed = t.completeness
for (const ln of Array.from(completeness.keys())) {
@ -36,127 +37,162 @@ class TranslatorsPanelContent extends Combine {
const completenessTr = {}
const completenessPercentage = {}
seed.SupportedLanguages().forEach(ln => {
seed.SupportedLanguages().forEach((ln) => {
completenessTr[ln] = "" + (completeness.get(ln) ?? 0)
completenessPercentage[ln] = "" + Math.round(100 * (completeness.get(ln) ?? 0) / total)
completenessPercentage[ln] =
"" + Math.round((100 * (completeness.get(ln) ?? 0)) / total)
})
function missingTranslationsFor(language: string): BaseUIElement[] {
// e.g. "themes:<themename>.layers.0.tagRenderings..., or "layers:<layername>.description
const missingKeys = Utils.NoNull(untranslated.get(language) ?? [])
.filter(ctx => ctx.indexOf(":") >= 0)
.map(ctx => ctx.replace(/note_import_[a-zA-Z0-9_]*/, "note_import"))
.filter((ctx) => ctx.indexOf(":") >= 0)
.map((ctx) => ctx.replace(/note_import_[a-zA-Z0-9_]*/, "note_import"))
const hasMissingTheme = missingKeys.some(k => k.startsWith("themes:"))
const missingLayers = Utils.Dedup( missingKeys.filter(k => k.startsWith("layers:"))
.map(k => k.slice("layers:".length).split(".")[0]))
const hasMissingTheme = missingKeys.some((k) => k.startsWith("themes:"))
const missingLayers = Utils.Dedup(
missingKeys
.filter((k) => k.startsWith("layers:"))
.map((k) => k.slice("layers:".length).split(".")[0])
)
console.log("Getting untranslated string for",language,"raw:",missingKeys,"hasMissingTheme:",hasMissingTheme,"missingLayers:",missingLayers)
console.log(
"Getting untranslated string for",
language,
"raw:",
missingKeys,
"hasMissingTheme:",
hasMissingTheme,
"missingLayers:",
missingLayers
)
return [
hasMissingTheme ? new Link("themes:" + layout.id + ".* (zen mode)", LinkToWeblate.hrefToWeblateZen(language, "themes", layout.id), true) : undefined,
...missingLayers.map(id => new Link("layer:" + id + ".* (zen mode)", LinkToWeblate.hrefToWeblateZen(language, "layers", id), true)),
...missingKeys.map(context => new Link(context, LinkToWeblate.hrefToWeblate(language, context), true))
hasMissingTheme
? new Link(
"themes:" + layout.id + ".* (zen mode)",
LinkToWeblate.hrefToWeblateZen(language, "themes", layout.id),
true
)
: undefined,
...missingLayers.map(
(id) =>
new Link(
"layer:" + id + ".* (zen mode)",
LinkToWeblate.hrefToWeblateZen(language, "layers", id),
true
)
),
...missingKeys.map(
(context) =>
new Link(context, LinkToWeblate.hrefToWeblate(language, context), true)
),
]
}
//
//
// "translationCompleteness": "Translations for {theme} in {language} are at {percentage}: {translated} out of {total}",
const translated = seed.Subs({
total, theme: layout.title,
total,
theme: layout.title,
percentage: new Translation(completenessPercentage),
translated: new Translation(completenessTr),
language: seed.OnEveryLanguage((_, lng) => native_languages[lng] ?? lng)
language: seed.OnEveryLanguage((_, lng) => native_languages[lng] ?? lng),
})
super([
new Title(
Translations.t.translations.activateButton,
),
new Title(Translations.t.translations.activateButton),
new Toggle(t.isTranslator.SetClass("thanks block"), undefined, isTranslator),
t.help,
translated,
/*Disable button:*/
new SubtleButton(undefined, t.deactivate)
.onClick(() => {
Locale.showLinkToWeblate.setData(false)
}),
new SubtleButton(undefined, t.deactivate).onClick(() => {
Locale.showLinkToWeblate.setData(false)
}),
new VariableUiElement(Locale.language.map(ln => {
const missing = missingTranslationsFor(ln)
if (missing.length === 0) {
return undefined
}
let title = Translations.t.translations.allMissing;
if(untranslated.get(ln) !== undefined){
title = Translations.t.translations.missing.Subs({count: untranslated.get(ln).length})
}
return new Toggleable(
new Title(title),
new Combine(missing).SetClass("flex flex-col")
)
}))
new VariableUiElement(
Locale.language.map((ln) => {
const missing = missingTranslationsFor(ln)
if (missing.length === 0) {
return undefined
}
let title = Translations.t.translations.allMissing
if (untranslated.get(ln) !== undefined) {
title = Translations.t.translations.missing.Subs({
count: untranslated.get(ln).length,
})
}
return new Toggleable(
new Title(title),
new Combine(missing).SetClass("flex flex-col")
)
})
),
])
}
}
export default class TranslatorsPanel extends Toggle {
constructor(state: { layoutToUse: LayoutConfig, isTranslator: Store<boolean> }, iconStyle?: string) {
constructor(
state: { layoutToUse: LayoutConfig; isTranslator: Store<boolean> },
iconStyle?: string
) {
const t = Translations.t.translations
super(
new Lazy(() => new TranslatorsPanelContent(state.layoutToUse, state.isTranslator)
new Lazy(
() => new TranslatorsPanelContent(state.layoutToUse, state.isTranslator)
).SetClass("flex flex-col"),
new SubtleButton(Svg.translate_ui().SetStyle(iconStyle), t.activateButton).onClick(() => Locale.showLinkToWeblate.setData(true)),
new SubtleButton(Svg.translate_ui().SetStyle(iconStyle), t.activateButton).onClick(() =>
Locale.showLinkToWeblate.setData(true)
),
Locale.showLinkToWeblate
)
this.SetClass("hidden-on-mobile")
}
public static MissingTranslationsFor(layout: LayoutConfig): { completeness: Map<string, number>, untranslated: Map<string, string[]>, total: number } {
public static MissingTranslationsFor(layout: LayoutConfig): {
completeness: Map<string, number>
untranslated: Map<string, string[]>
total: number
} {
let total = 0
const completeness = new Map<string, number>()
const untranslated = new Map<string, string[]>()
Utils.WalkObject(layout, (o) => {
const translation = <Translation><any>o;
if (translation.translations["*"] !== undefined) {
return
}
if (translation.context === undefined || translation.context.indexOf(":") < 0) {
// no source given - lets ignore
return
}
total ++
used_languages.languages.forEach(ln => {
const trans = translation.translations
if (trans["*"] !== undefined) {
return;
Utils.WalkObject(
layout,
(o) => {
const translation = <Translation>(<any>o)
if (translation.translations["*"] !== undefined) {
return
}
if (trans[ln] === undefined) {
if (!untranslated.has(ln)) {
untranslated.set(ln, [])
}
untranslated.get(ln).push(translation.context)
}else{
completeness.set(ln, 1 + (completeness.get(ln) ?? 0))
if (translation.context === undefined || translation.context.indexOf(":") < 0) {
// no source given - lets ignore
return
}
})
}, o => {
if (o === undefined || o === null) {
return false;
}
return o instanceof Translation;
})
return {completeness, untranslated, total}
total++
used_languages.languages.forEach((ln) => {
const trans = translation.translations
if (trans["*"] !== undefined) {
return
}
if (trans[ln] === undefined) {
if (!untranslated.has(ln)) {
untranslated.set(ln, [])
}
untranslated.get(ln).push(translation.context)
} else {
completeness.set(ln, 1 + (completeness.get(ln) ?? 0))
}
})
},
(o) => {
if (o === undefined || o === null) {
return false
}
return o instanceof Translation
}
)
return { completeness, untranslated, total }
}
}

View file

@ -1,104 +1,102 @@
import {VariableUiElement} from "../Base/VariableUIElement";
import Svg from "../../Svg";
import Combine from "../Base/Combine";
import {FixedUiElement} from "../Base/FixedUiElement";
import LanguagePicker from "../LanguagePicker";
import Translations from "../i18n/Translations";
import Link from "../Base/Link";
import Toggle from "../Input/Toggle";
import Img from "../Base/Img";
import MapState from "../../Logic/State/MapState";
import {LoginToggle} from "../Popup/LoginButton";
import { VariableUiElement } from "../Base/VariableUIElement"
import Svg from "../../Svg"
import Combine from "../Base/Combine"
import { FixedUiElement } from "../Base/FixedUiElement"
import LanguagePicker from "../LanguagePicker"
import Translations from "../i18n/Translations"
import Link from "../Base/Link"
import Toggle from "../Input/Toggle"
import Img from "../Base/Img"
import MapState from "../../Logic/State/MapState"
import { LoginToggle } from "../Popup/LoginButton"
export default class UserBadge extends LoginToggle {
constructor(state: MapState) {
const userDetails = state.osmConnection.userDetails;
const logout =
Svg.logout_svg()
.onClick(() => {
state.osmConnection.LogOut();
});
const userDetails = state.osmConnection.userDetails
const logout = Svg.logout_svg().onClick(() => {
state.osmConnection.LogOut()
})
const userBadge = new VariableUiElement(userDetails.map(user => {
{
const homeButton = new VariableUiElement(
userDetails.map((userinfo) => {
if (userinfo.home) {
return Svg.home_svg();
const userBadge = new VariableUiElement(
userDetails.map((user) => {
{
const homeButton = new VariableUiElement(
userDetails.map((userinfo) => {
if (userinfo.home) {
return Svg.home_svg()
}
return " "
})
).onClick(() => {
const home = state.osmConnection.userDetails.data?.home
if (home === undefined) {
return
}
return " ";
state.leafletMap.data?.setView([home.lat, home.lon], 16)
})
).onClick(() => {
const home = state.osmConnection.userDetails.data?.home;
if (home === undefined) {
return;
}
state.leafletMap.data?.setView([home.lat, home.lon], 16);
});
const linkStyle = "flex items-baseline"
const languagePicker = (new LanguagePicker(state.layoutToUse.language, "") ?? new FixedUiElement(""))
.SetStyle("width:min-content;");
const linkStyle = "flex items-baseline"
const languagePicker = (
new LanguagePicker(state.layoutToUse.language, "") ?? new FixedUiElement("")
).SetStyle("width:min-content;")
let messageSpan =
new Link(
let messageSpan = new Link(
new Combine([Svg.envelope, "" + user.totalMessages]).SetClass(linkStyle),
`${user.backend}/messages/inbox`,
true
)
const csCount =
new Link(
const csCount = new Link(
new Combine([Svg.star, "" + user.csCount]).SetClass(linkStyle),
`${user.backend}/user/${user.name}/history`,
true);
if (user.unreadMessages > 0) {
messageSpan = new Link(
new Combine([Svg.envelope, "" + user.unreadMessages]),
`${user.backend}/messages/inbox`,
true
).SetClass("alert")
}
)
let dryrun = new Toggle(
new FixedUiElement("TESTING").SetClass("alert font-xs p-0 max-h-4"),
undefined,
state.featureSwitchIsTesting
)
if (user.unreadMessages > 0) {
messageSpan = new Link(
new Combine([Svg.envelope, "" + user.unreadMessages]),
`${user.backend}/messages/inbox`,
true
).SetClass("alert")
}
const settings =
new Link(Svg.gear,
let dryrun = new Toggle(
new FixedUiElement("TESTING").SetClass("alert font-xs p-0 max-h-4"),
undefined,
state.featureSwitchIsTesting
)
const settings = new Link(
Svg.gear,
`${user.backend}/user/${encodeURIComponent(user.name)}/account`,
true)
true
)
const userName = new Link(
new FixedUiElement(user.name),
`${user.backend}/user/${user.name}`,
true
)
const userName = new Link(
new FixedUiElement(user.name),
`${user.backend}/user/${user.name}`,
true);
const userStats = new Combine([
homeButton,
settings,
messageSpan,
csCount,
languagePicker,
logout,
]).SetClass("userstats")
const userStats = new Combine([
homeButton,
settings,
messageSpan,
csCount,
languagePicker,
logout
])
.SetClass("userstats")
const usertext = new Combine([
new Combine([userName, dryrun]).SetClass("flex justify-end w-full"),
userStats
]).SetClass("flex flex-col sm:w-auto sm:pl-2 overflow-hidden w-0")
const userIcon =
(user.img === undefined ? Svg.osm_logo_ui() : new Img(user.img)).SetClass("rounded-full opacity-0 m-0 p-0 duration-500 w-16 min-width-16 h16 float-left")
const usertext = new Combine([
new Combine([userName, dryrun]).SetClass("flex justify-end w-full"),
userStats,
]).SetClass("flex flex-col sm:w-auto sm:pl-2 overflow-hidden w-0")
const userIcon = (
user.img === undefined ? Svg.osm_logo_ui() : new Img(user.img)
)
.SetClass(
"rounded-full opacity-0 m-0 p-0 duration-500 w-16 min-width-16 h16 float-left"
)
.onClick(() => {
if (usertext.HasClass("w-0")) {
usertext.RemoveClass("w-0")
@ -110,23 +108,17 @@ export default class UserBadge extends LoginToggle {
}
})
return new Combine([
usertext,
userIcon,
]).SetClass("h-16 flex bg-white")
return new Combine([usertext, userIcon]).SetClass("h-16 flex bg-white")
}
})
)
}
}));
super(
new Combine([userBadge.SetClass("inline-block m-0 w-full").SetStyle("pointer-events: all")])
.SetClass("shadow rounded-full h-min overflow-hidden block w-full md:w-max"),
new Combine([
userBadge.SetClass("inline-block m-0 w-full").SetStyle("pointer-events: all"),
]).SetClass("shadow rounded-full h-min overflow-hidden block w-full md:w-max"),
Translations.t.general.loginWithOpenStreetMap,
state
)
}
}

View file

@ -1,48 +1,43 @@
import Translations from "./i18n/Translations";
import {VariableUiElement} from "./Base/VariableUIElement";
import FeaturePipelineState from "../Logic/State/FeaturePipelineState";
import Loading from "./Base/Loading";
import Translations from "./i18n/Translations"
import { VariableUiElement } from "./Base/VariableUIElement"
import FeaturePipelineState from "../Logic/State/FeaturePipelineState"
import Loading from "./Base/Loading"
export default class CenterMessageBox extends VariableUiElement {
constructor(state: FeaturePipelineState) {
const updater = state.featurePipeline;
const t = Translations.t.centerMessage;
const updater = state.featurePipeline
const t = Translations.t.centerMessage
const message = updater.runningQuery.map(
isRunning => {
(isRunning) => {
if (isRunning) {
return {el: new Loading(t.loadingData)};
return { el: new Loading(t.loadingData) }
}
if (!updater.sufficientlyZoomed.data) {
return {el: t.zoomIn}
return { el: t.zoomIn }
}
if (updater.timeout.data > 0) {
return {el: t.retrying.Subs({count: "" + updater.timeout.data})}
return { el: t.retrying.Subs({ count: "" + updater.timeout.data }) }
}
return {el: t.ready, isDone: true}
return { el: t.ready, isDone: true }
},
[updater.timeout, updater.sufficientlyZoomed, state.locationControl]
)
super(message.map(toShow => toShow.el))
super(message.map((toShow) => toShow.el))
this.SetClass("flex justify-center " +
"rounded-3xl bg-white text-xl font-bold pointer-events-none p-4")
this.SetClass(
"flex justify-center " +
"rounded-3xl bg-white text-xl font-bold pointer-events-none p-4"
)
this.SetStyle("transition: opacity 750ms linear")
message.addCallbackAndRun(toShow => {
const isDone = toShow.isDone ?? false;
message.addCallbackAndRun((toShow) => {
const isDone = toShow.isDone ?? false
if (isDone) {
this.SetStyle("transition: opacity 750ms linear; opacity: 0")
} else {
this.SetStyle("transition: opacity 750ms linear; opacity: 0.75")
}
})
}
}

View file

@ -1,55 +1,61 @@
import FeaturePipelineState from "../Logic/State/FeaturePipelineState";
import {DefaultGuiState} from "./DefaultGuiState";
import {FixedUiElement} from "./Base/FixedUiElement";
import {Utils} from "../Utils";
import Combine from "./Base/Combine";
import ShowDataLayer from "./ShowDataLayer/ShowDataLayer";
import LayerConfig from "../Models/ThemeConfig/LayerConfig";
import * as home_location_json from "../assets/layers/home_location/home_location.json";
import State from "../State";
import Title from "./Base/Title";
import {MinimapObj} from "./Base/Minimap";
import BaseUIElement from "./BaseUIElement";
import {VariableUiElement} from "./Base/VariableUIElement";
import {GeoOperations} from "../Logic/GeoOperations";
import {OsmFeature} from "../Models/OsmFeature";
import SearchAndGo from "./BigComponents/SearchAndGo";
import FeatureInfoBox from "./Popup/FeatureInfoBox";
import {UIEventSource} from "../Logic/UIEventSource";
import LanguagePicker from "./LanguagePicker";
import Lazy from "./Base/Lazy";
import TagRenderingAnswer from "./Popup/TagRenderingAnswer";
import Hash from "../Logic/Web/Hash";
import FilterView from "./BigComponents/FilterView";
import Translations from "./i18n/Translations";
import Constants from "../Models/Constants";
import SimpleAddUI from "./BigComponents/SimpleAddUI";
import BackToIndex from "./BigComponents/BackToIndex";
import StatisticsPanel from "./BigComponents/StatisticsPanel";
import FeaturePipelineState from "../Logic/State/FeaturePipelineState"
import { DefaultGuiState } from "./DefaultGuiState"
import { FixedUiElement } from "./Base/FixedUiElement"
import { Utils } from "../Utils"
import Combine from "./Base/Combine"
import ShowDataLayer from "./ShowDataLayer/ShowDataLayer"
import LayerConfig from "../Models/ThemeConfig/LayerConfig"
import * as home_location_json from "../assets/layers/home_location/home_location.json"
import State from "../State"
import Title from "./Base/Title"
import { MinimapObj } from "./Base/Minimap"
import BaseUIElement from "./BaseUIElement"
import { VariableUiElement } from "./Base/VariableUIElement"
import { GeoOperations } from "../Logic/GeoOperations"
import { OsmFeature } from "../Models/OsmFeature"
import SearchAndGo from "./BigComponents/SearchAndGo"
import FeatureInfoBox from "./Popup/FeatureInfoBox"
import { UIEventSource } from "../Logic/UIEventSource"
import LanguagePicker from "./LanguagePicker"
import Lazy from "./Base/Lazy"
import TagRenderingAnswer from "./Popup/TagRenderingAnswer"
import Hash from "../Logic/Web/Hash"
import FilterView from "./BigComponents/FilterView"
import Translations from "./i18n/Translations"
import Constants from "../Models/Constants"
import SimpleAddUI from "./BigComponents/SimpleAddUI"
import BackToIndex from "./BigComponents/BackToIndex"
import StatisticsPanel from "./BigComponents/StatisticsPanel"
export default class DashboardGui {
private readonly state: FeaturePipelineState;
private readonly currentView: UIEventSource<{ title: string | BaseUIElement, contents: string | BaseUIElement }> = new UIEventSource(undefined)
private readonly state: FeaturePipelineState
private readonly currentView: UIEventSource<{
title: string | BaseUIElement
contents: string | BaseUIElement
}> = new UIEventSource(undefined)
constructor(state: FeaturePipelineState, guiState: DefaultGuiState) {
this.state = state;
this.state = state
}
private viewSelector(shown: BaseUIElement, title: string | BaseUIElement, contents: string | BaseUIElement, hash?: string): BaseUIElement {
private viewSelector(
shown: BaseUIElement,
title: string | BaseUIElement,
contents: string | BaseUIElement,
hash?: string
): BaseUIElement {
const currentView = this.currentView
const v = {title, contents}
const v = { title, contents }
shown.SetClass("pl-1 pr-1 rounded-md")
shown.onClick(() => {
currentView.setData(v)
})
Hash.hash.addCallbackAndRunD(h => {
Hash.hash.addCallbackAndRunD((h) => {
if (h === hash) {
currentView.setData(v)
}
})
currentView.addCallbackAndRunD(cv => {
currentView.addCallbackAndRunD((cv) => {
if (cv == v) {
shown.SetClass("bg-unsubtle")
Hash.hash.setData(hash)
@ -57,29 +63,38 @@ export default class DashboardGui {
shown.RemoveClass("bg-unsubtle")
}
})
return shown;
return shown
}
private singleElementCache: Record<string, BaseUIElement> = {}
private singleElementView(element: OsmFeature, layer: LayerConfig, distance: number): BaseUIElement {
private singleElementView(
element: OsmFeature,
layer: LayerConfig,
distance: number
): BaseUIElement {
if (this.singleElementCache[element.properties.id] !== undefined) {
return this.singleElementCache[element.properties.id]
}
const tags = this.state.allElements.getEventSourceById(element.properties.id)
const title = new Combine([new Title(new TagRenderingAnswer(tags, layer.title, this.state), 4),
distance < 900 ? Math.floor(distance) + "m away" :
Utils.Round(distance / 1000) + "km away"
]).SetClass("flex justify-between");
const title = new Combine([
new Title(new TagRenderingAnswer(tags, layer.title, this.state), 4),
distance < 900
? Math.floor(distance) + "m away"
: Utils.Round(distance / 1000) + "km away",
]).SetClass("flex justify-between")
return this.singleElementCache[element.properties.id] = this.viewSelector(title,
return (this.singleElementCache[element.properties.id] = this.viewSelector(
title,
new Lazy(() => FeatureInfoBox.GenerateTitleBar(tags, layer, this.state)),
new Lazy(() => FeatureInfoBox.GenerateContent(tags, layer, this.state)),
new Lazy(() => FeatureInfoBox.GenerateContent(tags, layer, this.state))
// element.properties.id
);
))
}
private mainElementsView(elements: { element: OsmFeature, layer: LayerConfig, distance: number }[]): BaseUIElement {
private mainElementsView(
elements: { element: OsmFeature; layer: LayerConfig; distance: number }[]
): BaseUIElement {
const self = this
if (elements === undefined) {
return new FixedUiElement("Initializing")
@ -87,64 +102,84 @@ export default class DashboardGui {
if (elements.length == 0) {
return new FixedUiElement("No elements in view")
}
return new Combine(elements.map(e => self.singleElementView(e.element, e.layer, e.distance)))
return new Combine(
elements.map((e) => self.singleElementView(e.element, e.layer, e.distance))
)
}
private documentationButtonFor(layerConfig: LayerConfig): BaseUIElement {
return this.viewSelector(Translations.W(layerConfig.name?.Clone() ?? layerConfig.id), new Combine(["Documentation about ", layerConfig.name?.Clone() ?? layerConfig.id]),
private documentationButtonFor(layerConfig: LayerConfig): BaseUIElement {
return this.viewSelector(
Translations.W(layerConfig.name?.Clone() ?? layerConfig.id),
new Combine(["Documentation about ", layerConfig.name?.Clone() ?? layerConfig.id]),
layerConfig.GenerateDocumentation([]),
"documentation-" + layerConfig.id)
"documentation-" + layerConfig.id
)
}
private allDocumentationButtons(): BaseUIElement {
const layers = this.state.layoutToUse.layers.filter(l => Constants.priviliged_layers.indexOf(l.id) < 0)
.filter(l => !l.id.startsWith("note_import_"));
const layers = this.state.layoutToUse.layers
.filter((l) => Constants.priviliged_layers.indexOf(l.id) < 0)
.filter((l) => !l.id.startsWith("note_import_"))
if (layers.length === 1) {
return this.documentationButtonFor(layers[0])
}
return this.viewSelector(new FixedUiElement("Documentation"), "Documentation",
new Combine(layers.map(l => this.documentationButtonFor(l).SetClass("flex flex-col"))))
return this.viewSelector(
new FixedUiElement("Documentation"),
"Documentation",
new Combine(layers.map((l) => this.documentationButtonFor(l).SetClass("flex flex-col")))
)
}
public setup(): void {
const state = this.state;
const state = this.state
if (this.state.layoutToUse.customCss !== undefined) {
if (window.location.pathname.indexOf("index") >= 0) {
Utils.LoadCustomCss(this.state.layoutToUse.customCss)
}
}
const map = this.SetupMap();
const map = this.SetupMap()
Utils.downloadJson("./service-worker-version").then(data => console.log("Service worker", data)).catch(_ => console.log("Service worker not active"))
Utils.downloadJson("./service-worker-version")
.then((data) => console.log("Service worker", data))
.catch((_) => console.log("Service worker not active"))
document.getElementById("centermessage").classList.add("hidden")
const layers: Record<string, LayerConfig> = {}
for (const layer of state.layoutToUse.layers) {
layers[layer.id] = layer;
layers[layer.id] = layer
}
const self = this;
const elementsInview = new UIEventSource<{ distance: number, center: [number, number], element: OsmFeature, layer: LayerConfig }[]>([]);
const self = this
const elementsInview = new UIEventSource<
{
distance: number
center: [number, number]
element: OsmFeature
layer: LayerConfig
}[]
>([])
function update() {
const mapCenter = <[number,number]> [self.state.locationControl.data.lon, self.state.locationControl.data.lon]
const elements = self.state.featurePipeline.getAllVisibleElementsWithmeta(self.state.currentBounds.data).map(el => {
const distance = GeoOperations.distanceBetween(el.center, mapCenter)
return {...el, distance }
})
const mapCenter = <[number, number]>[
self.state.locationControl.data.lon,
self.state.locationControl.data.lon,
]
const elements = self.state.featurePipeline
.getAllVisibleElementsWithmeta(self.state.currentBounds.data)
.map((el) => {
const distance = GeoOperations.distanceBetween(el.center, mapCenter)
return { ...el, distance }
})
elements.sort((e0, e1) => e0.distance - e1.distance)
elementsInview.setData(elements)
}
map.bounds.addCallbackAndRun(update)
state.featurePipeline.newDataLoadedSignal.addCallback(update);
state.filteredLayers.addCallbackAndRun(fls => {
state.featurePipeline.newDataLoadedSignal.addCallback(update)
state.filteredLayers.addCallbackAndRun((fls) => {
for (const fl of fls) {
fl.isDisplayed.addCallback(update)
fl.appliedFilters.addCallback(update)
@ -153,28 +188,33 @@ export default class DashboardGui {
const filterView = new Lazy(() => {
return new FilterView(state.filteredLayers, state.overlayToggles, state)
});
const welcome = new Combine([state.layoutToUse.description, state.layoutToUse.descriptionTail])
self.currentView.setData({title: state.layoutToUse.title, contents: welcome})
})
const welcome = new Combine([
state.layoutToUse.description,
state.layoutToUse.descriptionTail,
])
self.currentView.setData({ title: state.layoutToUse.title, contents: welcome })
const filterViewIsOpened = new UIEventSource(false)
filterViewIsOpened.addCallback(_ => self.currentView.setData({title: "filters", contents: filterView}))
filterViewIsOpened.addCallback((_) =>
self.currentView.setData({ title: "filters", contents: filterView })
)
const newPointIsShown = new UIEventSource(false);
const newPointIsShown = new UIEventSource(false)
const addNewPoint = new SimpleAddUI(
new UIEventSource(true),
new UIEventSource(undefined),
filterViewIsOpened,
state,
state.locationControl
);
)
const addNewPointTitle = "Add a missing point"
this.currentView.addCallbackAndRunD(cv => {
this.currentView.addCallbackAndRunD((cv) => {
newPointIsShown.setData(cv.contents === addNewPoint)
})
newPointIsShown.addCallbackAndRun(isShown => {
newPointIsShown.addCallbackAndRun((isShown) => {
if (isShown) {
if (self.currentView.data.contents !== addNewPoint) {
self.currentView.setData({title: addNewPointTitle, contents: addNewPoint})
self.currentView.setData({ title: addNewPointTitle, contents: addNewPoint })
}
} else {
if (self.currentView.data.contents === addNewPoint) {
@ -183,60 +223,83 @@ export default class DashboardGui {
}
})
new Combine([
new Combine([
this.viewSelector(new Title(state.layoutToUse.title.Clone(), 2), state.layoutToUse.title.Clone(), welcome, "welcome"),
this.viewSelector(
new Title(state.layoutToUse.title.Clone(), 2),
state.layoutToUse.title.Clone(),
welcome,
"welcome"
),
map.SetClass("w-full h-64 shrink-0 rounded-lg"),
new SearchAndGo(state),
this.viewSelector(new Title(
new VariableUiElement(elementsInview.map(elements => "There are " + elements?.length + " elements in view"))),
this.viewSelector(
new Title(
new VariableUiElement(
elementsInview.map(
(elements) => "There are " + elements?.length + " elements in view"
)
)
),
"Statistics",
new StatisticsPanel(elementsInview, this.state), "statistics"),
new StatisticsPanel(elementsInview, this.state),
"statistics"
),
this.viewSelector(new FixedUiElement("Filter"),
"Filters", filterView, "filters"),
this.viewSelector(new Combine(["Add a missing point"]), addNewPointTitle,
this.viewSelector(new FixedUiElement("Filter"), "Filters", filterView, "filters"),
this.viewSelector(
new Combine(["Add a missing point"]),
addNewPointTitle,
addNewPoint
),
new VariableUiElement(elementsInview.map(elements => this.mainElementsView(elements).SetClass("block m-2")))
.SetClass("block shrink-2 overflow-x-auto h-full border-2 border-subtle rounded-lg"),
new VariableUiElement(
elementsInview.map((elements) =>
this.mainElementsView(elements).SetClass("block m-2")
)
).SetClass(
"block shrink-2 overflow-x-auto h-full border-2 border-subtle rounded-lg"
),
this.allDocumentationButtons(),
new LanguagePicker(Object.keys(state.layoutToUse.title.translations)).SetClass("mt-2"),
new BackToIndex()
new LanguagePicker(Object.keys(state.layoutToUse.title.translations)).SetClass(
"mt-2"
),
new BackToIndex(),
]).SetClass("w-1/2 lg:w-1/4 m-4 flex flex-col shrink-0 grow-0"),
new VariableUiElement(this.currentView.map(({title, contents}) => {
return new Combine([
new Title(Translations.W(title), 2).SetClass("shrink-0 border-b-4 border-subtle"),
Translations.W(contents).SetClass("shrink-2 overflow-y-auto block")
]).SetClass("flex flex-col h-full")
})).SetClass("w-1/2 lg:w-3/4 m-4 p-2 border-2 border-subtle rounded-xl m-4 ml-0 mr-8 shrink-0 grow-0"),
]).SetClass("flex h-full")
new VariableUiElement(
this.currentView.map(({ title, contents }) => {
return new Combine([
new Title(Translations.W(title), 2).SetClass(
"shrink-0 border-b-4 border-subtle"
),
Translations.W(contents).SetClass("shrink-2 overflow-y-auto block"),
]).SetClass("flex flex-col h-full")
})
).SetClass(
"w-1/2 lg:w-3/4 m-4 p-2 border-2 border-subtle rounded-xl m-4 ml-0 mr-8 shrink-0 grow-0"
),
])
.SetClass("flex h-full")
.AttachTo("leafletDiv")
}
private SetupMap(): MinimapObj & BaseUIElement {
const state = this.state;
const state = this.state
new ShowDataLayer({
leafletMap: state.leafletMap,
layerToShow: new LayerConfig(home_location_json, "home_location", true),
features: state.homeLocation,
state
state,
})
state.leafletMap.addCallbackAndRunD(_ => {
state.leafletMap.addCallbackAndRunD((_) => {
// Lets assume that all showDataLayers are initialized at this point
state.selectedElement.ping()
State.state.locationControl.ping();
return true;
State.state.locationControl.ping()
return true
})
return state.mainMapObject
}
}
}

View file

@ -1,31 +1,30 @@
import FeaturePipelineState from "../Logic/State/FeaturePipelineState";
import State from "../State";
import {Utils} from "../Utils";
import {UIEventSource} from "../Logic/UIEventSource";
import FullWelcomePaneWithTabs from "./BigComponents/FullWelcomePaneWithTabs";
import MapControlButton from "./MapControlButton";
import Svg from "../Svg";
import Toggle from "./Input/Toggle";
import UserBadge from "./BigComponents/UserBadge";
import SearchAndGo from "./BigComponents/SearchAndGo";
import BaseUIElement from "./BaseUIElement";
import LeftControls from "./BigComponents/LeftControls";
import RightControls from "./BigComponents/RightControls";
import CenterMessageBox from "./CenterMessageBox";
import ShowDataLayer from "./ShowDataLayer/ShowDataLayer";
import ScrollableFullScreen from "./Base/ScrollableFullScreen";
import Translations from "./i18n/Translations";
import SimpleAddUI from "./BigComponents/SimpleAddUI";
import StrayClickHandler from "../Logic/Actors/StrayClickHandler";
import {DefaultGuiState} from "./DefaultGuiState";
import LayerConfig from "../Models/ThemeConfig/LayerConfig";
import * as home_location_json from "../assets/layers/home_location/home_location.json";
import NewNoteUi from "./Popup/NewNoteUi";
import Combine from "./Base/Combine";
import AddNewMarker from "./BigComponents/AddNewMarker";
import FilteredLayer from "../Models/FilteredLayer";
import ExtraLinkButton from "./BigComponents/ExtraLinkButton";
import FeaturePipelineState from "../Logic/State/FeaturePipelineState"
import State from "../State"
import { Utils } from "../Utils"
import { UIEventSource } from "../Logic/UIEventSource"
import FullWelcomePaneWithTabs from "./BigComponents/FullWelcomePaneWithTabs"
import MapControlButton from "./MapControlButton"
import Svg from "../Svg"
import Toggle from "./Input/Toggle"
import UserBadge from "./BigComponents/UserBadge"
import SearchAndGo from "./BigComponents/SearchAndGo"
import BaseUIElement from "./BaseUIElement"
import LeftControls from "./BigComponents/LeftControls"
import RightControls from "./BigComponents/RightControls"
import CenterMessageBox from "./CenterMessageBox"
import ShowDataLayer from "./ShowDataLayer/ShowDataLayer"
import ScrollableFullScreen from "./Base/ScrollableFullScreen"
import Translations from "./i18n/Translations"
import SimpleAddUI from "./BigComponents/SimpleAddUI"
import StrayClickHandler from "../Logic/Actors/StrayClickHandler"
import { DefaultGuiState } from "./DefaultGuiState"
import LayerConfig from "../Models/ThemeConfig/LayerConfig"
import * as home_location_json from "../assets/layers/home_location/home_location.json"
import NewNoteUi from "./Popup/NewNoteUi"
import Combine from "./Base/Combine"
import AddNewMarker from "./BigComponents/AddNewMarker"
import FilteredLayer from "../Models/FilteredLayer"
import ExtraLinkButton from "./BigComponents/ExtraLinkButton"
/**
* The default MapComplete GUI initializor
@ -33,49 +32,64 @@ import ExtraLinkButton from "./BigComponents/ExtraLinkButton";
* Adds a welcome pane, contorl buttons, ... etc to index.html
*/
export default class DefaultGUI {
private readonly guiState: DefaultGuiState;
private readonly state: FeaturePipelineState;
private readonly guiState: DefaultGuiState
private readonly state: FeaturePipelineState
constructor(state: FeaturePipelineState, guiState: DefaultGuiState) {
this.state = state;
this.guiState = guiState;
this.state = state
this.guiState = guiState
}
}
public setup(){
this.SetupUIElements();
public setup() {
this.SetupUIElements()
this.SetupMap()
if (this.state.layoutToUse.customCss !== undefined && window.location.pathname.indexOf("index") >= 0) {
if (
this.state.layoutToUse.customCss !== undefined &&
window.location.pathname.indexOf("index") >= 0
) {
Utils.LoadCustomCss(this.state.layoutToUse.customCss)
}
Utils.downloadJson("./service-worker-version").then(data => console.log("Service worker", data)).catch(e => console.log("Service worker not active"))
}
public setupClickDialogOnMap(filterViewIsOpened: UIEventSource<boolean>, state: FeaturePipelineState) {
const hasPresets = state.layoutToUse.layers.some(layer => layer.presets.length > 0);
const noteLayer: FilteredLayer = state.filteredLayers.data.filter(l => l.layerDef.id === "note")[0]
let addNewNoteDialog: (isShown: UIEventSource<boolean>) => BaseUIElement = undefined;
Utils.downloadJson("./service-worker-version")
.then((data) => console.log("Service worker", data))
.catch((e) => console.log("Service worker not active"))
}
public setupClickDialogOnMap(
filterViewIsOpened: UIEventSource<boolean>,
state: FeaturePipelineState
) {
const hasPresets = state.layoutToUse.layers.some((layer) => layer.presets.length > 0)
const noteLayer: FilteredLayer = state.filteredLayers.data.filter(
(l) => l.layerDef.id === "note"
)[0]
let addNewNoteDialog: (isShown: UIEventSource<boolean>) => BaseUIElement = undefined
if (noteLayer !== undefined) {
addNewNoteDialog = (isShown) => new NewNoteUi(noteLayer, isShown, state)
}
function setup() {
if (!hasPresets && addNewNoteDialog === undefined) {
return; // nothing to do
return // nothing to do
}
const newPointDialogIsShown = new UIEventSource<boolean>(false);
const newPointDialogIsShown = new UIEventSource<boolean>(false)
const addNewPoint = new ScrollableFullScreen(
() => hasPresets ? Translations.t.general.add.title : Translations.t.notes.createNoteTitle,
({resetScrollSignal}) => {
let addNew = undefined;
() =>
hasPresets
? Translations.t.general.add.title
: Translations.t.notes.createNoteTitle,
({ resetScrollSignal }) => {
let addNew = undefined
if (hasPresets) {
addNew = new SimpleAddUI(newPointDialogIsShown, resetScrollSignal, filterViewIsOpened, state);
addNew = new SimpleAddUI(
newPointDialogIsShown,
resetScrollSignal,
filterViewIsOpened,
state
)
}
let addNote = undefined;
let addNote = undefined
if (noteLayer !== undefined) {
addNote = addNewNoteDialog(newPointDialogIsShown)
}
@ -83,22 +97,23 @@ export default class DefaultGUI {
},
"new",
newPointDialogIsShown
);
)
addNewPoint.isShown.addCallback((isShown) => {
if (!isShown) {
// Clear the 'last-click'-location when the dialog is closed - this causes the popup and the marker to be removed
state.LastClickLocation.setData(undefined);
state.LastClickLocation.setData(undefined)
}
});
})
let noteMarker = undefined;
let noteMarker = undefined
if (!hasPresets && addNewNoteDialog !== undefined) {
noteMarker = new Combine(
[Svg.note_svg().SetClass("absolute bottom-0").SetStyle("height: 40px"),
Svg.addSmall_svg().SetClass("absolute w-6 animate-pulse")
.SetStyle("right: 10px; bottom: -8px;")
])
noteMarker = new Combine([
Svg.note_svg().SetClass("absolute bottom-0").SetStyle("height: 40px"),
Svg.addSmall_svg()
.SetClass("absolute w-6 animate-pulse")
.SetStyle("right: 10px; bottom: -8px;"),
])
.SetClass("block relative h-full")
.SetStyle("left: calc( 50% - 15px )") // This is a bit hacky, yes I know!
}
@ -107,91 +122,83 @@ export default class DefaultGUI {
state,
addNewPoint,
hasPresets ? new AddNewMarker(state.filteredLayers) : noteMarker
);
)
}
if (noteLayer !== undefined) {
setup()
} else {
state.featureSwitchAddNew.addCallbackAndRunD(addNewAllowed => {
state.featureSwitchAddNew.addCallbackAndRunD((addNewAllowed) => {
if (addNewAllowed) {
setup()
return true;
return true
}
})
}
}
private SetupMap() {
const state = this.state;
const guiState = this.guiState;
const state = this.state
const guiState = this.guiState
// Attach the map
state.mainMapObject.SetClass("w-full h-full")
.AttachTo("leafletDiv")
this.setupClickDialogOnMap(
guiState.filterViewIsOpened,
state
)
state.mainMapObject.SetClass("w-full h-full").AttachTo("leafletDiv")
this.setupClickDialogOnMap(guiState.filterViewIsOpened, state)
new ShowDataLayer({
leafletMap: state.leafletMap,
layerToShow: new LayerConfig(home_location_json, "home_location", true),
features: state.homeLocation,
state
state,
})
state.leafletMap.addCallbackAndRunD(_ => {
state.leafletMap.addCallbackAndRunD((_) => {
// Lets assume that all showDataLayers are initialized at this point
state.selectedElement.ping()
State.state.locationControl.ping();
return true;
State.state.locationControl.ping()
return true
})
}
private SetupUIElements() {
const state = this.state;
const guiState = this.guiState;
const state = this.state
const guiState = this.guiState
const self = this
new Combine([
Toggle.If(state.featureSwitchUserbadge,
() => new UserBadge(state)
),
Toggle.If(state.featureSwitchExtraLinkEnabled,
Toggle.If(state.featureSwitchUserbadge, () => new UserBadge(state)),
Toggle.If(
state.featureSwitchExtraLinkEnabled,
() => new ExtraLinkButton(state, state.layoutToUse.extraLink)
)
]).SetClass("flex flex-col")
),
])
.SetClass("flex flex-col")
.AttachTo("userbadge")
new Combine([
new ExtraLinkButton(state, {...state.layoutToUse.extraLink, newTab: true, requirements: new Set<"iframe" | "no-iframe" | "welcome-message" | "no-welcome-message">( ) })
]).SetClass("flex items-center justify-center normal-background h-full")
new ExtraLinkButton(state, {
...state.layoutToUse.extraLink,
newTab: true,
requirements: new Set<
"iframe" | "no-iframe" | "welcome-message" | "no-welcome-message"
>(),
}),
])
.SetClass("flex items-center justify-center normal-background h-full")
.AttachTo("on-small-screen")
Toggle.If(state.featureSwitchSearch,
() => new SearchAndGo(state))
.AttachTo("searchbox");
Toggle.If(state.featureSwitchSearch, () => new SearchAndGo(state)).AttachTo("searchbox")
Toggle.If(
state.featureSwitchWelcomeMessage,
() => self.InitWelcomeMessage()
Toggle.If(state.featureSwitchWelcomeMessage, () => self.InitWelcomeMessage()).AttachTo(
"messagesbox"
)
.AttachTo("messagesbox");
new LeftControls(state, guiState).AttachTo("bottom-left")
new RightControls(state).AttachTo("bottom-right")
new LeftControls(state, guiState).AttachTo("bottom-left");
new RightControls(state).AttachTo("bottom-right");
new CenterMessageBox(state).AttachTo("centermessage");
document
.getElementById("centermessage")
.classList.add("pointer-events-none");
new CenterMessageBox(state).AttachTo("centermessage")
document.getElementById("centermessage").classList.add("pointer-events-none")
// We have to ping the welcomeMessageIsOpened and other isOpened-stuff to activate the FullScreenMessage if needed
for (const state of guiState.allFullScreenStates) {
@ -205,39 +212,40 @@ export default class DefaultGUI {
*/
state.selectedElement.addCallbackAndRunD((_) => {
guiState.allFullScreenStates.forEach(s => s.setData(false))
});
guiState.allFullScreenStates.forEach((s) => s.setData(false))
})
}
private InitWelcomeMessage(): BaseUIElement {
const isOpened = this.guiState.welcomeMessageIsOpened
const fullOptions = new FullWelcomePaneWithTabs(isOpened, this.guiState.welcomeMessageOpenedTab, this.state);
const fullOptions = new FullWelcomePaneWithTabs(
isOpened,
this.guiState.welcomeMessageOpenedTab,
this.state
)
// ?-Button on Desktop, opens panel with close-X.
const help = new MapControlButton(Svg.help_svg());
help.onClick(() => isOpened.setData(true));
const help = new MapControlButton(Svg.help_svg())
help.onClick(() => isOpened.setData(true))
const openedTime = new Date().getTime();
const openedTime = new Date().getTime()
this.state.locationControl.addCallback(() => {
if (new Date().getTime() - openedTime < 15 * 1000) {
// Don't autoclose the first 15 secs when the map is moving
return;
return
}
isOpened.setData(false);
return true; // Unregister this caller - we only autoclose once
});
isOpened.setData(false)
return true // Unregister this caller - we only autoclose once
})
this.state.selectedElement.addCallbackAndRunD((_) => {
isOpened.setData(false);
});
isOpened.setData(false)
})
return new Toggle(
fullOptions.SetClass("welcomeMessage pointer-events-auto"),
help.SetClass("pointer-events-auto"),
isOpened
)
}
}
}

View file

@ -1,25 +1,25 @@
import {UIEventSource} from "../Logic/UIEventSource";
import {QueryParameters} from "../Logic/Web/QueryParameters";
import Hash from "../Logic/Web/Hash";
import { UIEventSource } from "../Logic/UIEventSource"
import { QueryParameters } from "../Logic/Web/QueryParameters"
import Hash from "../Logic/Web/Hash"
export class DefaultGuiState {
static state: DefaultGuiState;
public readonly welcomeMessageIsOpened: UIEventSource<boolean>;
public readonly downloadControlIsOpened: UIEventSource<boolean>;
public readonly filterViewIsOpened: UIEventSource<boolean>;
public readonly copyrightViewIsOpened: UIEventSource<boolean>;
public readonly currentViewControlIsOpened: UIEventSource<boolean>;
static state: DefaultGuiState
public readonly welcomeMessageIsOpened: UIEventSource<boolean>
public readonly downloadControlIsOpened: UIEventSource<boolean>
public readonly filterViewIsOpened: UIEventSource<boolean>
public readonly copyrightViewIsOpened: UIEventSource<boolean>
public readonly currentViewControlIsOpened: UIEventSource<boolean>
public readonly welcomeMessageOpenedTab: UIEventSource<number>
public readonly allFullScreenStates: UIEventSource<boolean>[] = []
constructor() {
this.welcomeMessageOpenedTab = UIEventSource.asFloat(QueryParameters.GetQueryParameter(
"tab",
"0",
`The tab that is shown in the welcome-message.`
));
this.welcomeMessageOpenedTab = UIEventSource.asFloat(
QueryParameters.GetQueryParameter(
"tab",
"0",
`The tab that is shown in the welcome-message.`
)
)
this.welcomeMessageIsOpened = QueryParameters.GetBooleanQueryParameter(
"welcome-control-toggle",
false,
@ -50,9 +50,9 @@ export class DefaultGuiState {
filters: this.filterViewIsOpened,
copyright: this.copyrightViewIsOpened,
currentview: this.currentViewControlIsOpened,
welcome: this.welcomeMessageIsOpened
welcome: this.welcomeMessageIsOpened,
}
Hash.hash.addCallbackAndRunD(hash => {
Hash.hash.addCallbackAndRunD((hash) => {
hash = hash.toLowerCase()
states[hash]?.setData(true)
})
@ -61,22 +61,27 @@ export class DefaultGuiState {
this.welcomeMessageIsOpened.setData(true)
}
this.allFullScreenStates.push(this.downloadControlIsOpened, this.filterViewIsOpened, this.copyrightViewIsOpened, this.welcomeMessageIsOpened, this.currentViewControlIsOpened)
this.allFullScreenStates.push(
this.downloadControlIsOpened,
this.filterViewIsOpened,
this.copyrightViewIsOpened,
this.welcomeMessageIsOpened,
this.currentViewControlIsOpened
)
for (let i = 0; i < this.allFullScreenStates.length; i++) {
const fullScreenState = this.allFullScreenStates[i];
const fullScreenState = this.allFullScreenStates[i]
for (let j = 0; j < this.allFullScreenStates.length; j++) {
if (i == j) {
continue
}
const otherState = this.allFullScreenStates[j];
fullScreenState.addCallbackAndRunD(isOpened => {
const otherState = this.allFullScreenStates[j]
fullScreenState.addCallbackAndRunD((isOpened) => {
if (isOpened) {
otherState.setData(false)
}
})
}
}
}
}
}

View file

@ -1,16 +1,16 @@
import jsPDF from "jspdf";
import {UIEventSource} from "../Logic/UIEventSource";
import Minimap, {MinimapObj} from "./Base/Minimap";
import Loc from "../Models/Loc";
import BaseLayer from "../Models/BaseLayer";
import {FixedUiElement} from "./Base/FixedUiElement";
import Translations from "./i18n/Translations";
import State from "../State";
import Constants from "../Models/Constants";
import LayoutConfig from "../Models/ThemeConfig/LayoutConfig";
import FeaturePipeline from "../Logic/FeatureSource/FeaturePipeline";
import ShowDataLayer from "./ShowDataLayer/ShowDataLayer";
import {BBox} from "../Logic/BBox";
import jsPDF from "jspdf"
import { UIEventSource } from "../Logic/UIEventSource"
import Minimap, { MinimapObj } from "./Base/Minimap"
import Loc from "../Models/Loc"
import BaseLayer from "../Models/BaseLayer"
import { FixedUiElement } from "./Base/FixedUiElement"
import Translations from "./i18n/Translations"
import State from "../State"
import Constants from "../Models/Constants"
import LayoutConfig from "../Models/ThemeConfig/LayoutConfig"
import FeaturePipeline from "../Logic/FeatureSource/FeaturePipeline"
import ShowDataLayer from "./ShowDataLayer/ShowDataLayer"
import { BBox } from "../Logic/BBox"
/**
* Creates screenshoter to take png screenshot
@ -26,81 +26,77 @@ export default class ExportPDF {
// dimensions of the map in milimeter
public isRunning = new UIEventSource(true)
// A4: 297 * 210mm
private readonly mapW = 297;
private readonly mapH = 210;
private readonly mapW = 297
private readonly mapH = 210
private readonly scaling = 2
private readonly freeDivId: string;
private readonly _layout: LayoutConfig;
private _screenhotTaken = false;
private readonly freeDivId: string
private readonly _layout: LayoutConfig
private _screenhotTaken = false
constructor(
options: {
freeDivId: string,
location: UIEventSource<Loc>,
background?: UIEventSource<BaseLayer>
features: FeaturePipeline,
layout: LayoutConfig
}
) {
this.freeDivId = options.freeDivId;
this._layout = options.layout;
const self = this;
constructor(options: {
freeDivId: string
location: UIEventSource<Loc>
background?: UIEventSource<BaseLayer>
features: FeaturePipeline
layout: LayoutConfig
}) {
this.freeDivId = options.freeDivId
this._layout = options.layout
const self = this
// We create a minimap at the given location and attach it to the given 'hidden' element
const l = options.location.data;
const l = options.location.data
const loc = {
lat: l.lat,
lon: l.lon,
zoom: l.zoom + 1
zoom: l.zoom + 1,
}
const minimap = Minimap.createMiniMap({
location: new UIEventSource<Loc>(loc), // We remove the link between the old and the new UI-event source as moving the map while the export is running fucks up the screenshot
background: options.background,
allowMoving: false,
onFullyLoaded: _ => window.setTimeout(() => {
if (self._screenhotTaken) {
return;
}
try {
self.CreatePdf(minimap)
.then(() => self.cleanup())
.catch(() => self.cleanup())
} catch (e) {
console.error(e)
self.cleanup()
}
}, 500)
onFullyLoaded: (_) =>
window.setTimeout(() => {
if (self._screenhotTaken) {
return
}
try {
self.CreatePdf(minimap)
.then(() => self.cleanup())
.catch(() => self.cleanup())
} catch (e) {
console.error(e)
self.cleanup()
}
}, 500)
})
minimap.SetStyle(`width: ${this.mapW * this.scaling}mm; height: ${this.mapH * this.scaling}mm;`)
minimap.SetStyle(
`width: ${this.mapW * this.scaling}mm; height: ${this.mapH * this.scaling}mm;`
)
minimap.AttachTo(options.freeDivId)
// Next: we prepare the features. Only fully contained features are shown
minimap.leafletMap.addCallbackAndRunD(leaflet => {
minimap.leafletMap.addCallbackAndRunD((leaflet) => {
const bounds = BBox.fromLeafletBounds(leaflet.getBounds().pad(0.2))
options.features.GetTilesPerLayerWithin(bounds, tile => {
options.features.GetTilesPerLayerWithin(bounds, (tile) => {
if (tile.layer.layerDef.minzoom > l.zoom) {
return
}
if(tile.layer.layerDef.id.startsWith("note_import")){
if (tile.layer.layerDef.id.startsWith("note_import")) {
// Don't export notes to import
return;
return
}
new ShowDataLayer(
{
features: tile,
leafletMap: minimap.leafletMap,
layerToShow: tile.layer.layerDef,
doShowLayer: tile.layer.isDisplayed,
state: undefined
}
)
new ShowDataLayer({
features: tile,
leafletMap: minimap.leafletMap,
layerToShow: tile.layer.layerDef,
doShowLayer: tile.layer.isDisplayed,
state: undefined,
})
})
})
State.state.AddAllOverlaysToMap(minimap.leafletMap)
@ -108,85 +104,92 @@ export default class ExportPDF {
private cleanup() {
new FixedUiElement("Screenshot taken!").AttachTo(this.freeDivId)
this._screenhotTaken = true;
this._screenhotTaken = true
}
private async CreatePdf(minimap: MinimapObj) {
console.log("PDF creation started")
const t = Translations.t.general.pdf;
const t = Translations.t.general.pdf
const layout = this._layout
let doc = new jsPDF('landscape');
let doc = new jsPDF("landscape")
const image = await minimap.TakeScreenshot()
// @ts-ignore
doc.addImage(image, 'PNG', 0, 0, this.mapW, this.mapH);
doc.addImage(image, "PNG", 0, 0, this.mapW, this.mapH)
doc.setDrawColor(255, 255, 255)
doc.setFillColor(255, 255, 255)
doc.roundedRect(12, 10, 145, 25, 5, 5, 'FD')
doc.roundedRect(12, 10, 145, 25, 5, 5, "FD")
doc.setFontSize(20)
doc.textWithLink(layout.title.txt, 40, 18.5, {
maxWidth: 125,
url: window.location.href
url: window.location.href,
})
doc.setFontSize(10)
doc.text(t.generatedWith.txt, 40, 23, {
maxWidth: 125
maxWidth: 125,
})
const backgroundLayer: BaseLayer = State.state.backgroundLayer.data
const attribution = new FixedUiElement(backgroundLayer.layer().getAttribution() ?? backgroundLayer.name).ConstructElement().textContent
const attribution = new FixedUiElement(
backgroundLayer.layer().getAttribution() ?? backgroundLayer.name
).ConstructElement().textContent
doc.textWithLink(t.attr.txt, 40, 26.5, {
maxWidth: 125,
url: "https://www.openstreetmap.org/copyright"
url: "https://www.openstreetmap.org/copyright",
})
doc.text(t.attrBackground.Subs({
background: attribution
}).txt, 40, 30)
doc.text(
t.attrBackground.Subs({
background: attribution,
}).txt,
40,
30
)
let date = new Date().toISOString().substr(0, 16)
doc.setFontSize(7)
doc.text(t.versionInfo.Subs({
version: Constants.vNumber,
date: date
}).txt, 40, 34, {
maxWidth: 125
})
doc.text(
t.versionInfo.Subs({
version: Constants.vNumber,
date: date,
}).txt,
40,
34,
{
maxWidth: 125,
}
)
// Add the logo of the layout
let img = document.createElement('img');
let img = document.createElement("img")
const imgSource = layout.icon
const imgType = imgSource.substr(imgSource.lastIndexOf(".") + 1);
const imgType = imgSource.substring(imgSource.lastIndexOf(".") + 1)
img.src = imgSource
if (imgType.toLowerCase() === "svg") {
new FixedUiElement("").AttachTo(this.freeDivId)
// This is an svg image, we use the canvas to convert it to a png
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d');
const canvas = document.createElement("canvas")
const ctx = canvas.getContext("2d")
canvas.width = 500
canvas.height = 500
img.style.width = "100%"
img.style.height = "100%"
ctx.drawImage(img, 0, 0, 500, 500);
ctx.drawImage(img, 0, 0, 500, 500)
const base64img = canvas.toDataURL("image/png")
doc.addImage(base64img, 'png', 15, 12, 20, 20);
doc.addImage(base64img, "png", 15, 12, 20, 20)
} else {
try {
doc.addImage(img, imgType, 15, 12, 20, 20);
doc.addImage(img, imgType, 15, 12, 20, 20)
} catch (e) {
console.error(e)
}
}
doc.save(`MapComplete_${layout.title.txt}_${date}.pdf`);
doc.save(`MapComplete_${layout.title.txt}_${date}.pdf`)
this.isRunning.setData(false)
}

View file

@ -1,37 +1,29 @@
import Combine from "../Base/Combine";
import Attribution from "./Attribution";
import Img from "../Base/Img";
import ImageProvider from "../../Logic/ImageProviders/ImageProvider";
import BaseUIElement from "../BaseUIElement";
import {Mapillary} from "../../Logic/ImageProviders/Mapillary";
import {UIEventSource} from "../../Logic/UIEventSource";
import Combine from "../Base/Combine"
import Attribution from "./Attribution"
import Img from "../Base/Img"
import ImageProvider from "../../Logic/ImageProviders/ImageProvider"
import BaseUIElement from "../BaseUIElement"
import { Mapillary } from "../../Logic/ImageProviders/Mapillary"
import { UIEventSource } from "../../Logic/UIEventSource"
export class AttributedImage extends Combine {
constructor(imageInfo: {
url: string,
provider?: ImageProvider,
date?: Date
}
) {
let img: BaseUIElement;
constructor(imageInfo: { url: string; provider?: ImageProvider; date?: Date }) {
let img: BaseUIElement
img = new Img(imageInfo.url, false, {
fallbackImage: imageInfo.provider === Mapillary.singleton ? "./assets/svg/blocked.svg" : undefined
});
fallbackImage:
imageInfo.provider === Mapillary.singleton ? "./assets/svg/blocked.svg" : undefined,
})
let attr: BaseUIElement = undefined
if(imageInfo.provider !== undefined){
attr = new Attribution(UIEventSource.FromPromise( imageInfo.provider?.DownloadAttribution(imageInfo.url)),
if (imageInfo.provider !== undefined) {
attr = new Attribution(
UIEventSource.FromPromise(imageInfo.provider?.DownloadAttribution(imageInfo.url)),
imageInfo.provider?.SourceIcon(),
imageInfo.date
)
}
super([img, attr]);
this.SetClass('block relative h-full');
super([img, attr])
this.SetClass("block relative h-full")
}
}
}

View file

@ -1,13 +1,16 @@
import Combine from "../Base/Combine";
import Translations from "../i18n/Translations";
import BaseUIElement from "../BaseUIElement";
import {VariableUiElement} from "../Base/VariableUIElement";
import {Store, UIEventSource} from "../../Logic/UIEventSource";
import {LicenseInfo} from "../../Logic/ImageProviders/LicenseInfo";
import {FixedUiElement} from "../Base/FixedUiElement";
import Combine from "../Base/Combine"
import Translations from "../i18n/Translations"
import BaseUIElement from "../BaseUIElement"
import { VariableUiElement } from "../Base/VariableUIElement"
import { Store } from "../../Logic/UIEventSource"
import { LicenseInfo } from "../../Logic/ImageProviders/LicenseInfo"
import { FixedUiElement } from "../Base/FixedUiElement"
import Link from "../Base/Link"
/**
* Small box in the bottom left of an image, e.g. the image in a popup
*/
export default class Attribution extends VariableUiElement {
constructor(license: Store<LicenseInfo>, icon: BaseUIElement, date?: Date) {
if (license === undefined) {
throw "No license source given in the attribution element"
@ -18,18 +21,30 @@ export default class Attribution extends VariableUiElement {
return undefined
}
let title = undefined
if (license?.title) {
title = Translations.W(license?.title).SetClass("block")
if (license.informationLocation) {
title = new Link(title, license.informationLocation.href, true)
}
}
return new Combine([
icon?.SetClass("block left").SetStyle("height: 2em; width: 2em; padding-right: 0.5em;"),
icon
?.SetClass("block left")
.SetStyle("height: 2em; width: 2em; padding-right: 0.5em;"),
new Combine([
Translations.W(license?.title).SetClass("block"),
title,
Translations.W(license?.artist ?? "").SetClass("block font-bold"),
Translations.W(license?.license ?? license?.licenseShortName),
date === undefined ? undefined : new FixedUiElement(date.toLocaleDateString())
]).SetClass("flex flex-col")
]).SetClass("flex flex-row bg-black text-white text-sm absolute bottom-0 left-0 p-0.5 pl-5 pr-3 rounded-lg no-images")
}));
date === undefined
? undefined
: new FixedUiElement(date.toLocaleDateString()),
]).SetClass("flex flex-col"),
]).SetClass(
"flex flex-row bg-black text-white text-sm absolute bottom-0 left-0 p-0.5 pl-5 pr-3 rounded-lg no-images"
)
})
)
}
}
}

View file

@ -1,47 +1,56 @@
import {Store} from "../../Logic/UIEventSource";
import Translations from "../i18n/Translations";
import Toggle, {ClickableToggle} from "../Input/Toggle";
import Combine from "../Base/Combine";
import Svg from "../../Svg";
import {Tag} from "../../Logic/Tags/Tag";
import ChangeTagAction from "../../Logic/Osm/Actions/ChangeTagAction";
import {Changes} from "../../Logic/Osm/Changes";
import {OsmConnection} from "../../Logic/Osm/OsmConnection";
import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig";
import { Store } from "../../Logic/UIEventSource"
import Translations from "../i18n/Translations"
import Toggle, { ClickableToggle } from "../Input/Toggle"
import Combine from "../Base/Combine"
import Svg from "../../Svg"
import { Tag } from "../../Logic/Tags/Tag"
import ChangeTagAction from "../../Logic/Osm/Actions/ChangeTagAction"
import { Changes } from "../../Logic/Osm/Changes"
import { OsmConnection } from "../../Logic/Osm/OsmConnection"
import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig"
export default class DeleteImage extends Toggle {
constructor(key: string, tags: Store<any>, state: { layoutToUse: LayoutConfig, changes?: Changes, osmConnection?: OsmConnection }) {
constructor(
key: string,
tags: Store<any>,
state: { layoutToUse: LayoutConfig; changes?: Changes; osmConnection?: OsmConnection }
) {
const oldValue = tags.data[key]
const isDeletedBadge = Translations.t.image.isDeleted.Clone()
const isDeletedBadge = Translations.t.image.isDeleted
.Clone()
.SetClass("rounded-full p-1")
.SetStyle("color:white;background:#ff8c8c")
.onClick(async () => {
await state?.changes?.applyAction(new ChangeTagAction(tags.data.id, new Tag(key, oldValue), tags.data, {
changeType: "delete-image",
theme: state.layoutToUse.id
}))
});
await state?.changes?.applyAction(
new ChangeTagAction(tags.data.id, new Tag(key, oldValue), tags.data, {
changeType: "delete-image",
theme: state.layoutToUse.id,
})
)
})
const deleteButton = Translations.t.image.doDelete.Clone()
const deleteButton = Translations.t.image.doDelete
.Clone()
.SetClass("block w-full pl-4 pr-4")
.SetStyle("color:white;background:#ff8c8c; border-top-left-radius:30rem; border-top-right-radius: 30rem;")
.SetStyle(
"color:white;background:#ff8c8c; border-top-left-radius:30rem; border-top-right-radius: 30rem;"
)
.onClick(async () => {
await state?.changes?.applyAction(
new ChangeTagAction(tags.data.id, new Tag(key, ""), tags.data, {
changeType: "answer",
theme: state.layoutToUse.id
theme: state.layoutToUse.id,
})
)
});
})
const cancelButton = Translations.t.general.cancel.Clone().SetClass("bg-white pl-4 pr-4").SetStyle("border-bottom-left-radius:30rem; border-bottom-right-radius: 30rem;");
const cancelButton = Translations.t.general.cancel
.Clone()
.SetClass("bg-white pl-4 pr-4")
.SetStyle("border-bottom-left-radius:30rem; border-bottom-right-radius: 30rem;")
const openDelete = Svg.delete_icon_svg().SetStyle("width: 2em; height: 2em; display:block;")
const deleteDialog = new ClickableToggle(
new Combine([
deleteButton,
cancelButton
]).SetClass("flex flex-col background-black"),
new Combine([deleteButton, cancelButton]).SetClass("flex flex-col background-black"),
openDelete
)
@ -52,12 +61,11 @@ export default class DeleteImage extends Toggle {
new Toggle(
deleteDialog,
isDeletedBadge,
tags.map(tags => (tags[key] ?? "") !== "")
tags.map((tags) => (tags[key] ?? "") !== "")
),
undefined /*Login (and thus editing) is disabled*/,
state?.osmConnection?.isLoggedIn
)
this.SetClass("cursor-pointer")
}
}
}

View file

@ -1,50 +1,53 @@
import {SlideShow} from "./SlideShow";
import {Store} from "../../Logic/UIEventSource";
import Combine from "../Base/Combine";
import DeleteImage from "./DeleteImage";
import {AttributedImage} from "./AttributedImage";
import BaseUIElement from "../BaseUIElement";
import Toggle from "../Input/Toggle";
import ImageProvider from "../../Logic/ImageProviders/ImageProvider";
import {OsmConnection} from "../../Logic/Osm/OsmConnection";
import {Changes} from "../../Logic/Osm/Changes";
import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig";
import { SlideShow } from "./SlideShow"
import { Store } from "../../Logic/UIEventSource"
import Combine from "../Base/Combine"
import DeleteImage from "./DeleteImage"
import { AttributedImage } from "./AttributedImage"
import BaseUIElement from "../BaseUIElement"
import Toggle from "../Input/Toggle"
import ImageProvider from "../../Logic/ImageProviders/ImageProvider"
import { OsmConnection } from "../../Logic/Osm/OsmConnection"
import { Changes } from "../../Logic/Osm/Changes"
import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig"
export class ImageCarousel extends Toggle {
constructor(
images: Store<{ key: string; url: string; provider: ImageProvider }[]>,
tags: Store<any>,
state: { osmConnection?: OsmConnection; changes?: Changes; layoutToUse: LayoutConfig }
) {
const uiElements = images.map(
(imageURLS: { key: string; url: string; provider: ImageProvider }[]) => {
const uiElements: BaseUIElement[] = []
for (const url of imageURLS) {
try {
let image = new AttributedImage(url)
constructor(images: Store<{ key: string, url: string, provider: ImageProvider }[]>,
tags: Store<any>,
state: { osmConnection?: OsmConnection, changes?: Changes, layoutToUse: LayoutConfig }) {
const uiElements = images.map((imageURLS: { key: string, url: string, provider: ImageProvider }[]) => {
const uiElements: BaseUIElement[] = [];
for (const url of imageURLS) {
try {
let image = new AttributedImage(url)
if (url.key !== undefined) {
image = new Combine([
image,
new DeleteImage(url.key, tags, state).SetClass("delete-image-marker absolute top-0 left-0 pl-3")
]).SetClass("relative");
if (url.key !== undefined) {
image = new Combine([
image,
new DeleteImage(url.key, tags, state).SetClass(
"delete-image-marker absolute top-0 left-0 pl-3"
),
]).SetClass("relative")
}
image
.SetClass("w-full block")
.SetStyle("min-width: 50px; background: grey;")
uiElements.push(image)
} catch (e) {
console.error("Could not generate image element for", url.url, "due to", e)
}
image
.SetClass("w-full block")
.SetStyle("min-width: 50px; background: grey;")
uiElements.push(image);
} catch (e) {
console.error("Could not generate image element for", url.url, "due to", e)
}
return uiElements
}
return uiElements;
});
)
super(
new SlideShow(uiElements).SetClass("w-full"),
undefined,
uiElements.map(els => els.length > 0)
uiElements.map((els) => els.length > 0)
)
this.SetClass("block w-full");
this.SetClass("block w-full")
}
}
}

View file

@ -1,167 +1,189 @@
import {Store, UIEventSource} from "../../Logic/UIEventSource";
import Combine from "../Base/Combine";
import Translations from "../i18n/Translations";
import Svg from "../../Svg";
import {Tag} from "../../Logic/Tags/Tag";
import BaseUIElement from "../BaseUIElement";
import LicensePicker from "../BigComponents/LicensePicker";
import Toggle from "../Input/Toggle";
import FileSelectorButton from "../Input/FileSelectorButton";
import ImgurUploader from "../../Logic/ImageProviders/ImgurUploader";
import ChangeTagAction from "../../Logic/Osm/Actions/ChangeTagAction";
import LayerConfig from "../../Models/ThemeConfig/LayerConfig";
import {FixedUiElement} from "../Base/FixedUiElement";
import {VariableUiElement} from "../Base/VariableUIElement";
import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig";
import {OsmConnection} from "../../Logic/Osm/OsmConnection";
import {Changes} from "../../Logic/Osm/Changes";
import Loading from "../Base/Loading";
import { Store, UIEventSource } from "../../Logic/UIEventSource"
import Combine from "../Base/Combine"
import Translations from "../i18n/Translations"
import Svg from "../../Svg"
import { Tag } from "../../Logic/Tags/Tag"
import BaseUIElement from "../BaseUIElement"
import LicensePicker from "../BigComponents/LicensePicker"
import Toggle from "../Input/Toggle"
import FileSelectorButton from "../Input/FileSelectorButton"
import ImgurUploader from "../../Logic/ImageProviders/ImgurUploader"
import ChangeTagAction from "../../Logic/Osm/Actions/ChangeTagAction"
import LayerConfig from "../../Models/ThemeConfig/LayerConfig"
import { FixedUiElement } from "../Base/FixedUiElement"
import { VariableUiElement } from "../Base/VariableUIElement"
import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig"
import { OsmConnection } from "../../Logic/Osm/OsmConnection"
import { Changes } from "../../Logic/Osm/Changes"
import Loading from "../Base/Loading"
export class ImageUploadFlow extends Toggle {
private static readonly uploadCountsPerId = new Map<string, UIEventSource<number>>()
constructor(tagsSource: Store<any>,
state: {
osmConnection: OsmConnection;
layoutToUse: LayoutConfig;
changes: Changes,
featureSwitchUserbadge: Store<boolean>;
},
imagePrefix: string = "image", text: string = undefined) {
constructor(
tagsSource: Store<any>,
state: {
osmConnection: OsmConnection
layoutToUse: LayoutConfig
changes: Changes
featureSwitchUserbadge: Store<boolean>
},
imagePrefix: string = "image",
text: string = undefined
) {
const perId = ImageUploadFlow.uploadCountsPerId
const id = tagsSource.data.id
if (!perId.has(id)) {
perId.set(id, new UIEventSource<number>(0))
}
const uploadedCount = perId.get(id)
const uploader = new ImgurUploader(url => {
const uploader = new ImgurUploader(async (url) => {
// A file was uploaded - we add it to the tags of the object
const tags = tagsSource.data
let key = imagePrefix
if (tags[imagePrefix] !== undefined) {
let freeIndex = 0;
let freeIndex = 0
while (tags[imagePrefix + ":" + freeIndex] !== undefined) {
freeIndex++;
freeIndex++
}
key = imagePrefix + ":" + freeIndex;
key = imagePrefix + ":" + freeIndex
}
console.log("Adding image:" + key, url);
await state.changes.applyAction(
new ChangeTagAction(tags.id, new Tag(key, url), tagsSource.data, {
changeType: "add-image",
theme: state.layoutToUse.id,
})
)
console.log("Adding image:" + key, url)
uploadedCount.data++
uploadedCount.ping()
Promise.resolve(state.changes
.applyAction(new ChangeTagAction(
tags.id, new Tag(key, url), tagsSource.data,
{
changeType: "add-image",
theme: state.layoutToUse.id
}
)))
})
const licensePicker = new LicensePicker(state)
const explanations = LicensePicker.LicenseExplanations()
const chosenLicense = new VariableUiElement(licensePicker.GetValue().map(license => explanations.get(license)))
const chosenLicense = new VariableUiElement(
licensePicker.GetValue().map((license) => explanations.get(license))
)
const t = Translations.t.image;
const t = Translations.t.image
let labelContent: BaseUIElement
if (text === undefined) {
labelContent = Translations.t.image.addPicture.Clone().SetClass("block align-middle mt-1 ml-3 text-4xl ")
labelContent = Translations.t.image.addPicture
.Clone()
.SetClass("block align-middle mt-1 ml-3 text-4xl ")
} else {
labelContent = new FixedUiElement(text).SetClass("block align-middle mt-1 ml-3 text-2xl ")
labelContent = new FixedUiElement(text).SetClass(
"block align-middle mt-1 ml-3 text-2xl "
)
}
const label = new Combine([
Svg.camera_plus_ui().SetClass("block w-12 h-12 p-1 text-4xl "),
labelContent
]).SetClass("p-2 border-4 border-detail rounded-full font-bold h-full align-middle w-full flex justify-center")
labelContent,
]).SetClass(
"p-2 border-4 border-detail rounded-full font-bold h-full align-middle w-full flex justify-center"
)
const fileSelector = new FileSelectorButton(label)
fileSelector.GetValue().addCallback(filelist => {
fileSelector.GetValue().addCallback((filelist) => {
if (filelist === undefined || filelist.length === 0) {
return;
return
}
for (var i = 0; i < filelist.length; i++) {
const sizeInBytes = filelist[i].size
console.log(filelist[i].name + " has a size of " + sizeInBytes + " Bytes");
console.log(filelist[i].name + " has a size of " + sizeInBytes + " Bytes")
if (sizeInBytes > uploader.maxFileSizeInMegabytes * 1000000) {
alert(Translations.t.image.toBig.Subs({
actual_size: (Math.floor(sizeInBytes / 1000000)) + "MB",
max_size: uploader.maxFileSizeInMegabytes + "MB"
}).txt)
return;
alert(
Translations.t.image.toBig.Subs({
actual_size: Math.floor(sizeInBytes / 1000000) + "MB",
max_size: uploader.maxFileSizeInMegabytes + "MB",
}).txt
)
return
}
}
console.log("Received images from the user, starting upload")
const license = licensePicker.GetValue()?.data ?? "CC0"
const tags = tagsSource.data;
const tags = tagsSource.data
const layout = state?.layoutToUse
let matchingLayer: LayerConfig = undefined
for (const layer of layout?.layers ?? []) {
if (layer.source.osmTags.matchesProperties(tags)) {
matchingLayer = layer;
break;
matchingLayer = layer
break
}
}
const title = matchingLayer?.title?.GetRenderValue(tags)?.Subs(tags)?.ConstructElement()?.textContent ?? tags.name ?? "https//osm.org/"+tags.id;
const title =
matchingLayer?.title?.GetRenderValue(tags)?.Subs(tags)?.ConstructElement()
?.textContent ??
tags.name ??
"https//osm.org/" + tags.id
const description = [
"author:" + state.osmConnection.userDetails.data.name,
"license:" + license,
"osmid:" + tags.id,
].join("\n");
].join("\n")
uploader.uploadMany(title, description, filelist)
})
const uploadFlow: BaseUIElement = new Combine([
new VariableUiElement(uploader.queue.map(q => q.length).map(l => {
if (l == 0) {
return undefined;
}
if (l == 1) {
return new Loading( t.uploadingPicture).SetClass("alert")
} else {
return new Loading(t.uploadingMultiple.Subs({count: "" + l})).SetClass("alert")
}
})),
new VariableUiElement(uploader.failed.map(q => q.length).map(l => {
if (l == 0) {
return undefined
}
console.log(l)
return t.uploadFailed.SetClass("block alert");
})),
new VariableUiElement(uploadedCount.map(l => {
if (l == 0) {
return undefined;
}
if (l == 1) {
return t.uploadDone.Clone().SetClass("thanks block");
}
return t.uploadMultipleDone.Subs({count: l}).SetClass("thanks block")
})),
new VariableUiElement(
uploader.queue
.map((q) => q.length)
.map((l) => {
if (l == 0) {
return undefined
}
if (l == 1) {
return new Loading(t.uploadingPicture).SetClass("alert")
} else {
return new Loading(
t.uploadingMultiple.Subs({ count: "" + l })
).SetClass("alert")
}
})
),
new VariableUiElement(
uploader.failed
.map((q) => q.length)
.map((l) => {
if (l == 0) {
return undefined
}
console.log(l)
return t.uploadFailed.SetClass("block alert")
})
),
new VariableUiElement(
uploadedCount.map((l) => {
if (l == 0) {
return undefined
}
if (l == 1) {
return t.uploadDone.Clone().SetClass("thanks block")
}
return t.uploadMultipleDone.Subs({ count: l }).SetClass("thanks block")
})
),
fileSelector,
Translations.t.image.respectPrivacy.Clone().SetStyle("font-size:small;"),
licensePicker,
chosenLicense.SetClass("subtle text-sm")
chosenLicense.SetClass("subtle text-sm"),
]).SetClass("flex flex-col image-upload-flow mt-4 mb-8 text-center")
const pleaseLoginButton = t.pleaseLogin.Clone()
const pleaseLoginButton = t.pleaseLogin
.Clone()
.onClick(() => state.osmConnection.AttemptLogin())
.SetClass("login-button-friendly");
.SetClass("login-button-friendly")
super(
new Toggle(
/*We can show the actual upload button!*/
@ -172,8 +194,5 @@ export class ImageUploadFlow extends Toggle {
undefined /* Nothing as the user badge is disabled*/,
state?.featureSwitchUserbadge
)
}
}
}

View file

@ -1,16 +1,14 @@
import {Store, UIEventSource} from "../../Logic/UIEventSource";
import BaseUIElement from "../BaseUIElement";
import {Utils} from "../../Utils";
import Combine from "../Base/Combine";
import { Store, UIEventSource } from "../../Logic/UIEventSource"
import BaseUIElement from "../BaseUIElement"
import { Utils } from "../../Utils"
import Combine from "../Base/Combine"
export class SlideShow extends BaseUIElement {
private readonly embeddedElements: Store<BaseUIElement[]>;
private readonly embeddedElements: Store<BaseUIElement[]>
constructor(embeddedElements: Store<BaseUIElement[]>) {
super()
this.embeddedElements = embeddedElements;
this.embeddedElements = embeddedElements
this.SetStyle("scroll-snap-type: x mandatory; overflow-x: auto")
}
@ -19,8 +17,7 @@ export class SlideShow extends BaseUIElement {
el.style.minWidth = "min-content"
el.style.display = "flex"
el.style.justifyContent = "center"
this.embeddedElements.addCallbackAndRun(elements => {
this.embeddedElements.addCallbackAndRun((elements) => {
if (elements.length > 1) {
el.style.justifyContent = "unset"
}
@ -29,21 +26,23 @@ export class SlideShow extends BaseUIElement {
el.removeChild(el.lastChild)
}
elements = Utils.NoNull(elements).map(el => new Combine([el])
.SetClass("block relative ml-1 bg-gray-200 m-1 rounded slideshow-item")
.SetStyle("min-width: 150px; width: max-content; height: var(--image-carousel-height);max-height: var(--image-carousel-height);scroll-snap-align: start;")
elements = Utils.NoNull(elements).map((el) =>
new Combine([el])
.SetClass("block relative ml-1 bg-gray-200 m-1 rounded slideshow-item")
.SetStyle(
"min-width: 150px; width: max-content; height: var(--image-carousel-height);max-height: var(--image-carousel-height);scroll-snap-align: start;"
)
)
for (const element of elements ?? []) {
el.appendChild(element.ConstructElement())
}
});
})
const wrapper = document.createElement("div")
wrapper.style.maxWidth = "100%"
wrapper.style.overflowX = "auto"
wrapper.appendChild(el)
return wrapper;
return wrapper
}
}
}

View file

@ -1,117 +1,128 @@
import Combine from "../Base/Combine";
import {FlowStep} from "./FlowStep";
import {Store} from "../../Logic/UIEventSource";
import ValidatedTextField from "../Input/ValidatedTextField";
import {LocalStorageSource} from "../../Logic/Web/LocalStorageSource";
import Title from "../Base/Title";
import {VariableUiElement} from "../Base/VariableUIElement";
import Translations from "../i18n/Translations";
import {SubtleButton} from "../Base/SubtleButton";
import Svg from "../../Svg";
import {Utils} from "../../Utils";
export class AskMetadata extends Combine implements FlowStep<{
features: any[],
wikilink: string,
intro: string,
source: string,
theme: string
}> {
import Combine from "../Base/Combine"
import { FlowStep } from "./FlowStep"
import { Store } from "../../Logic/UIEventSource"
import ValidatedTextField from "../Input/ValidatedTextField"
import { LocalStorageSource } from "../../Logic/Web/LocalStorageSource"
import Title from "../Base/Title"
import { VariableUiElement } from "../Base/VariableUIElement"
import Translations from "../i18n/Translations"
import { SubtleButton } from "../Base/SubtleButton"
import Svg from "../../Svg"
import { Utils } from "../../Utils"
export class AskMetadata
extends Combine
implements
FlowStep<{
features: any[]
wikilink: string
intro: string
source: string
theme: string
}>
{
public readonly Value: Store<{
features: any[],
wikilink: string,
intro: string,
source: string,
features: any[]
wikilink: string
intro: string
source: string
theme: string
}>;
public readonly IsValid: Store<boolean>;
}>
public readonly IsValid: Store<boolean>
constructor(params: ({ features: any[], theme: string })) {
constructor(params: { features: any[]; theme: string }) {
const t = Translations.t.importHelper.askMetadata
const introduction = ValidatedTextField.ForType("text").ConstructInputElement({
value: LocalStorageSource.Get("import-helper-introduction-text"),
inputStyle: "width: 100%"
inputStyle: "width: 100%",
})
const wikilink = ValidatedTextField.ForType("url").ConstructInputElement({
value: LocalStorageSource.Get("import-helper-wikilink-text"),
inputStyle: "width: 100%"
inputStyle: "width: 100%",
})
const source = ValidatedTextField.ForType("string").ConstructInputElement({
value: LocalStorageSource.Get("import-helper-source-text"),
inputStyle: "width: 100%"
inputStyle: "width: 100%",
})
super([
new Title(t.title),
t.intro.Subs({count: params.features.length}),
t.giveDescription,
t.intro.Subs({ count: params.features.length }),
t.giveDescription,
introduction.SetClass("w-full border border-black"),
t.giveSource,
source.SetClass("w-full border border-black"),
t.giveWikilink ,
t.giveSource,
source.SetClass("w-full border border-black"),
t.giveWikilink,
wikilink.SetClass("w-full border border-black"),
new VariableUiElement(wikilink.GetValue().map(wikilink => {
try{
const url = new URL(wikilink)
if(url.hostname.toLowerCase() !== "wiki.openstreetmap.org"){
return t.shouldBeOsmWikilink.SetClass("alert");
}
new VariableUiElement(
wikilink.GetValue().map((wikilink) => {
try {
const url = new URL(wikilink)
if (url.hostname.toLowerCase() !== "wiki.openstreetmap.org") {
return t.shouldBeOsmWikilink.SetClass("alert")
}
if(url.pathname.toLowerCase() === "/wiki/main_page"){
return t.shouldNotBeHomepage.SetClass("alert");
if (url.pathname.toLowerCase() === "/wiki/main_page") {
return t.shouldNotBeHomepage.SetClass("alert")
}
} catch (e) {
return t.shouldBeUrl.SetClass("alert")
}
}catch(e){
return t.shouldBeUrl.SetClass("alert")
}
})),
t.orDownload,
new SubtleButton(Svg.download_svg(), t.downloadGeojson).OnClickWithLoading("Preparing your download",
async ( ) => {
const geojson = {
type:"FeatureCollection",
features: params.features
}
Utils.offerContentsAsDownloadableFile(JSON.stringify(geojson), "prepared_import_"+params.theme+".geojson",{
mimetype: "application/vnd.geo+json"
})
})
]);
),
t.orDownload,
new SubtleButton(Svg.download_svg(), t.downloadGeojson).OnClickWithLoading(
"Preparing your download",
async () => {
const geojson = {
type: "FeatureCollection",
features: params.features,
}
Utils.offerContentsAsDownloadableFile(
JSON.stringify(geojson),
"prepared_import_" + params.theme + ".geojson",
{
mimetype: "application/vnd.geo+json",
}
)
}
),
])
this.SetClass("flex flex-col")
this.Value = introduction.GetValue().map(intro => {
return {
features: params.features,
wikilink: wikilink.GetValue().data,
intro,
source: source.GetValue().data,
theme: params.theme
}
}, [wikilink.GetValue(), source.GetValue()])
this.IsValid = this.Value.map(obj => {
if (obj === undefined) {
return false;
}
if ([ obj.features, obj.intro, obj.wikilink, obj.source].some(v => v === undefined)){
return false;
}
try{
const url = new URL(obj.wikilink)
if(url.hostname.toLowerCase() !== "wiki.openstreetmap.org"){
return false;
this.Value = introduction.GetValue().map(
(intro) => {
return {
features: params.features,
wikilink: wikilink.GetValue().data,
intro,
source: source.GetValue().data,
theme: params.theme,
}
}catch(e){
},
[wikilink.GetValue(), source.GetValue()]
)
this.IsValid = this.Value.map((obj) => {
if (obj === undefined) {
return false
}
return true;
if ([obj.features, obj.intro, obj.wikilink, obj.source].some((v) => v === undefined)) {
return false
}
try {
const url = new URL(obj.wikilink)
if (url.hostname.toLowerCase() !== "wiki.openstreetmap.org") {
return false
}
} catch (e) {
return false
}
return true
})
}
}
}

View file

@ -1,74 +1,84 @@
import Combine from "../Base/Combine";
import {FlowStep} from "./FlowStep";
import {BBox} from "../../Logic/BBox";
import LayerConfig from "../../Models/ThemeConfig/LayerConfig";
import {Store, UIEventSource} from "../../Logic/UIEventSource";
import CreateNoteImportLayer from "../../Models/ThemeConfig/Conversion/CreateNoteImportLayer";
import FilteredLayer, {FilterState} from "../../Models/FilteredLayer";
import GeoJsonSource from "../../Logic/FeatureSource/Sources/GeoJsonSource";
import MetaTagging from "../../Logic/MetaTagging";
import RelationsTracker from "../../Logic/Osm/RelationsTracker";
import FilteringFeatureSource from "../../Logic/FeatureSource/Sources/FilteringFeatureSource";
import Minimap from "../Base/Minimap";
import ShowDataLayer from "../ShowDataLayer/ShowDataLayer";
import FeatureInfoBox from "../Popup/FeatureInfoBox";
import {ImportUtils} from "./ImportUtils";
import * as import_candidate from "../../assets/layers/import_candidate/import_candidate.json";
import StaticFeatureSource from "../../Logic/FeatureSource/Sources/StaticFeatureSource";
import Title from "../Base/Title";
import Loading from "../Base/Loading";
import {VariableUiElement} from "../Base/VariableUIElement";
import Combine from "../Base/Combine"
import { FlowStep } from "./FlowStep"
import { BBox } from "../../Logic/BBox"
import LayerConfig from "../../Models/ThemeConfig/LayerConfig"
import { Store, UIEventSource } from "../../Logic/UIEventSource"
import CreateNoteImportLayer from "../../Models/ThemeConfig/Conversion/CreateNoteImportLayer"
import FilteredLayer, { FilterState } from "../../Models/FilteredLayer"
import GeoJsonSource from "../../Logic/FeatureSource/Sources/GeoJsonSource"
import MetaTagging from "../../Logic/MetaTagging"
import RelationsTracker from "../../Logic/Osm/RelationsTracker"
import FilteringFeatureSource from "../../Logic/FeatureSource/Sources/FilteringFeatureSource"
import Minimap from "../Base/Minimap"
import ShowDataLayer from "../ShowDataLayer/ShowDataLayer"
import FeatureInfoBox from "../Popup/FeatureInfoBox"
import { ImportUtils } from "./ImportUtils"
import * as import_candidate from "../../assets/layers/import_candidate/import_candidate.json"
import StaticFeatureSource from "../../Logic/FeatureSource/Sources/StaticFeatureSource"
import Title from "../Base/Title"
import Loading from "../Base/Loading"
import { VariableUiElement } from "../Base/VariableUIElement"
import * as known_layers from "../../assets/generated/known_layers.json"
import {LayerConfigJson} from "../../Models/ThemeConfig/Json/LayerConfigJson";
import Translations from "../i18n/Translations";
import { LayerConfigJson } from "../../Models/ThemeConfig/Json/LayerConfigJson"
import Translations from "../i18n/Translations"
/**
* Filters out points for which the import-note already exists, to prevent duplicates
*/
export class CompareToAlreadyExistingNotes extends Combine implements FlowStep<{ bbox: BBox, layer: LayerConfig, features: any[], theme: string }> {
export class CompareToAlreadyExistingNotes
extends Combine
implements FlowStep<{ bbox: BBox; layer: LayerConfig; features: any[]; theme: string }>
{
public IsValid: Store<boolean>
public Value: Store<{ bbox: BBox, layer: LayerConfig, features: any[], theme: string }>
public Value: Store<{ bbox: BBox; layer: LayerConfig; features: any[]; theme: string }>
constructor(state, params: { bbox: BBox, layer: LayerConfig, features: any[], theme: string }) {
constructor(state, params: { bbox: BBox; layer: LayerConfig; features: any[]; theme: string }) {
const t = Translations.t.importHelper.compareToAlreadyExistingNotes
const layerConfig = known_layers.layers.filter(l => l.id === params.layer.id)[0]
const layerConfig = known_layers.layers.filter((l) => l.id === params.layer.id)[0]
if (layerConfig === undefined) {
console.error("WEIRD: layer not found in the builtin layer overview")
}
const importLayerJson = new CreateNoteImportLayer(150).convertStrict(<LayerConfigJson>layerConfig, "CompareToAlreadyExistingNotes")
const importLayerJson = new CreateNoteImportLayer(150).convertStrict(
<LayerConfigJson>layerConfig,
"CompareToAlreadyExistingNotes"
)
const importLayer = new LayerConfig(importLayerJson, "import-layer-dynamic")
const flayer: FilteredLayer = {
appliedFilters: new UIEventSource<Map<string, FilterState>>(new Map<string, FilterState>()),
appliedFilters: new UIEventSource<Map<string, FilterState>>(
new Map<string, FilterState>()
),
isDisplayed: new UIEventSource<boolean>(true),
layerDef: importLayer
layerDef: importLayer,
}
const allNotesWithinBbox = new GeoJsonSource(flayer, params.bbox.padAbsolute(0.0001))
allNotesWithinBbox.features.map(f => MetaTagging.addMetatags(
allNotesWithinBbox.features.map((f) =>
MetaTagging.addMetatags(
f,
{
memberships: new RelationsTracker(),
getFeaturesWithin: () => [],
getFeatureById: () => undefined
getFeatureById: () => undefined,
},
importLayer,
state,
{
includeDates: true,
// We assume that the non-dated metatags are already set by the cache generator
includeNonDates: true
includeNonDates: true,
}
)
)
const alreadyOpenImportNotes = new FilteringFeatureSource(state, undefined, allNotesWithinBbox)
const alreadyOpenImportNotes = new FilteringFeatureSource(
state,
undefined,
allNotesWithinBbox
)
const map = Minimap.createMiniMap()
map.SetClass("w-full").SetStyle("height: 500px")
const comparisonMap = Minimap.createMiniMap({
location: map.location,
})
comparisonMap.SetClass("w-full").SetStyle("height: 500px")
@ -78,94 +88,109 @@ export class CompareToAlreadyExistingNotes extends Combine implements FlowStep<{
zoomToFeatures: true,
leafletMap: map.leafletMap,
features: alreadyOpenImportNotes,
popup: (tags, layer) => new FeatureInfoBox(tags, layer, state)
popup: (tags, layer) => new FeatureInfoBox(tags, layer, state),
})
const maxDistance = new UIEventSource<number>(10)
const partitionedImportPoints = ImportUtils.partitionFeaturesIfNearby(params, alreadyOpenImportNotes.features
.map(ff => ({features: ff.map(ff => ff.feature)})), maxDistance)
const partitionedImportPoints = ImportUtils.partitionFeaturesIfNearby(
params,
alreadyOpenImportNotes.features.map((ff) => ({ features: ff.map((ff) => ff.feature) })),
maxDistance
)
new ShowDataLayer({
layerToShow: new LayerConfig(import_candidate),
state,
zoomToFeatures: true,
leafletMap: comparisonMap.leafletMap,
features: StaticFeatureSource.fromGeojsonStore(partitionedImportPoints.map(p => p.hasNearby)),
popup: (tags, layer) => new FeatureInfoBox(tags, layer, state)
features: StaticFeatureSource.fromGeojsonStore(
partitionedImportPoints.map((p) => p.hasNearby)
),
popup: (tags, layer) => new FeatureInfoBox(tags, layer, state),
})
super([
new Title(t.titleLong),
new VariableUiElement(
alreadyOpenImportNotes.features.map(notesWithImport => {
if (allNotesWithinBbox.state.data !== undefined && allNotesWithinBbox.state.data["error"] !== undefined) {
const error = allNotesWithinBbox.state.data["error"]
t.loadingFailed.Subs({error})
}
if (allNotesWithinBbox.features.data === undefined || allNotesWithinBbox.features.data.length === 0) {
return new Loading(t.loading)
}
if (notesWithImport.length === 0) {
return t.noPreviousNotesFound.SetClass("thanks")
}
return new Combine([
t.mapExplanation.Subs(params.features),
map,
alreadyOpenImportNotes.features.map(
(notesWithImport) => {
if (
allNotesWithinBbox.state.data !== undefined &&
allNotesWithinBbox.state.data["error"] !== undefined
) {
const error = allNotesWithinBbox.state.data["error"]
t.loadingFailed.Subs({ error })
}
if (
allNotesWithinBbox.features.data === undefined ||
allNotesWithinBbox.features.data.length === 0
) {
return new Loading(t.loading)
}
if (notesWithImport.length === 0) {
return t.noPreviousNotesFound.SetClass("thanks")
}
return new Combine([
t.mapExplanation.Subs(params.features),
map,
new VariableUiElement(partitionedImportPoints.map(({noNearby, hasNearby}) => {
new VariableUiElement(
partitionedImportPoints.map(({ noNearby, hasNearby }) => {
if (noNearby.length === 0) {
// Nothing can be imported
return t.completelyImported
.SetClass("alert w-full block")
.SetStyle("padding: 0.5rem")
}
if (noNearby.length === 0) {
// Nothing can be imported
return t.completelyImported.SetClass("alert w-full block").SetStyle("padding: 0.5rem")
}
if (hasNearby.length === 0) {
// All points can be imported
return t.nothingNearby
.SetClass("thanks w-full block")
.SetStyle("padding: 0.5rem")
}
if (hasNearby.length === 0) {
// All points can be imported
return t.nothingNearby.SetClass("thanks w-full block").SetStyle("padding: 0.5rem")
}
return new Combine([
t.someNearby.Subs({
hasNearby: hasNearby.length,
distance: maxDistance.data
}).SetClass("alert"),
t.wontBeImported,
comparisonMap.SetClass("w-full")
]).SetClass("w-full")
}))
]).SetClass("flex flex-col")
}, [allNotesWithinBbox.features, allNotesWithinBbox.state])
return new Combine([
t.someNearby
.Subs({
hasNearby: hasNearby.length,
distance: maxDistance.data,
})
.SetClass("alert"),
t.wontBeImported,
comparisonMap.SetClass("w-full"),
]).SetClass("w-full")
})
),
]).SetClass("flex flex-col")
},
[allNotesWithinBbox.features, allNotesWithinBbox.state]
)
),
]);
])
this.SetClass("flex flex-col")
this.Value = partitionedImportPoints.map(({noNearby}) => ({
this.Value = partitionedImportPoints.map(({ noNearby }) => ({
features: noNearby,
bbox: params.bbox,
layer: params.layer,
theme: params.theme
theme: params.theme,
}))
this.IsValid = alreadyOpenImportNotes.features.map(ff => {
if (allNotesWithinBbox.features.data.length === 0) {
// Not yet loaded
return false
}
if (ff.length == 0) {
// No import notes at all
return true;
}
this.IsValid = alreadyOpenImportNotes.features.map(
(ff) => {
if (allNotesWithinBbox.features.data.length === 0) {
// Not yet loaded
return false
}
if (ff.length == 0) {
// No import notes at all
return true
}
return partitionedImportPoints.data.noNearby.length > 0; // at least _something_ can be imported
}, [partitionedImportPoints, allNotesWithinBbox.features])
return partitionedImportPoints.data.noNearby.length > 0 // at least _something_ can be imported
},
[partitionedImportPoints, allNotesWithinBbox.features]
)
}
}
}

View file

@ -1,32 +1,35 @@
import Combine from "../Base/Combine";
import {FlowStep} from "./FlowStep";
import {Store, UIEventSource} from "../../Logic/UIEventSource";
import Link from "../Base/Link";
import CheckBoxes from "../Input/Checkboxes";
import Title from "../Base/Title";
import Translations from "../i18n/Translations";
export class ConfirmProcess extends Combine implements FlowStep<{ features: any[], theme: string }> {
import Combine from "../Base/Combine"
import { FlowStep } from "./FlowStep"
import { Store, UIEventSource } from "../../Logic/UIEventSource"
import Link from "../Base/Link"
import CheckBoxes from "../Input/Checkboxes"
import Title from "../Base/Title"
import Translations from "../i18n/Translations"
export class ConfirmProcess
extends Combine
implements FlowStep<{ features: any[]; theme: string }>
{
public IsValid: Store<boolean>
public Value: Store<{ features: any[], theme: string }>
public Value: Store<{ features: any[]; theme: string }>
constructor(v: { features: any[], theme: string }) {
const t = Translations.t.importHelper.confirmProcess;
constructor(v: { features: any[]; theme: string }) {
const t = Translations.t.importHelper.confirmProcess
const elements = [
new Link(t.readImportGuidelines, "https://wiki.openstreetmap.org/wiki/Import_guidelines", true),
new Link(
t.readImportGuidelines,
"https://wiki.openstreetmap.org/wiki/Import_guidelines",
true
),
t.contactedCommunity,
t.licenseIsCompatible,
t.wikipageIsMade
t.wikipageIsMade,
]
const toConfirm = new CheckBoxes(elements);
const toConfirm = new CheckBoxes(elements)
super([
new Title(t.titleLong),
toConfirm,
]);
super([new Title(t.titleLong), toConfirm])
this.SetClass("link-underline")
this.IsValid = toConfirm.GetValue().map(selected => elements.length == selected.length)
this.Value = new UIEventSource<{ features: any[], theme: string }>(v)
this.IsValid = toConfirm.GetValue().map((selected) => elements.length == selected.length)
this.Value = new UIEventSource<{ features: any[]; theme: string }>(v)
}
}
}

View file

@ -1,120 +1,134 @@
import {BBox} from "../../Logic/BBox";
import LayerConfig from "../../Models/ThemeConfig/LayerConfig";
import Combine from "../Base/Combine";
import Title from "../Base/Title";
import {Overpass} from "../../Logic/Osm/Overpass";
import {Store, UIEventSource} from "../../Logic/UIEventSource";
import Constants from "../../Models/Constants";
import RelationsTracker from "../../Logic/Osm/RelationsTracker";
import {VariableUiElement} from "../Base/VariableUIElement";
import {FlowStep} from "./FlowStep";
import Loading from "../Base/Loading";
import {SubtleButton} from "../Base/SubtleButton";
import Svg from "../../Svg";
import {Utils} from "../../Utils";
import {IdbLocalStorage} from "../../Logic/Web/IdbLocalStorage";
import Minimap from "../Base/Minimap";
import BaseLayer from "../../Models/BaseLayer";
import AvailableBaseLayers from "../../Logic/Actors/AvailableBaseLayers";
import Loc from "../../Models/Loc";
import Attribution from "../BigComponents/Attribution";
import ShowDataLayer from "../ShowDataLayer/ShowDataLayer";
import StaticFeatureSource from "../../Logic/FeatureSource/Sources/StaticFeatureSource";
import ValidatedTextField from "../Input/ValidatedTextField";
import {LocalStorageSource} from "../../Logic/Web/LocalStorageSource";
import { BBox } from "../../Logic/BBox"
import LayerConfig from "../../Models/ThemeConfig/LayerConfig"
import Combine from "../Base/Combine"
import Title from "../Base/Title"
import { Overpass } from "../../Logic/Osm/Overpass"
import { Store, UIEventSource } from "../../Logic/UIEventSource"
import Constants from "../../Models/Constants"
import RelationsTracker from "../../Logic/Osm/RelationsTracker"
import { VariableUiElement } from "../Base/VariableUIElement"
import { FlowStep } from "./FlowStep"
import Loading from "../Base/Loading"
import { SubtleButton } from "../Base/SubtleButton"
import Svg from "../../Svg"
import { Utils } from "../../Utils"
import { IdbLocalStorage } from "../../Logic/Web/IdbLocalStorage"
import Minimap from "../Base/Minimap"
import BaseLayer from "../../Models/BaseLayer"
import AvailableBaseLayers from "../../Logic/Actors/AvailableBaseLayers"
import Loc from "../../Models/Loc"
import Attribution from "../BigComponents/Attribution"
import ShowDataLayer from "../ShowDataLayer/ShowDataLayer"
import StaticFeatureSource from "../../Logic/FeatureSource/Sources/StaticFeatureSource"
import ValidatedTextField from "../Input/ValidatedTextField"
import { LocalStorageSource } from "../../Logic/Web/LocalStorageSource"
import * as import_candidate from "../../assets/layers/import_candidate/import_candidate.json"
import {GeoOperations} from "../../Logic/GeoOperations";
import FeatureInfoBox from "../Popup/FeatureInfoBox";
import {ImportUtils} from "./ImportUtils";
import Translations from "../i18n/Translations";
import ShowDataMultiLayer from "../ShowDataLayer/ShowDataMultiLayer";
import FilteredLayer, {FilterState} from "../../Models/FilteredLayer";
import {Feature, FeatureCollection} from "@turf/turf";
import { GeoOperations } from "../../Logic/GeoOperations"
import FeatureInfoBox from "../Popup/FeatureInfoBox"
import { ImportUtils } from "./ImportUtils"
import Translations from "../i18n/Translations"
import ShowDataMultiLayer from "../ShowDataLayer/ShowDataMultiLayer"
import FilteredLayer, { FilterState } from "../../Models/FilteredLayer"
import { Feature, FeatureCollection } from "@turf/turf"
import * as currentview from "../../assets/layers/current_view/current_view.json"
import {CheckBox} from "../Input/Checkboxes";
import BackgroundMapSwitch from "../BigComponents/BackgroundMapSwitch";
import { CheckBox } from "../Input/Checkboxes"
import BackgroundMapSwitch from "../BigComponents/BackgroundMapSwitch"
/**
* Given the data to import, the bbox and the layer, will query overpass for similar items
*/
export default class ConflationChecker extends Combine implements FlowStep<{ features: any[], theme: string }> {
export default class ConflationChecker
extends Combine
implements FlowStep<{ features: any[]; theme: string }>
{
public readonly IsValid
public readonly Value: Store<{ features: any[], theme: string }>
constructor(
state,
params: { bbox: BBox, layer: LayerConfig, theme: string, features: any[] }) {
public readonly Value: Store<{ features: any[]; theme: string }>
constructor(state, params: { bbox: BBox; layer: LayerConfig; theme: string; features: any[] }) {
const t = Translations.t.importHelper.conflationChecker
const bbox = params.bbox.padAbsolute(0.0001)
const layer = params.layer;
const toImport: { features: any[] } = params;
let overpassStatus = new UIEventSource<{ error: string } | "running" | "success" | "idle" | "cached">("idle")
const layer = params.layer
const toImport: { features: any[] } = params
let overpassStatus = new UIEventSource<
{ error: string } | "running" | "success" | "idle" | "cached"
>("idle")
function loadDataFromOverpass() {
// Load the data!
const url = Constants.defaultOverpassUrls[1]
const relationTracker = new RelationsTracker()
const overpass = new Overpass(params.layer.source.osmTags, [], url, new UIEventSource<number>(180), relationTracker, true)
const overpass = new Overpass(
params.layer.source.osmTags,
[],
url,
new UIEventSource<number>(180),
relationTracker,
true
)
console.log("Loading from overpass!")
overpassStatus.setData("running")
overpass.queryGeoJson(bbox).then(
([data, date]) => {
console.log("Received overpass-data: ", data.features.length, "features are loaded at ", date);
console.log(
"Received overpass-data: ",
data.features.length,
"features are loaded at ",
date
)
overpassStatus.setData("success")
fromLocalStorage.setData([data, date])
},
(error) => {
overpassStatus.setData({error})
})
overpassStatus.setData({ error })
}
)
}
const fromLocalStorage = IdbLocalStorage.Get<[any, Date]>("importer-overpass-cache-" + layer.id, {
whenLoaded: (v) => {
if (v !== undefined && v !== null) {
console.log("Loaded from local storage:", v)
overpassStatus.setData("cached")
}else{
loadDataFromOverpass()
}
const fromLocalStorage = IdbLocalStorage.Get<[any, Date]>(
"importer-overpass-cache-" + layer.id,
{
whenLoaded: (v) => {
if (v !== undefined && v !== null) {
console.log("Loaded from local storage:", v)
overpassStatus.setData("cached")
} else {
loadDataFromOverpass()
}
},
}
});
)
const cacheAge = fromLocalStorage.map(d => {
if(d === undefined || d[1] === undefined){
const cacheAge = fromLocalStorage.map((d) => {
if (d === undefined || d[1] === undefined) {
return undefined
}
const [_, loadedDate] = d
return (new Date().getTime() - loadedDate.getTime()) / 1000;
return (new Date().getTime() - loadedDate.getTime()) / 1000
})
cacheAge.addCallbackD(timeDiff => {
cacheAge.addCallbackD((timeDiff) => {
if (timeDiff < 24 * 60 * 60) {
// Recently cached!
// Recently cached!
overpassStatus.setData("cached")
return;
return
} else {
loadDataFromOverpass()
}
})
const geojson: Store<FeatureCollection> = fromLocalStorage.map(d => {
const geojson: Store<FeatureCollection> = fromLocalStorage.map((d) => {
if (d === undefined) {
return undefined
}
return d[0]
})
const background = new UIEventSource<BaseLayer>(AvailableBaseLayers.osmCarto)
const location = new UIEventSource<Loc>({lat: 0, lon: 0, zoom: 1})
const location = new UIEventSource<Loc>({ lat: 0, lon: 0, zoom: 1 })
const currentBounds = new UIEventSource<BBox>(undefined)
const zoomLevel = ValidatedTextField.ForType("pnat").ConstructInputElement({
value: LocalStorageSource.GetParsed<string>("importer-zoom-level", "0")
value: LocalStorageSource.GetParsed<string>("importer-zoom-level", "0"),
})
zoomLevel.SetClass("ml-1 border border-black")
const osmLiveData = Minimap.createMiniMap({
@ -122,26 +136,34 @@ export default class ConflationChecker extends Combine implements FlowStep<{ fea
location,
background,
bounds: currentBounds,
attribution: new Attribution(location, state.osmConnection.userDetails, undefined, currentBounds)
attribution: new Attribution(
location,
state.osmConnection.userDetails,
undefined,
currentBounds
),
})
osmLiveData.SetClass("w-full").SetStyle("height: 500px")
const geojsonFeatures : Store<Feature[]> = geojson.map(geojson => {
if (geojson?.features === undefined) {
return []
}
const currentZoom = zoomLevel.GetValue().data
const zoomedEnough: boolean = osmLiveData.location.data.zoom >= Number(currentZoom)
if (currentZoom !== undefined && !zoomedEnough) {
return []
}
const bounds = osmLiveData.bounds.data
if(bounds === undefined){
return geojson.features;
}
return geojson.features.filter(f => BBox.get(f).overlapsWith(bounds))
}, [osmLiveData.bounds, zoomLevel.GetValue()])
const geojsonFeatures: Store<Feature[]> = geojson.map(
(geojson) => {
if (geojson?.features === undefined) {
return []
}
const currentZoom = zoomLevel.GetValue().data
const zoomedEnough: boolean = osmLiveData.location.data.zoom >= Number(currentZoom)
if (currentZoom !== undefined && !zoomedEnough) {
return []
}
const bounds = osmLiveData.bounds.data
if (bounds === undefined) {
return geojson.features
}
return geojson.features.filter((f) => BBox.get(f).overlapsWith(bounds))
},
[osmLiveData.bounds, zoomLevel.GetValue()]
)
const preview = StaticFeatureSource.fromGeojsonStore(geojsonFeatures)
new ShowDataLayer({
@ -150,32 +172,32 @@ export default class ConflationChecker extends Combine implements FlowStep<{ fea
leafletMap: osmLiveData.leafletMap,
popup: undefined,
zoomToFeatures: true,
features: StaticFeatureSource.fromGeojson([
bbox.asGeoJson({})
])
features: StaticFeatureSource.fromGeojson([bbox.asGeoJson({})]),
})
new ShowDataMultiLayer({
//layerToShow: layer,
layers: new UIEventSource<FilteredLayer[]>([{
layerDef: layer,
isDisplayed: new UIEventSource<boolean>(true),
appliedFilters: new UIEventSource<Map<string, FilterState>>(undefined)
}]),
layers: new UIEventSource<FilteredLayer[]>([
{
layerDef: layer,
isDisplayed: new UIEventSource<boolean>(true),
appliedFilters: new UIEventSource<Map<string, FilterState>>(undefined),
},
]),
state,
leafletMap: osmLiveData.leafletMap,
popup: (tags, layer) => new FeatureInfoBox(tags, layer, state, {setHash: false}),
popup: (tags, layer) => new FeatureInfoBox(tags, layer, state, { setHash: false }),
zoomToFeatures: false,
features: preview
features: preview,
})
new ShowDataLayer({
layerToShow: new LayerConfig(import_candidate),
state,
leafletMap: osmLiveData.leafletMap,
popup: (tags, layer) => new FeatureInfoBox(tags, layer, state, {setHash: false}),
popup: (tags, layer) => new FeatureInfoBox(tags, layer, state, { setHash: false }),
zoomToFeatures: false,
features: StaticFeatureSource.fromGeojson(toImport.features)
features: StaticFeatureSource.fromGeojson(toImport.features),
})
const nearbyCutoff = ValidatedTextField.ForType("pnat").ConstructInputElement()
@ -184,138 +206,172 @@ export default class ConflationChecker extends Combine implements FlowStep<{ fea
const matchedFeaturesMap = Minimap.createMiniMap({
allowMoving: true,
background
background,
})
matchedFeaturesMap.SetClass("w-full").SetStyle("height: 500px")
// Featuresource showing OSM-features which are nearby a toImport-feature
const geojsonMapped: Store<Feature[]> = geojson.map(osmData => {
if (osmData?.features === undefined) {
return []
}
const maxDist = Number(nearbyCutoff.GetValue().data)
return osmData.features.filter(f =>
toImport.features.some(imp =>
maxDist >= GeoOperations.distanceBetween(imp.geometry.coordinates, GeoOperations.centerpointCoordinates(f))))
}, [nearbyCutoff.GetValue().stabilized(500)])
const nearbyFeatures = StaticFeatureSource.fromGeojsonStore(geojsonMapped);
const paritionedImport = ImportUtils.partitionFeaturesIfNearby(toImport, geojson, nearbyCutoff.GetValue().map(Number));
// Featuresource showing OSM-features which are nearby a toImport-feature
const geojsonMapped: Store<Feature[]> = geojson.map(
(osmData) => {
if (osmData?.features === undefined) {
return []
}
const maxDist = Number(nearbyCutoff.GetValue().data)
return osmData.features.filter((f) =>
toImport.features.some(
(imp) =>
maxDist >=
GeoOperations.distanceBetween(
imp.geometry.coordinates,
GeoOperations.centerpointCoordinates(f)
)
)
)
},
[nearbyCutoff.GetValue().stabilized(500)]
)
const nearbyFeatures = StaticFeatureSource.fromGeojsonStore(geojsonMapped)
const paritionedImport = ImportUtils.partitionFeaturesIfNearby(
toImport,
geojson,
nearbyCutoff.GetValue().map(Number)
)
// Featuresource showing OSM-features which are nearby a toImport-feature
const toImportWithNearby = StaticFeatureSource.fromGeojsonStore(paritionedImport.map(els => els?.hasNearby ?? []));
toImportWithNearby.features.addCallback(nearby => console.log("The following features are near an already existing object:", nearby))
// Featuresource showing OSM-features which are nearby a toImport-feature
const toImportWithNearby = StaticFeatureSource.fromGeojsonStore(
paritionedImport.map((els) => els?.hasNearby ?? [])
)
toImportWithNearby.features.addCallback((nearby) =>
console.log("The following features are near an already existing object:", nearby)
)
new ShowDataLayer({
layerToShow: new LayerConfig(import_candidate),
state,
leafletMap: matchedFeaturesMap.leafletMap,
popup: (tags, layer) => new FeatureInfoBox(tags, layer, state, {setHash: false}),
popup: (tags, layer) => new FeatureInfoBox(tags, layer, state, { setHash: false }),
zoomToFeatures: false,
features: toImportWithNearby
features: toImportWithNearby,
})
const showOsmLayer = new CheckBox(t.showOsmLayerInConflationMap, true)
new ShowDataLayer({
layerToShow: layer,
state,
leafletMap: matchedFeaturesMap.leafletMap,
popup: (tags, layer) => new FeatureInfoBox(tags, layer, state, {setHash: false}),
popup: (tags, layer) => new FeatureInfoBox(tags, layer, state, { setHash: false }),
zoomToFeatures: true,
features: nearbyFeatures,
doShowLayer: showOsmLayer.GetValue()
doShowLayer: showOsmLayer.GetValue(),
})
const conflationMaps = new Combine([
new VariableUiElement(
geojson.map(geojson => {
geojson.map((geojson) => {
if (geojson === undefined) {
return undefined;
return undefined
}
return new SubtleButton(Svg.download_svg(), t.downloadOverpassData).onClick(() => {
Utils.offerContentsAsDownloadableFile(JSON.stringify(geojson, null, " "), "mapcomplete-" + layer.id + ".geojson", {
mimetype: "application/json+geo"
})
});
})),
new VariableUiElement(cacheAge.map(age => {
if (age === undefined) {
return undefined;
}
if (age < 0) {
return t.cacheExpired
}
return new Combine([t.loadedDataAge.Subs({age: Utils.toHumanTime(age)}),
new SubtleButton(Svg.reload_svg().SetClass("h-8"), t.reloadTheCache)
.onClick(loadDataFromOverpass)
.SetClass("h-12")
])
})),
return new SubtleButton(Svg.download_svg(), t.downloadOverpassData).onClick(
() => {
Utils.offerContentsAsDownloadableFile(
JSON.stringify(geojson, null, " "),
"mapcomplete-" + layer.id + ".geojson",
{
mimetype: "application/json+geo",
}
)
}
)
})
),
new VariableUiElement(
cacheAge.map((age) => {
if (age === undefined) {
return undefined
}
if (age < 0) {
return t.cacheExpired
}
return new Combine([
t.loadedDataAge.Subs({ age: Utils.toHumanTime(age) }),
new SubtleButton(Svg.reload_svg().SetClass("h-8"), t.reloadTheCache)
.onClick(loadDataFromOverpass)
.SetClass("h-12"),
])
})
),
new Title(t.titleLive),
t.importCandidatesCount.Subs({count: toImport.features.length}),
new VariableUiElement(geojson.map(geojson => {
if (geojson?.features?.length === undefined || geojson?.features?.length === 0) {
return t.nothingLoaded.Subs(layer).SetClass("alert")
}
return new Combine([
t.osmLoaded.Subs({count: geojson.features.length, name: layer.name}),
])
})),
t.importCandidatesCount.Subs({ count: toImport.features.length }),
new VariableUiElement(
geojson.map((geojson) => {
if (
geojson?.features?.length === undefined ||
geojson?.features?.length === 0
) {
return t.nothingLoaded.Subs(layer).SetClass("alert")
}
return new Combine([
t.osmLoaded.Subs({ count: geojson.features.length, name: layer.name }),
])
})
),
osmLiveData,
new Combine([
t.zoomLevelSelection,
zoomLevel,
new VariableUiElement(osmLiveData.location.map(location => {
return t.zoomIn.Subs(<any>{current: location.zoom})
})),
new VariableUiElement(
osmLiveData.location.map((location) => {
return t.zoomIn.Subs(<any>{ current: location.zoom })
})
),
]).SetClass("flex"),
new Title(t.titleNearby),
new Combine([t.mapShowingNearbyIntro, nearbyCutoff]).SetClass("flex"),
new VariableUiElement(toImportWithNearby.features.map(feats =>
t.nearbyWarn.Subs({count: feats.length}).SetClass("alert"))),
new VariableUiElement(
toImportWithNearby.features.map((feats) =>
t.nearbyWarn.Subs({ count: feats.length }).SetClass("alert")
)
),
t.setRangeToZero,
matchedFeaturesMap,
new Combine([
new BackgroundMapSwitch({backgroundLayer: background, locationControl: matchedFeaturesMap.location}, background),
showOsmLayer,
]).SetClass("flex")
new BackgroundMapSwitch(
{ backgroundLayer: background, locationControl: matchedFeaturesMap.location },
background
),
showOsmLayer,
]).SetClass("flex"),
]).SetClass("flex flex-col")
super([
new Title(t.title),
new VariableUiElement(overpassStatus.map(d => {
if (d === "idle") {
return new Loading(t.states.idle)
}
if (d === "running") {
return new Loading(t.states.running)
}
if (d["error"] !== undefined) {
return t.states.error.Subs({error: d["error"]}).SetClass("alert")
}
if (d === "cached") {
return conflationMaps
}
if (d === "success") {
return conflationMaps
}
return t.states.unexpected.Subs({state: d}).SetClass("alert")
}))
new VariableUiElement(
overpassStatus.map((d) => {
if (d === "idle") {
return new Loading(t.states.idle)
}
if (d === "running") {
return new Loading(t.states.running)
}
if (d["error"] !== undefined) {
return t.states.error.Subs({ error: d["error"] }).SetClass("alert")
}
if (d === "cached") {
return conflationMaps
}
if (d === "success") {
return conflationMaps
}
return t.states.unexpected.Subs({ state: d }).SetClass("alert")
})
),
])
this.Value = paritionedImport.map(feats => ({
this.Value = paritionedImport.map((feats) => ({
theme: params.theme,
features: feats?.noNearby,
layer: params.layer
layer: params.layer,
}))
this.IsValid = this.Value.map(v => v?.features !== undefined && v.features.length > 0)
this.IsValid = this.Value.map((v) => v?.features !== undefined && v.features.length > 0)
}
}
}

View file

@ -1,22 +1,21 @@
import Combine from "../Base/Combine";
import {OsmConnection} from "../../Logic/Osm/OsmConnection";
import {UIEventSource} from "../../Logic/UIEventSource";
import Title from "../Base/Title";
import Toggle from "../Input/Toggle";
import Loading from "../Base/Loading";
import {VariableUiElement} from "../Base/VariableUIElement";
import {FixedUiElement} from "../Base/FixedUiElement";
import {SubtleButton} from "../Base/SubtleButton";
import Svg from "../../Svg";
import Translations from "../i18n/Translations";
import {Translation} from "../i18n/Translation";
import Combine from "../Base/Combine"
import { OsmConnection } from "../../Logic/Osm/OsmConnection"
import { UIEventSource } from "../../Logic/UIEventSource"
import Title from "../Base/Title"
import Toggle from "../Input/Toggle"
import Loading from "../Base/Loading"
import { VariableUiElement } from "../Base/VariableUIElement"
import { FixedUiElement } from "../Base/FixedUiElement"
import { SubtleButton } from "../Base/SubtleButton"
import Svg from "../../Svg"
import Translations from "../i18n/Translations"
import { Translation } from "../i18n/Translation"
export class CreateNotes extends Combine {
public static createNoteContentsUi(feature: {properties: any, geometry: {coordinates: [number,number]}},
options: {wikilink: string; intro: string; source: string, theme: string }
): (Translation | string)[]{
public static createNoteContentsUi(
feature: { properties: any; geometry: { coordinates: [number, number] } },
options: { wikilink: string; intro: string; source: string; theme: string }
): (Translation | string)[] {
const src = feature.properties["source"] ?? feature.properties["src"] ?? options.source
delete feature.properties["source"]
delete feature.properties["src"]
@ -26,7 +25,7 @@ export class CreateNotes extends Combine {
delete feature.properties["note"]
}
const tags: string [] = []
const tags: string[] = []
for (const key in feature.properties) {
if (feature.properties[key] === null || feature.properties[key] === undefined) {
console.warn("Null or undefined key for ", feature.properties)
@ -35,7 +34,14 @@ export class CreateNotes extends Combine {
if (feature.properties[key] === "") {
continue
}
tags.push(key + "=" + (feature.properties[key]+"").replace(/=/, "\\=").replace(/;/g, "\\;").replace(/\n/g, "\\n"))
tags.push(
key +
"=" +
(feature.properties[key] + "")
.replace(/=/, "\\=")
.replace(/;/g, "\\;")
.replace(/\n/g, "\\n")
)
}
const lat = feature.geometry.coordinates[1]
const lon = feature.geometry.coordinates[0]
@ -43,82 +49,88 @@ export class CreateNotes extends Combine {
return [
options.intro,
extraNote,
note.datasource.Subs({source: src}),
note.datasource.Subs({ source: src }),
note.wikilink.Subs(options),
'',
"",
note.importEasily,
`https://mapcomplete.osm.be/${options.theme}.html?z=18&lat=${lat}&lon=${lon}#import`,
...tags]
...tags,
]
}
public static createNoteContents(feature: {properties: any, geometry: {coordinates: [number,number]}},
options: {wikilink: string; intro: string; source: string, theme: string }
): string[]{
return CreateNotes.createNoteContentsUi(feature, options).map(trOrStr => {
if(typeof trOrStr === "string"){
public static createNoteContents(
feature: { properties: any; geometry: { coordinates: [number, number] } },
options: { wikilink: string; intro: string; source: string; theme: string }
): string[] {
return CreateNotes.createNoteContentsUi(feature, options).map((trOrStr) => {
if (typeof trOrStr === "string") {
return trOrStr
}
return trOrStr.txt
})
}
constructor(state: { osmConnection: OsmConnection }, v: { features: any[]; wikilink: string; intro: string; source: string, theme: string }) {
const t = Translations.t.importHelper.createNotes;
constructor(
state: { osmConnection: OsmConnection },
v: { features: any[]; wikilink: string; intro: string; source: string; theme: string }
) {
const t = Translations.t.importHelper.createNotes
const createdNotes: UIEventSource<number[]> = new UIEventSource<number[]>([])
const failed = new UIEventSource<string[]>([])
const currentNote = createdNotes.map(n => n.length)
const currentNote = createdNotes.map((n) => n.length)
for (const f of v.features) {
const lat = f.geometry.coordinates[1]
const lon = f.geometry.coordinates[0]
const text = CreateNotes.createNoteContents(f, v).join("\n")
state.osmConnection.openNote(
lat, lon, text)
.then(({id}) => {
state.osmConnection.openNote(lat, lon, text).then(
({ id }) => {
createdNotes.data.push(id)
createdNotes.ping()
}, err => {
},
(err) => {
failed.data.push(err)
failed.ping()
})
}
)
}
super([
new Title(t.title),
t.loading ,
t.loading,
new Toggle(
new Loading(new VariableUiElement(currentNote.map(count => t.creating.Subs({
count, total: v.features.length
}
)))),
new Loading(
new VariableUiElement(
currentNote.map((count) =>
t.creating.Subs({
count,
total: v.features.length,
})
)
)
),
new Combine([
Svg.party_svg().SetClass("w-24"),
t.done.Subs({count: v.features.length}).SetClass("thanks"),
new SubtleButton(Svg.note_svg(),
t.openImportViewer , {
url: "import_viewer.html"
})
]
),
currentNote.map(count => count < v.features.length)
t.done.Subs({ count: v.features.length }).SetClass("thanks"),
new SubtleButton(Svg.note_svg(), t.openImportViewer, {
url: "import_viewer.html",
}),
]),
currentNote.map((count) => count < v.features.length)
),
new VariableUiElement(
failed.map((failed) => {
if (failed.length === 0) {
return undefined
}
return new Combine([
new FixedUiElement("Some entries failed").SetClass("alert"),
...failed,
]).SetClass("flex flex-col")
})
),
new VariableUiElement(failed.map(failed => {
if (failed.length === 0) {
return undefined
}
return new Combine([
new FixedUiElement("Some entries failed").SetClass("alert"),
...failed
]).SetClass("flex flex-col")
}))
])
this.SetClass("flex flex-col");
this.SetClass("flex flex-col")
}
}
}

View file

@ -1,13 +1,13 @@
import {Store, UIEventSource} from "../../Logic/UIEventSource";
import Combine from "../Base/Combine";
import BaseUIElement from "../BaseUIElement";
import {SubtleButton} from "../Base/SubtleButton";
import Svg from "../../Svg";
import Translations from "../i18n/Translations";
import {VariableUiElement} from "../Base/VariableUIElement";
import Toggle from "../Input/Toggle";
import {UIElement} from "../UIElement";
import {FixedUiElement} from "../Base/FixedUiElement";
import { Store, UIEventSource } from "../../Logic/UIEventSource"
import Combine from "../Base/Combine"
import BaseUIElement from "../BaseUIElement"
import { SubtleButton } from "../Base/SubtleButton"
import Svg from "../../Svg"
import Translations from "../i18n/Translations"
import { VariableUiElement } from "../Base/VariableUIElement"
import Toggle from "../Input/Toggle"
import { UIElement } from "../UIElement"
import { FixedUiElement } from "../Base/FixedUiElement"
export interface FlowStep<T> extends BaseUIElement {
readonly IsValid: Store<boolean>
@ -15,21 +15,31 @@ export interface FlowStep<T> extends BaseUIElement {
}
export class FlowPanelFactory<T> {
private _initial: FlowStep<any>;
private _steps: ((x: any) => FlowStep<any>)[];
private _stepNames: (string | BaseUIElement)[];
private _initial: FlowStep<any>
private _steps: ((x: any) => FlowStep<any>)[]
private _stepNames: (string | BaseUIElement)[]
private constructor(initial: FlowStep<any>, steps: ((x: any) => FlowStep<any>)[], stepNames: (string | BaseUIElement)[]) {
this._initial = initial;
this._steps = steps;
this._stepNames = stepNames;
private constructor(
initial: FlowStep<any>,
steps: ((x: any) => FlowStep<any>)[],
stepNames: (string | BaseUIElement)[]
) {
this._initial = initial
this._steps = steps
this._stepNames = stepNames
}
public static start<TOut>(name:{title: BaseUIElement}, step: FlowStep<TOut>): FlowPanelFactory<TOut> {
public static start<TOut>(
name: { title: BaseUIElement },
step: FlowStep<TOut>
): FlowPanelFactory<TOut> {
return new FlowPanelFactory(step, [], [name.title])
}
public then<TOut>(name: string | {title: BaseUIElement}, construct: ((t: T) => FlowStep<TOut>)): FlowPanelFactory<TOut> {
public then<TOut>(
name: string | { title: BaseUIElement },
construct: (t: T) => FlowStep<TOut>
): FlowPanelFactory<TOut> {
return new FlowPanelFactory<TOut>(
this._initial,
this._steps.concat([construct]),
@ -37,25 +47,30 @@ export class FlowPanelFactory<T> {
)
}
public finish(name: string | BaseUIElement, construct: ((t: T, backButton?: BaseUIElement) => BaseUIElement)): {
flow: BaseUIElement,
furthestStep: UIEventSource<number>,
public finish(
name: string | BaseUIElement,
construct: (t: T, backButton?: BaseUIElement) => BaseUIElement
): {
flow: BaseUIElement
furthestStep: UIEventSource<number>
titles: (string | BaseUIElement)[]
} {
const furthestStep = new UIEventSource(0)
// Construct all the flowpanels step by step (in reverse order)
const nextConstr: ((t: any, back?: UIElement) => BaseUIElement)[] = this._steps.map(_ => undefined)
const nextConstr: ((t: any, back?: UIElement) => BaseUIElement)[] = this._steps.map(
(_) => undefined
)
nextConstr.push(construct)
for (let i = this._steps.length - 1; i >= 0; i--) {
const createFlowStep: (value) => FlowStep<any> = this._steps[i];
const isConfirm = i == this._steps.length - 1;
const createFlowStep: (value) => FlowStep<any> = this._steps[i]
const isConfirm = i == this._steps.length - 1
nextConstr[i] = (value, backButton) => {
const flowStep = createFlowStep(value)
furthestStep.setData(i + 1);
const panel = new FlowPanel(flowStep, nextConstr[i + 1], backButton, isConfirm);
panel.isActive.addCallbackAndRun(active => {
furthestStep.setData(i + 1)
const panel = new FlowPanel(flowStep, nextConstr[i + 1], backButton, isConfirm)
panel.isActive.addCallbackAndRun((active) => {
if (active) {
furthestStep.setData(i + 1);
furthestStep.setData(i + 1)
}
})
return panel
@ -63,32 +78,31 @@ export class FlowPanelFactory<T> {
}
const flow = new FlowPanel(this._initial, nextConstr[0])
flow.isActive.addCallbackAndRun(active => {
flow.isActive.addCallbackAndRun((active) => {
if (active) {
furthestStep.setData(0);
furthestStep.setData(0)
}
})
return {
flow,
furthestStep,
titles: this._stepNames
titles: this._stepNames,
}
}
}
export class FlowPanel<T> extends Toggle {
public isActive: UIEventSource<boolean>
constructor(
initial: (FlowStep<T>),
constructNextstep: ((input: T, backButton: BaseUIElement) => BaseUIElement),
initial: FlowStep<T>,
constructNextstep: (input: T, backButton: BaseUIElement) => BaseUIElement,
backbutton?: BaseUIElement,
isConfirm = false
) {
const t = Translations.t.general;
const t = Translations.t.general
const currentStepActive = new UIEventSource(true);
const currentStepActive = new UIEventSource(true)
let nextStep: UIEventSource<BaseUIElement> = new UIEventSource<BaseUIElement>(undefined)
const backButtonForNextStep = new SubtleButton(Svg.back_svg(), t.back).onClick(() => {
@ -106,13 +120,13 @@ export class FlowPanel<T> extends Toggle {
backbutton,
new Toggle(
new SubtleButton(
isConfirm ? Svg.checkmark_svg() :
Svg.back_svg().SetStyle("transform: rotate(180deg);"),
isConfirm
? Svg.checkmark_svg()
: Svg.back_svg().SetStyle("transform: rotate(180deg);"),
isConfirm ? t.confirm : t.next
).onClick(() => {
try {
const v = initial.Value.data;
const v = initial.Value.data
nextStep.setData(constructNextstep(v, backButtonForNextStep))
currentStepActive.setData(false)
} catch (e) {
@ -123,24 +137,16 @@ export class FlowPanel<T> extends Toggle {
new SubtleButton(Svg.invalid_svg(), t.notValid),
initial.IsValid
),
new Toggle(
t.error.SetClass("alert"),
undefined,
isError),
new Toggle(t.error.SetClass("alert"), undefined, isError),
]).SetClass("flex w-full justify-end space-x-2"),
]
}
super(
new Combine(elements).SetClass("h-full flex flex-col justify-between"),
new VariableUiElement(nextStep),
currentStepActive
);
)
this.isActive = currentStepActive
}
}
}

View file

@ -1,77 +1,84 @@
import Combine from "../Base/Combine";
import Toggle from "../Input/Toggle";
import LanguagePicker from "../LanguagePicker";
import UserRelatedState from "../../Logic/State/UserRelatedState";
import BaseUIElement from "../BaseUIElement";
import MinimapImplementation from "../Base/MinimapImplementation";
import Translations from "../i18n/Translations";
import {FlowPanelFactory} from "./FlowStep";
import {RequestFile} from "./RequestFile";
import {PreviewAttributesPanel} from "./PreviewPanel";
import ConflationChecker from "./ConflationChecker";
import {AskMetadata} from "./AskMetadata";
import {ConfirmProcess} from "./ConfirmProcess";
import {CreateNotes} from "./CreateNotes";
import {VariableUiElement} from "../Base/VariableUIElement";
import List from "../Base/List";
import {CompareToAlreadyExistingNotes} from "./CompareToAlreadyExistingNotes";
import Introdution from "./Introdution";
import LoginToImport from "./LoginToImport";
import {MapPreview} from "./MapPreview";
import LeftIndex from "../Base/LeftIndex";
import {SubtleButton} from "../Base/SubtleButton";
import SelectTheme from "./SelectTheme";
import Combine from "../Base/Combine"
import Toggle from "../Input/Toggle"
import LanguagePicker from "../LanguagePicker"
import UserRelatedState from "../../Logic/State/UserRelatedState"
import BaseUIElement from "../BaseUIElement"
import MinimapImplementation from "../Base/MinimapImplementation"
import Translations from "../i18n/Translations"
import { FlowPanelFactory } from "./FlowStep"
import { RequestFile } from "./RequestFile"
import { PreviewAttributesPanel } from "./PreviewPanel"
import ConflationChecker from "./ConflationChecker"
import { AskMetadata } from "./AskMetadata"
import { ConfirmProcess } from "./ConfirmProcess"
import { CreateNotes } from "./CreateNotes"
import { VariableUiElement } from "../Base/VariableUIElement"
import List from "../Base/List"
import { CompareToAlreadyExistingNotes } from "./CompareToAlreadyExistingNotes"
import Introdution from "./Introdution"
import LoginToImport from "./LoginToImport"
import { MapPreview } from "./MapPreview"
import LeftIndex from "../Base/LeftIndex"
import { SubtleButton } from "../Base/SubtleButton"
import SelectTheme from "./SelectTheme"
export default class ImportHelperGui extends LeftIndex {
constructor() {
const state = new UserRelatedState(undefined)
const t = Translations.t.importHelper;
const {flow, furthestStep, titles} =
FlowPanelFactory
.start(t.introduction, new Introdution())
.then(t.login, _ => new LoginToImport(state))
.then(t.selectFile, _ => new RequestFile())
.then(t.previewAttributes, geojson => new PreviewAttributesPanel(state, geojson))
.then(t.mapPreview, geojson => new MapPreview(state, geojson))
.then(t.selectTheme, v => new SelectTheme(v))
.then(t.compareToAlreadyExistingNotes, v => new CompareToAlreadyExistingNotes(state, v))
.then(t.conflationChecker, v => new ConflationChecker(state, v))
.then(t.confirmProcess, v => new ConfirmProcess(v))
.then(t.askMetadata, (v) => new AskMetadata(v))
.finish(t.createNotes.title, v => new CreateNotes(state, v));
const t = Translations.t.importHelper
const { flow, furthestStep, titles } = FlowPanelFactory.start(
t.introduction,
new Introdution()
)
.then(t.login, (_) => new LoginToImport(state))
.then(t.selectFile, (_) => new RequestFile())
.then(t.previewAttributes, (geojson) => new PreviewAttributesPanel(state, geojson))
.then(t.mapPreview, (geojson) => new MapPreview(state, geojson))
.then(t.selectTheme, (v) => new SelectTheme(v))
.then(
t.compareToAlreadyExistingNotes,
(v) => new CompareToAlreadyExistingNotes(state, v)
)
.then(t.conflationChecker, (v) => new ConflationChecker(state, v))
.then(t.confirmProcess, (v) => new ConfirmProcess(v))
.then(t.askMetadata, (v) => new AskMetadata(v))
.finish(t.createNotes.title, (v) => new CreateNotes(state, v))
const toc = new List(
titles.map((title, i) => new VariableUiElement(furthestStep.map(currentStep => {
if (i > currentStep) {
return new Combine([title]).SetClass("subtle");
}
if (i == currentStep) {
return new Combine([title]).SetClass("font-bold");
}
if (i < currentStep) {
return title
}
})))
, true)
titles.map(
(title, i) =>
new VariableUiElement(
furthestStep.map((currentStep) => {
if (i > currentStep) {
return new Combine([title]).SetClass("subtle")
}
if (i == currentStep) {
return new Combine([title]).SetClass("font-bold")
}
if (i < currentStep) {
return title
}
})
)
),
true
)
const leftContents: BaseUIElement[] = [
new SubtleButton(undefined, t.gotoImportViewer, {
url: "import_viewer.html"
url: "import_viewer.html",
}),
toc,
new Toggle(t.testMode.SetClass("block alert"), undefined, state.featureSwitchIsTesting),
new LanguagePicker(Translations.t.importHelper.title.SupportedLanguages(), "")?.SetClass("mt-4 self-end flex-col"),
].map(el => el?.SetClass("pl-4"))
super(
leftContents,
flow)
new LanguagePicker(
Translations.t.importHelper.title.SupportedLanguages(),
""
)?.SetClass("mt-4 self-end flex-col"),
].map((el) => el?.SetClass("pl-4"))
super(leftContents, flow)
}
}
MinimapImplementation.initialize()
new ImportHelperGui().AttachTo("main")
new ImportHelperGui().AttachTo("main")

View file

@ -1,35 +1,44 @@
import {Store} from "../../Logic/UIEventSource";
import {GeoOperations} from "../../Logic/GeoOperations";
import {Feature, Geometry} from "@turf/turf";
import { Store } from "../../Logic/UIEventSource"
import { GeoOperations } from "../../Logic/GeoOperations"
import { Feature, Geometry } from "@turf/turf"
export class ImportUtils {
public static partitionFeaturesIfNearby(
toPartitionFeatureCollection: ({ features: Feature<Geometry>[] }),
toPartitionFeatureCollection: { features: Feature<Geometry>[] },
compareWith: Store<{ features: Feature[] }>,
cutoffDistanceInMeters: Store<number>)
: Store<{ hasNearby: Feature[], noNearby: Feature[] }> {
return compareWith.map(osmData => {
if (osmData?.features === undefined) {
return undefined
}
if (osmData.features.length === 0) {
return {noNearby: toPartitionFeatureCollection.features, hasNearby: []}
}
const maxDist = cutoffDistanceInMeters.data
const hasNearby = []
const noNearby = []
for (const toImportElement of toPartitionFeatureCollection.features) {
const hasNearbyFeature = osmData.features.some(f =>
maxDist >= GeoOperations.distanceBetween(<any> toImportElement.geometry.coordinates, GeoOperations.centerpointCoordinates(f)))
if (hasNearbyFeature) {
hasNearby.push(toImportElement)
} else {
noNearby.push(toImportElement)
cutoffDistanceInMeters: Store<number>
): Store<{ hasNearby: Feature[]; noNearby: Feature[] }> {
return compareWith.map(
(osmData) => {
if (osmData?.features === undefined) {
return undefined
}
}
if (osmData.features.length === 0) {
return { noNearby: toPartitionFeatureCollection.features, hasNearby: [] }
}
const maxDist = cutoffDistanceInMeters.data
return {hasNearby, noNearby}
}, [cutoffDistanceInMeters]);
const hasNearby = []
const noNearby = []
for (const toImportElement of toPartitionFeatureCollection.features) {
const hasNearbyFeature = osmData.features.some(
(f) =>
maxDist >=
GeoOperations.distanceBetween(
<any>toImportElement.geometry.coordinates,
GeoOperations.centerpointCoordinates(f)
)
)
if (hasNearbyFeature) {
hasNearby.push(toImportElement)
} else {
noNearby.push(toImportElement)
}
}
return { hasNearby, noNearby }
},
[cutoffDistanceInMeters]
)
}
}
}

View file

@ -1,56 +1,62 @@
import Combine from "../Base/Combine";
import UserRelatedState from "../../Logic/State/UserRelatedState";
import {VariableUiElement} from "../Base/VariableUIElement";
import {Utils} from "../../Utils";
import {UIEventSource} from "../../Logic/UIEventSource";
import Title from "../Base/Title";
import Translations from "../i18n/Translations";
import Loading from "../Base/Loading";
import {FixedUiElement} from "../Base/FixedUiElement";
import Link from "../Base/Link";
import {DropDown} from "../Input/DropDown";
import BaseUIElement from "../BaseUIElement";
import ValidatedTextField from "../Input/ValidatedTextField";
import {SubtleButton} from "../Base/SubtleButton";
import Svg from "../../Svg";
import Toggle, {ClickableToggle} from "../Input/Toggle";
import Table from "../Base/Table";
import LeftIndex from "../Base/LeftIndex";
import Toggleable, {Accordeon} from "../Base/Toggleable";
import TableOfContents from "../Base/TableOfContents";
import {LoginToggle} from "../Popup/LoginButton";
import {QueryParameters} from "../../Logic/Web/QueryParameters";
import Lazy from "../Base/Lazy";
import {Button} from "../Base/Button";
import Combine from "../Base/Combine"
import UserRelatedState from "../../Logic/State/UserRelatedState"
import { VariableUiElement } from "../Base/VariableUIElement"
import { Utils } from "../../Utils"
import { UIEventSource } from "../../Logic/UIEventSource"
import Title from "../Base/Title"
import Translations from "../i18n/Translations"
import Loading from "../Base/Loading"
import { FixedUiElement } from "../Base/FixedUiElement"
import Link from "../Base/Link"
import { DropDown } from "../Input/DropDown"
import BaseUIElement from "../BaseUIElement"
import ValidatedTextField from "../Input/ValidatedTextField"
import { SubtleButton } from "../Base/SubtleButton"
import Svg from "../../Svg"
import Toggle, { ClickableToggle } from "../Input/Toggle"
import Table from "../Base/Table"
import LeftIndex from "../Base/LeftIndex"
import Toggleable, { Accordeon } from "../Base/Toggleable"
import TableOfContents from "../Base/TableOfContents"
import { LoginToggle } from "../Popup/LoginButton"
import { QueryParameters } from "../../Logic/Web/QueryParameters"
import Lazy from "../Base/Lazy"
import { Button } from "../Base/Button"
interface NoteProperties {
"id": number,
"url": string,
"date_created": string,
closed_at?: string,
"status": "open" | "closed",
"comments": {
date: string,
uid: number,
user: string,
text: string,
id: number
url: string
date_created: string
closed_at?: string
status: "open" | "closed"
comments: {
date: string
uid: number
user: string
text: string
html: string
}[]
}
interface NoteState {
props: NoteProperties,
theme: string,
intro: string,
dateStr: string,
status: "imported" | "already_mapped" | "invalid" | "closed" | "not_found" | "open" | "has_comments"
props: NoteProperties
theme: string
intro: string
dateStr: string
status:
| "imported"
| "already_mapped"
| "invalid"
| "closed"
| "not_found"
| "open"
| "has_comments"
}
class DownloadStatisticsButton extends SubtleButton {
constructor(states: NoteState[][]) {
super(Svg.statistics_svg(), "Download statistics");
super(Svg.statistics_svg(), "Download statistics")
this.onClick(() => {
const st: NoteState[] = [].concat(...states)
const fields = [
@ -61,26 +67,27 @@ class DownloadStatisticsButton extends SubtleButton {
"date_closed",
"days_open",
"intro",
"...comments"
"...comments",
]
const values: string[][] = st.map(note => {
return [note.props.id + "",
const values: string[][] = st.map((note) => {
return [
note.props.id + "",
note.status,
note.theme,
note.props.date_created?.substr(0, note.props.date_created.length - 3),
note.props.closed_at?.substr(0, note.props.closed_at.length - 3) ?? "",
JSON.stringify(note.intro),
...note.props.comments.map(c => JSON.stringify(c.user) + ": " + JSON.stringify(c.text))
...note.props.comments.map(
(c) => JSON.stringify(c.user) + ": " + JSON.stringify(c.text)
),
]
})
Utils.offerContentsAsDownloadableFile(
[fields, ...values].map(c => c.join(", ")).join("\n"),
[fields, ...values].map((c) => c.join(", ")).join("\n"),
"mapcomplete_import_notes_overview.csv",
{
mimetype: "text/csv"
mimetype: "text/csv",
}
)
})
@ -92,32 +99,32 @@ class MassAction extends Combine {
const textField = ValidatedTextField.ForType("text").ConstructInputElement()
const actions = new DropDown<{
predicate: (p: NoteProperties) => boolean,
predicate: (p: NoteProperties) => boolean
action: (p: NoteProperties) => Promise<void>
}>("On which notes should an action be performed?", [
{
value: undefined,
shown: <string | BaseUIElement>"Pick an option..."
shown: <string | BaseUIElement>"Pick an option...",
},
{
value: {
predicate: p => p.status === "open",
action: async p => {
predicate: (p) => p.status === "open",
action: async (p) => {
const txt = textField.GetValue().data
state.osmConnection.closeNote(p.id, txt)
}
},
},
shown: "Add comment to every open note and close all notes"
shown: "Add comment to every open note and close all notes",
},
{
value: {
predicate: p => p.status === "open",
action: async p => {
predicate: (p) => p.status === "open",
action: async (p) => {
const txt = textField.GetValue().data
state.osmConnection.addCommentToNote(p.id, txt)
}
},
},
shown: "Add comment to every open note"
shown: "Add comment to every open note",
},
/*
{
@ -131,25 +138,22 @@ class MassAction extends Combine {
},
shown:"On every open note, read the 'note='-tag and and this note as comment. (This action ignores the textfield)"
},//*/
])
const handledNotesCounter = new UIEventSource<number>(undefined)
const apply = new SubtleButton(Svg.checkmark_svg(), "Apply action")
.onClick(async () => {
const {predicate, action} = actions.GetValue().data
for (let i = 0; i < props.length; i++) {
handledNotesCounter.setData(i)
const prop = props[i]
if (!predicate(prop)) {
continue
}
await action(prop)
const apply = new SubtleButton(Svg.checkmark_svg(), "Apply action").onClick(async () => {
const { predicate, action } = actions.GetValue().data
for (let i = 0; i < props.length; i++) {
handledNotesCounter.setData(i)
const prop = props[i]
if (!predicate(prop)) {
continue
}
handledNotesCounter.setData(props.length)
})
await action(prop)
}
handledNotesCounter.setData(props.length)
})
super([
actions,
textField.SetClass("w-full border border-black"),
new Toggle(
@ -157,37 +161,57 @@ class MassAction extends Combine {
apply,
new Toggle(
new Loading(new VariableUiElement(handledNotesCounter.map(state => {
if (state === props.length) {
return "All done!"
}
return "Handling note " + (state + 1) + " out of " + props.length;
}))),
new Combine([Svg.checkmark_svg().SetClass("h-8"), "All done!"]).SetClass("thanks flex p-4"),
handledNotesCounter.map(s => s < props.length)
new Loading(
new VariableUiElement(
handledNotesCounter.map((state) => {
if (state === props.length) {
return "All done!"
}
return (
"Handling note " + (state + 1) + " out of " + props.length
)
})
)
),
new Combine([Svg.checkmark_svg().SetClass("h-8"), "All done!"]).SetClass(
"thanks flex p-4"
),
handledNotesCounter.map((s) => s < props.length)
),
handledNotesCounter.map(s => s === undefined)
)
handledNotesCounter.map((s) => s === undefined)
),
, new VariableUiElement(textField.GetValue().map(txt => "Type a text of at least 15 characters to apply the action. Currently, there are " + (txt?.length ?? 0) + " characters")).SetClass("alert"),
actions.GetValue().map(v => v !== undefined && textField.GetValue()?.data?.length > 15, [textField.GetValue()])
new VariableUiElement(
textField
.GetValue()
.map(
(txt) =>
"Type a text of at least 15 characters to apply the action. Currently, there are " +
(txt?.length ?? 0) +
" characters"
)
).SetClass("alert"),
actions
.GetValue()
.map(
(v) => v !== undefined && textField.GetValue()?.data?.length > 15,
[textField.GetValue()]
)
),
new Toggle(
new FixedUiElement("Testmode enable").SetClass("alert"), undefined,
new FixedUiElement("Testmode enable").SetClass("alert"),
undefined,
state.featureSwitchIsTesting
)
]);
),
])
}
}
class NoteTable extends Combine {
private static individualActions: [() => BaseUIElement, string][] = [
[Svg.not_found_svg, "This feature does not exist"],
[Svg.addSmall_svg, "imported"],
[Svg.duplicate_svg, "Already mapped"]
[Svg.duplicate_svg, "Already mapped"],
]
constructor(noteStates: NoteState[], state?: UserRelatedState) {
@ -195,18 +219,21 @@ class NoteTable extends Combine {
const table = new Table(
["id", "status", "last comment", "last modified by", "actions"],
noteStates.map(ns => NoteTable.noteField(ns, state)),
{sortable: true}
).SetClass("zebra-table link-underline");
noteStates.map((ns) => NoteTable.noteField(ns, state)),
{ sortable: true }
).SetClass("zebra-table link-underline")
super([
new Title("Mass apply an action on " + noteStates.length + " notes below"),
state !== undefined ? new MassAction(state, noteStates.map(ns => ns.props)).SetClass("block") : undefined,
state !== undefined
? new MassAction(
state,
noteStates.map((ns) => ns.props)
).SetClass("block")
: undefined,
table,
new Title("Example note", 4),
new FixedUiElement(typicalComment).SetClass("literal-code link-underline"),
])
this.SetClass("flex flex-col")
}
@ -214,9 +241,10 @@ class NoteTable extends Combine {
private static noteField(ns: NoteState, state: UserRelatedState) {
const link = new Link(
"" + ns.props.id,
"https://openstreetmap.org/note/" + ns.props.id, true
"https://openstreetmap.org/note/" + ns.props.id,
true
)
let last_comment = "";
let last_comment = ""
const last_comment_props = ns.props.comments[ns.props.comments.length - 1]
const before_last_comment = ns.props.comments[ns.props.comments.length - 2]
if (ns.props.comments.length > 1) {
@ -226,41 +254,56 @@ class NoteTable extends Combine {
}
}
const statusIcon = BatchView.icons[ns.status]().SetClass("h-4 w-4 shrink-0")
const togglestate = new UIEventSource(false);
const changed = new UIEventSource<string>(undefined);
const lazyButtons = new Lazy(( ) => new Combine(
this.individualActions.map(([img, text]) =>
img().onClick(async () => {
if (ns.props.status === "closed") {
await state.osmConnection.reopenNote(ns.props.id)
}
await state.osmConnection.closeNote(ns.props.id, text)
changed.setData(text)
}).SetClass("h-8 w-8"))
).SetClass("flex"));
const appliedButtons = new VariableUiElement(changed.map(currentState => currentState === undefined ? lazyButtons : currentState));
const buttons = Toggle.If(state?.osmConnection?.isLoggedIn,
() => new ClickableToggle(
appliedButtons,
new Button("edit...", () => {
console.log("Enabling...")
togglestate.setData(true);
}),
togglestate
));
return [link, new Combine([statusIcon, ns.status]).SetClass("flex"), last_comment,
new Link(last_comment_props.user, "https://www.openstreetmap.org/user/" + last_comment_props.user, true),
buttons
const togglestate = new UIEventSource(false)
const changed = new UIEventSource<string>(undefined)
const lazyButtons = new Lazy(() =>
new Combine(
this.individualActions.map(([img, text]) =>
img()
.onClick(async () => {
if (ns.props.status === "closed") {
await state.osmConnection.reopenNote(ns.props.id)
}
await state.osmConnection.closeNote(ns.props.id, text)
changed.setData(text)
})
.SetClass("h-8 w-8")
)
).SetClass("flex")
)
const appliedButtons = new VariableUiElement(
changed.map((currentState) => (currentState === undefined ? lazyButtons : currentState))
)
const buttons = Toggle.If(
state?.osmConnection?.isLoggedIn,
() =>
new ClickableToggle(
appliedButtons,
new Button("edit...", () => {
console.log("Enabling...")
togglestate.setData(true)
}),
togglestate
)
)
return [
link,
new Combine([statusIcon, ns.status]).SetClass("flex"),
last_comment,
new Link(
last_comment_props.user,
"https://www.openstreetmap.org/user/" + last_comment_props.user,
true
),
buttons,
]
}
}
class BatchView extends Toggleable {
public static icons = {
open: Svg.compass_svg,
has_comments: Svg.speech_bubble_svg,
@ -272,10 +315,9 @@ class BatchView extends Toggleable {
}
constructor(noteStates: NoteState[], state?: UserRelatedState) {
noteStates.sort((a, b) => a.props.id - b.props.id)
const {theme, intro, dateStr} = noteStates[0]
const { theme, intro, dateStr } = noteStates[0]
const statusHist = new Map<string, number>()
for (const noteState of noteStates) {
@ -284,109 +326,151 @@ class BatchView extends Toggleable {
statusHist.set(st, c + 1)
}
const unresolvedTotal = (statusHist.get("open") ?? 0) + (statusHist.get("has_comments") ?? 0)
const badges: (BaseUIElement)[] = [
const unresolvedTotal =
(statusHist.get("open") ?? 0) + (statusHist.get("has_comments") ?? 0)
const badges: BaseUIElement[] = [
new FixedUiElement(dateStr).SetClass("literal-code rounded-full"),
new FixedUiElement(noteStates.length + " total").SetClass("literal-code rounded-full ml-1 border-4 border-gray")
new FixedUiElement(noteStates.length + " total")
.SetClass("literal-code rounded-full ml-1 border-4 border-gray")
.onClick(() => filterOn.setData(undefined)),
unresolvedTotal === 0 ?
new Combine([Svg.party_svg().SetClass("h-6 m-1"), "All done!"])
.SetClass("flex ml-1 mb-1 pl-1 pr-3 items-center rounded-full border border-black") :
new FixedUiElement(Math.round(100 - 100 * unresolvedTotal / noteStates.length) + "%").SetClass("literal-code rounded-full ml-1")
unresolvedTotal === 0
? new Combine([Svg.party_svg().SetClass("h-6 m-1"), "All done!"]).SetClass(
"flex ml-1 mb-1 pl-1 pr-3 items-center rounded-full border border-black"
)
: new FixedUiElement(
Math.round(100 - (100 * unresolvedTotal) / noteStates.length) + "%"
).SetClass("literal-code rounded-full ml-1"),
]
const filterOn = new UIEventSource<string>(undefined)
Object.keys(BatchView.icons).forEach(status => {
Object.keys(BatchView.icons).forEach((status) => {
const count = statusHist.get(status)
if (count === undefined) {
return undefined
}
const normal = new Combine([BatchView.icons[status]().SetClass("h-6 m-1"), count + " " + status])
.SetClass("flex ml-1 mb-1 pl-1 pr-3 items-center rounded-full border border-black")
const selected = new Combine([BatchView.icons[status]().SetClass("h-6 m-1"), count + " " + status])
.SetClass("flex ml-1 mb-1 pl-1 pr-3 items-center rounded-full border-4 border-black animate-pulse")
const normal = new Combine([
BatchView.icons[status]().SetClass("h-6 m-1"),
count + " " + status,
]).SetClass("flex ml-1 mb-1 pl-1 pr-3 items-center rounded-full border border-black")
const selected = new Combine([
BatchView.icons[status]().SetClass("h-6 m-1"),
count + " " + status,
]).SetClass(
"flex ml-1 mb-1 pl-1 pr-3 items-center rounded-full border-4 border-black animate-pulse"
)
const toggle = new ClickableToggle(selected, normal, filterOn.sync(f => f === status, [], (selected, previous) => {
if (selected) {
return status;
}
if (previous === status) {
return undefined
}
return previous
})).ToggleOnClick()
const toggle = new ClickableToggle(
selected,
normal,
filterOn.sync(
(f) => f === status,
[],
(selected, previous) => {
if (selected) {
return status
}
if (previous === status) {
return undefined
}
return previous
}
)
).ToggleOnClick()
badges.push(toggle)
})
const fullTable = new NoteTable(noteStates, state);
const fullTable = new NoteTable(noteStates, state)
super(
new Combine([
new Title(theme + ": " + intro, 2),
new Combine(badges).SetClass("flex flex-wrap"),
]),
new VariableUiElement(filterOn.map(filter => {
if (filter === undefined) {
return fullTable
}
return new NoteTable(noteStates.filter(ns => ns.status === filter), state)
})),
new VariableUiElement(
filterOn.map((filter) => {
if (filter === undefined) {
return fullTable
}
return new NoteTable(
noteStates.filter((ns) => ns.status === filter),
state
)
})
),
{
closeOnClick: false
})
closeOnClick: false,
}
)
}
}
class ImportInspector extends VariableUiElement {
constructor(userDetails: { uid: number } | { display_name: string, search?: string }, state: UserRelatedState) {
let url;
constructor(
userDetails: { uid: number } | { display_name: string; search?: string },
state: UserRelatedState
) {
let url
if (userDetails["uid"] !== undefined) {
url = "https://api.openstreetmap.org/api/0.6/notes/search.json?user=" + userDetails["uid"] + "&closed=730&limit=10000&sort=created_at&q=%23import"
url =
"https://api.openstreetmap.org/api/0.6/notes/search.json?user=" +
userDetails["uid"] +
"&closed=730&limit=10000&sort=created_at&q=%23import"
} else {
url = "https://api.openstreetmap.org/api/0.6/notes/search.json?display_name=" +
encodeURIComponent(userDetails["display_name"]) + "&limit=10000&closed=730&sort=created_at&q=" + encodeURIComponent(userDetails["search"] ?? "#import")
url =
"https://api.openstreetmap.org/api/0.6/notes/search.json?display_name=" +
encodeURIComponent(userDetails["display_name"]) +
"&limit=10000&closed=730&sort=created_at&q=" +
encodeURIComponent(userDetails["search"] ?? "#import")
}
const notes: UIEventSource<
{ error: string } | { success: { features: { properties: NoteProperties }[] } }
> = UIEventSource.FromPromiseWithErr(Utils.downloadJson(url))
super(
notes.map((notes) => {
if (notes === undefined) {
return new Loading("Loading notes which mention '#import'")
}
if (notes["error"] !== undefined) {
return new FixedUiElement("Something went wrong: " + notes["error"]).SetClass(
"alert"
)
}
// We only care about the properties here
const props: NoteProperties[] = notes["success"].features.map((f) => f.properties)
const perBatch: NoteState[][] = Array.from(
ImportInspector.SplitNotesIntoBatches(props).values()
)
const els: Toggleable[] = perBatch.map(
(noteStates) => new BatchView(noteStates, state)
)
const notes: UIEventSource<{ error: string } | { success: { features: { properties: NoteProperties }[] } }> = UIEventSource.FromPromiseWithErr(Utils.downloadJson(url))
super(notes.map(notes => {
if (notes === undefined) {
return new Loading("Loading notes which mention '#import'")
}
if (notes["error"] !== undefined) {
return new FixedUiElement("Something went wrong: " + notes["error"]).SetClass("alert")
}
// We only care about the properties here
const props: NoteProperties[] = notes["success"].features.map(f => f.properties)
const perBatch: NoteState[][] = Array.from(ImportInspector.SplitNotesIntoBatches(props).values());
const els: Toggleable[] = perBatch.map(noteStates => new BatchView(noteStates, state))
const accordeon = new Accordeon(els)
let contents = [];
if (state?.osmConnection?.isLoggedIn?.data) {
contents =
[
const accordeon = new Accordeon(els)
let contents = []
if (state?.osmConnection?.isLoggedIn?.data) {
contents = [
new Title(Translations.t.importInspector.title, 1),
new SubtleButton(undefined, "Create a new batch of imports", {url: 'import_helper.html'})]
}
contents.push(accordeon)
const content = new Combine(contents)
return new LeftIndex(
[new TableOfContents(content, {noTopLevel: true, maxDepth: 1}).SetClass("subtle"),
new DownloadStatisticsButton(perBatch)
],
content
)
}));
new SubtleButton(undefined, "Create a new batch of imports", {
url: "import_helper.html",
}),
]
}
contents.push(accordeon)
const content = new Combine(contents)
return new LeftIndex(
[
new TableOfContents(content, { noTopLevel: true, maxDepth: 1 }).SetClass(
"subtle"
),
new DownloadStatisticsButton(perBatch),
],
content
)
})
)
}
/**
@ -397,7 +481,7 @@ class ImportInspector extends VariableUiElement {
const prefix = "https://mapcomplete.osm.be/"
for (const prop of props) {
const lines = prop.comments[0].text.split("\n")
const trigger = lines.findIndex(l => l.startsWith(prefix) && l.endsWith("#import"))
const trigger = lines.findIndex((l) => l.startsWith(prefix) && l.endsWith("#import"))
if (trigger < 0) {
continue
}
@ -409,16 +493,30 @@ class ImportInspector extends VariableUiElement {
if (!perBatch.has(key)) {
perBatch.set(key, [])
}
let status: "open" | "closed" | "imported" | "invalid" | "already_mapped" | "not_found" | "has_comments" = "open"
let status:
| "open"
| "closed"
| "imported"
| "invalid"
| "already_mapped"
| "not_found"
| "has_comments" = "open"
if (prop.closed_at !== undefined) {
const lastComment = prop.comments[prop.comments.length - 1].text.toLowerCase()
if (lastComment.indexOf("does not exist") >= 0) {
status = "not_found"
} else if (lastComment.indexOf("already mapped") >= 0) {
status = "already_mapped"
} else if (lastComment.indexOf("invalid") >= 0 || lastComment.indexOf("incorrecto") >= 0) {
} else if (
lastComment.indexOf("invalid") >= 0 ||
lastComment.indexOf("incorrecto") >= 0
) {
status = "invalid"
} else if (["imported", "erbij", "toegevoegd", "added"].some(keyword => lastComment.toLowerCase().indexOf(keyword) >= 0)) {
} else if (
["imported", "erbij", "toegevoegd", "added"].some(
(keyword) => lastComment.toLowerCase().indexOf(keyword) >= 0
)
) {
status = "imported"
} else {
status = "closed"
@ -435,28 +533,41 @@ class ImportInspector extends VariableUiElement {
status,
})
}
return perBatch;
return perBatch
}
}
class ImportViewerGui extends LoginToggle {
constructor() {
const state = new UserRelatedState(undefined)
const displayNameParam = QueryParameters.GetQueryParameter("user", "", "The username of the person whom you want to see the notes for");
const searchParam = QueryParameters.GetQueryParameter("search", "", "A text that should be included in the first comment of the note to be shown")
const displayNameParam = QueryParameters.GetQueryParameter(
"user",
"",
"The username of the person whom you want to see the notes for"
)
const searchParam = QueryParameters.GetQueryParameter(
"search",
"",
"A text that should be included in the first comment of the note to be shown"
)
super(
new VariableUiElement(state.osmConnection.userDetails.map(ud => {
const display_name = displayNameParam.data;
const search = searchParam.data;
if (display_name !== "" && search !== "") {
return new ImportInspector({display_name, search}, undefined);
}
return new ImportInspector(ud, state);
}, [displayNameParam, searchParam])),
"Login to inspect your import flows", state
new VariableUiElement(
state.osmConnection.userDetails.map(
(ud) => {
const display_name = displayNameParam.data
const search = searchParam.data
if (display_name !== "" && search !== "") {
return new ImportInspector({ display_name, search }, undefined)
}
return new ImportInspector(ud, state)
},
[displayNameParam, searchParam]
)
),
"Login to inspect your import flows",
state
)
}
}
new ImportViewerGui().AttachTo("main")
new ImportViewerGui().AttachTo("main")

View file

@ -1,45 +1,43 @@
import Combine from "../Base/Combine";
import {FlowStep} from "./FlowStep";
import {UIEventSource} from "../../Logic/UIEventSource";
import Translations from "../i18n/Translations";
import Title from "../Base/Title";
import {CreateNotes} from "./CreateNotes";
import {FixedUiElement} from "../Base/FixedUiElement";
import Combine from "../Base/Combine"
import { FlowStep } from "./FlowStep"
import { UIEventSource } from "../../Logic/UIEventSource"
import Translations from "../i18n/Translations"
import Title from "../Base/Title"
import { CreateNotes } from "./CreateNotes"
import { FixedUiElement } from "../Base/FixedUiElement"
export default class Introdution extends Combine implements FlowStep<void> {
readonly IsValid: UIEventSource<boolean>;
readonly Value: UIEventSource<void>;
readonly IsValid: UIEventSource<boolean>
readonly Value: UIEventSource<void>
constructor() {
const example = CreateNotes.createNoteContentsUi({
properties:{
"some_key":"some_value",
"note":"a note in the original dataset"
const example = CreateNotes.createNoteContentsUi(
{
properties: {
some_key: "some_value",
note: "a note in the original dataset",
},
geometry: {
coordinates: [3.4, 51.2],
},
},
geometry:{
coordinates: [3.4,51.2]
{
wikilink:
"https://wiki.openstreetmap.org/wiki/Imports/<documentation of your import>",
intro: "There might be an XYZ here",
theme: "theme",
source: "source of the data",
}
}, {
wikilink: "https://wiki.openstreetmap.org/wiki/Imports/<documentation of your import>",
intro: "There might be an XYZ here",
theme: "theme",
source: "source of the data"
}).map(el => el === "" ? new FixedUiElement("").SetClass("block") : el)
).map((el) => (el === "" ? new FixedUiElement("").SetClass("block") : el))
super([
new Title(Translations.t.importHelper.introduction.title),
Translations.t.importHelper.introduction.description,
Translations.t.importHelper.introduction.importFormat,
new Combine(
[new Combine(
example
).SetClass("flex flex-col")
] ).SetClass("literal-code")
]);
new Combine([new Combine(example).SetClass("flex flex-col")]).SetClass("literal-code"),
])
this.SetClass("flex flex-col")
this. IsValid= new UIEventSource<boolean>(true);
this. Value = new UIEventSource<void>(undefined);
this.IsValid = new UIEventSource<boolean>(true)
this.Value = new UIEventSource<void>(undefined)
}
}
}

View file

@ -1,55 +1,74 @@
import Combine from "../Base/Combine";
import {FlowStep} from "./FlowStep";
import UserRelatedState from "../../Logic/State/UserRelatedState";
import {Store, UIEventSource} from "../../Logic/UIEventSource";
import Translations from "../i18n/Translations";
import Title from "../Base/Title";
import {VariableUiElement} from "../Base/VariableUIElement";
import {LoginToggle} from "../Popup/LoginButton";
import Img from "../Base/Img";
import Constants from "../../Models/Constants";
import Toggle from "../Input/Toggle";
import {SubtleButton} from "../Base/SubtleButton";
import Svg from "../../Svg";
import MoreScreen from "../BigComponents/MoreScreen";
import CheckBoxes from "../Input/Checkboxes";
import Combine from "../Base/Combine"
import { FlowStep } from "./FlowStep"
import UserRelatedState from "../../Logic/State/UserRelatedState"
import { Store, UIEventSource } from "../../Logic/UIEventSource"
import Translations from "../i18n/Translations"
import Title from "../Base/Title"
import { VariableUiElement } from "../Base/VariableUIElement"
import { LoginToggle } from "../Popup/LoginButton"
import Img from "../Base/Img"
import Constants from "../../Models/Constants"
import Toggle from "../Input/Toggle"
import { SubtleButton } from "../Base/SubtleButton"
import Svg from "../../Svg"
import MoreScreen from "../BigComponents/MoreScreen"
import CheckBoxes from "../Input/Checkboxes"
export default class LoginToImport extends Combine implements FlowStep<UserRelatedState> {
readonly IsValid: Store<boolean>;
readonly Value: Store<UserRelatedState>;
readonly IsValid: Store<boolean>
readonly Value: Store<UserRelatedState>
private static readonly whitelist = [15015689]
private static readonly whitelist = [15015689];
constructor(state: UserRelatedState) {
const t = Translations.t.importHelper.login
const check = new CheckBoxes([new VariableUiElement(state.osmConnection.userDetails.map(ud => t.loginIsCorrect.Subs(ud)))])
const isValid = state.osmConnection.userDetails.map(ud =>
LoginToImport.whitelist.indexOf(ud.uid) >= 0 || ud.csCount >= Constants.userJourney.importHelperUnlock)
const check = new CheckBoxes([
new VariableUiElement(
state.osmConnection.userDetails.map((ud) => t.loginIsCorrect.Subs(ud))
),
])
const isValid = state.osmConnection.userDetails.map(
(ud) =>
LoginToImport.whitelist.indexOf(ud.uid) >= 0 ||
ud.csCount >= Constants.userJourney.importHelperUnlock
)
super([
new Title(t.userAccountTitle),
new LoginToggle(
new VariableUiElement(state.osmConnection.userDetails.map(ud => {
if (ud === undefined) {
return undefined
}
return new Combine([
new Img(ud.img ?? "./assets/svgs/help.svg").SetClass("w-16 h-16 rounded-full"),
t.loggedInWith.Subs(ud),
new SubtleButton(Svg.logout_svg().SetClass("h-8"), Translations.t.general.logout)
.onClick(() => state.osmConnection.LogOut()),
check
]);
})),
new VariableUiElement(
state.osmConnection.userDetails.map((ud) => {
if (ud === undefined) {
return undefined
}
return new Combine([
new Img(ud.img ?? "./assets/svgs/help.svg").SetClass(
"w-16 h-16 rounded-full"
),
t.loggedInWith.Subs(ud),
new SubtleButton(
Svg.logout_svg().SetClass("h-8"),
Translations.t.general.logout
).onClick(() => state.osmConnection.LogOut()),
check,
])
})
),
t.loginRequired,
state
),
new Toggle(undefined,
new Combine(
[t.lockNotice.Subs(Constants.userJourney).SetClass("alert"),
MoreScreen.CreateProffessionalSerivesButton()])
, isValid)
new Toggle(
undefined,
new Combine([
t.lockNotice.Subs(Constants.userJourney).SetClass("alert"),
MoreScreen.CreateProffessionalSerivesButton(),
]),
isValid
),
])
this.Value = new UIEventSource<UserRelatedState>(state)
this.IsValid = isValid.map(isValid => isValid && check.GetValue().data.length > 0, [check.GetValue()]);
this.IsValid = isValid.map(
(isValid) => isValid && check.GetValue().data.length > 0,
[check.GetValue()]
)
}
}
}

View file

@ -1,82 +1,86 @@
import Combine from "../Base/Combine";
import {Store, UIEventSource} from "../../Logic/UIEventSource";
import {BBox} from "../../Logic/BBox";
import UserRelatedState from "../../Logic/State/UserRelatedState";
import Translations from "../i18n/Translations";
import {AllKnownLayouts} from "../../Customizations/AllKnownLayouts";
import Constants from "../../Models/Constants";
import {DropDown} from "../Input/DropDown";
import {Utils} from "../../Utils";
import LayerConfig from "../../Models/ThemeConfig/LayerConfig";
import BaseLayer from "../../Models/BaseLayer";
import AvailableBaseLayers from "../../Logic/Actors/AvailableBaseLayers";
import Loc from "../../Models/Loc";
import Minimap from "../Base/Minimap";
import Attribution from "../BigComponents/Attribution";
import ShowDataMultiLayer from "../ShowDataLayer/ShowDataMultiLayer";
import FilteredLayer, {FilterState} from "../../Models/FilteredLayer";
import StaticFeatureSource from "../../Logic/FeatureSource/Sources/StaticFeatureSource";
import Toggle from "../Input/Toggle";
import {VariableUiElement} from "../Base/VariableUIElement";
import {FixedUiElement} from "../Base/FixedUiElement";
import {FlowStep} from "./FlowStep";
import ScrollableFullScreen from "../Base/ScrollableFullScreen";
import Title from "../Base/Title";
import CheckBoxes from "../Input/Checkboxes";
import {AllTagsPanel} from "../AllTagsPanel";
import BackgroundMapSwitch from "../BigComponents/BackgroundMapSwitch";
import Combine from "../Base/Combine"
import { Store, UIEventSource } from "../../Logic/UIEventSource"
import { BBox } from "../../Logic/BBox"
import UserRelatedState from "../../Logic/State/UserRelatedState"
import Translations from "../i18n/Translations"
import { AllKnownLayouts } from "../../Customizations/AllKnownLayouts"
import Constants from "../../Models/Constants"
import { DropDown } from "../Input/DropDown"
import { Utils } from "../../Utils"
import LayerConfig from "../../Models/ThemeConfig/LayerConfig"
import BaseLayer from "../../Models/BaseLayer"
import AvailableBaseLayers from "../../Logic/Actors/AvailableBaseLayers"
import Loc from "../../Models/Loc"
import Minimap from "../Base/Minimap"
import Attribution from "../BigComponents/Attribution"
import ShowDataMultiLayer from "../ShowDataLayer/ShowDataMultiLayer"
import FilteredLayer, { FilterState } from "../../Models/FilteredLayer"
import StaticFeatureSource from "../../Logic/FeatureSource/Sources/StaticFeatureSource"
import Toggle from "../Input/Toggle"
import { VariableUiElement } from "../Base/VariableUIElement"
import { FixedUiElement } from "../Base/FixedUiElement"
import { FlowStep } from "./FlowStep"
import ScrollableFullScreen from "../Base/ScrollableFullScreen"
import Title from "../Base/Title"
import CheckBoxes from "../Input/Checkboxes"
import { AllTagsPanel } from "../AllTagsPanel"
import BackgroundMapSwitch from "../BigComponents/BackgroundMapSwitch"
class PreviewPanel extends ScrollableFullScreen {
constructor(tags: UIEventSource<any>) {
super(
_ => new FixedUiElement("Element to import"),
_ => new Combine(["The tags are:",
new AllTagsPanel(tags)
]).SetClass("flex flex-col"),
(_) => new FixedUiElement("Element to import"),
(_) => new Combine(["The tags are:", new AllTagsPanel(tags)]).SetClass("flex flex-col"),
"element"
);
)
}
}
/**
* Shows the data to import on a map, asks for the correct layer to be selected
*/
export class MapPreview extends Combine implements FlowStep<{ bbox: BBox, layer: LayerConfig, features: any[] }> {
public readonly IsValid: Store<boolean>;
public readonly Value: Store<{ bbox: BBox, layer: LayerConfig, features: any[] }>
export class MapPreview
extends Combine
implements FlowStep<{ bbox: BBox; layer: LayerConfig; features: any[] }>
{
public readonly IsValid: Store<boolean>
public readonly Value: Store<{ bbox: BBox; layer: LayerConfig; features: any[] }>
constructor(
state: UserRelatedState,
geojson: { features: { properties: any, geometry: { coordinates: [number, number] } }[] }) {
const t = Translations.t.importHelper.mapPreview;
geojson: { features: { properties: any; geometry: { coordinates: [number, number] } }[] }
) {
const t = Translations.t.importHelper.mapPreview
const propertyKeys = new Set<string>()
for (const f of geojson.features) {
Object.keys(f.properties).forEach(key => propertyKeys.add(key))
Object.keys(f.properties).forEach((key) => propertyKeys.add(key))
}
const availableLayers = AllKnownLayouts.AllPublicLayers().filter(l => l.name !== undefined && Constants.priviliged_layers.indexOf(l.id) < 0)
const layerPicker = new DropDown(t.selectLayer,
[{shown: t.selectLayer, value: undefined}].concat(availableLayers.map(l => ({
shown: l.name,
value: l
})))
const availableLayers = AllKnownLayouts.AllPublicLayers().filter(
(l) => l.name !== undefined && Constants.priviliged_layers.indexOf(l.id) < 0
)
const layerPicker = new DropDown(
t.selectLayer,
[{ shown: t.selectLayer, value: undefined }].concat(
availableLayers.map((l) => ({
shown: l.name,
value: l,
}))
)
)
let autodetected = new UIEventSource(false)
for (const layer of availableLayers) {
const mismatched = geojson.features.some(f =>
!layer.source.osmTags.matchesProperties(f.properties)
const mismatched = geojson.features.some(
(f) => !layer.source.osmTags.matchesProperties(f.properties)
)
if (!mismatched) {
console.log("Autodected layer", layer.id)
layerPicker.GetValue().setData(layer);
layerPicker.GetValue().addCallback(_ => autodetected.setData(false))
layerPicker.GetValue().setData(layer)
layerPicker.GetValue().addCallback((_) => autodetected.setData(false))
autodetected.setData(true)
break;
break
}
}
@ -86,65 +90,84 @@ export class MapPreview extends Combine implements FlowStep<{ bbox: BBox, layer:
return copy
})
const matching: Store<{ properties: any, geometry: { coordinates: [number, number] } }[]> = layerPicker.GetValue().map((layer: LayerConfig) => {
if (layer === undefined) {
return [];
}
const matching: { properties: any, geometry: { coordinates: [number, number] } }[] = []
for (const feature of withId) {
if (layer.source.osmTags.matchesProperties(feature.properties)) {
matching.push(feature)
const matching: Store<{ properties: any; geometry: { coordinates: [number, number] } }[]> =
layerPicker.GetValue().map((layer: LayerConfig) => {
if (layer === undefined) {
return []
}
}
const matching: { properties: any; geometry: { coordinates: [number, number] } }[] =
[]
return matching
})
for (const feature of withId) {
if (layer.source.osmTags.matchesProperties(feature.properties)) {
matching.push(feature)
}
}
return matching
})
const background = new UIEventSource<BaseLayer>(AvailableBaseLayers.osmCarto)
const location = new UIEventSource<Loc>({lat: 0, lon: 0, zoom: 1})
const location = new UIEventSource<Loc>({ lat: 0, lon: 0, zoom: 1 })
const currentBounds = new UIEventSource<BBox>(undefined)
const map = Minimap.createMiniMap({
allowMoving: true,
location,
background,
bounds: currentBounds,
attribution: new Attribution(location, state.osmConnection.userDetails, undefined, currentBounds)
attribution: new Attribution(
location,
state.osmConnection.userDetails,
undefined,
currentBounds
),
})
const layerControl = new BackgroundMapSwitch( {
backgroundLayer: background,
locationControl: location
},background)
const layerControl = new BackgroundMapSwitch(
{
backgroundLayer: background,
locationControl: location,
},
background
)
map.SetClass("w-full").SetStyle("height: 500px")
new ShowDataMultiLayer({
layers: new UIEventSource<FilteredLayer[]>(AllKnownLayouts.AllPublicLayers()
.filter(l => l.source.geojsonSource === undefined)
.map(l => ({
layerDef: l,
isDisplayed: new UIEventSource<boolean>(true),
appliedFilters: new UIEventSource<Map<string, FilterState>>(undefined)
}))),
layers: new UIEventSource<FilteredLayer[]>(
AllKnownLayouts.AllPublicLayers()
.filter((l) => l.source.geojsonSource === undefined)
.map((l) => ({
layerDef: l,
isDisplayed: new UIEventSource<boolean>(true),
appliedFilters: new UIEventSource<Map<string, FilterState>>(undefined),
}))
),
zoomToFeatures: true,
features: StaticFeatureSource.fromDateless(matching.map(features => features.map(feature => ({feature})))),
features: StaticFeatureSource.fromDateless(
matching.map((features) => features.map((feature) => ({ feature })))
),
leafletMap: map.leafletMap,
popup: (tag) => new PreviewPanel(tag).SetClass("font-lg")
popup: (tag) => new PreviewPanel(tag).SetClass("font-lg"),
})
var bbox = matching.map(feats => BBox.bboxAroundAll(feats.map(f => new BBox([f.geometry.coordinates]))))
var bbox = matching.map((feats) =>
BBox.bboxAroundAll(feats.map((f) => new BBox([f.geometry.coordinates])))
)
const mismatchIndicator = new VariableUiElement(
matching.map((matching) => {
if (matching === undefined) {
return undefined
}
const diff = geojson.features.length - matching.length
if (diff === 0) {
return undefined
}
const obligatory = layerPicker
.GetValue()
.data?.source?.osmTags?.asHumanString(false, false, {})
return t.mismatch.Subs({ count: diff, tags: obligatory }).SetClass("alert")
})
)
const mismatchIndicator = new VariableUiElement(matching.map(matching => {
if (matching === undefined) {
return undefined
}
const diff = geojson.features.length - matching.length;
if (diff === 0) {
return undefined
}
const obligatory = layerPicker.GetValue().data?.source?.osmTags?.asHumanString(false, false, {});
return t.mismatch.Subs({count: diff, tags: obligatory}).SetClass("alert")
}))
const confirm = new CheckBoxes([t.confirm]);
const confirm = new CheckBoxes([t.confirm])
super([
new Title(t.title, 1),
layerPicker,
@ -153,26 +176,30 @@ export class MapPreview extends Combine implements FlowStep<{ bbox: BBox, layer:
mismatchIndicator,
map,
layerControl,
confirm
]);
confirm,
])
this.Value = bbox.map(bbox =>
({
this.Value = bbox.map(
(bbox) => ({
bbox,
features: geojson.features,
layer: layerPicker.GetValue().data
}), [layerPicker.GetValue()])
this.IsValid = matching.map(matching => {
if (matching === undefined) {
return false
}
if (confirm.GetValue().data.length !== 1) {
return false
}
const diff = geojson.features.length - matching.length;
return diff === 0;
}, [confirm.GetValue()])
layer: layerPicker.GetValue().data,
}),
[layerPicker.GetValue()]
)
this.IsValid = matching.map(
(matching) => {
if (matching === undefined) {
return false
}
if (confirm.GetValue().data.length !== 1) {
return false
}
const diff = geojson.features.length - matching.length
return diff === 0
},
[confirm.GetValue()]
)
}
}
}

View file

@ -1,47 +1,53 @@
import Combine from "../Base/Combine";
import {Store, UIEventSource} from "../../Logic/UIEventSource";
import UserRelatedState from "../../Logic/State/UserRelatedState";
import Translations from "../i18n/Translations";
import {Utils} from "../../Utils";
import {FlowStep} from "./FlowStep";
import Title from "../Base/Title";
import BaseUIElement from "../BaseUIElement";
import Histogram from "../BigComponents/Histogram";
import Toggleable from "../Base/Toggleable";
import List from "../Base/List";
import CheckBoxes from "../Input/Checkboxes";
import Combine from "../Base/Combine"
import { Store, UIEventSource } from "../../Logic/UIEventSource"
import UserRelatedState from "../../Logic/State/UserRelatedState"
import Translations from "../i18n/Translations"
import { Utils } from "../../Utils"
import { FlowStep } from "./FlowStep"
import Title from "../Base/Title"
import BaseUIElement from "../BaseUIElement"
import Histogram from "../BigComponents/Histogram"
import Toggleable from "../Base/Toggleable"
import List from "../Base/List"
import CheckBoxes from "../Input/Checkboxes"
/**
* Shows the attributes by value, requests to check them of
*/
export class PreviewAttributesPanel extends Combine implements FlowStep<{ features: { properties: any, geometry: { coordinates: [number, number] } }[] }> {
public readonly IsValid: Store<boolean>;
public readonly Value: Store<{ features: { properties: any, geometry: { coordinates: [number, number] } }[] }>
export class PreviewAttributesPanel
extends Combine
implements
FlowStep<{ features: { properties: any; geometry: { coordinates: [number, number] } }[] }>
{
public readonly IsValid: Store<boolean>
public readonly Value: Store<{
features: { properties: any; geometry: { coordinates: [number, number] } }[]
}>
constructor(
state: UserRelatedState,
geojson: { features: { properties: any, geometry: { coordinates: [number, number] } }[] }) {
const t = Translations.t.importHelper.previewAttributes;
geojson: { features: { properties: any; geometry: { coordinates: [number, number] } }[] }
) {
const t = Translations.t.importHelper.previewAttributes
const propertyKeys = new Set<string>()
for (const f of geojson.features) {
Object.keys(f.properties).forEach(key => propertyKeys.add(key))
Object.keys(f.properties).forEach((key) => propertyKeys.add(key))
}
const attributeOverview: BaseUIElement[] = []
const n = geojson.features.length;
const n = geojson.features.length
for (const key of Array.from(propertyKeys)) {
const values = Utils.NoNull(geojson.features.map(f => f.properties[key]))
const allSame = !values.some(v => v !== values[0])
const values = Utils.NoNull(geojson.features.map((f) => f.properties[key]))
const allSame = !values.some((v) => v !== values[0])
let countSummary: BaseUIElement
if (values.length === n) {
countSummary = t.allAttributesSame
} else {
countSummary = t.someHaveSame.Subs({
count: values.length,
percentage: Math.floor(100 * values.length / n)
percentage: Math.floor((100 * values.length) / n),
})
}
if (allSame) {
@ -54,25 +60,16 @@ export class PreviewAttributesPanel extends Combine implements FlowStep<{ featur
if (uniqueCount !== values.length && uniqueCount < 15) {
attributeOverview.push()
// There are some overlapping values: histogram time!
let hist: BaseUIElement =
new Combine([
countSummary,
new Histogram(
new UIEventSource<string[]>(values),
"Value",
"Occurence",
{
sortMode: "count-rev"
})
]).SetClass("flex flex-col")
let hist: BaseUIElement = new Combine([
countSummary,
new Histogram(new UIEventSource<string[]>(values), "Value", "Occurence", {
sortMode: "count-rev",
}),
]).SetClass("flex flex-col")
const title = new Title(key + "=*")
if (uniqueCount > 15) {
hist = new Toggleable(title,
hist.SetClass("block")
).Collapse()
hist = new Toggleable(title, hist.SetClass("block")).Collapse()
} else {
attributeOverview.push(title)
}
@ -82,27 +79,23 @@ export class PreviewAttributesPanel extends Combine implements FlowStep<{ featur
}
// All values are different or too much unique values, we add a boring (but collapsable) list
attributeOverview.push(new Toggleable(
new Title(key + "=*"),
new Combine([
countSummary,
new List(values)
])
))
attributeOverview.push(
new Toggleable(new Title(key + "=*"), new Combine([countSummary, new List(values)]))
)
}
const confirm = new CheckBoxes([t.inspectLooksCorrect])
super([
new Title(t.inspectDataTitle.Subs({count: geojson.features.length})),
new Title(t.inspectDataTitle.Subs({ count: geojson.features.length })),
"Extra remark: An attribute with 'source' or 'src' will be added as 'source' into the map pin; an attribute 'note' will be added into the map pin as well. These values won't be imported",
...attributeOverview,
confirm
]);
this.Value = new UIEventSource<{ features: { properties: any; geometry: { coordinates: [number, number] } }[] }>(geojson)
this.IsValid = confirm.GetValue().map(selected => selected.length == 1)
confirm,
])
this.Value = new UIEventSource<{
features: { properties: any; geometry: { coordinates: [number, number] } }[]
}>(geojson)
this.IsValid = confirm.GetValue().map((selected) => selected.length == 1)
}
}
}

View file

@ -1,33 +1,33 @@
import Combine from "../Base/Combine";
import {Store, Stores} from "../../Logic/UIEventSource";
import Translations from "../i18n/Translations";
import {SubtleButton} from "../Base/SubtleButton";
import {VariableUiElement} from "../Base/VariableUIElement";
import Title from "../Base/Title";
import InputElementMap from "../Input/InputElementMap";
import BaseUIElement from "../BaseUIElement";
import FileSelectorButton from "../Input/FileSelectorButton";
import {FlowStep} from "./FlowStep";
import {parse} from "papaparse";
import {FixedUiElement} from "../Base/FixedUiElement";
import {TagUtils} from "../../Logic/Tags/TagUtils";
import Combine from "../Base/Combine"
import { Store, Stores } from "../../Logic/UIEventSource"
import Translations from "../i18n/Translations"
import { SubtleButton } from "../Base/SubtleButton"
import { VariableUiElement } from "../Base/VariableUIElement"
import Title from "../Base/Title"
import InputElementMap from "../Input/InputElementMap"
import BaseUIElement from "../BaseUIElement"
import FileSelectorButton from "../Input/FileSelectorButton"
import { FlowStep } from "./FlowStep"
import { parse } from "papaparse"
import { FixedUiElement } from "../Base/FixedUiElement"
import { TagUtils } from "../../Logic/Tags/TagUtils"
class FileSelector extends InputElementMap<FileList, { name: string, contents: Promise<string> }> {
class FileSelector extends InputElementMap<FileList, { name: string; contents: Promise<string> }> {
constructor(label: BaseUIElement) {
super(
new FileSelectorButton(label, {allowMultiple: false, acceptType: "*"}),
new FileSelectorButton(label, { allowMultiple: false, acceptType: "*" }),
(x0, x1) => {
// Total hack: x1 is undefined is the backvalue - we effectively make this a one-way-story
return x1 === undefined || x0 === x1;
return x1 === undefined || x0 === x1
},
filelist => {
(filelist) => {
if (filelist === undefined) {
return undefined
}
const file = filelist.item(0)
return {name: file.name, contents: file.text()}
return { name: file.name, contents: file.text() }
},
_ => undefined
(_) => undefined
)
}
}
@ -35,149 +35,153 @@ class FileSelector extends InputElementMap<FileList, { name: string, contents: P
/**
* The first step in the import flow: load a file and validate that it is a correct geojson or CSV file
*/
export class RequestFile extends Combine implements FlowStep<{features: any[]}> {
export class RequestFile extends Combine implements FlowStep<{ features: any[] }> {
public readonly IsValid: Store<boolean>
/**
* The loaded GeoJSON
*/
public readonly Value: Store<{features: any[]}>
public readonly Value: Store<{ features: any[] }>
constructor() {
const t = Translations.t.importHelper.selectFile;
const t = Translations.t.importHelper.selectFile
const csvSelector = new FileSelector(new SubtleButton(undefined, t.description))
const loadedFiles = new VariableUiElement(csvSelector.GetValue().map(file => {
if (file === undefined) {
return t.noFilesLoaded.SetClass("alert")
}
return t.loadedFilesAre.Subs({file: file.name}).SetClass("thanks")
}))
const loadedFiles = new VariableUiElement(
csvSelector.GetValue().map((file) => {
if (file === undefined) {
return t.noFilesLoaded.SetClass("alert")
}
return t.loadedFilesAre.Subs({ file: file.name }).SetClass("thanks")
})
)
const text = Stores.flatten(
csvSelector.GetValue().map(v => {
csvSelector.GetValue().map((v) => {
if (v === undefined) {
return undefined
}
return Stores.FromPromise(v.contents)
}))
})
)
const asGeoJson: Store<any | { error: string | BaseUIElement }> = text.map((src: string) => {
if (src === undefined) {
return undefined
}
try {
const parsed = JSON.parse(src)
if (parsed["type"] !== "FeatureCollection") {
return {error: t.errNotFeatureCollection}
const asGeoJson: Store<any | { error: string | BaseUIElement }> = text.map(
(src: string) => {
if (src === undefined) {
return undefined
}
if (parsed.features.some(f => f.geometry.type != "Point")) {
return {error: t.errPointsOnly}
}
parsed.features.forEach(f => {
const props = f.properties
for (const key in props) {
if(props[key] === undefined || props[key] === null || props[key] === ""){
delete props[key]
}
if(!TagUtils.isValidKey(key)){
return {error: "Probably an invalid key: "+key}
try {
const parsed = JSON.parse(src)
if (parsed["type"] !== "FeatureCollection") {
return { error: t.errNotFeatureCollection }
}
if (parsed.features.some((f) => f.geometry.type != "Point")) {
return { error: t.errPointsOnly }
}
})
return parsed;
} catch (e) {
// Loading as CSV
var lines: string[][] = <any>parse(src).data;
const header = lines[0]
lines.splice(0, 1)
if (header.indexOf("lat") < 0 || header.indexOf("lon") < 0) {
return {error: t.errNoLatOrLon}
}
if (header.some(h => h.trim() == "")) {
return {error: t.errNoName}
}
if (new Set(header).size !== header.length) {
return {error: t.errDuplicate}
}
const features = []
for (let i = 0; i < lines.length; i++) {
const attrs = lines[i];
if (attrs.length == 0 || (attrs.length == 1 && attrs[0] == "")) {
// empty line
continue
parsed.features.forEach((f) => {
const props = f.properties
for (const key in props) {
if (
props[key] === undefined ||
props[key] === null ||
props[key] === ""
) {
delete props[key]
}
if (!TagUtils.isValidKey(key)) {
return { error: "Probably an invalid key: " + key }
}
}
})
return parsed
} catch (e) {
// Loading as CSV
var lines: string[][] = <any>parse(src).data
const header = lines[0]
lines.splice(0, 1)
if (header.indexOf("lat") < 0 || header.indexOf("lon") < 0) {
return { error: t.errNoLatOrLon }
}
const properties = {}
for (let i = 0; i < header.length; i++) {
const v = attrs[i]
if (v === undefined || v === "") {
if (header.some((h) => h.trim() == "")) {
return { error: t.errNoName }
}
if (new Set(header).size !== header.length) {
return { error: t.errDuplicate }
}
const features = []
for (let i = 0; i < lines.length; i++) {
const attrs = lines[i]
if (attrs.length == 0 || (attrs.length == 1 && attrs[0] == "")) {
// empty line
continue
}
properties[header[i]] = v;
}
const coordinates = [Number(properties["lon"]), Number(properties["lat"])]
delete properties["lat"]
delete properties["lon"]
if (coordinates.some(isNaN)) {
return {error: "A coordinate could not be parsed for line " + (i + 2)}
}
const f = {
type: "Feature",
properties,
geometry: {
type: "Point",
coordinates
const properties = {}
for (let i = 0; i < header.length; i++) {
const v = attrs[i]
if (v === undefined || v === "") {
continue
}
properties[header[i]] = v
}
};
features.push(f)
}
const coordinates = [Number(properties["lon"]), Number(properties["lat"])]
delete properties["lat"]
delete properties["lon"]
if (coordinates.some(isNaN)) {
return { error: "A coordinate could not be parsed for line " + (i + 2) }
}
const f = {
type: "Feature",
properties,
geometry: {
type: "Point",
coordinates,
},
}
features.push(f)
}
return {
type: "FeatureCollection",
features
return {
type: "FeatureCollection",
features,
}
}
}
})
)
const errorIndicator = new VariableUiElement(asGeoJson.map(v => {
if (v === undefined) {
return undefined;
}
if (v?.error === undefined) {
return undefined;
}
let err: BaseUIElement;
if(typeof v.error === "string"){
err = new FixedUiElement(v.error)
}else if(v.error.Clone !== undefined){
err = v.error.Clone()
}else{
err = v.error
}
return err.SetClass("alert");
}))
const errorIndicator = new VariableUiElement(
asGeoJson.map((v) => {
if (v === undefined) {
return undefined
}
if (v?.error === undefined) {
return undefined
}
let err: BaseUIElement
if (typeof v.error === "string") {
err = new FixedUiElement(v.error)
} else if (v.error.Clone !== undefined) {
err = v.error.Clone()
} else {
err = v.error
}
return err.SetClass("alert")
})
)
super([
new Title(t.title, 1),
t.fileFormatDescription,
t.fileFormatDescriptionCsv,
t.fileFormatDescriptionGeoJson,
csvSelector,
loadedFiles,
errorIndicator
]);
errorIndicator,
])
this.SetClass("flex flex-col wi")
this.IsValid = asGeoJson.map(geojson => geojson !== undefined && geojson["error"] === undefined)
this.IsValid = asGeoJson.map(
(geojson) => geojson !== undefined && geojson["error"] === undefined
)
this.Value = asGeoJson
}
}
}

View file

@ -1,156 +1,183 @@
import {FlowStep} from "./FlowStep";
import Combine from "../Base/Combine";
import {Store} from "../../Logic/UIEventSource";
import LayerConfig from "../../Models/ThemeConfig/LayerConfig";
import {InputElement} from "../Input/InputElement";
import {AllKnownLayouts} from "../../Customizations/AllKnownLayouts";
import {FixedInputElement} from "../Input/FixedInputElement";
import Img from "../Base/Img";
import Title from "../Base/Title";
import {RadioButton} from "../Input/RadioButton";
import {And} from "../../Logic/Tags/And";
import {VariableUiElement} from "../Base/VariableUIElement";
import Toggleable from "../Base/Toggleable";
import {BBox} from "../../Logic/BBox";
import BaseUIElement from "../BaseUIElement";
import PresetConfig from "../../Models/ThemeConfig/PresetConfig";
import List from "../Base/List";
import Translations from "../i18n/Translations";
export default class SelectTheme extends Combine implements FlowStep<{
features: any[],
theme: string,
layer: LayerConfig,
bbox: BBox,
}> {
import { FlowStep } from "./FlowStep"
import Combine from "../Base/Combine"
import { Store } from "../../Logic/UIEventSource"
import LayerConfig from "../../Models/ThemeConfig/LayerConfig"
import { InputElement } from "../Input/InputElement"
import { AllKnownLayouts } from "../../Customizations/AllKnownLayouts"
import { FixedInputElement } from "../Input/FixedInputElement"
import Img from "../Base/Img"
import Title from "../Base/Title"
import { RadioButton } from "../Input/RadioButton"
import { And } from "../../Logic/Tags/And"
import { VariableUiElement } from "../Base/VariableUIElement"
import Toggleable from "../Base/Toggleable"
import { BBox } from "../../Logic/BBox"
import BaseUIElement from "../BaseUIElement"
import PresetConfig from "../../Models/ThemeConfig/PresetConfig"
import List from "../Base/List"
import Translations from "../i18n/Translations"
export default class SelectTheme
extends Combine
implements
FlowStep<{
features: any[]
theme: string
layer: LayerConfig
bbox: BBox
}>
{
public readonly Value: Store<{
features: any[],
theme: string,
layer: LayerConfig,
bbox: BBox,
}>;
public readonly IsValid: Store<boolean>;
features: any[]
theme: string
layer: LayerConfig
bbox: BBox
}>
public readonly IsValid: Store<boolean>
constructor(params: ({ features: any[], layer: LayerConfig, bbox: BBox, })) {
constructor(params: { features: any[]; layer: LayerConfig; bbox: BBox }) {
const t = Translations.t.importHelper.selectTheme
let options: InputElement<string>[] = AllKnownLayouts.layoutsList
.filter(th => th.layers.some(l => l.id === params.layer.id))
.filter(th => th.id !== "personal")
.map(th => new FixedInputElement<string>(
new Combine([
new Img(th.icon).SetClass("block h-12 w-12 br-4"),
new Title(th.title)
]).SetClass("flex items-center"),
th.id))
.filter((th) => th.layers.some((l) => l.id === params.layer.id))
.filter((th) => th.id !== "personal")
.map(
(th) =>
new FixedInputElement<string>(
new Combine([
new Img(th.icon).SetClass("block h-12 w-12 br-4"),
new Title(th.title),
]).SetClass("flex items-center"),
th.id
)
)
const themeRadios = new RadioButton<string>(options, {
selectFirstAsDefault: false
selectFirstAsDefault: false,
})
const applicablePresets = themeRadios.GetValue().map(theme => {
const applicablePresets = themeRadios.GetValue().map((theme) => {
if (theme === undefined) {
return []
}
// we get the layer with the correct ID via the actual theme config, as the actual theme might have different presets due to overrides
const themeConfig = AllKnownLayouts.layoutsList.find(th => th.id === theme)
const layer = themeConfig.layers.find(l => l.id === params.layer.id)
const themeConfig = AllKnownLayouts.layoutsList.find((th) => th.id === theme)
const layer = themeConfig.layers.find((l) => l.id === params.layer.id)
return layer.presets
})
const nonMatchedElements = applicablePresets.map(presets => {
const nonMatchedElements = applicablePresets.map((presets) => {
if (presets === undefined || presets.length === 0) {
return undefined
}
return params.features.filter(feat => !presets.some(preset => new And(preset.tags).matchesProperties(feat.properties)))
return params.features.filter(
(feat) =>
!presets.some((preset) =>
new And(preset.tags).matchesProperties(feat.properties)
)
)
})
super([
new Title(t.title),
t.intro,
t.intro,
themeRadios,
new VariableUiElement(applicablePresets.map(applicablePresets => {
if (themeRadios.GetValue().data === undefined) {
return undefined
}
if (applicablePresets === undefined || applicablePresets.length === 0) {
return t.noMatchingPresets.SetClass("alert")
}
}, [themeRadios.GetValue()])),
new VariableUiElement(
applicablePresets.map(
(applicablePresets) => {
if (themeRadios.GetValue().data === undefined) {
return undefined
}
if (applicablePresets === undefined || applicablePresets.length === 0) {
return t.noMatchingPresets.SetClass("alert")
}
},
[themeRadios.GetValue()]
)
),
new VariableUiElement(nonMatchedElements.map(unmatched => SelectTheme.nonMatchedElementsPanel(unmatched, applicablePresets.data), [applicablePresets]))
]);
new VariableUiElement(
nonMatchedElements.map(
(unmatched) =>
SelectTheme.nonMatchedElementsPanel(unmatched, applicablePresets.data),
[applicablePresets]
)
),
])
this.SetClass("flex flex-col")
this.Value = themeRadios.GetValue().map(theme => ({
this.Value = themeRadios.GetValue().map((theme) => ({
features: params.features,
layer: params.layer,
bbox: params.bbox,
theme
theme,
}))
this.IsValid = this.Value.map(obj => {
if (obj === undefined) {
return false;
}
if ([obj.theme, obj.features].some(v => v === undefined)) {
return false;
}
if (applicablePresets.data === undefined || applicablePresets.data.length === 0) {
return false
}
if ((nonMatchedElements.data?.length ?? 0) > 0) {
return false;
}
this.IsValid = this.Value.map(
(obj) => {
if (obj === undefined) {
return false
}
if ([obj.theme, obj.features].some((v) => v === undefined)) {
return false
}
if (applicablePresets.data === undefined || applicablePresets.data.length === 0) {
return false
}
if ((nonMatchedElements.data?.length ?? 0) > 0) {
return false
}
return true;
}, [applicablePresets])
return true
},
[applicablePresets]
)
}
private static nonMatchedElementsPanel(unmatched: any[], applicablePresets: PresetConfig[]): BaseUIElement {
private static nonMatchedElementsPanel(
unmatched: any[],
applicablePresets: PresetConfig[]
): BaseUIElement {
if (unmatched === undefined || unmatched.length === 0) {
return
}
const t = Translations.t.importHelper.selectTheme
const applicablePresetsOverview = applicablePresets.map(preset =>
t.needsTags.Subs(
{title: preset.title,
tags:preset.tags.map(t => t.asHumanString()).join(" & ") })
const t = Translations.t.importHelper.selectTheme
const applicablePresetsOverview = applicablePresets.map((preset) =>
t.needsTags
.Subs({
title: preset.title,
tags: preset.tags.map((t) => t.asHumanString()).join(" & "),
})
.SetClass("thanks")
);
)
const unmatchedPanels: BaseUIElement[] = []
for (const feat of unmatched) {
const parts: BaseUIElement[] = []
parts.push(new Combine(Object.keys(feat.properties).map(k =>
k+"="+feat.properties[k]
)).SetClass("flex flex-col"))
parts.push(
new Combine(
Object.keys(feat.properties).map((k) => k + "=" + feat.properties[k])
).SetClass("flex flex-col")
)
for (const preset of applicablePresets) {
const tags = new And(preset.tags).asChange({})
const missing = []
for (const {k, v} of tags) {
for (const { k, v } of tags) {
if (preset[k] === undefined) {
missing.push(t.missing.Subs({k,v}))
missing.push(t.missing.Subs({ k, v }))
} else if (feat.properties[k] !== v) {
missing.push(t.misMatch.Subs({k, v, properties: feat.properties}))
missing.push(t.misMatch.Subs({ k, v, properties: feat.properties }))
}
}
if (missing.length > 0) {
parts.push(
new Combine([
t.notApplicable.Subs(preset),
new List(missing)
]).SetClass("flex flex-col alert")
new Combine([t.notApplicable.Subs(preset), new List(missing)]).SetClass(
"flex flex-col alert"
)
)
}
}
unmatchedPanels.push(new Combine(parts).SetClass("flex flex-col"))
@ -159,11 +186,7 @@ export default class SelectTheme extends Combine implements FlowStep<{
return new Combine([
t.displayNonMatchingCount.Subs(unmatched).SetClass("alert"),
...applicablePresetsOverview,
new Toggleable(new Title(t.unmatchedTitle),
new Combine(unmatchedPanels))
new Toggleable(new Title(t.unmatchedTitle), new Combine(unmatchedPanels)),
]).SetClass("flex flex-col")
}
}
}

View file

@ -1,18 +1,19 @@
import {InputElement} from "./InputElement";
import {UIEventSource} from "../../Logic/UIEventSource";
import {Utils} from "../../Utils";
import BaseUIElement from "../BaseUIElement";
import InputElementMap from "./InputElementMap";
import { InputElement } from "./InputElement"
import { UIEventSource } from "../../Logic/UIEventSource"
import { Utils } from "../../Utils"
import BaseUIElement from "../BaseUIElement"
import InputElementMap from "./InputElementMap"
import Translations from "../i18n/Translations";
export class CheckBox extends InputElementMap<number[], boolean> {
constructor(el: BaseUIElement , defaultValue?: boolean) {
constructor(el: (BaseUIElement | string), defaultValue?: boolean) {
super(
new CheckBoxes([el]),
new CheckBoxes([Translations.W(el)]),
(x0, x1) => x0 === x1,
t => t.length > 0,
x => x ? [0] : [],
);
if(defaultValue !== undefined){
(t) => t.length > 0,
(x) => (x ? [0] : [])
)
if (defaultValue !== undefined) {
this.GetValue().setData(defaultValue)
}
}
@ -23,94 +24,78 @@ export class CheckBox extends InputElementMap<number[], boolean> {
* The value will contain the indexes of the selected checkboxes
*/
export default class CheckBoxes extends InputElement<number[]> {
private static _nextId = 0;
IsSelected: UIEventSource<boolean> = new UIEventSource<boolean>(false);
private readonly value: UIEventSource<number[]>;
private readonly _elements: BaseUIElement[];
private static _nextId = 0
IsSelected: UIEventSource<boolean> = new UIEventSource<boolean>(false)
private readonly value: UIEventSource<number[]>
private readonly _elements: BaseUIElement[]
constructor(
elements: BaseUIElement[],
value = new UIEventSource<number[]>([])
) {
super();
this.value = value;
this._elements = Utils.NoNull(elements);
this.SetClass("flex flex-col");
constructor(elements: BaseUIElement[], value = new UIEventSource<number[]>([])) {
super()
this.value = value
this._elements = Utils.NoNull(elements)
this.SetClass("flex flex-col")
}
IsValid(ts: number[]): boolean {
return ts !== undefined;
return ts !== undefined
}
GetValue(): UIEventSource<number[]> {
return this.value;
return this.value
}
protected InnerConstructElement(): HTMLElement {
const formTag = document.createElement("form");
const formTag = document.createElement("form")
const value = this.value;
const elements = this._elements;
const value = this.value
const elements = this._elements
for (let i = 0; i < elements.length; i++) {
let inputI = elements[i];
const input = document.createElement("input");
const id = CheckBoxes._nextId;
CheckBoxes._nextId++;
input.id = "checkbox" + id;
let inputI = elements[i]
const input = document.createElement("input")
const id = CheckBoxes._nextId
CheckBoxes._nextId++
input.id = "checkbox" + id
input.type = "checkbox";
input.classList.add("p-1", "cursor-pointer", "m-3", "pl-3", "mr-0");
input.type = "checkbox"
input.classList.add("p-1", "cursor-pointer", "m-3", "pl-3", "mr-0")
const label = document.createElement("label");
label.htmlFor = input.id;
label.appendChild(inputI.ConstructElement());
label.classList.add(
"block",
"w-full",
"p-2",
"cursor-pointer",
"bg-red"
);
const label = document.createElement("label")
label.htmlFor = input.id
label.appendChild(inputI.ConstructElement())
label.classList.add("block", "w-full", "p-2", "cursor-pointer", "bg-red")
const wrapper = document.createElement("div");
wrapper.classList.add(
"wrapper",
"flex",
"w-full",
"border",
"border-gray-400",
"mb-1"
);
wrapper.appendChild(input);
wrapper.appendChild(label);
formTag.appendChild(wrapper);
const wrapper = document.createElement("div")
wrapper.classList.add("wrapper", "flex", "w-full", "border", "border-gray-400", "mb-1")
wrapper.appendChild(input)
wrapper.appendChild(label)
formTag.appendChild(wrapper)
value.addCallbackAndRunD((selectedValues) => {
input.checked = selectedValues.indexOf(i) >= 0;
input.checked = selectedValues.indexOf(i) >= 0
if (input.checked) {
wrapper.classList.remove("border-gray-400");
wrapper.classList.add("border-black");
wrapper.classList.remove("border-gray-400")
wrapper.classList.add("border-black")
} else {
wrapper.classList.add("border-gray-400");
wrapper.classList.remove("border-black");
wrapper.classList.add("border-gray-400")
wrapper.classList.remove("border-black")
}
});
})
input.onchange = () => {
// Index = index in the list of already checked items
const index = value.data.indexOf(i);
const index = value.data.indexOf(i)
if (input.checked && index < 0) {
value.data.push(i);
value.ping();
value.data.push(i)
value.ping()
} else if (index >= 0) {
value.data.splice(index, 1);
value.ping();
value.data.splice(index, 1)
value.ping()
}
};
}
}
return formTag;
return formTag
}
}

View file

@ -1,43 +1,39 @@
import {InputElement} from "./InputElement";
import {UIEventSource} from "../../Logic/UIEventSource";
import { InputElement } from "./InputElement"
import { UIEventSource } from "../../Logic/UIEventSource"
export default class ColorPicker extends InputElement<string> {
IsSelected: UIEventSource<boolean> = new UIEventSource<boolean>(false);
IsSelected: UIEventSource<boolean> = new UIEventSource<boolean>(false)
private readonly value: UIEventSource<string>
private readonly _element: HTMLElement
constructor(
value: UIEventSource<string> = new UIEventSource<string>(undefined)
) {
super();
this.value = value;
constructor(value: UIEventSource<string> = new UIEventSource<string>(undefined)) {
super()
this.value = value
const el = document.createElement("input")
this._element = el;
this._element = el
el.type = "color"
this.value.addCallbackAndRunD(v => {
this.value.addCallbackAndRunD((v) => {
el.value = v
});
})
el.oninput = () => {
const hex = el.value;
value.setData(hex);
const hex = el.value
value.setData(hex)
}
}
GetValue(): UIEventSource<string> {
return this.value;
return this.value
}
IsValid(t: string): boolean {
return false;
return false
}
protected InnerConstructElement(): HTMLElement {
return this._element;
return this._element
}
}
}

View file

@ -1,28 +1,30 @@
import {InputElement} from "./InputElement";
import {UIEventSource} from "../../Logic/UIEventSource";
import Combine from "../Base/Combine";
import BaseUIElement from "../BaseUIElement";
import { InputElement } from "./InputElement"
import { UIEventSource } from "../../Logic/UIEventSource"
import Combine from "../Base/Combine"
import BaseUIElement from "../BaseUIElement"
export default class CombinedInputElement<T, J, X> extends InputElement<X> {
private readonly _a: InputElement<T>;
private readonly _b: InputElement<J>;
private readonly _combined: BaseUIElement;
private readonly _a: InputElement<T>
private readonly _b: InputElement<J>
private readonly _combined: BaseUIElement
private readonly _value: UIEventSource<X>
private readonly _split: (x: X) => [T, J];
private readonly _split: (x: X) => [T, J]
constructor(a: InputElement<T>, b: InputElement<J>,
combine: (t: T, j: J) => X,
split: (x: X) => [T, J]) {
super();
this._a = a;
this._b = b;
this._split = split;
this._combined = new Combine([this._a, this._b]);
constructor(
a: InputElement<T>,
b: InputElement<J>,
combine: (t: T, j: J) => X,
split: (x: X) => [T, J]
) {
super()
this._a = a
this._b = b
this._split = split
this._combined = new Combine([this._a, this._b])
this._value = this._a.GetValue().sync(
t => combine(t, this._b?.GetValue()?.data),
(t) => combine(t, this._b?.GetValue()?.data),
[this._b.GetValue()],
x => {
(x) => {
const [t, j] = split(x)
this._b.GetValue()?.setData(j)
return t
@ -31,16 +33,15 @@ export default class CombinedInputElement<T, J, X> extends InputElement<X> {
}
GetValue(): UIEventSource<X> {
return this._value;
return this._value
}
IsValid(x: X): boolean {
const [t, j] = this._split(x)
return this._a.IsValid(t) && this._b.IsValid(j);
return this._a.IsValid(t) && this._b.IsValid(j)
}
protected InnerConstructElement(): HTMLElement {
return this._combined.ConstructElement();
return this._combined.ConstructElement()
}
}
}

View file

@ -1,120 +1,118 @@
import {InputElement} from "./InputElement";
import {UIEventSource} from "../../Logic/UIEventSource";
import Combine from "../Base/Combine";
import Svg from "../../Svg";
import BaseUIElement from "../BaseUIElement";
import {FixedUiElement} from "../Base/FixedUiElement";
import {Utils} from "../../Utils";
import Loc from "../../Models/Loc";
import Minimap from "../Base/Minimap";
import { InputElement } from "./InputElement"
import { UIEventSource } from "../../Logic/UIEventSource"
import Combine from "../Base/Combine"
import Svg from "../../Svg"
import BaseUIElement from "../BaseUIElement"
import { FixedUiElement } from "../Base/FixedUiElement"
import { Utils } from "../../Utils"
import Loc from "../../Models/Loc"
import Minimap from "../Base/Minimap"
/**
* Selects a direction in degrees
*/
export default class DirectionInput extends InputElement<string> {
public readonly IsSelected: UIEventSource<boolean> = new UIEventSource<boolean>(false);
private readonly _location: UIEventSource<Loc>;
private readonly value: UIEventSource<string>;
private background;
public readonly IsSelected: UIEventSource<boolean> = new UIEventSource<boolean>(false)
private readonly _location: UIEventSource<Loc>
private readonly value: UIEventSource<string>
private background
constructor(mapBackground: UIEventSource<any>,
location: UIEventSource<Loc>,
value?: UIEventSource<string>) {
super();
this._location = location;
this.value = value ?? new UIEventSource<string>(undefined);
this.background = mapBackground;
constructor(
mapBackground: UIEventSource<any>,
location: UIEventSource<Loc>,
value?: UIEventSource<string>
) {
super()
this._location = location
this.value = value ?? new UIEventSource<string>(undefined)
this.background = mapBackground
}
GetValue(): UIEventSource<string> {
return this.value;
return this.value
}
IsValid(str: string): boolean {
const t = Number(str);
return !isNaN(t) && t >= 0 && t <= 360;
const t = Number(str)
return !isNaN(t) && t >= 0 && t <= 360
}
protected InnerConstructElement(): HTMLElement {
let map: BaseUIElement = new FixedUiElement("")
if (!Utils.runningFromConsole) {
map = Minimap.createMiniMap({
background: this.background,
allowMoving: false,
location: this._location
location: this._location,
})
}
const element = new Combine([
Svg.direction_stroke_svg().SetStyle(
`position: absolute;top: 0;left: 0;width: 100%;height: 100%;transform:rotate(${this.value.data ?? 0}deg);`)
Svg.direction_stroke_svg()
.SetStyle(
`position: absolute;top: 0;left: 0;width: 100%;height: 100%;transform:rotate(${
this.value.data ?? 0
}deg);`
)
.SetClass("direction-svg relative")
.SetStyle("z-index: 1000"),
map.SetStyle(
`position: absolute;top: 0;left: 0;width: 100%;height: 100%;`)
map.SetStyle(`position: absolute;top: 0;left: 0;width: 100%;height: 100%;`),
])
.SetStyle("width: min(100%, 25em); height: 0; padding-bottom: 100%") // A bit a weird CSS , see https://stackoverflow.com/questions/13851940/pure-css-solution-square-elements#19448481
.SetClass("relative block bg-white border border-black overflow-hidden rounded-full")
.ConstructElement()
this.value.addCallbackAndRunD(rotation => {
this.value.addCallbackAndRunD((rotation) => {
const cone = element.getElementsByClassName("direction-svg")[0] as HTMLElement
cone.style.transform = `rotate(${rotation}deg)`;
cone.style.transform = `rotate(${rotation}deg)`
})
this.RegisterTriggers(element)
element.style.overflow = "hidden"
element.style.display = "block"
return element;
return element
}
private RegisterTriggers(htmlElement: HTMLElement) {
const self = this;
const self = this
function onPosChange(x: number, y: number) {
const rect = htmlElement.getBoundingClientRect();
const dx = -(rect.left + rect.right) / 2 + x;
const dy = (rect.top + rect.bottom) / 2 - y;
const angle = 180 * Math.atan2(dy, dx) / Math.PI;
const angleGeo = Math.floor((450 - angle) % 360);
const rect = htmlElement.getBoundingClientRect()
const dx = -(rect.left + rect.right) / 2 + x
const dy = (rect.top + rect.bottom) / 2 - y
const angle = (180 * Math.atan2(dy, dx)) / Math.PI
const angleGeo = Math.floor((450 - angle) % 360)
self.value.setData("" + angleGeo)
}
htmlElement.ontouchmove = (ev: TouchEvent) => {
onPosChange(ev.touches[0].clientX, ev.touches[0].clientY);
ev.preventDefault();
onPosChange(ev.touches[0].clientX, ev.touches[0].clientY)
ev.preventDefault()
}
htmlElement.ontouchstart = (ev: TouchEvent) => {
onPosChange(ev.touches[0].clientX, ev.touches[0].clientY);
onPosChange(ev.touches[0].clientX, ev.touches[0].clientY)
}
let isDown = false;
let isDown = false
htmlElement.onmousedown = (ev: MouseEvent) => {
isDown = true;
onPosChange(ev.clientX, ev.clientY);
ev.preventDefault();
isDown = true
onPosChange(ev.clientX, ev.clientY)
ev.preventDefault()
}
htmlElement.onmouseup = (ev) => {
isDown = false;
ev.preventDefault();
isDown = false
ev.preventDefault()
}
htmlElement.onmousemove = (ev: MouseEvent) => {
if (isDown) {
onPosChange(ev.clientX, ev.clientY);
onPosChange(ev.clientX, ev.clientY)
}
ev.preventDefault();
ev.preventDefault()
}
}
}
}

View file

@ -1,59 +1,58 @@
import {InputElement} from "./InputElement";
import Translations from "../i18n/Translations";
import {UIEventSource} from "../../Logic/UIEventSource";
import BaseUIElement from "../BaseUIElement";
import { InputElement } from "./InputElement"
import Translations from "../i18n/Translations"
import { UIEventSource } from "../../Logic/UIEventSource"
import BaseUIElement from "../BaseUIElement"
export class DropDown<T> extends InputElement<T> {
private static _nextDropdownId = 0
public IsSelected: UIEventSource<boolean> = new UIEventSource<boolean>(false)
private static _nextDropdownId = 0;
public IsSelected: UIEventSource<boolean> = new UIEventSource<boolean>(false);
private readonly _element: HTMLElement
private readonly _element: HTMLElement;
private readonly _value: UIEventSource<T>;
private readonly _values: { value: T; shown: string | BaseUIElement }[];
private readonly _value: UIEventSource<T>
private readonly _values: { value: T; shown: string | BaseUIElement }[]
/**
*
*
* const dropdown = new DropDown<number>("test",[{value: 42, shown: "the answer"}])
* dropdown.GetValue().data // => 42
*/
constructor(label: string | BaseUIElement,
values: { value: T, shown: string | BaseUIElement }[],
value: UIEventSource<T> = undefined,
options?: {
select_class?: string
}
constructor(
label: string | BaseUIElement,
values: { value: T; shown: string | BaseUIElement }[],
value: UIEventSource<T> = undefined,
options?: {
select_class?: string
}
) {
super();
super()
value = value ?? new UIEventSource<T>(values[0].value)
this._value = value
this._values = values;
this._values = values
if (values.length <= 1) {
return;
return
}
const id = DropDown._nextDropdownId;
DropDown._nextDropdownId++;
const id = DropDown._nextDropdownId
DropDown._nextDropdownId++
const el = document.createElement("form")
this._element = el;
el.id = "dropdown" + id;
this._element = el
el.id = "dropdown" + id
{
const labelEl = Translations.W(label)?.ConstructElement()
if (labelEl !== undefined) {
const labelHtml = document.createElement("label")
labelHtml.appendChild(labelEl)
labelHtml.htmlFor = el.id;
labelHtml.htmlFor = el.id
el.appendChild(labelHtml)
}
}
options = options ?? {}
options.select_class = options.select_class ?? 'w-full bg-indigo-100 p-1 rounded hover:bg-indigo-200'
options.select_class =
options.select_class ?? "w-full bg-indigo-100 p-1 rounded hover:bg-indigo-200"
{
const select = document.createElement("select")
@ -66,42 +65,38 @@ export class DropDown<T> extends InputElement<T> {
}
el.appendChild(select)
select.onchange = () => {
const index = select.selectedIndex
value.setData(values[index].value)
}
select.onchange = (() => {
const index = select.selectedIndex;
value.setData(values[index].value);
});
value.addCallbackAndRun(selected => {
value.addCallbackAndRun((selected) => {
for (let i = 0; i < values.length; i++) {
const value = values[i].value;
const value = values[i].value
if (value === selected) {
select.selectedIndex = i;
select.selectedIndex = i
}
}
})
}
this.onClick(() => {
}) // by registering a click, the click event is consumed and doesn't bubble further to other elements, e.g. checkboxes
this.onClick(() => {}) // by registering a click, the click event is consumed and doesn't bubble further to other elements, e.g. checkboxes
}
GetValue(): UIEventSource<T> {
return this._value;
return this._value
}
IsValid(t: T): boolean {
for (const value of this._values) {
if (value.value === t) {
return true;
return true
}
}
return false
}
protected InnerConstructElement(): HTMLElement {
return this._element;
return this._element
}
}
}

View file

@ -1,54 +1,55 @@
import BaseUIElement from "../BaseUIElement";
import {InputElement} from "./InputElement";
import {UIEventSource} from "../../Logic/UIEventSource";
import BaseUIElement from "../BaseUIElement"
import { InputElement } from "./InputElement"
import { UIEventSource } from "../../Logic/UIEventSource"
export default class FileSelectorButton extends InputElement<FileList> {
private static _nextid
IsSelected: UIEventSource<boolean>
private readonly _value = new UIEventSource<FileList>(undefined)
private readonly _label: BaseUIElement
private readonly _acceptType: string
private readonly allowMultiple: boolean
private static _nextid;
IsSelected: UIEventSource<boolean>;
private readonly _value = new UIEventSource<FileList>(undefined);
private readonly _label: BaseUIElement;
private readonly _acceptType: string;
private readonly allowMultiple: boolean;
constructor(label: BaseUIElement, options?:
{
acceptType: "image/*" | string,
constructor(
label: BaseUIElement,
options?: {
acceptType: "image/*" | string
allowMultiple: true | boolean
}) {
super();
this._label = label;
this._acceptType = options?.acceptType ?? "image/*";
}
) {
super()
this._label = label
this._acceptType = options?.acceptType ?? "image/*"
this.SetClass("block cursor-pointer")
label.SetClass("cursor-pointer")
this.allowMultiple = options?.allowMultiple ?? true
}
GetValue(): UIEventSource<FileList> {
return this._value;
return this._value
}
IsValid(t: FileList): boolean {
return true;
return true
}
protected InnerConstructElement(): HTMLElement {
const self = this;
const self = this
const el = document.createElement("form")
const label = document.createElement("label")
label.appendChild(this._label.ConstructElement())
el.appendChild(label)
const actualInputElement = document.createElement("input");
actualInputElement.style.cssText = "display:none";
actualInputElement.type = "file";
actualInputElement.accept = this._acceptType;
actualInputElement.name = "picField";
actualInputElement.multiple = this.allowMultiple;
actualInputElement.id = "fileselector" + FileSelectorButton._nextid;
FileSelectorButton._nextid++;
const actualInputElement = document.createElement("input")
actualInputElement.style.cssText = "display:none"
actualInputElement.type = "file"
actualInputElement.accept = this._acceptType
actualInputElement.name = "picField"
actualInputElement.multiple = this.allowMultiple
actualInputElement.id = "fileselector" + FileSelectorButton._nextid
FileSelectorButton._nextid++
label.htmlFor = actualInputElement.id;
label.htmlFor = actualInputElement.id
actualInputElement.onchange = () => {
if (actualInputElement.files !== null) {
@ -56,7 +57,7 @@ export default class FileSelectorButton extends InputElement<FileList> {
}
}
el.addEventListener('submit', e => {
el.addEventListener("submit", (e) => {
if (actualInputElement.files !== null) {
self._value.setData(actualInputElement.files)
}
@ -65,22 +66,20 @@ export default class FileSelectorButton extends InputElement<FileList> {
el.appendChild(actualInputElement)
el.addEventListener('dragover', (event) => {
event.stopPropagation();
event.preventDefault();
el.addEventListener("dragover", (event) => {
event.stopPropagation()
event.preventDefault()
// Style the drag-and-drop as a "copy file" operation.
event.dataTransfer.dropEffect = 'copy';
});
event.dataTransfer.dropEffect = "copy"
})
el.addEventListener('drop', (event) => {
event.stopPropagation();
event.preventDefault();
const fileList = event.dataTransfer.files;
el.addEventListener("drop", (event) => {
event.stopPropagation()
event.preventDefault()
const fileList = event.dataTransfer.files
this._value.setData(fileList)
});
})
return el;
return el
}
}
}

View file

@ -1,23 +1,25 @@
import {InputElement} from "./InputElement";
import {UIEventSource} from "../../Logic/UIEventSource";
import Translations from "../i18n/Translations";
import BaseUIElement from "../BaseUIElement";
import { InputElement } from "./InputElement"
import { UIEventSource } from "../../Logic/UIEventSource"
import Translations from "../i18n/Translations"
import BaseUIElement from "../BaseUIElement"
export class FixedInputElement<T> extends InputElement<T> {
private readonly value: UIEventSource<T>;
private readonly _comparator: (t0: T, t1: T) => boolean;
private readonly value: UIEventSource<T>
private readonly _comparator: (t0: T, t1: T) => boolean
private readonly _el: HTMLElement;
private readonly _el: HTMLElement
constructor(rendering: BaseUIElement | string,
value: T | UIEventSource<T>,
comparator: ((t0: T, t1: T) => boolean) = undefined) {
super();
this._comparator = comparator ?? ((t0, t1) => t0 == t1);
constructor(
rendering: BaseUIElement | string,
value: T | UIEventSource<T>,
comparator: (t0: T, t1: T) => boolean = undefined
) {
super()
this._comparator = comparator ?? ((t0, t1) => t0 == t1)
if (value instanceof UIEventSource) {
this.value = value
} else {
this.value = new UIEventSource<T>(value);
this.value = new UIEventSource<T>(value)
}
this._el = document.createElement("span")
@ -25,18 +27,17 @@ export class FixedInputElement<T> extends InputElement<T> {
if (e) {
this._el.appendChild(e)
}
}
GetValue(): UIEventSource<T> {
return this.value;
return this.value
}
IsValid(t: T): boolean {
return this._comparator(t, this.value.data);
return this._comparator(t, this.value.data)
}
protected InnerConstructElement(): HTMLElement {
return this._el;
return this._el
}
}

View file

@ -1,89 +1,94 @@
import {InputElement} from "./InputElement";
import {Store, Stores, UIEventSource} from "../../Logic/UIEventSource";
import Combine from "../Base/Combine";
import Slider from "./Slider";
import {ClickableToggle} from "./Toggle";
import {FixedUiElement} from "../Base/FixedUiElement";
import {VariableUiElement} from "../Base/VariableUIElement";
import BaseUIElement from "../BaseUIElement";
export default class FloorLevelInputElement extends VariableUiElement implements InputElement<string> {
private readonly _value: UIEventSource<string>;
constructor(currentLevels: Store<Record<string, number>>, options?: {
value?: UIEventSource<string>
}) {
import { InputElement } from "./InputElement"
import { Store, Stores, UIEventSource } from "../../Logic/UIEventSource"
import Combine from "../Base/Combine"
import Slider from "./Slider"
import { ClickableToggle } from "./Toggle"
import { FixedUiElement } from "../Base/FixedUiElement"
import { VariableUiElement } from "../Base/VariableUIElement"
import BaseUIElement from "../BaseUIElement"
export default class FloorLevelInputElement
extends VariableUiElement
implements InputElement<string>
{
private readonly _value: UIEventSource<string>
constructor(
currentLevels: Store<Record<string, number>>,
options?: {
value?: UIEventSource<string>
}
) {
const value = options?.value ?? new UIEventSource<string>("0")
super(currentLevels.map(levels => {
super(
currentLevels.map((levels) => {
const allLevels = Object.keys(levels)
allLevels.sort((a, b) => {
const an = Number(a)
const bn = Number(b)
if (isNaN(an) || isNaN(bn)) {
return a < b ? -1 : 1;
return a < b ? -1 : 1
}
return an - bn;
return an - bn
})
return FloorLevelInputElement.constructPicker(allLevels, value)
}
))
})
)
this._value = value
}
private static constructPicker(levels: string[], value: UIEventSource<string>): BaseUIElement {
let slider = new Slider(0, levels.length - 1, {vertical: true});
const toggleClass = "flex border-2 border-blue-500 w-10 h-10 place-content-center items-center border-box"
slider.SetClass("flex elevator w-10").SetStyle(`height: ${2.5 * levels.length}rem; background: #00000000`)
let slider = new Slider(0, levels.length - 1, { vertical: true })
const toggleClass =
"flex border-2 border-blue-500 w-10 h-10 place-content-center items-center border-box"
slider
.SetClass("flex elevator w-10")
.SetStyle(`height: ${2.5 * levels.length}rem; background: #00000000`)
const values = levels.map((data, i) => new ClickableToggle(
new FixedUiElement(data).SetClass("font-bold active bg-subtle " + toggleClass),
new FixedUiElement(data).SetClass("normal-background " + toggleClass),
slider.GetValue().sync(
(sliderVal) => {
return sliderVal === i
},
[],
(isSelected) => {
return isSelected ? i : slider.GetValue().data
}
))
.ToggleOnClick()
.SetClass("flex w-10 h-10"))
const values = levels.map((data, i) =>
new ClickableToggle(
new FixedUiElement(data).SetClass("font-bold active bg-subtle " + toggleClass),
new FixedUiElement(data).SetClass("normal-background " + toggleClass),
slider.GetValue().sync(
(sliderVal) => {
return sliderVal === i
},
[],
(isSelected) => {
return isSelected ? i : slider.GetValue().data
}
)
)
.ToggleOnClick()
.SetClass("flex w-10 h-10")
)
values.reverse(/* This is a new list, no side-effects */)
const combine = new Combine([new Combine(values), slider])
combine.SetClass("flex flex-row overflow-hidden");
combine.SetClass("flex flex-row overflow-hidden")
slider.GetValue().addCallbackD(i => {
slider.GetValue().addCallbackD((i) => {
if (levels === undefined) {
return
}
if(levels[i] == undefined){
if (levels[i] == undefined) {
return
}
value.setData(levels[i]);
value.setData(levels[i])
})
value.addCallbackAndRunD(level => {
const i = levels.findIndex(l => l === level)
value.addCallbackAndRunD((level) => {
const i = levels.findIndex((l) => l === level)
slider.GetValue().setData(i)
})
return combine
}
GetValue(): UIEventSource<string> {
return this._value;
return this._value
}
IsValid(t: string): boolean {
return false;
return false
}
}
}

View file

@ -1,13 +1,12 @@
import {Store, UIEventSource} from "../../Logic/UIEventSource";
import BaseUIElement from "../BaseUIElement";
import { Store, UIEventSource } from "../../Logic/UIEventSource"
import BaseUIElement from "../BaseUIElement"
export interface ReadonlyInputElement<T> extends BaseUIElement{
GetValue(): Store<T>;
IsValid(t: T): boolean;
export interface ReadonlyInputElement<T> extends BaseUIElement {
GetValue(): Store<T>
IsValid(t: T): boolean
}
export abstract class InputElement<T> extends BaseUIElement implements ReadonlyInputElement<any>{
abstract GetValue(): UIEventSource<T>;
abstract IsValid(t: T): boolean;
export abstract class InputElement<T> extends BaseUIElement implements ReadonlyInputElement<any> {
abstract GetValue(): UIEventSource<T>
abstract IsValid(t: T): boolean
}

View file

@ -1,56 +1,58 @@
import {InputElement} from "./InputElement";
import {Store, UIEventSource} from "../../Logic/UIEventSource";
import { InputElement } from "./InputElement"
import { Store, UIEventSource } from "../../Logic/UIEventSource"
export default class InputElementMap<T, X> extends InputElement<X> {
private readonly _inputElement: InputElement<T>;
private isSame: (x0: X, x1: X) => boolean;
private readonly fromX: (x: X) => T;
private readonly toX: (t: T) => X;
private readonly _value: UIEventSource<X>;
private readonly _inputElement: InputElement<T>
private isSame: (x0: X, x1: X) => boolean
private readonly fromX: (x: X) => T
private readonly toX: (t: T) => X
private readonly _value: UIEventSource<X>
constructor(inputElement: InputElement<T>,
isSame: (x0: X, x1: X) => boolean,
toX: (t: T) => X,
fromX: (x: X) => T,
extraSources: Store<any>[] = []
constructor(
inputElement: InputElement<T>,
isSame: (x0: X, x1: X) => boolean,
toX: (t: T) => X,
fromX: (x: X) => T,
extraSources: Store<any>[] = []
) {
super();
this.isSame = isSame;
this.fromX = fromX;
this.toX = toX;
this._inputElement = inputElement;
const self = this;
super()
this.isSame = isSame
this.fromX = fromX
this.toX = toX
this._inputElement = inputElement
const self = this
this._value = inputElement.GetValue().sync(
(t => {
const newX = toX(t);
const currentX = self.GetValue()?.data;
(t) => {
const newX = toX(t)
const currentX = self.GetValue()?.data
if (isSame(currentX, newX)) {
return currentX;
return currentX
}
return newX;
}), extraSources, x => {
return fromX(x);
});
return newX
},
extraSources,
(x) => {
return fromX(x)
}
)
}
GetValue(): UIEventSource<X> {
return this._value;
return this._value
}
IsValid(x: X): boolean {
if (x === undefined) {
return false;
return false
}
const t = this.fromX(x);
const t = this.fromX(x)
if (t === undefined) {
return false;
return false
}
return this._inputElement.IsValid(t);
return this._inputElement.IsValid(t)
}
protected InnerConstructElement(): HTMLElement {
return this._inputElement.ConstructElement();
return this._inputElement.ConstructElement()
}
}
}

View file

@ -1,37 +1,43 @@
import {InputElement} from "./InputElement";
import {UIEventSource} from "../../Logic/UIEventSource";
import BaseUIElement from "../BaseUIElement";
import {Translation} from "../i18n/Translation";
import {SubstitutedTranslation} from "../SubstitutedTranslation";
import FeaturePipelineState from "../../Logic/State/FeaturePipelineState";
import { InputElement } from "./InputElement"
import { UIEventSource } from "../../Logic/UIEventSource"
import BaseUIElement from "../BaseUIElement"
import { Translation } from "../i18n/Translation"
import { SubstitutedTranslation } from "../SubstitutedTranslation"
import FeaturePipelineState from "../../Logic/State/FeaturePipelineState"
export default class InputElementWrapper<T> extends InputElement<T> {
private readonly _inputElement: InputElement<T>;
private readonly _inputElement: InputElement<T>
private readonly _renderElement: BaseUIElement
constructor(inputElement: InputElement<T>, translation: Translation, key: string,
tags: UIEventSource<any>, state: FeaturePipelineState) {
constructor(
inputElement: InputElement<T>,
translation: Translation,
key: string,
tags: UIEventSource<any>,
state: FeaturePipelineState
) {
super()
this._inputElement = inputElement;
this._inputElement = inputElement
const mapping = new Map<string, BaseUIElement>()
mapping.set(key, inputElement)
// Bit of a hack: the SubstitutedTranslation expects a special rendering, but those are formatted '{key()}' instead of '{key}', so we substitute it first
translation = translation.OnEveryLanguage((txt) => txt.replace("{" + key + "}", "{" + key + "()}"))
translation = translation.OnEveryLanguage((txt) =>
txt.replace("{" + key + "}", "{" + key + "()}")
)
this._renderElement = new SubstitutedTranslation(translation, tags, state, mapping)
}
GetValue(): UIEventSource<T> {
return this._inputElement.GetValue();
return this._inputElement.GetValue()
}
IsValid(t: T): boolean {
return this._inputElement.IsValid(t);
return this._inputElement.IsValid(t)
}
protected InnerConstructElement(): HTMLElement {
return this._renderElement.ConstructElement();
return this._renderElement.ConstructElement()
}
}
}

View file

@ -1,47 +1,46 @@
import {InputElement} from "./InputElement";
import {UIEventSource} from "../../Logic/UIEventSource";
import Combine from "../Base/Combine";
import Svg from "../../Svg";
import {Utils} from "../../Utils";
import Loc from "../../Models/Loc";
import {GeoOperations} from "../../Logic/GeoOperations";
import Minimap, {MinimapObj} from "../Base/Minimap";
import BackgroundMapSwitch from "../BigComponents/BackgroundMapSwitch";
import BaseUIElement from "../BaseUIElement";
import { InputElement } from "./InputElement"
import { UIEventSource } from "../../Logic/UIEventSource"
import Combine from "../Base/Combine"
import Svg from "../../Svg"
import { Utils } from "../../Utils"
import Loc from "../../Models/Loc"
import { GeoOperations } from "../../Logic/GeoOperations"
import Minimap, { MinimapObj } from "../Base/Minimap"
import BackgroundMapSwitch from "../BigComponents/BackgroundMapSwitch"
import BaseUIElement from "../BaseUIElement"
/**
* Selects a length after clicking on the minimap, in meters
*/
export default class LengthInput extends InputElement<string> {
private readonly _location: UIEventSource<Loc>;
private readonly value: UIEventSource<string>;
private readonly background: UIEventSource<any>;
private readonly _location: UIEventSource<Loc>
private readonly value: UIEventSource<string>
private readonly background: UIEventSource<any>
constructor(mapBackground: UIEventSource<any>,
location: UIEventSource<Loc>,
value?: UIEventSource<string>) {
super();
this._location = location;
this.value = value ?? new UIEventSource<string>(undefined);
this.background = mapBackground;
constructor(
mapBackground: UIEventSource<any>,
location: UIEventSource<Loc>,
value?: UIEventSource<string>
) {
super()
this._location = location
this.value = value ?? new UIEventSource<string>(undefined)
this.background = mapBackground
this.SetClass("block")
}
GetValue(): UIEventSource<string> {
return this.value;
return this.value
}
IsValid(str: string): boolean {
const t = Number(str)
return !isNaN(t) && t >= 0;
return !isNaN(t) && t >= 0
}
protected InnerConstructElement(): HTMLElement {
let map : (BaseUIElement & MinimapObj) = undefined
let layerControl : BaseUIElement = undefined
let map: BaseUIElement & MinimapObj = undefined
let layerControl: BaseUIElement = undefined
if (!Utils.runningFromConsole) {
map = Minimap.createMiniMap({
background: this.background,
@ -49,139 +48,157 @@ export default class LengthInput extends InputElement<string> {
location: this._location,
attribution: true,
leafletOptions: {
tap: true
tap: true,
},
})
layerControl = new BackgroundMapSwitch(
{
locationControl: this._location,
backgroundLayer: this.background,
},
this.background,
{
allowedCategories: ["map", "photo"],
}
})
layerControl = new BackgroundMapSwitch({
locationControl: this._location,
backgroundLayer: this.background,
}, this.background,{
allowedCategories: ["map","photo"]
})
)
}
const crosshair = new Combine([Svg.length_crosshair_svg().SetStyle(
`position: absolute;top: 0;left: 0;transform:rotate(${this.value.data ?? 0}deg);`)
]) .SetClass("block length-crosshair-svg relative pointer-events-none")
const crosshair = new Combine([
Svg.length_crosshair_svg().SetStyle(
`position: absolute;top: 0;left: 0;transform:rotate(${this.value.data ?? 0}deg);`
),
])
.SetClass("block length-crosshair-svg relative pointer-events-none")
.SetStyle("z-index: 1000; visibility: hidden")
const element = new Combine([
crosshair,
layerControl?.SetStyle("position: absolute; bottom: 0.25rem; left: 0.25rem; z-index: 1000"),
layerControl?.SetStyle(
"position: absolute; bottom: 0.25rem; left: 0.25rem; z-index: 1000"
),
map?.SetClass("w-full h-full block absolute top-0 left-O overflow-hidden"),
])
.SetClass("relative block bg-white border border-black rounded-xl overflow-hidden")
.ConstructElement()
this.RegisterTriggers(map?.ConstructElement(), map?.leafletMap, crosshair.ConstructElement())
this.RegisterTriggers(
map?.ConstructElement(),
map?.leafletMap,
crosshair.ConstructElement()
)
element.style.overflow = "hidden"
element.style.display = "block"
return element
}
private RegisterTriggers(htmlElement: HTMLElement, leafletMap: UIEventSource<L.Map>, measurementCrosshair: HTMLElement) {
private RegisterTriggers(
htmlElement: HTMLElement,
leafletMap: UIEventSource<L.Map>,
measurementCrosshair: HTMLElement
) {
let firstClickXY: [number, number] = undefined
let lastClickXY: [number, number] = undefined
const self = this;
const self = this
function onPosChange(x: number, y: number, isDown: boolean, isUp?: boolean) {
if (x === undefined || y === undefined) {
// Touch end
firstClickXY = undefined;
lastClickXY = undefined;
return;
firstClickXY = undefined
lastClickXY = undefined
return
}
const rect = htmlElement.getBoundingClientRect();
const rect = htmlElement.getBoundingClientRect()
// From the central part of location
const dx = x - rect.left;
const dy = y - rect.top;
const dx = x - rect.left
const dy = y - rect.top
if (isDown) {
if (lastClickXY === undefined && firstClickXY === undefined) {
firstClickXY = [dx, dy];
firstClickXY = [dx, dy]
} else if (firstClickXY !== undefined && lastClickXY === undefined) {
lastClickXY = [dx, dy]
} else if (firstClickXY !== undefined && lastClickXY !== undefined) {
// we measure again
firstClickXY = [dx, dy]
lastClickXY = undefined;
lastClickXY = undefined
}
}
if (firstClickXY === undefined) {
measurementCrosshair.style.visibility = "hidden"
return;
return
}
const distance = Math.sqrt((dy - firstClickXY[1]) * (dy - firstClickXY[1]) + (dx - firstClickXY[0]) * (dx - firstClickXY[0]))
const distance = Math.sqrt(
(dy - firstClickXY[1]) * (dy - firstClickXY[1]) +
(dx - firstClickXY[0]) * (dx - firstClickXY[0])
)
if (isUp) {
if (distance > 15) {
lastClickXY = [dx, dy]
}
} else if (lastClickXY !== undefined) {
return;
return
}
measurementCrosshair.style.visibility = "unset"
measurementCrosshair.style.left = firstClickXY[0] + "px";
measurementCrosshair.style.left = firstClickXY[0] + "px"
measurementCrosshair.style.top = firstClickXY[1] + "px"
const angle = 180 * Math.atan2(firstClickXY[1] - dy, firstClickXY[0] - dx) / Math.PI;
const angle = (180 * Math.atan2(firstClickXY[1] - dy, firstClickXY[0] - dx)) / Math.PI
const angleGeo = (angle + 270) % 360
const measurementCrosshairInner: HTMLElement = <HTMLElement>measurementCrosshair.firstChild
measurementCrosshairInner.style.transform = `rotate(${angleGeo}deg)`;
const measurementCrosshairInner: HTMLElement = <HTMLElement>(
measurementCrosshair.firstChild
)
measurementCrosshairInner.style.transform = `rotate(${angleGeo}deg)`
measurementCrosshairInner.style.width = (distance * 2) + "px"
measurementCrosshairInner.style.width = distance * 2 + "px"
measurementCrosshairInner.style.marginLeft = -distance + "px"
measurementCrosshairInner.style.marginTop = -distance + "px"
const leaflet = leafletMap?.data
if (leaflet) {
const first = leaflet.layerPointToLatLng(firstClickXY)
const last = leaflet.layerPointToLatLng([dx, dy])
const geoDist = Math.floor(GeoOperations.distanceBetween([first.lng, first.lat], [last.lng, last.lat]) * 10) / 10
const geoDist =
Math.floor(
GeoOperations.distanceBetween(
[first.lng, first.lat],
[last.lng, last.lat]
) * 10
) / 10
self.value.setData("" + geoDist)
}
}
htmlElement.ontouchstart = (ev: TouchEvent) => {
onPosChange(ev.touches[0].clientX, ev.touches[0].clientY, true);
ev.preventDefault();
onPosChange(ev.touches[0].clientX, ev.touches[0].clientY, true)
ev.preventDefault()
}
htmlElement.ontouchmove = (ev: TouchEvent) => {
onPosChange(ev.touches[0].clientX, ev.touches[0].clientY, false);
ev.preventDefault();
onPosChange(ev.touches[0].clientX, ev.touches[0].clientY, false)
ev.preventDefault()
}
htmlElement.ontouchend = (ev: TouchEvent) => {
onPosChange(undefined, undefined, false, true);
ev.preventDefault();
onPosChange(undefined, undefined, false, true)
ev.preventDefault()
}
htmlElement.onmousedown = (ev: MouseEvent) => {
onPosChange(ev.clientX, ev.clientY, true);
ev.preventDefault();
onPosChange(ev.clientX, ev.clientY, true)
ev.preventDefault()
}
htmlElement.onmouseup = (ev) => {
onPosChange(ev.clientX, ev.clientY, false, true);
ev.preventDefault();
onPosChange(ev.clientX, ev.clientY, false, true)
ev.preventDefault()
}
htmlElement.onmousemove = (ev: MouseEvent) => {
onPosChange(ev.clientX, ev.clientY, false);
ev.preventDefault();
onPosChange(ev.clientX, ev.clientY, false)
ev.preventDefault()
}
}
}
}

View file

@ -1,71 +1,137 @@
import {ReadonlyInputElement} from "./InputElement";
import Loc from "../../Models/Loc";
import {Store, UIEventSource} from "../../Logic/UIEventSource";
import Minimap, {MinimapObj} from "../Base/Minimap";
import BaseLayer from "../../Models/BaseLayer";
import Combine from "../Base/Combine";
import Svg from "../../Svg";
import State from "../../State";
import {GeoOperations} from "../../Logic/GeoOperations";
import ShowDataMultiLayer from "../ShowDataLayer/ShowDataMultiLayer";
import StaticFeatureSource from "../../Logic/FeatureSource/Sources/StaticFeatureSource";
import LayerConfig from "../../Models/ThemeConfig/LayerConfig";
import {BBox} from "../../Logic/BBox";
import {FixedUiElement} from "../Base/FixedUiElement";
import ShowDataLayer from "../ShowDataLayer/ShowDataLayer";
import BaseUIElement from "../BaseUIElement";
import Toggle from "./Toggle";
import {ReadonlyInputElement} from "./InputElement"
import Loc from "../../Models/Loc"
import {Store, UIEventSource} from "../../Logic/UIEventSource"
import Minimap, {MinimapObj} from "../Base/Minimap"
import BaseLayer from "../../Models/BaseLayer"
import Combine from "../Base/Combine"
import Svg from "../../Svg"
import {GeoOperations} from "../../Logic/GeoOperations"
import ShowDataMultiLayer from "../ShowDataLayer/ShowDataMultiLayer"
import StaticFeatureSource from "../../Logic/FeatureSource/Sources/StaticFeatureSource"
import LayerConfig from "../../Models/ThemeConfig/LayerConfig"
import {BBox} from "../../Logic/BBox"
import {FixedUiElement} from "../Base/FixedUiElement"
import ShowDataLayer from "../ShowDataLayer/ShowDataLayer"
import BaseUIElement from "../BaseUIElement"
import Toggle from "./Toggle"
import * as matchpoint from "../../assets/layers/matchpoint/matchpoint.json"
import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig";
import FilteredLayer from "../../Models/FilteredLayer";
import {ElementStorage} from "../../Logic/ElementStorage";
import AvailableBaseLayers from "../../Logic/Actors/AvailableBaseLayers";
import {RelationId, WayId} from "../../Models/OsmFeature";
import {Feature, LineString, Polygon} from "geojson";
import {OsmObject, OsmWay} from "../../Logic/Osm/OsmObject";
export default class LocationInput extends BaseUIElement implements ReadonlyInputElement<Loc>, MinimapObj {
export default class LocationInput
extends BaseUIElement
implements ReadonlyInputElement<Loc>, MinimapObj {
private static readonly matchLayer = new LayerConfig(
matchpoint,
"LocationInput.matchpoint",
true
)
private static readonly matchLayer = new LayerConfig(matchpoint, "LocationInput.matchpoint", true)
public readonly snappedOnto: UIEventSource<any> = new UIEventSource<any>(undefined)
public readonly _matching_layer: LayerConfig;
public readonly snappedOnto: UIEventSource<Feature & { properties : { id : WayId} }> = new UIEventSource(undefined)
public readonly _matching_layer: LayerConfig
public readonly leafletMap: UIEventSource<any>
public readonly bounds;
public readonly location;
private _centerLocation: UIEventSource<Loc>;
private readonly mapBackground: UIEventSource<BaseLayer>;
public readonly bounds
public readonly location
private readonly _centerLocation: UIEventSource<Loc>
private readonly mapBackground: UIEventSource<BaseLayer>
/**
* The features to which the input should be snapped
* @private
*/
private readonly _snapTo: Store<{ feature: any }[]>
private readonly _snapTo: Store< (Feature<LineString | Polygon> & {properties: {id : WayId}})[]>
/**
* The features to which the input should be snapped without cleanup of relations and memberships
* Used for rendering
* @private
*/
private readonly _snapToRaw: Store< {feature: Feature}[]>
private readonly _value: Store<Loc>
private readonly _snappedPoint: Store<any>
private readonly _maxSnapDistance: number
private readonly _snappedPointTags: any;
private readonly _bounds: UIEventSource<BBox>;
private readonly map: BaseUIElement & MinimapObj;
private readonly clickLocation: UIEventSource<Loc>;
private readonly _minZoom: number;
private readonly _snappedPointTags: any
private readonly _bounds: UIEventSource<BBox>
private readonly map: BaseUIElement & MinimapObj
private readonly clickLocation: UIEventSource<Loc>
private readonly _minZoom: number
private readonly _state: {
readonly filteredLayers: Store<FilteredLayer[]>;
readonly backgroundLayer: UIEventSource<BaseLayer>;
readonly layoutToUse: LayoutConfig;
readonly selectedElement: UIEventSource<any>;
readonly allElements: ElementStorage
}
constructor(options: {
minZoom?: number,
mapBackground?: UIEventSource<BaseLayer>,
snapTo?: UIEventSource<{ feature: any }[]>,
maxSnapDistance?: number,
snappedPointTags?: any,
requiresSnapping?: boolean,
centerLocation: UIEventSource<Loc>,
/**
* Given a list of geojson-features, will prepare these features to be snappable:
* - points are removed
* - LineStrings are passed as-is
* - Multipolygons are decomposed into their member ways by downloading them
*
* @private
*/
private static async prepareSnapOnto(features: Feature[]): Promise<(Feature<LineString | Polygon> & {properties : {id: WayId}})[]> {
const linesAndPolygon : Feature<LineString | Polygon>[] = <any> features.filter(f => f.geometry.type !== "Point")
// Clean the features: multipolygons are split into their it's members
const linestrings : (Feature<LineString | Polygon> & {properties: {id: WayId}})[] = []
for (const feature of linesAndPolygon) {
if(feature.properties.id.startsWith("way")){
// A normal way - we continue
linestrings.push(<any> feature)
continue
}
// We have a multipolygon, thus: a relation
// Download the members
const relation = await OsmObject.DownloadObjectAsync(<RelationId> feature.properties.id, 60 * 60)
const members: OsmWay[] = await Promise.all(relation.members
.filter(m => m.type === "way")
.map(m => OsmObject.DownloadObjectAsync(<WayId> ("way/"+m.ref), 60 * 60)))
linestrings.push(...members.map(m => m.asGeoJson()))
}
return linestrings
}
constructor(options?: {
minZoom?: number
mapBackground?: UIEventSource<BaseLayer>
snapTo?: UIEventSource<{ feature: Feature }[]>
maxSnapDistance?: number
snappedPointTags?: any
requiresSnapping?: boolean
centerLocation?: UIEventSource<Loc>
bounds?: UIEventSource<BBox>
state?: {
readonly filteredLayers: Store<FilteredLayer[]>;
readonly backgroundLayer: UIEventSource<BaseLayer>;
readonly layoutToUse: LayoutConfig;
readonly selectedElement: UIEventSource<any>;
readonly allElements: ElementStorage
}
}) {
super();
this._snapTo = options.snapTo?.map(features => features?.filter(feat => feat.feature.geometry.type !== "Point"))
this._maxSnapDistance = options.maxSnapDistance
this._centerLocation = options.centerLocation;
this._snappedPointTags = options.snappedPointTags
this._bounds = options.bounds;
this._minZoom = options.minZoom
super()
this._snapToRaw = options?.snapTo?.map(feats => feats.filter(f => f.feature.geometry.type !== "Point"))
this._snapTo = options?.snapTo?.bind((features) => UIEventSource.FromPromise(LocationInput.prepareSnapOnto(features.map(f => f.feature))))?.map(f => f ?? [])
this._maxSnapDistance = options?.maxSnapDistance
this._centerLocation = options?.centerLocation ?? new UIEventSource<Loc>({
lat: 0, lon: 0, zoom: 0
})
this._snappedPointTags = options?.snappedPointTags
this._bounds = options?.bounds
this._minZoom = options?.minZoom
this._state = options?.state
if (this._snapTo === undefined) {
this._value = this._centerLocation;
this._value = this._centerLocation
} else {
const self = this;
const self = this
if (self._snappedPointTags !== undefined) {
const layout = State.state.layoutToUse
const layout = this._state.layoutToUse
let matchingLayer = LocationInput.matchLayer
for (const layer of layout.layers) {
@ -73,132 +139,147 @@ export default class LocationInput extends BaseUIElement implements ReadonlyInpu
matchingLayer = layer
}
}
this._matching_layer = matchingLayer;
this._matching_layer = matchingLayer
} else {
this._matching_layer = LocationInput.matchLayer
}
this._snappedPoint = options.centerLocation.map(loc => {
if (loc === undefined) {
return undefined;
}
// We reproject the location onto every 'snap-to-feature' and select the closest
let min = undefined;
let matchedWay = undefined;
for (const feature of self._snapTo.data ?? []) {
try {
const nearestPointOnLine = GeoOperations.nearestPoint(feature.feature, [loc.lon, loc.lat])
if (min === undefined) {
min = nearestPointOnLine
matchedWay = feature.feature;
continue;
}
if (min.properties.dist > nearestPointOnLine.properties.dist) {
min = nearestPointOnLine
matchedWay = feature.feature;
}
} catch (e) {
console.log("Snapping to a nearest point failed for ", feature.feature, "due to ", e)
}
}
if (min === undefined || min.properties.dist * 1000 > self._maxSnapDistance) {
if (options.requiresSnapping) {
// Calculate the location of the point based by snapping it onto a way
// As a side-effect, the actual snapped-onto way (if any) is saved into 'snappedOnto'
this._snappedPoint = this._centerLocation.map(
(loc) => {
if (loc === undefined) {
return undefined
} else {
return {
"type": "Feature",
"properties": options.snappedPointTags ?? min.properties,
"geometry": {"type": "Point", "coordinates": [loc.lon, loc.lat]}
}
// We reproject the location onto every 'snap-to-feature' and select the closest
let min = undefined
let matchedWay: Feature<LineString | Polygon> & {properties : {id : WayId}} = undefined
for (const feature of self._snapTo.data ?? []) {
try {
const nearestPointOnLine = GeoOperations.nearestPoint(feature, [
loc.lon,
loc.lat,
])
if (min === undefined) {
min = nearestPointOnLine
matchedWay = feature
continue
}
if (min.properties.dist > nearestPointOnLine.properties.dist) {
min = nearestPointOnLine
matchedWay = feature
}
} catch (e) {
console.log(
"Snapping to a nearest point failed for ",
feature,
"due to ",
e
)
}
}
}
min.properties = options.snappedPointTags ?? min.properties
self.snappedOnto.setData(matchedWay)
return min
}, [this._snapTo])
this._value = this._snappedPoint.map(f => {
const [lon, lat] = f.geometry.coordinates;
if (min === undefined || min.properties.dist * 1000 > self._maxSnapDistance) {
if (options?.requiresSnapping) {
return undefined
} else {
// No match found - the original coordinates are returned as is
return {
type: "Feature",
properties: options?.snappedPointTags ?? min.properties,
geometry: {type: "Point", coordinates: [loc.lon, loc.lat]},
}
}
}
min.properties = options?.snappedPointTags ?? min.properties
if(matchedWay.properties.id.startsWith("relation/")){
// We matched a relation instead of a way
console.log("Snapping onto a relation. The relation is", matchedWay)
}
self.snappedOnto.setData(<any> matchedWay)
return min
},
[this._snapTo]
)
this._value = this._snappedPoint.map((f) => {
const [lon, lat] = f.geometry.coordinates
return {
lon: lon, lat: lat, zoom: undefined
lon: lon,
lat: lat,
zoom: undefined,
}
})
}
this.mapBackground = options.mapBackground ?? State.state?.backgroundLayer
this.mapBackground = options?.mapBackground ?? this._state?.backgroundLayer ?? new UIEventSource<BaseLayer>(AvailableBaseLayers.osmCarto)
this.SetClass("block h-full")
this.clickLocation = new UIEventSource<Loc>(undefined);
this.map = Minimap.createMiniMap(
{
location: this._centerLocation,
background: this.mapBackground,
attribution: this.mapBackground !== State.state?.backgroundLayer,
lastClickLocation: this.clickLocation,
bounds: this._bounds,
addLayerControl: true
}
)
this.clickLocation = new UIEventSource<Loc>(undefined)
this.map = Minimap.createMiniMap({
location: this._centerLocation,
background: this.mapBackground,
attribution: this.mapBackground !== this._state?.backgroundLayer,
lastClickLocation: this.clickLocation,
bounds: this._bounds,
addLayerControl: true,
})
this.leafletMap = this.map.leafletMap
this.location = this.map.location;
this.location = this.map.location
}
GetValue(): Store<Loc> {
return this._value;
return this._value
}
IsValid(t: Loc): boolean {
return t !== undefined;
return t !== undefined
}
installBounds(factor: number | BBox, showRange?: boolean): void {
this.map.installBounds(factor, showRange)
}
TakeScreenshot(): Promise<any> {
return this.map.TakeScreenshot()
}
protected InnerConstructElement(): HTMLElement {
try {
const self = this;
const self = this
const hasMoved = new UIEventSource(false)
const startLocation = {...this._centerLocation.data}
this._centerLocation.addCallbackD(newLocation => {
this._centerLocation.addCallbackD((newLocation) => {
const f = 100000
console.log(newLocation.lon, startLocation.lon)
const diff = (Math.abs(newLocation.lon * f - startLocation.lon * f) + Math.abs(newLocation.lat * f - startLocation.lat * f))
const diff =
Math.abs(newLocation.lon * f - startLocation.lon * f) +
Math.abs(newLocation.lat * f - startLocation.lat * f)
if (diff < 1) {
return;
return
}
hasMoved.setData(true)
return true;
return true
})
this.clickLocation.addCallbackAndRunD(location => this._centerLocation.setData(location))
if (this._snapTo !== undefined) {
this.clickLocation.addCallbackAndRunD((location) =>
this._centerLocation.setData(location)
)
if (this._snapToRaw !== undefined) {
// Show the lines to snap to
console.log("Constructing the snap-to layer", this._snapTo)
console.log("Constructing the snap-to layer", this._snapToRaw)
new ShowDataMultiLayer({
features: StaticFeatureSource.fromDateless(this._snapTo),
zoomToFeatures: false,
leafletMap: this.map.leafletMap,
layers: State.state.filteredLayers
}
)
features: StaticFeatureSource.fromDateless(this._snapToRaw),
zoomToFeatures: false,
leafletMap: this.map.leafletMap,
layers: this._state.filteredLayers,
})
// Show the central point
const matchPoint = this._snappedPoint.map(loc => {
const matchPoint = this._snappedPoint.map((loc) => {
if (loc === undefined) {
return []
}
return [{feature: loc}];
return [{feature: loc}]
})
console.log("Constructing the match layer", matchPoint)
@ -207,22 +288,23 @@ export default class LocationInput extends BaseUIElement implements ReadonlyInpu
zoomToFeatures: false,
leafletMap: this.map.leafletMap,
layerToShow: this._matching_layer,
state: State.state,
selectedElement: State.state.selectedElement
state: this._state,
selectedElement: this._state.selectedElement,
})
}
this.mapBackground.map(layer => {
const leaflet = this.map.leafletMap.data
if (leaflet === undefined || layer === undefined) {
return;
}
this.mapBackground.map(
(layer) => {
const leaflet = this.map.leafletMap.data
if (leaflet === undefined || layer === undefined) {
return
}
leaflet.setMaxZoom(layer.max_zoom)
leaflet.setMinZoom(self._minZoom ?? layer.max_zoom - 2)
leaflet.setZoom(layer.max_zoom - 1)
}, [this.map.leafletMap])
leaflet.setMaxZoom(layer.max_zoom)
leaflet.setMinZoom(self._minZoom ?? layer.max_zoom - 2)
leaflet.setZoom(layer.max_zoom - 1)
},
[this.map.leafletMap]
)
const animatedHand = Svg.hand_ui()
.SetStyle("width: 2rem; height: unset;")
@ -232,23 +314,31 @@ export default class LocationInput extends BaseUIElement implements ReadonlyInpu
new Combine([
Svg.move_arrows_ui()
.SetClass("block relative pointer-events-none")
.SetStyle("left: -2.5rem; top: -2.5rem; width: 5rem; height: 5rem")
]).SetClass("block w-0 h-0 z-10 relative")
.SetStyle("background: rgba(255, 128, 128, 0.21); left: 50%; top: 50%; opacity: 0.5"),
.SetStyle("left: -2.5rem; top: -2.5rem; width: 5rem; height: 5rem"),
])
.SetClass("block w-0 h-0 z-10 relative")
.SetStyle(
"background: rgba(255, 128, 128, 0.21); left: 50%; top: 50%; opacity: 0.5"
),
new Toggle(undefined,
animatedHand, hasMoved)
new Toggle(undefined, animatedHand, hasMoved)
.SetClass("block w-0 h-0 z-10 relative")
.SetStyle("left: calc(50% + 3rem); top: calc(50% + 2rem); opacity: 0.7"),
this.map
.SetClass("z-0 relative block w-full h-full bg-gray-100")
]).ConstructElement();
this.map.SetClass("z-0 relative block w-full h-full bg-gray-100"),
]).ConstructElement()
} catch (e) {
console.error("Could not generate LocationInputElement:", e)
return new FixedUiElement("Constructing a locationInput failed due to" + e).SetClass("alert").ConstructElement();
return new FixedUiElement("Constructing a locationInput failed due to" + e)
.SetClass("alert")
.ConstructElement()
}
}
}
TakeScreenshot(format: "image"): Promise<string>;
TakeScreenshot(format: "blob"): Promise<Blob>;
TakeScreenshot(format: "image" | "blob"): Promise<string | Blob>;
TakeScreenshot(format: "image" | "blob"): Promise<string | Blob> {
return this.map.TakeScreenshot(format)
}
}

Some files were not shown because too many files have changed in this diff Show more