Merge develop

This commit is contained in:
Pieter Vander Vennet 2021-11-12 01:45:31 +01:00
commit 1843927d00
495 changed files with 95272 additions and 55870 deletions

View file

@ -11,23 +11,30 @@ import FeaturedMessage from "./BigComponents/FeaturedMessage";
export default class AllThemesGui {
constructor() {
new FixedUiElement("").AttachTo("centermessage")
const state = new UserRelatedState(undefined);
const intro = new Combine([
LanguagePicker.CreateLanguagePicker(Translations.t.index.title.SupportedLanguages())
.SetClass("absolute top-2 right-3"),
new IndexText()
]);
new Combine([
intro,
new FeaturedMessage(),
new MoreScreen(state, true),
Translations.t.general.aboutMapcomplete
.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")
.SetStyle("pointer-events: all;")
.AttachTo("topleft-tools");
try {
new FixedUiElement("").AttachTo("centermessage")
const state = new UserRelatedState(undefined, undefined);
const intro = new Combine([
LanguagePicker.CreateLanguagePicker(Translations.t.index.title.SupportedLanguages())
.SetClass("absolute top-2 right-3"),
new IndexText()
]);
new Combine([
intro,
new FeaturedMessage(),
new MoreScreen(state, true),
Translations.t.general.aboutMapcomplete
.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")
.SetStyle("pointer-events: all;")
.AttachTo("topleft-tools");
} catch (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")
.AttachTo("centermessage")
}
}
}
}

27
UI/Base/AsyncLazy.ts Normal file
View file

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

View file

@ -30,13 +30,13 @@ export default class Combine extends BaseUIElement {
if (subEl === undefined || subEl === null) {
continue;
}
try{
const subHtml = subEl.ConstructElement()
if (subHtml !== undefined) {
el.appendChild(subHtml)
}
}catch(e){
try {
const subHtml = subEl.ConstructElement()
if (subHtml !== undefined) {
el.appendChild(subHtml)
}
} catch (e) {
console.error("Could not generate subelement in combine due to ", e)
}
}

View file

@ -10,7 +10,7 @@ export default class Img extends BaseUIElement {
fallbackImage?: string
}) {
super();
if(src === undefined || src === "undefined"){
if (src === undefined || src === "undefined") {
throw "Undefined src for image"
}
this._src = src;
@ -44,7 +44,7 @@ export default class Img extends BaseUIElement {
}
el.onerror = () => {
if (self._options?.fallbackImage) {
if(el.src === self._options.fallbackImage){
if (el.src === self._options.fallbackImage) {
// Sigh... nothing to be done anymore
return;
}

View file

@ -1,16 +1,16 @@
import BaseUIElement from "../BaseUIElement";
export default class Lazy extends BaseUIElement{
export default class Lazy extends BaseUIElement {
private readonly _f: () => BaseUIElement;
constructor(f: () => BaseUIElement) {
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();
}
}

View file

@ -1,4 +1,3 @@
import {FixedUiElement} from "./FixedUiElement";
import {Translation} from "../i18n/Translation";
import Combine from "./Combine";
import Svg from "../../Svg";
@ -6,11 +5,11 @@ import Translations from "../i18n/Translations";
export default class Loading extends Combine {
constructor(msg?: Translation | string) {
const t = Translations.T(msg ) ?? Translations.t.general.loading.Clone();
const t = Translations.T(msg) ?? Translations.t.general.loading.Clone();
t.SetClass("pl-2")
super([
Svg.loading_svg().SetClass("animate-spin").SetStyle("width: 1.5rem; height: 1.5rem;"),
t
t
])
this.SetClass("flex p-1")
}

View file

@ -17,8 +17,11 @@ export interface MinimapOptions {
}
export interface MinimapObj {
readonly leafletMap: UIEventSource<any>,
installBounds(factor: number | BBox, showRange?: boolean) : void
readonly leafletMap: UIEventSource<any>,
installBounds(factor: number | BBox, showRange?: boolean): void
TakeScreenshot(): Promise<any>;
}
export default class Minimap {

View file

@ -8,6 +8,8 @@ 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";
export default class MinimapImplementation extends BaseUIElement implements MinimapObj {
private static _nextId = 0;
@ -101,6 +103,12 @@ export default class MinimapImplementation extends BaseUIElement implements Mini
})
}
public async TakeScreenshot() {
const screenshotter = new SimpleMapScreenshoter();
screenshotter.addTo(this.leafletMap.data);
return await screenshotter.takeScreen('image')
}
protected InnerConstructElement(): HTMLElement {
const div = document.createElement("div")
div.id = this._id;
@ -146,8 +154,8 @@ export default class MinimapImplementation extends BaseUIElement implements Mini
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])){
latLon = [0,0]
if (isNaN(latLon[0]) || isNaN(latLon[1])) {
latLon = [0, 0]
}
const options = {
center: latLon,

View file

@ -20,6 +20,7 @@ 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;
@ -28,6 +29,7 @@ export default class ScrollableFullScreen extends UIElement {
isShown: UIEventSource<boolean> = new UIEventSource<boolean>(false)
) {
super();
this.hashToShow = hashToShow;
this.isShown = isShown;
if (hashToShow === undefined) {
@ -45,26 +47,20 @@ export default class ScrollableFullScreen extends UIElement {
self.Activate();
Hash.hash.setData(hashToShow)
} else {
ScrollableFullScreen.clear();
self.clear();
}
})
Hash.hash.addCallback(hash => {
if (hash === hashToShow) {
return
if (!isShown.data) {
return;
}
if (hash === undefined || hash === "" || hash !== hashToShow) {
isShown.setData(false)
}
isShown.setData(false)
})
}
private static clear() {
ScrollableFullScreen.empty.AttachTo("fullscreen")
const fs = document.getElementById("fullscreen");
ScrollableFullScreen._currentlyOpen?.isShown?.setData(false);
fs.classList.add("hidden")
Hash.hash.setData(undefined);
}
InnerRender(): BaseUIElement {
return this._component;
}
@ -77,6 +73,13 @@ export default class ScrollableFullScreen extends UIElement {
fs.classList.remove("hidden")
}
private clear() {
ScrollableFullScreen.empty.AttachTo("fullscreen")
const fs = document.getElementById("fullscreen");
ScrollableFullScreen._currentlyOpen?.isShown?.setData(false);
fs.classList.add("hidden")
}
private BuildComponent(title: BaseUIElement, content: BaseUIElement, isShown: UIEventSource<boolean>) {
const returnToTheMap =
new Combine([

View file

@ -6,7 +6,7 @@ import {VariableUiElement} from "./VariableUIElement";
export class TabbedComponent extends Combine {
constructor(elements: { header: BaseUIElement | string, content: BaseUIElement | string }[],
constructor(elements: { header: BaseUIElement | string, content: BaseUIElement | string }[],
openedTab: (UIEventSource<number> | number) = 0,
options?: {
leftOfHeader?: BaseUIElement
@ -15,12 +15,15 @@ export class TabbedComponent extends Combine {
const openedTabSrc = typeof (openedTab) === "number" ? new UIEventSource(openedTab) : (openedTab ?? new UIEventSource<number>(0))
const tabs: BaseUIElement[] = [options?.leftOfHeader ]
const tabs: BaseUIElement[] = [options?.leftOfHeader]
const contentElements: BaseUIElement[] = [];
for (let i = 0; i < elements.length; i++) {
let element = elements[i];
const header = Translations.W(element.header).onClick(() => openedTabSrc.setData(i))
openedTabSrc.addCallbackAndRun(selected => {
if (selected >= elements.length) {
selected = 0
}
if (selected === i) {
header.SetClass("tab-active")
header.RemoveClass("tab-non-active")
@ -37,7 +40,7 @@ export class TabbedComponent extends Combine {
}
const header = new Combine(tabs).SetClass("tabs-header-bar")
if(options?.styleHeader){
if (options?.styleHeader) {
options.styleHeader(header)
}
const actualContent = new VariableUiElement(

View file

@ -7,9 +7,9 @@ export default class Title extends BaseUIElement {
constructor(embedded: string | BaseUIElement, level: number = 3) {
super()
if(typeof embedded === "string"){
this._embedded = new FixedUiElement(embedded)
}else{
if (typeof embedded === "string") {
this._embedded = new FixedUiElement(embedded)
} else {
this._embedded = embedded
}
this._level = level;
@ -19,14 +19,14 @@ export default class Title extends BaseUIElement {
const embedded = " " + this._embedded.AsMarkdown() + " ";
if (this._level == 1) {
return "\n" + embedded + "\n" + "=".repeat(embedded.length) + "\n\n"
return "\n\n" + embedded + "\n" + "=".repeat(embedded.length) + "\n\n"
}
if (this._level == 2) {
return "\n" + embedded + "\n" + "-".repeat(embedded.length) + "\n\n"
return "\n\n" + embedded + "\n" + "-".repeat(embedded.length) + "\n\n"
}
return "\n" + "#".repeat(this._level) + embedded + "\n\n";
return "\n\n" + "#".repeat(this._level) + embedded + "\n\n";
}
protected InnerConstructElement(): HTMLElement {

View file

@ -45,6 +45,9 @@ export default abstract class BaseUIElement {
* Adds all the relevant classes, space separated
*/
public SetClass(clss: string) {
if (clss == undefined) {
return
}
const all = clss.split(" ").map(clsName => clsName.trim());
let recordedChange = false;
for (let c of all) {
@ -158,7 +161,7 @@ export default abstract class BaseUIElement {
}
public AsMarkdown(): string {
throw "AsMarkdown is not implemented by " + this.constructor.name
throw "AsMarkdown is not implemented by " + this.constructor.name+"; implement it in the subclass"
}
protected abstract InnerConstructElement(): HTMLElement;

View file

@ -16,12 +16,12 @@ export default class AddNewMarker extends Combine {
const layer = filteredLayer.layerDef;
for (const preset of filteredLayer.layerDef.presets) {
const tags = TagUtils.KVtoProperties(preset.tags)
const icon = layer.GenerateLeafletStyle(new UIEventSource<any>(tags), false).icon.html
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.GenerateLeafletStyle(new UIEventSource<any>(tags), false).icon.html
last = layer.mapRendering[0].GenerateLeafletStyle(new UIEventSource<any>(tags), false).html
.SetClass("block relative")
.SetStyle("width: 42px; height: 42px;");
}

View file

@ -15,7 +15,7 @@ import {Utils} from "../../Utils";
*/
export default class Attribution extends Combine {
constructor(location: UIEventSource<Loc>,
constructor(location: UIEventSource<Loc>,
userDetails: UIEventSource<UserDetails>,
layoutToUse: LayoutConfig,
currentBounds: UIEventSource<BBox>) {
@ -56,6 +56,7 @@ export default class Attribution extends Combine {
)
)
super([mapComplete, reportBug, stats, editHere, editWithJosm, mapillary]);
this.SetClass("flex")
}

View file

@ -1,140 +0,0 @@
import Combine from "../Base/Combine";
import Translations from "../i18n/Translations";
import Attribution from "./Attribution";
import State from "../../State";
import {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 * as contributors from "../../assets/contributors.json"
import BaseUIElement from "../BaseUIElement";
import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig";
/**
* The attribution panel shown on mobile
*/
export default class AttributionPanel extends Combine {
private static LicenseObject = AttributionPanel.GenerateLicenses();
constructor(layoutToUse: LayoutConfig, contributions: UIEventSource<Map<string, number>>) {
super([
Translations.t.general.attribution.attributionContent,
((layoutToUse.maintainer ?? "") == "") ? "" : Translations.t.general.attribution.themeBy.Subs({author: layoutToUse.maintainer}),
layoutToUse.credits,
"<br/>",
new Attribution(State.state.locationControl, State.state.osmConnection.userDetails, State.state.layoutToUse, State.state.currentBounds),
"<br/>",
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");
if (sorted.length === 0) {
return "";
}
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 contribs = links.join(", ")
if (hiddenCount <= 0) {
return Translations.t.general.attribution.mapContributionsBy.Subs({
contributors: contribs
})
} else {
return Translations.t.general.attribution.mapContributionsByAndHidden.Subs({
contributors: contribs,
hiddenCount: hiddenCount
});
}
})),
"<br/>",
AttributionPanel.CodeContributors(),
"<h3>", Translations.t.general.attribution.iconAttribution.title.Clone().SetClass("pt-6 pb-3"), "</h3>",
...Utils.NoNull(Array.from(layoutToUse.ExtractImages()))
.map(AttributionPanel.IconAttribution)
]);
this.SetClass("flex flex-col link-underline overflow-hidden")
this.SetStyle("max-width: calc(100vw - 5em); width: 40rem;")
}
private static CodeContributors(): BaseUIElement {
const total = contributors.contributors.length;
let filtered = [...contributors.contributors]
filtered.splice(10, total - 10);
let contribsStr = filtered.map(c => c.contributor).join(", ")
if (contribsStr === "") {
// Hmm, something went wrong loading the contributors list. Lets show nothing
return undefined;
}
return Translations.t.general.attribution.codeContributionsBy.Subs({
contributors: contribsStr,
hiddenCount: total - 10
});
}
private static IconAttribution(iconPath: string): BaseUIElement {
if (iconPath.startsWith("http")) {
iconPath = "." + new URL(iconPath).pathname;
}
const license: SmallLicense = AttributionPanel.LicenseObject[iconPath]
if (license == undefined) {
return undefined;
}
if (license.license.indexOf("trivial") >= 0) {
return undefined;
}
const sources = Utils.NoNull(Utils.NoEmpty(license.sources))
return new Combine([
`<img src='${iconPath}' style="width: 50px; height: 50px; min-width: 50px; min-height: 50px; margin-right: 0.5em;">`,
new Combine([
new FixedUiElement(license.authors.join("; ")).SetClass("font-bold"),
new Combine([license.license,
sources.length > 0 ? " - " : "",
...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("block m-2")
]).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];
allLicenses[license.path] = license
}
return allLicenses;
}
}

View file

@ -0,0 +1,217 @@
import Combine from "../Base/Combine";
import Translations from "../i18n/Translations";
import Attribution from "./Attribution";
import State from "../../State";
import {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 * as contributors from "../../assets/contributors.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";
/**
* The attribution panel shown on mobile
*/
export default class CopyrightPanel extends Combine {
private static LicenseObject = CopyrightPanel.GenerateLicenses();
constructor(state: {
layoutToUse: LayoutConfig,
featurePipeline: FeaturePipeline,
currentBounds: UIEventSource<BBox>,
locationControl: UIEventSource<Loc>,
osmConnection: OsmConnection
}, contributions: UIEventSource<Map<string, number>>) {
const t = Translations.t.general.attribution
const layoutToUse = state.layoutToUse
const josmState = new UIEventSource<string>(undefined)
// Reset after 15s
josmState.stabilized(15000).addCallbackD(_ => josmState.setData(undefined))
const iconStyle = "height: 1.5rem; width: auto"
const actionButtons = [
new SubtleButton(Svg.liberapay_ui().SetStyle(iconStyle), t.donate, {
url: "https://liberapay.com/pietervdvn/",
newTab: true
}),
new SubtleButton(Svg.bug_ui().SetStyle(iconStyle), t.openIssueTracker, {
url: "https://github.com/pietervdvn/MapComplete/issues",
newTab: true
}),
new SubtleButton(Svg.statistics_ui().SetStyle(iconStyle), t.openOsmcha.Subs({theme: state.layoutToUse.title}), {
url: Utils.OsmChaLinkFor(31, state.layoutToUse.id),
newTab: true
}),
new VariableUiElement(state.locationControl.map(location => {
const idLink = `https://www.openstreetmap.org/edit?editor=id#map=${location?.zoom ?? 0}/${location?.lat ?? 0}/${location?.lon ?? 0}`
return new SubtleButton(Svg.pencil_ui().SetStyle(iconStyle), t.editId, {url: idLink, newTab: true})
})),
new VariableUiElement(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), t.openMapillary, {
url: mapillaryLink,
newTab: true
})
})),
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")
})),
new Toggle(
new SubtleButton(Svg.josm_logo_ui().SetStyle(iconStyle), t.editJosm).onClick(() => {
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 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)),
]
const iconAttributions = Utils.NoNull(Array.from(layoutToUse.ExtractImages()))
.map(CopyrightPanel.IconAttribution)
let maintainer: BaseUIElement = undefined
if (layoutToUse.maintainer !== undefined && layoutToUse.maintainer !== "" && layoutToUse.maintainer.toLowerCase() !== "mapcomplete") {
maintainer = Translations.t.general.attribution.themeBy.Subs({author: layoutToUse.maintainer})
}
super([
Translations.t.general.attribution.attributionContent,
new FixedUiElement("MapComplete "+Constants.vNumber).SetClass("font-bold"),
maintainer,
new Combine(actionButtons).SetClass("block w-full"),
new FixedUiElement(layoutToUse.credits),
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");
if (sorted.length === 0) {
return "";
}
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 contribs = links.join(", ")
if (hiddenCount <= 0) {
return Translations.t.general.attribution.mapContributionsBy.Subs({
contributors: contribs
})
} else {
return Translations.t.general.attribution.mapContributionsByAndHidden.Subs({
contributors: contribs,
hiddenCount: hiddenCount
});
}
})),
CopyrightPanel.CodeContributors(),
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: calc(100vw - 3em); width: 40rem; margin-left: 0.75rem; margin-right: 0.5rem")
}
private static CodeContributors(): BaseUIElement {
const total = contributors.contributors.length;
let filtered = [...contributors.contributors]
filtered.splice(10, total - 10);
let contribsStr = filtered.map(c => c.contributor).join(", ")
if (contribsStr === "") {
// Hmm, something went wrong loading the contributors list. Lets show nothing
return undefined;
}
return Translations.t.general.attribution.codeContributionsBy.Subs({
contributors: contribsStr,
hiddenCount: total - 10
});
}
private static IconAttribution(iconPath: string): BaseUIElement {
if (iconPath.startsWith("http")) {
iconPath = "." + new URL(iconPath).pathname;
}
const license: SmallLicense = CopyrightPanel.LicenseObject[iconPath]
if (license == undefined) {
return undefined;
}
if (license.license.indexOf("trivial") >= 0) {
return undefined;
}
const sources = Utils.NoNull(Utils.NoEmpty(license.sources))
return new Combine([
`<img src='${iconPath}' style="width: 50px; height: 50px; min-width: 50px; min-height: 50px; margin-right: 0.5em;">`,
new Combine([
new FixedUiElement(license.authors.join("; ")).SetClass("font-bold"),
new Combine([license.license,
sources.length > 0 ? " - " : "",
...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("block m-2")
]).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];
allLicenses[license.path] = license
}
return allLicenses;
}
}

View file

@ -12,26 +12,25 @@ import FeaturePipeline from "../../Logic/FeatureSource/FeaturePipeline";
import {UIEventSource} from "../../Logic/UIEventSource";
import SimpleMetaTagger from "../../Logic/SimpleMetaTagger";
import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig";
import {meta} from "@turf/turf";
import {BBox} from "../../Logic/BBox";
export class DownloadPanel extends Toggle {
constructor() {
const state: {
featurePipeline: FeaturePipeline,
layoutToUse: LayoutConfig,
currentBounds: UIEventSource<BBox>
} = State.state
const t = Translations.t.general.download
const name = State.state.layoutToUse.id;
const includeMetaToggle = new CheckBoxes([t.includeMetaData.Clone()])
const metaisIncluded = includeMetaToggle.GetValue().map(selected => selected.length > 0)
const buttonGeoJson = new SubtleButton(Svg.floppy_ui(),
new Combine([t.downloadGeojson.Clone().SetClass("font-bold"),
t.downloadGeoJsonHelper.Clone()]).SetClass("flex flex-col"))
@ -42,7 +41,7 @@ export class DownloadPanel extends Toggle {
mimetype: "application/vnd.geo+json"
});
})
const buttonCSV = new SubtleButton(Svg.floppy_ui(), new Combine(
[t.downloadCSV.Clone().SetClass("font-bold"),
@ -59,9 +58,9 @@ export class DownloadPanel extends Toggle {
const downloadButtons = new Combine(
[new Title(t.title),
buttonGeoJson,
buttonGeoJson,
buttonCSV,
includeMetaToggle,
includeMetaToggle,
t.licenseInfo.Clone().SetClass("link-underline")])
.SetClass("w-full flex flex-col border-4 border-gray-300 rounded-3xl p-4")
@ -107,7 +106,7 @@ export class DownloadPanel extends Toggle {
}
return {
type:"FeatureCollection",
type: "FeatureCollection",
features: resultFeatures
}

View file

@ -20,7 +20,7 @@ export default class FeaturedMessage extends Combine {
if (wm.end_date <= now) {
continue
}
if (welcome_message !== undefined) {
console.warn("Multiple applicable messages today:", welcome_message.featured_theme)
}
@ -62,7 +62,7 @@ export default class FeaturedMessage extends Combine {
message: wm.message,
featured_theme: wm.featured_theme
})
}
return all_messages
}

View file

@ -42,9 +42,8 @@ export default class FilterView extends VariableUiElement {
);
const name: Translation = config.config.name;
const styledNameChecked = name.Clone().SetStyle("font-size:large;padding-left:1.25rem");
const styledNameUnChecked = name.Clone().SetStyle("font-size:large;padding-left:1.25rem");
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(
@ -82,6 +81,8 @@ export default class FilterView extends VariableUiElement {
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 layer = filteredLayer.layerDef
const iconUnselected = new Combine([Svg.checkbox_empty]).SetStyle(
iconStyle
);
@ -95,9 +96,9 @@ export default class FilterView extends VariableUiElement {
filteredLayer.layerDef.name
);
const styledNameChecked = name.Clone().SetStyle("font-size:large;padding-left:1.25rem");
const styledNameChecked = name.Clone().SetStyle("font-size:large").SetClass("ml-3");
const styledNameUnChecked = name.Clone().SetStyle("font-size:large;padding-left:1.25rem");
const styledNameUnChecked = name.Clone().SetStyle("font-size:large").SetClass("ml-3");
const zoomStatus =
new Toggle(
@ -111,11 +112,14 @@ export default class FilterView extends VariableUiElement {
const style =
"display:flex;align-items:center;padding:0.5rem 0;";
const layerChecked = new Combine([icon, styledNameChecked, zoomStatus])
const layerIcon = layer.defaultIcon()?.SetClass("w-8 h-8 ml-2")
const layerIconUnchecked = layer.defaultIcon()?.SetClass("opacity-50 w-8 h-8 ml-2")
const layerChecked = new Combine([icon, layerIcon, styledNameChecked, zoomStatus])
.SetStyle(style)
.onClick(() => filteredLayer.isDisplayed.setData(false));
const layerNotChecked = new Combine([iconUnselected, styledNameUnChecked])
const layerNotChecked = new Combine([iconUnselected, layerIconUnchecked, styledNameUnChecked])
.SetStyle(style)
.onClick(() => filteredLayer.isDisplayed.setData(true));

View file

@ -14,6 +14,9 @@ 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";
export default class FullWelcomePaneWithTabs extends ScrollableFullScreen {
@ -24,7 +27,10 @@ export default class FullWelcomePaneWithTabs extends ScrollableFullScreen {
layoutToUse: LayoutConfig,
osmConnection: OsmConnection,
featureSwitchShareScreen: UIEventSource<boolean>,
featureSwitchMoreQuests: UIEventSource<boolean>
featureSwitchMoreQuests: UIEventSource<boolean>,
locationControl: UIEventSource<Loc>,
backgroundLayer: UIEventSource<BaseLayer>,
filteredLayers: UIEventSource<FilteredLayer[]>
} & UserRelatedState) {
const layoutToUse = state.layoutToUse;
super(
@ -39,7 +45,8 @@ export default class FullWelcomePaneWithTabs extends ScrollableFullScreen {
layoutToUse: LayoutConfig,
osmConnection: OsmConnection,
featureSwitchShareScreen: UIEventSource<boolean>,
featureSwitchMoreQuests: UIEventSource<boolean>
featureSwitchMoreQuests: UIEventSource<boolean>,
locationControl: UIEventSource<Loc>, backgroundLayer: UIEventSource<BaseLayer>, filteredLayers: UIEventSource<FilteredLayer[]>
} & UserRelatedState,
isShown: UIEventSource<boolean>):
{ header: string | BaseUIElement; content: BaseUIElement }[] {
@ -63,10 +70,10 @@ export default class FullWelcomePaneWithTabs extends ScrollableFullScreen {
tabs.push({
header: Svg.add_img,
content:
new Combine([
Translations.t.general.morescreen.intro,
new MoreScreen(state)
]).SetClass("flex flex-col")
new Combine([
Translations.t.general.morescreen.intro,
new MoreScreen(state)
]).SetClass("flex flex-col")
});
}
@ -77,13 +84,14 @@ export default class FullWelcomePaneWithTabs extends ScrollableFullScreen {
layoutToUse: LayoutConfig,
osmConnection: OsmConnection,
featureSwitchShareScreen: UIEventSource<boolean>,
featureSwitchMoreQuests: UIEventSource<boolean>
featureSwitchMoreQuests: UIEventSource<boolean>,
locationControl: UIEventSource<Loc>, backgroundLayer: UIEventSource<BaseLayer>, filteredLayers: UIEventSource<FilteredLayer[]>
} & UserRelatedState, currentTab: UIEventSource<number>, isShown: UIEventSource<boolean>) {
const tabs = FullWelcomePaneWithTabs.ConstructBaseTabs(state, isShown)
const tabsWithAboutMc = [...FullWelcomePaneWithTabs.ConstructBaseTabs(state, isShown)]
tabsWithAboutMc.push({
header: Svg.help,
content: new Combine([Translations.t.general.aboutMapcomplete

View file

@ -4,27 +4,210 @@ import {UIEventSource} from "../../Logic/UIEventSource";
import Combine from "../Base/Combine";
import {VariableUiElement} from "../Base/VariableUIElement";
import Translations from "../i18n/Translations";
import State from "../../State";
import Constants from "../../Models/Constants";
import Toggle from "../Input/Toggle";
import CreateNewNodeAction from "../../Logic/Osm/Actions/CreateNewNodeAction";
import {Tag} from "../../Logic/Tags/Tag";
import Loading from "../Base/Loading";
import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig";
import {OsmConnection} from "../../Logic/Osm/OsmConnection";
import {Changes} from "../../Logic/Osm/Changes";
import {ElementStorage} from "../../Logic/ElementStorage";
import FeaturePipeline from "../../Logic/FeatureSource/FeaturePipeline";
import Lazy from "../Base/Lazy";
import ConfirmLocationOfPoint from "../NewPoint/ConfirmLocationOfPoint";
import {PresetInfo} from "./SimpleAddUI";
import Img from "../Base/Img";
import {Translation} from "../i18n/Translation";
import FilteredLayer from "../../Models/FilteredLayer";
import SpecialVisualizations, {SpecialVisualization} from "../SpecialVisualizations";
import {FixedUiElement} from "../Base/FixedUiElement";
import Svg from "../../Svg";
import {Utils} from "../../Utils";
import Minimap from "../Base/Minimap";
import ShowDataLayer from "../ShowDataLayer/ShowDataLayer";
import AllKnownLayers from "../../Customizations/AllKnownLayers";
import StaticFeatureSource from "../../Logic/FeatureSource/Sources/StaticFeatureSource";
import ShowDataMultiLayer from "../ShowDataLayer/ShowDataMultiLayer";
import BaseLayer from "../../Models/BaseLayer";
import ReplaceGeometryAction from "../../Logic/Osm/Actions/ReplaceGeometryAction";
import CreateWayWithPointReuseAction from "../../Logic/Osm/Actions/CreateWayWithPointReuseAction";
import OsmChangeAction from "../../Logic/Osm/Actions/OsmChangeAction";
import FeatureSource from "../../Logic/FeatureSource/FeatureSource";
export interface ImportButtonState {
description?: Translation;
image: () => BaseUIElement,
message: string | BaseUIElement,
originalTags: UIEventSource<any>,
newTags: UIEventSource<Tag[]>,
targetLayer: FilteredLayer,
feature: any,
minZoom: number,
state: {
backgroundLayer: UIEventSource<BaseLayer>;
filteredLayers: UIEventSource<FilteredLayer[]>;
featureSwitchUserbadge: UIEventSource<boolean>;
featurePipeline: FeaturePipeline;
allElements: ElementStorage;
selectedElement: UIEventSource<any>;
layoutToUse: LayoutConfig,
osmConnection: OsmConnection,
changes: Changes,
locationControl: UIEventSource<{ zoom: number }>
},
guiState: { filterViewIsOpened: UIEventSource<boolean> },
snapSettings?: {
snapToLayers: string[],
snapToLayersMaxDist?: number
},
conflationSettings?: {
conflateWayId: string
}
}
export class ImportButtonSpecialViz implements SpecialVisualization {
funcName = "import_button"
docs = `This button will copy the data from an external dataset into OpenStreetMap. It is only functional in official themes but can be tested in unofficial themes.
#### Importing a dataset into OpenStreetMap: requirements
If you want to import a dataset, make sure that:
1. The dataset to import has a suitable license
2. The community has been informed of the import
3. All other requirements of the [import guidelines](https://wiki.openstreetmap.org/wiki/Import/Guidelines) have been followed
There are also some technicalities in your theme to keep in mind:
1. The new feature will be added and will flow through the program as any other new point as if it came from OSM.
This means that there should be a layer which will match the new tags and which will display it.
2. The original feature from your geojson layer will gain the tag '_imported=yes'.
This should be used to change the appearance or even to hide it (eg by changing the icon size to zero)
3. There should be a way for the theme to detect previously imported points, even after reloading.
A reference number to the original dataset is an excellent way to do this
4. When importing ways, the theme creator is also responsible of avoiding overlapping ways.
#### Disabled in unofficial themes
The import button can be tested in an unofficial theme by adding \`test=true\` or \`backend=osm-test\` as [URL-paramter](URL_Parameters.md).
The import button will show up then. If in testmode, you can read the changeset-XML directly in the web console.
In the case that MapComplete is pointed to the testing grounds, the edit will be made on ${OsmConnection.oauth_configs["osm-test"].url}
#### Specifying which tags to copy or add
The argument \`tags\` of the import button takes a \`;\`-seperated list of tags to add.
${Utils.Special_visualizations_tagsToApplyHelpText}
`
args = [
{
name: "targetLayer",
doc: "The id of the layer where this point should end up. This is not very strict, it will simply result in checking that this layer is shown preventing possible duplicate elements"
},
{
name: "tags",
doc: "The tags to add onto the new object - see specification above"
},
{
name: "text",
doc: "The text to show on the button",
defaultValue: "Import this data into OpenStreetMap"
},
{
name: "icon",
doc: "A nice icon to show in the button",
defaultValue: "./assets/svg/addSmall.svg"
},
{
name: "minzoom",
doc: "How far the contributor must zoom in before being able to import the point",
defaultValue: "18"
}, {
name: "Snap onto layer(s)/replace geometry with this other way",
doc: " - If the value corresponding with this key starts with 'way/' and the feature is a LineString or Polygon, the original OSM-way geometry will be changed to match the new geometry\n" +
" - If a way of the given layer(s) is closeby, will snap the new point onto this way (similar as preset might snap). To show multiple layers to snap onto, use a `;`-seperated list",
}, {
name: "snap max distance",
doc: "The maximum distance that this point will move to snap onto a layer (in meters)",
defaultValue: "5"
}]
constr(state, tagSource, args, guiState) {
if (!state.layoutToUse.official && !(state.featureSwitchIsTesting.data || state.osmConnection._oauth_config.url === OsmConnection.oauth_configs["osm-test"].url)) {
return new Combine([new FixedUiElement("The import button is disabled for unofficial themes to prevent accidents.").SetClass("alert"),
new FixedUiElement("To test, add <b>test=true</b> or <b>backend=osm-test</b> to the URL. The changeset will be printed in the console. Please open a PR to officialize this theme to actually enable the import button.")])
}
const newTags = SpecialVisualizations.generateTagsToApply(args[1], tagSource)
const id = tagSource.data.id;
const feature = state.allElements.ContainingFeatures.get(id)
let minZoom = args[4] == "" ? 18 : Number(args[4])
if (isNaN(minZoom)) {
console.warn("Invalid minzoom:", minZoom)
minZoom = 18
}
const message = args[2]
const imageUrl = args[3]
let img: () => BaseUIElement
const targetLayer: FilteredLayer = state.filteredLayers.data.filter(fl => fl.layerDef.id === args[0])[0]
if (imageUrl !== undefined && imageUrl !== "") {
img = () => new Img(imageUrl)
} else {
img = () => Svg.add_ui()
}
let snapSettings = undefined
let conflationSettings = undefined
const possibleWayId = tagSource.data[args[5]]
if (possibleWayId?.startsWith("way/")) {
// This is a conflation
conflationSettings = {
conflateWayId: possibleWayId
}
} else {
const snapToLayers = args[5]?.split(";").filter(s => s !== "")
const snapToLayersMaxDist = Number(args[6] ?? 6)
if (targetLayer === undefined) {
const e = "Target layer not defined: error in import button for theme: " + state.layoutToUse.id + ": layer " + args[0] + " not found"
console.error(e)
return new FixedUiElement(e).SetClass("alert")
}
snapSettings = {
snapToLayers,
snapToLayersMaxDist
}
}
return new ImportButton(
{
state, guiState, image: img,
feature, newTags, message, minZoom,
originalTags: tagSource,
targetLayer,
snapSettings,
conflationSettings
}
);
}
}
export default class ImportButton extends Toggle {
constructor(imageUrl: string | BaseUIElement, message: string | BaseUIElement,
originalTags: UIEventSource<any>,
newTags: UIEventSource<Tag[]>,
lat: number, lon: number,
minZoom: number,
state: {
locationControl: UIEventSource<{ zoom: number }>
}) {
constructor(o: ImportButtonState) {
const t = Translations.t.general.add;
const isImported = originalTags.map(tags => tags._imported === "yes")
const isImported = o.originalTags.map(tags => tags._imported === "yes")
const appliedTags = new Toggle(
new VariableUiElement(
newTags.map(tgs => {
o.newTags.map(tgs => {
const parts = []
for (const tag of tgs) {
parts.push(tag.key + "=" + tag.value)
@ -32,53 +215,232 @@ export default class ImportButton extends Toggle {
const txt = parts.join(" & ")
return t.presetInfo.Subs({tags: txt}).SetClass("subtle")
})), undefined,
State.state.osmConnection.userDetails.map(ud => ud.csCount >= Constants.userJourney.tagsVisibleAt)
o.state.osmConnection.userDetails.map(ud => ud.csCount >= Constants.userJourney.tagsVisibleAt)
)
const button = new SubtleButton(imageUrl, message)
const button = new SubtleButton(o.image(), o.message)
minZoom = Math.max(16, minZoom ?? 19)
o.minZoom = Math.max(16, o.minZoom ?? 19)
button.onClick(async () => {
if (isImported.data) {
return
}
originalTags.data["_imported"] = "yes"
originalTags.ping() // will set isImported as per its definition
const newElementAction = new CreateNewNodeAction(newTags.data, lat, lon, {
theme: State.state.layoutToUse.id,
changeType: "import"
})
await State.state.changes.applyAction(newElementAction)
State.state.selectedElement.setData(State.state.allElements.ContainingFeatures.get(
newElementAction.newElementId
))
console.log("Did set selected element to", State.state.allElements.ContainingFeatures.get(
newElementAction.newElementId
))
})
const withLoadingCheck = new Toggle(new Toggle(
new Loading(t.stillLoading.Clone()),
new Combine([button, appliedTags]).SetClass("flex flex-col"),
State.state.featurePipeline.runningQuery
),t.zoomInFurther.Clone(),
state.locationControl.map(l => l.zoom >= minZoom)
)
new Loading(t.stillLoading.Clone()),
new Combine([button, appliedTags]).SetClass("flex flex-col"),
o.state.featurePipeline.runningQuery
), t.zoomInFurther.Clone(),
o.state.locationControl.map(l => l.zoom >= o.minZoom)
)
const importButton = new Toggle(t.hasBeenImported, withLoadingCheck, isImported)
const importClicked = new UIEventSource(false);
const importFlow = new Toggle(
ImportButton.createConfirmPanel(o, isImported, importClicked),
importButton,
importClicked
)
button.onClick(() => {
importClicked.setData(true);
})
const pleaseLoginButton =
new Toggle(t.pleaseLogin.Clone()
.onClick(() => State.state.osmConnection.AttemptLogin())
.onClick(() => o.state.osmConnection.AttemptLogin())
.SetClass("login-button-friendly"),
undefined,
State.state.featureSwitchUserbadge)
o.state.featureSwitchUserbadge)
super(importButton,
pleaseLoginButton,
State.state.osmConnection.isLoggedIn
super(new Toggle(importFlow,
pleaseLoginButton,
o.state.osmConnection.isLoggedIn
),
t.wrongType,
new UIEventSource(ImportButton.canBeImported(o.feature))
)
}
public static createConfirmPanel(o: ImportButtonState,
isImported: UIEventSource<boolean>,
importClicked: UIEventSource<boolean>) {
const geometry = o.feature.geometry
if (geometry.type === "Point") {
return new Lazy(() => ImportButton.createConfirmPanelForPoint(o, isImported, importClicked))
}
if (geometry.type === "Polygon" || geometry.type == "LineString") {
return new Lazy(() => ImportButton.createConfirmForWay(o, isImported, importClicked))
}
console.error("Invalid type to import", geometry.type)
return new FixedUiElement("Invalid geometry type:" + geometry.type).SetClass("alert")
}
public static createConfirmForWay(o: ImportButtonState,
isImported: UIEventSource<boolean>,
importClicked: UIEventSource<boolean>): BaseUIElement {
const confirmationMap = Minimap.createMiniMap({
allowMoving: true,
background: o.state.backgroundLayer
})
confirmationMap.SetStyle("height: 20rem; overflow: hidden").SetClass("rounded-xl")
const relevantFeatures = Utils.NoNull([o.feature, o.state.allElements?.ContainingFeatures?.get(o.conflationSettings?.conflateWayId)])
// SHow all relevant data - including (eventually) the way of which the geometry will be replaced
new ShowDataMultiLayer({
leafletMap: confirmationMap.leafletMap,
enablePopups: false,
zoomToFeatures: true,
features: new StaticFeatureSource(relevantFeatures, false),
allElements: o.state.allElements,
layers: o.state.filteredLayers
})
let action: OsmChangeAction & { getPreview(): Promise<FeatureSource> }
const theme = o.state.layoutToUse.id
const changes = o.state.changes
let confirm: () => Promise<string>
if (o.conflationSettings !== undefined) {
action = new ReplaceGeometryAction(
o.state,
o.feature,
o.conflationSettings.conflateWayId,
{
theme: o.state.layoutToUse.id,
newTags: o.newTags.data
}
)
confirm = async () => {
changes.applyAction(action)
return o.feature.properties.id
}
} else {
const geom = o.feature.geometry
let coordinates: [number, number][]
if (geom.type === "LineString") {
coordinates = geom.coordinates
} else if (geom.type === "Polygon") {
coordinates = geom.coordinates[0]
}
action = new CreateWayWithPointReuseAction(
o.newTags.data,
coordinates,
// @ts-ignore
o.state,
[{
withinRangeOfM: 1,
ifMatches: new Tag("_is_part_of_building", "true"),
mode: "move_osm_point"
}]
)
confirm = async () => {
changes.applyAction(action)
return undefined
}
}
action.getPreview().then(changePreview => {
new ShowDataLayer({
leafletMap: confirmationMap.leafletMap,
enablePopups: false,
zoomToFeatures: false,
features: changePreview,
allElements: o.state.allElements,
layerToShow: AllKnownLayers.sharedLayers.get("conflation")
})
})
const confirmButton = new SubtleButton(o.image(), o.message)
confirmButton.onClick(async () => {
{
if (isImported.data) {
return
}
o.originalTags.data["_imported"] = "yes"
o.originalTags.ping() // will set isImported as per its definition
const idToSelect = await confirm()
o.state.selectedElement.setData(o.state.allElements.ContainingFeatures.get(idToSelect))
}
})
const cancel = new SubtleButton(Svg.close_ui(), Translations.t.general.cancel).onClick(() => {
importClicked.setData(false)
})
return new Combine([confirmationMap, confirmButton, cancel]).SetClass("flex flex-col")
}
public static createConfirmPanelForPoint(
o: ImportButtonState,
isImported: UIEventSource<boolean>,
importClicked: UIEventSource<boolean>): BaseUIElement {
async function confirm() {
if (isImported.data) {
return
}
o.originalTags.data["_imported"] = "yes"
o.originalTags.ping() // will set isImported as per its definition
const geometry = o.feature.geometry
const lat = geometry.coordinates[1]
const lon = geometry.coordinates[0];
const newElementAction = new CreateNewNodeAction(o.newTags.data, lat, lon, {
theme: o.state.layoutToUse.id,
changeType: "import"
})
await o.state.changes.applyAction(newElementAction)
o.state.selectedElement.setData(o.state.allElements.ContainingFeatures.get(
newElementAction.newElementId
))
}
function cancel() {
importClicked.setData(false)
}
const presetInfo = <PresetInfo>{
tags: o.newTags.data,
icon: o.image,
description: o.description,
layerToAddTo: o.targetLayer,
name: o.message,
title: o.message,
preciseInput: {
snapToLayers: o.snapSettings?.snapToLayers,
maxSnapDistance: o.snapSettings?.snapToLayersMaxDist
}
}
const [lon, lat] = o.feature.geometry.coordinates
return new ConfirmLocationOfPoint(o.state, o.guiState.filterViewIsOpened, presetInfo, Translations.W(o.message), {
lon,
lat
}, confirm, cancel)
}
private static canBeImported(feature: any) {
const type = feature.geometry.type
return type === "Point" || type === "LineString" || (type === "Polygon" && feature.geometry.coordinates.length === 1)
}
}

View file

@ -1,7 +1,7 @@
import Combine from "../Base/Combine";
import ScrollableFullScreen from "../Base/ScrollableFullScreen";
import Translations from "../i18n/Translations";
import AttributionPanel from "./AttributionPanel";
import CopyrightPanel from "./CopyrightPanel";
import ContributorCount from "../../Logic/ContributorCount";
import Toggle from "../Input/Toggle";
import MapControlButton from "../MapControlButton";
@ -14,6 +14,8 @@ import Loc from "../../Models/Loc";
import {BBox} from "../../Logic/BBox";
import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig";
import FilteredLayer from "../../Models/FilteredLayer";
import BaseLayer from "../../Models/BaseLayer";
import {OsmConnection} from "../../Logic/Osm/OsmConnection";
export default class LeftControls extends Combine {
@ -26,7 +28,9 @@ export default class LeftControls extends Combine {
featureSwitchEnableExport: UIEventSource<boolean>,
featureSwitchExportAsPdf: UIEventSource<boolean>,
filteredLayers: UIEventSource<FilteredLayer[]>,
featureSwitchFilter: UIEventSource<boolean>
featureSwitchFilter: UIEventSource<boolean>,
backgroundLayer: UIEventSource<BaseLayer>,
osmConnection: OsmConnection
},
guiState: {
downloadControlIsOpened: UIEventSource<boolean>,
@ -37,12 +41,12 @@ export default class LeftControls extends Combine {
const toggledCopyright = new ScrollableFullScreen(
() => Translations.t.general.attribution.attributionTitle.Clone(),
() =>
new AttributionPanel(
state.layoutToUse,
new CopyrightPanel(
state,
new ContributorCount(state).Contributors
),
"copyright",
guiState.copyrightViewIsOpened
"copyright",
guiState.copyrightViewIsOpened
);
const copyrightButton = new Toggle(

View file

@ -39,110 +39,6 @@ export default class MoreScreen extends Combine {
]);
}
private static createUnofficialThemeList(buttonClass: string, state: UserRelatedState, themeListClasses): BaseUIElement {
return new VariableUiElement(state.installedThemes.map(customThemes => {
if (customThemes.length <= 0) {
return undefined;
}
const customThemeButtons = customThemes.map(theme => MoreScreen.createLinkButton(state, theme.layout, theme.definition)?.SetClass(buttonClass))
return new Combine([
Translations.t.general.customThemeIntro.Clone(),
new Combine(customThemeButtons).SetClass(themeListClasses)
]);
}));
}
private static createPreviouslyVistedHiddenList(state: UserRelatedState, buttonClass: string, themeListStyle: string) {
const t = Translations.t.general.morescreen
const prefix = "mapcomplete-hidden-theme-"
const hiddenTotal = AllKnownLayouts.layoutsList.filter(layout => layout.hideFromOverview).length
return new Toggle(
new VariableUiElement(
state.osmConnection.preferencesHandler.preferences.map(allPreferences => {
const knownThemes = Utils.NoNull(Object.keys(allPreferences)
.filter(key => key.startsWith(prefix))
.map(key => key.substring(prefix.length, key.length - "-enabled".length))
.map(theme => {
return AllKnownLayouts.allKnownLayouts.get(theme);
}))
if (knownThemes.length === 0) {
return undefined
}
const knownLayouts = new Combine(knownThemes.map(layout =>
MoreScreen.createLinkButton(state, layout)?.SetClass(buttonClass)
)).SetClass(themeListStyle)
return new Combine([
new Title(t.previouslyHiddenTitle),
t.hiddenExplanation.Subs({hidden_discovered: ""+knownThemes.length,total_hidden: ""+hiddenTotal}),
knownLayouts
])
})
).SetClass("flex flex-col"),
undefined,
state.osmConnection.isLoggedIn
)
}
private static createOfficialThemesList(state: { osmConnection: OsmConnection, locationControl?: UIEventSource<Loc> }, buttonClass: string): BaseUIElement {
let officialThemes = AllKnownLayouts.layoutsList
let buttons = 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) {
return new VariableUiElement(
state.osmConnection.userDetails.map(userdetails => userdetails.csCount)
.map(csCount => {
if (csCount < Constants.userJourney.personalLayoutUnlock) {
return undefined
} else {
return button
}
})
)
}
return button;
})
let customGeneratorLink = MoreScreen.createCustomGeneratorButton(state)
buttons.splice(0, 0, customGeneratorLink);
return new Combine(buttons)
}
/*
* 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 => {
if (userDetails.csCount < Constants.userJourney.themeGeneratorReadOnlyUnlock) {
return new SubtleButton(null, tr.requestATheme.Clone(), {
url: "https://github.com/pietervdvn/MapComplete/issues",
newTab: true
});
}
return new SubtleButton(Svg.pencil_ui(), tr.createYourOwnTheme.Clone(), {
url: "https://pietervdvn.github.io/mc/legacy/070/customGenerator.html",
newTab: false
});
})
)
}
/**
* Creates a button linking to the given theme
* @private
@ -161,7 +57,7 @@ export default class MoreScreen extends Combine {
console.error("ID is undefined for layout", layout);
return undefined;
}
if (layout.id === state?.layoutToUse?.id) {
return undefined;
}
@ -210,5 +106,110 @@ export default class MoreScreen extends Combine {
]), {url: linkText, newTab: false});
}
private static createUnofficialThemeList(buttonClass: string, state: UserRelatedState, themeListClasses): BaseUIElement {
return new VariableUiElement(state.installedThemes.map(customThemes => {
if (customThemes.length <= 0) {
return undefined;
}
const customThemeButtons = customThemes.map(theme => MoreScreen.createLinkButton(state, theme.layout, theme.definition)?.SetClass(buttonClass))
return new Combine([
Translations.t.general.customThemeIntro.Clone(),
new Combine(customThemeButtons).SetClass(themeListClasses)
]);
}));
}
private static createPreviouslyVistedHiddenList(state: UserRelatedState, buttonClass: string, themeListStyle: string) {
const t = Translations.t.general.morescreen
const prefix = "mapcomplete-hidden-theme-"
const hiddenTotal = AllKnownLayouts.layoutsList.filter(layout => layout.hideFromOverview).length
return new Toggle(
new VariableUiElement(
state.osmConnection.preferencesHandler.preferences.map(allPreferences => {
const knownThemes = Utils.NoNull(Object.keys(allPreferences)
.filter(key => key.startsWith(prefix))
.map(key => key.substring(prefix.length, key.length - "-enabled".length))
.map(theme => AllKnownLayouts.allKnownLayouts.get(theme)))
.filter(theme => theme?.hideFromOverview)
if (knownThemes.length === 0) {
return undefined
}
const knownLayouts = new Combine(knownThemes.map(layout =>
MoreScreen.createLinkButton(state, layout)?.SetClass(buttonClass)
)).SetClass(themeListStyle)
return new Combine([
new Title(t.previouslyHiddenTitle),
t.hiddenExplanation.Subs({
hidden_discovered: "" + knownThemes.length,
total_hidden: "" + hiddenTotal
}),
knownLayouts
])
})
).SetClass("flex flex-col"),
undefined,
state.osmConnection.isLoggedIn
)
}
private static createOfficialThemesList(state: { osmConnection: OsmConnection, locationControl?: UIEventSource<Loc> }, buttonClass: string): BaseUIElement {
let officialThemes = AllKnownLayouts.layoutsList
let buttons = 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) {
return new VariableUiElement(
state.osmConnection.userDetails.map(userdetails => userdetails.csCount)
.map(csCount => {
if (csCount < Constants.userJourney.personalLayoutUnlock) {
return undefined
} else {
return button
}
})
)
}
return button;
})
let customGeneratorLink = MoreScreen.createCustomGeneratorButton(state)
buttons.splice(0, 0, customGeneratorLink);
return new Combine(buttons)
}
/*
* 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 => {
if (userDetails.csCount < Constants.userJourney.themeGeneratorReadOnlyUnlock) {
return new SubtleButton(null, tr.requestATheme.Clone(), {
url: "https://github.com/pietervdvn/MapComplete/issues",
newTab: true
});
}
return new SubtleButton(Svg.pencil_ui(), tr.createYourOwnTheme.Clone(), {
url: "https://pietervdvn.github.io/mc/legacy/070/customGenerator.html",
newTab: false
});
})
)
}
}

View file

@ -4,17 +4,21 @@ import MapControlButton from "../MapControlButton";
import GeoLocationHandler from "../../Logic/Actors/GeoLocationHandler";
import Svg from "../../Svg";
import MapState from "../../Logic/State/MapState";
import ShowDataLayer from "../ShowDataLayer/ShowDataLayer";
import AllKnownLayers from "../../Customizations/AllKnownLayers";
export default class RightControls extends Combine {
constructor(state:MapState) {
constructor(state: MapState) {
const geolocatioHandler = new GeoLocationHandler(
state
)
const geolocationButton = new Toggle(
new MapControlButton(
new GeoLocationHandler(
state.currentGPSLocation,
state.leafletMap,
state.layoutToUse
), {
geolocatioHandler
, {
dontStyle: true
}
),

View file

@ -8,11 +8,14 @@ import Toggle from "../Input/Toggle";
import Translations from "../i18n/Translations";
import BaseUIElement from "../BaseUIElement";
import LayerConfig from "../../Models/ThemeConfig/LayerConfig";
import MapState from "../../Logic/State/MapState";
import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig";
import Loc from "../../Models/Loc";
import BaseLayer from "../../Models/BaseLayer";
import FilteredLayer from "../../Models/FilteredLayer";
export default class ShareScreen extends Combine {
constructor(state: MapState) {
constructor(state: { layoutToUse: LayoutConfig, locationControl: UIEventSource<Loc>, backgroundLayer: UIEventSource<BaseLayer>, filteredLayers: UIEventSource<FilteredLayer[]> }) {
const layout = state?.layoutToUse;
const tr = Translations.t.general.sharescreen;
@ -59,40 +62,39 @@ export default class ShareScreen extends Combine {
}
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 Toggle(
new Combine([check(), currentBackground]),
new Combine([nocheck(), currentBackground]),
new UIEventSource<boolean>(true)
).ToggleOnClick()
optionCheckboxes.push(includeCurrentBackground);
optionParts.push(includeCurrentBackground.isEnabled.map((includeBG) => {
if (includeBG) {
return "background=" + currentLayer.data.id
} else {
return null
}
}, [currentLayer]));
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 Toggle(
new Combine([check(), currentBackground]),
new Combine([nocheck(), currentBackground]),
new UIEventSource<boolean>(true)
).ToggleOnClick()
optionCheckboxes.push(includeCurrentBackground);
optionParts.push(includeCurrentBackground.isEnabled.map((includeBG) => {
if (includeBG) {
return "background=" + currentLayer.data.id
} else {
return null
}
}, [currentLayer]));
const includeLayerChoices = new Toggle(
new Combine([check(), tr.fsIncludeCurrentLayers.Clone()]),
new Combine([nocheck(), tr.fsIncludeCurrentLayers.Clone()]),
new UIEventSource<boolean>(true)
).ToggleOnClick()
optionCheckboxes.push(includeLayerChoices);
const includeLayerChoices = new Toggle(
new Combine([check(), tr.fsIncludeCurrentLayers.Clone()]),
new Combine([nocheck(), tr.fsIncludeCurrentLayers.Clone()]),
new UIEventSource<boolean>(true)
).ToggleOnClick()
optionCheckboxes.push(includeLayerChoices);
optionParts.push(includeLayerChoices.isEnabled.map((includeLayerSelection) => {
if (includeLayerSelection) {
return Utils.NoNull(state.filteredLayers.data.map(fLayerToParam)).join("&")
} else {
return null
}
}, state.filteredLayers.data.map((flayer) => flayer.isDisplayed)));
optionParts.push(includeLayerChoices.isEnabled.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 = [

View file

@ -12,18 +12,16 @@ import BaseUIElement from "../BaseUIElement";
import {VariableUiElement} from "../Base/VariableUIElement";
import Toggle from "../Input/Toggle";
import UserDetails, {OsmConnection} from "../../Logic/Osm/OsmConnection";
import LocationInput from "../Input/LocationInput";
import AvailableBaseLayers from "../../Logic/Actors/AvailableBaseLayers";
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 {BBox} from "../../Logic/BBox";
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";
/*
* The SimpleAddUI is a single panel, which can have multiple states:
@ -33,8 +31,7 @@ import {ElementStorage} from "../../Logic/ElementStorage";
* - A 'read your unread messages before adding a point'
*/
/*private*/
interface PresetInfo extends PresetConfig {
export interface PresetInfo extends PresetConfig {
name: string | BaseUIElement,
icon: () => BaseUIElement,
layerToAddTo: FilteredLayer
@ -91,20 +88,29 @@ export default class SimpleAddUI extends Toggle {
if (preset === undefined) {
return presetsOverview
}
return SimpleAddUI.CreateConfirmButton(state, filterViewIsOpened, preset,
(tags, location, snapOntoWayId?: string) => {
if (snapOntoWayId === undefined) {
createNewPoint(tags, location, undefined)
} else {
OsmObject.DownloadObject(snapOntoWayId).addCallbackAndRunD(way => {
createNewPoint(tags, location, <OsmWay>way)
return true;
})
}
},
() => {
selectedPreset.setData(undefined)
})
function confirm(tags, location, 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});
return new ConfirmLocationOfPoint(state, filterViewIsOpened, preset,
message,
state.LastClickLocation.data,
confirm,
cancel)
}
))
@ -134,170 +140,7 @@ export default class SimpleAddUI extends Toggle {
}
private static CreateConfirmButton(
state: {
LastClickLocation: UIEventSource<{ lat: number, lon: number }>,
osmConnection: OsmConnection,
featurePipeline: FeaturePipeline
},
filterViewIsOpened: UIEventSource<boolean>,
preset: PresetInfo,
confirm: (tags: any[], location: { lat: number, lon: number }, snapOntoWayId: string) => void,
cancel: () => void): BaseUIElement {
let location = state.LastClickLocation;
let preciseInput: LocationInput = undefined
if (preset.preciseInput !== undefined) {
// We uncouple the event source
const locationSrc = new UIEventSource({
lat: location.data.lat,
lon: location.data.lon,
zoom: 19
});
let backgroundLayer = undefined;
if (preset.preciseInput.preferredBackground) {
backgroundLayer = AvailableBaseLayers.SelectBestLayerAccordingTo(locationSrc, new UIEventSource<string | string[]>(preset.preciseInput.preferredBackground))
}
let snapToFeatures: UIEventSource<{ feature: any }[]> = undefined
let mapBounds: UIEventSource<BBox> = undefined
if (preset.preciseInput.snapToLayers) {
snapToFeatures = new UIEventSource<{ feature: any }[]>([])
mapBounds = new UIEventSource<BBox>(undefined)
}
const tags = TagUtils.KVtoProperties(preset.tags ?? []);
preciseInput = new LocationInput({
mapBackground: backgroundLayer,
centerLocation: locationSrc,
snapTo: snapToFeatures,
snappedPointTags: tags,
maxSnapDistance: preset.preciseInput.maxSnapDistance,
bounds: mapBounds
})
preciseInput.installBounds(0.15, true)
preciseInput.SetClass("h-32 rounded-xl overflow-hidden border border-gray").SetStyle("height: 12rem;")
if (preset.preciseInput.snapToLayers) {
// We have to snap to certain layers.
// Lets fetch them
let loadedBbox: BBox = undefined
mapBounds?.addCallbackAndRunD(bbox => {
if (loadedBbox !== undefined && bbox.isContainedIn(loadedBbox)) {
// All is already there
// return;
}
bbox = bbox.pad(2);
loadedBbox = bbox;
const allFeatures: { feature: any }[] = []
preset.preciseInput.snapToLayers.forEach(layerId => {
state.featurePipeline.GetFeaturesWithin(layerId, bbox).forEach(feats => allFeatures.push(...feats.map(f => ({feature: f}))))
})
snapToFeatures.setData(allFeatures)
})
}
}
let confirmButton: BaseUIElement = new SubtleButton(preset.icon(),
new Combine([
Translations.t.general.add.addNew.Subs({category: preset.name}),
Translations.t.general.add.warnVisibleForEveryone.Clone().SetClass("alert")
]).SetClass("flex flex-col")
).SetClass("font-bold break-words")
.onClick(() => {
confirm(preset.tags, (preciseInput?.GetValue() ?? location).data, preciseInput?.snappedOnto?.data?.properties?.id);
});
if (preciseInput !== undefined) {
confirmButton = new Combine([preciseInput, confirmButton])
}
const openLayerControl =
new SubtleButton(
Svg.layers_ui(),
new Combine([
Translations.t.general.add.layerNotEnabled
.Subs({layer: preset.layerToAddTo.layerDef.name})
.SetClass("alert"),
Translations.t.general.add.openLayerControl
])
)
.onClick(() => filterViewIsOpened.setData(true))
const openLayerOrConfirm = new Toggle(
confirmButton,
openLayerControl,
preset.layerToAddTo.isDisplayed
)
const disableFilter = new SubtleButton(
new Combine([
Svg.filter_ui().SetClass("absolute w-full"),
Svg.cross_bottom_right_svg().SetClass("absolute red-svg")
]).SetClass("relative"),
new Combine(
[
Translations.t.general.add.disableFiltersExplanation.Clone(),
Translations.t.general.add.disableFilters.Clone().SetClass("text-xl")
]
).SetClass("flex flex-col")
).onClick(() => {
preset.layerToAddTo.appliedFilters.setData([])
cancel()
})
const disableFiltersOrConfirm = new Toggle(
openLayerOrConfirm,
disableFilter,
preset.layerToAddTo.appliedFilters.map(filters => {
if (filters === undefined || filters.length === 0) {
return true;
}
for (const filter of filters) {
if (filter.selected === 0 && filter.filter.options.length === 1) {
return false;
}
if (filter.selected !== undefined) {
const tags = filter.filter.options[filter.selected].osmTags
if (tags !== undefined && tags["and"]?.length !== 0) {
// This actually doesn't filter anything at all
return false;
}
}
}
return true
})
)
const tagInfo = SimpleAddUI.CreateTagInfoFor(preset, state.osmConnection);
const cancelButton = new SubtleButton(Svg.close_ui(),
Translations.t.general.cancel
).onClick(cancel)
return new Combine([
state.osmConnection.userDetails.data.dryRun ?
Translations.t.general.testing.Clone().SetClass("alert") : undefined,
disableFiltersOrConfirm,
cancelButton,
preset.description,
tagInfo
]).SetClass("flex flex-col")
}
private static CreateTagInfoFor(preset: PresetInfo, osmConnection: OsmConnection, optionallyLinkToWiki = true) {
public static CreateTagInfoFor(preset: PresetInfo, osmConnection: OsmConnection, optionallyLinkToWiki = true) {
const csCount = osmConnection.userDetails.data.csCount;
return new Toggle(
Translations.t.general.add.presetInfo.Subs({
@ -329,7 +172,7 @@ export default class SimpleAddUI extends Toggle {
private static CreatePresetSelectButton(preset: PresetInfo, osmConnection: OsmConnection) {
const tagInfo = SimpleAddUI.CreateTagInfoFor(preset, osmConnection ,false);
const tagInfo = SimpleAddUI.CreateTagInfoFor(preset, osmConnection, false);
return new SubtleButton(
preset.icon(),
new Combine([
@ -368,7 +211,7 @@ export default class SimpleAddUI extends Toggle {
for (const preset of presets) {
const tags = TagUtils.KVtoProperties(preset.tags ?? []);
let icon: () => BaseUIElement = () => layer.layerDef.GenerateLeafletStyle(new UIEventSource<any>(tags), false).icon.html
let icon: () => BaseUIElement = () => layer.layerDef.mapRendering[0].GenerateLeafletStyle(new UIEventSource<any>(tags), false).html
.SetClass("w-12 h-12 block relative");
const presetInfo: PresetInfo = {
tags: preset.tags,

View file

@ -6,9 +6,6 @@ import FullWelcomePaneWithTabs from "./BigComponents/FullWelcomePaneWithTabs";
import MapControlButton from "./MapControlButton";
import Svg from "../Svg";
import Toggle from "./Input/Toggle";
import Hash from "../Logic/Web/Hash";
import {QueryParameters} from "../Logic/Web/QueryParameters";
import Constants from "../Models/Constants";
import UserBadge from "./BigComponents/UserBadge";
import SearchAndGo from "./BigComponents/SearchAndGo";
import Link from "./Base/Link";
@ -24,76 +21,7 @@ import Translations from "./i18n/Translations";
import SimpleAddUI from "./BigComponents/SimpleAddUI";
import StrayClickHandler from "../Logic/Actors/StrayClickHandler";
import Lazy from "./Base/Lazy";
export class DefaultGuiState {
public readonly welcomeMessageIsOpened : UIEventSource<boolean>;
public readonly downloadControlIsOpened: UIEventSource<boolean>;
public readonly filterViewIsOpened: UIEventSource<boolean>;
public readonly copyrightViewIsOpened: 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. 0 = the explanation of the theme,1 = OSM-credits, 2 = sharescreen, 3 = more themes, 4 = about mapcomplete (user must be logged in and have >${Constants.userJourney.mapCompleteHelpUnlock} changesets)`
));
this.welcomeMessageIsOpened = QueryParameters.GetBooleanQueryParameter(
"welcome-control-toggle",
"false",
"Whether or not the welcome panel is shown"
)
this.downloadControlIsOpened = QueryParameters.GetBooleanQueryParameter(
"download-control-toggle",
"false",
"Whether or not the download panel is shown"
)
this.filterViewIsOpened = QueryParameters.GetBooleanQueryParameter(
"filter-toggle",
"false",
"Whether or not the filter view is shown"
)
this.copyrightViewIsOpened = QueryParameters.GetBooleanQueryParameter(
"copyright-toggle",
"false",
"Whether or not the copyright view is shown"
)
if(Hash.hash.data === "download"){
this.downloadControlIsOpened.setData(true)
}
if(Hash.hash.data === "filter"){
this.filterViewIsOpened.setData(true)
}
if(Hash.hash.data === "copyright"){
this.copyrightViewIsOpened.setData(true)
}
if(Hash.hash.data === "" || Hash.hash.data === undefined || Hash.hash.data === "welcome"){
this.welcomeMessageIsOpened.setData(true)
}
this.allFullScreenStates.push(this.downloadControlIsOpened, this.filterViewIsOpened, this.copyrightViewIsOpened, this.welcomeMessageIsOpened)
for (let i = 0; i < this.allFullScreenStates.length; 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 => {
if(isOpened){
otherState.setData(false)
}
})
}
}
}
}
import {DefaultGuiState} from "./DefaultGuiState";
/**
@ -114,127 +42,13 @@ export default class DefaultGUI {
Utils.LoadCustomCss(state.layoutToUse.customCss);
}
this.SetupUIElements();
this.SetupMap()
}
private SetupMap(){
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
)
new ShowDataLayer({
leafletMap: state.leafletMap,
layerToShow: AllKnownLayers.sharedLayers.get("home_location"),
features: state.homeLocation,
enablePopups: false,
})
state.leafletMap.addCallbackAndRunD(_ => {
// Lets assume that all showDataLayers are initialized at this point
state.selectedElement.ping()
State.state.locationControl.ping();
return true;
})
}
private SetupUIElements(){
const state = this.state;
const guiState = this._guiState;
const self =this
Toggle.If(state.featureSwitchUserbadge,
() => new UserBadge(state)
).AttachTo("userbadge")
Toggle.If(state.featureSwitchSearch,
() => new SearchAndGo(state))
.AttachTo("searchbox");
let iframePopout: () => BaseUIElement = undefined;
if (window !== window.top) {
// MapComplete is running in an iframe
iframePopout = () => new VariableUiElement(state.locationControl.map(loc => {
const url = `${window.location.origin}${window.location.pathname}?z=${loc.zoom ?? 0}&lat=${loc.lat ?? 0}&lon=${loc.lon ?? 0}`;
const link = new Link(Svg.pop_out_img, url, true).SetClass("block w-full h-full p-1.5")
return new MapControlButton(link)
}))
}
new Toggle(new Lazy(() => self.InitWelcomeMessage()),
Toggle.If(state.featureSwitchIframePopoutEnabled, iframePopout),
state.featureSwitchWelcomeMessage
).AttachTo("messagesbox");
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");
// We have to ping the welcomeMessageIsOpened and other isOpened-stuff to activate the FullScreenMessage if needed
for (const state of guiState.allFullScreenStates) {
if(state.data){
state.ping()
}
if(state.layoutToUse.customCss !== undefined && window.location.pathname.indexOf("index") >= 0){
Utils.LoadCustomCss(state.layoutToUse.customCss)
}
/**
* At last, if the map moves or an element is selected, we close all the panels just as well
*/
state.selectedElement.addCallbackAndRunD((_) => {
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);
// ?-Button on Desktop, opens panel with close-X.
const help = new MapControlButton(Svg.help_svg());
help.onClick(() => isOpened.setData(true));
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;
}
isOpened.setData(false);
});
this.state.selectedElement.addCallbackAndRunD((_) => {
isOpened.setData(false);
});
return new Toggle(
fullOptions.SetClass("welcomeMessage pointer-events-auto"),
help.SetClass("pointer-events-auto"),
isOpened
)
}
public setupClickDialogOnMap(filterViewIsOpened: UIEventSource<boolean>, state: FeaturePipelineState) {
@ -281,4 +95,120 @@ export default class DefaultGUI {
}
private SetupMap() {
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
)
new ShowDataLayer({
leafletMap: state.leafletMap,
layerToShow: AllKnownLayers.sharedLayers.get("home_location"),
features: state.homeLocation,
enablePopups: false,
})
state.leafletMap.addCallbackAndRunD(_ => {
// Lets assume that all showDataLayers are initialized at this point
state.selectedElement.ping()
State.state.locationControl.ping();
return true;
})
}
private SetupUIElements() {
const state = this.state;
const guiState = this._guiState;
const self = this
Toggle.If(state.featureSwitchUserbadge,
() => new UserBadge(state)
).AttachTo("userbadge")
Toggle.If(state.featureSwitchSearch,
() => new SearchAndGo(state))
.AttachTo("searchbox");
let iframePopout: () => BaseUIElement = undefined;
if (window !== window.top) {
// MapComplete is running in an iframe
iframePopout = () => new VariableUiElement(state.locationControl.map(loc => {
const url = `${window.location.origin}${window.location.pathname}?z=${loc.zoom ?? 0}&lat=${loc.lat ?? 0}&lon=${loc.lon ?? 0}`;
const link = new Link(Svg.pop_out_img, url, true).SetClass("block w-full h-full p-1.5")
return new MapControlButton(link)
}))
}
new Toggle(new Lazy(() => self.InitWelcomeMessage()),
Toggle.If(state.featureSwitchIframePopoutEnabled, iframePopout),
state.featureSwitchWelcomeMessage
).AttachTo("messagesbox");
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");
// We have to ping the welcomeMessageIsOpened and other isOpened-stuff to activate the FullScreenMessage if needed
for (const state of guiState.allFullScreenStates) {
if (state.data) {
state.ping()
}
}
/**
* At last, if the map moves or an element is selected, we close all the panels just as well
*/
state.selectedElement.addCallbackAndRunD((_) => {
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);
// ?-Button on Desktop, opens panel with close-X.
const help = new MapControlButton(Svg.help_svg());
help.onClick(() => isOpened.setData(true));
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;
}
isOpened.setData(false);
});
this.state.selectedElement.addCallbackAndRunD((_) => {
isOpened.setData(false);
});
return new Toggle(
fullOptions.SetClass("welcomeMessage pointer-events-auto"),
help.SetClass("pointer-events-auto"),
isOpened
)
}
}

74
UI/DefaultGuiState.ts Normal file
View file

@ -0,0 +1,74 @@
import {UIEventSource} from "../Logic/UIEventSource";
import {QueryParameters} from "../Logic/Web/QueryParameters";
import Constants from "../Models/Constants";
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 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. 0 = the explanation of the theme,1 = OSM-credits, 2 = sharescreen, 3 = more themes, 4 = about mapcomplete (user must be logged in and have >${Constants.userJourney.mapCompleteHelpUnlock} changesets)`
));
this.welcomeMessageIsOpened = QueryParameters.GetBooleanQueryParameter(
"welcome-control-toggle",
"false",
"Whether or not the welcome panel is shown"
)
this.downloadControlIsOpened = QueryParameters.GetBooleanQueryParameter(
"download-control-toggle",
"false",
"Whether or not the download panel is shown"
)
this.filterViewIsOpened = QueryParameters.GetBooleanQueryParameter(
"filter-toggle",
"false",
"Whether or not the filter view is shown"
)
this.copyrightViewIsOpened = QueryParameters.GetBooleanQueryParameter(
"copyright-toggle",
"false",
"Whether or not the copyright view is shown"
)
if (Hash.hash.data === "download") {
this.downloadControlIsOpened.setData(true)
}
if (Hash.hash.data === "filters") {
this.filterViewIsOpened.setData(true)
}
if (Hash.hash.data === "copyright") {
this.copyrightViewIsOpened.setData(true)
}
if (Hash.hash.data === "" || Hash.hash.data === undefined || Hash.hash.data === "welcome") {
this.welcomeMessageIsOpened.setData(true)
}
this.allFullScreenStates.push(this.downloadControlIsOpened, this.filterViewIsOpened, this.copyrightViewIsOpened, this.welcomeMessageIsOpened)
for (let i = 0; i < this.allFullScreenStates.length; 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 => {
if (isOpened) {
otherState.setData(false)
}
})
}
}
}
}

View file

@ -1,9 +1,6 @@
import jsPDF from "jspdf";
import {SimpleMapScreenshoter} from "leaflet-simple-map-screenshoter";
import {UIEventSource} from "../Logic/UIEventSource";
import Minimap from "./Base/Minimap";
import Minimap, {MinimapObj} from "./Base/Minimap";
import Loc from "../Models/Loc";
import BaseLayer from "../Models/BaseLayer";
import {FixedUiElement} from "./Base/FixedUiElement";
@ -14,7 +11,7 @@ import LayoutConfig from "../Models/ThemeConfig/LayoutConfig";
import FeaturePipeline from "../Logic/FeatureSource/FeaturePipeline";
import ShowDataLayer from "./ShowDataLayer/ShowDataLayer";
import {BBox} from "../Logic/BBox";
import ShowOverlayLayer from "./ShowDataLayer/ShowOverlayLayer";
/**
* Creates screenshoter to take png screenshot
* Creates jspdf and downloads it
@ -63,14 +60,12 @@ export default class ExportPDF {
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: leaflet => window.setTimeout(() => {
onFullyLoaded: _ => window.setTimeout(() => {
if (self._screenhotTaken) {
return;
}
try {
self.CreatePdf(leaflet)
self.CreatePdf(minimap)
.then(() => self.cleanup())
.catch(() => self.cleanup())
} catch (e) {
@ -85,10 +80,10 @@ export default class ExportPDF {
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 => {
if(tile.layer.layerDef.minzoom > l.zoom){
if (tile.layer.layerDef.minzoom > l.zoom) {
return
}
new ShowDataLayer(
@ -101,7 +96,7 @@ export default class ExportPDF {
}
)
})
})
State.state.AddAllOverlaysToMap(minimap.leafletMap)
@ -112,20 +107,16 @@ export default class ExportPDF {
this._screenhotTaken = true;
}
private async CreatePdf(leaflet: L.Map) {
private async CreatePdf(minimap: MinimapObj) {
console.log("PDF creation started")
const t = Translations.t.general.pdf;
const layout = this._layout
const screenshotter = new SimpleMapScreenshoter();
//minimap op index.html -> hidden daar alles op doen en dan weg
//minimap - leaflet map ophalen - boundaries ophalen - State.state.featurePipeline
screenshotter.addTo(leaflet);
let doc = new jsPDF('landscape');
const image = (await screenshotter.takeScreen('image'))
const image = await minimap.TakeScreenshot()
// @ts-ignore
doc.addImage(image, 'PNG', 0, 0, this.mapW, this.mapH);

View file

@ -12,10 +12,10 @@ export class AttributedImage extends Combine {
let img: BaseUIElement;
let attr: 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
});
attr = new Attribution(imageInfo.provider.GetAttributionFor(imageInfo.url),
imageInfo.provider.SourceIcon(),
imageInfo.provider.SourceIcon(),
)

View file

@ -13,10 +13,10 @@ export default class Attribution extends VariableUiElement {
}
super(
license.map((license: LicenseInfo) => {
if(license === undefined){
if (license === undefined) {
return undefined
}
return new Combine([
icon?.SetClass("block left").SetStyle("height: 2em; width: 2em; padding-right: 0.5em;"),

View file

@ -15,19 +15,19 @@ export default class DeleteImage extends Toggle {
const isDeletedBadge = Translations.t.image.isDeleted.Clone()
.SetClass("rounded-full p-1")
.SetStyle("color:white;background:#ff8c8c")
.onClick(async() => {
await State.state?.changes?.applyAction(new ChangeTagAction(tags.data.id, new Tag(key, oldValue), tags.data, {
changeType: "answer",
theme: "test"
}))
.onClick(async () => {
await State.state?.changes?.applyAction(new ChangeTagAction(tags.data.id, new Tag(key, oldValue), tags.data, {
changeType: "answer",
theme: "test"
}))
});
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;")
.onClick( async() => {
await State.state?.changes?.applyAction(
new ChangeTagAction(tags.data.id, new Tag(key, ""), tags.data,{
.onClick(async () => {
await State.state?.changes?.applyAction(
new ChangeTagAction(tags.data.id, new Tag(key, ""), tags.data, {
changeType: "answer",
theme: "test"
})

View file

@ -9,7 +9,7 @@ import ImageProvider from "../../Logic/ImageProviders/ImageProvider";
export class ImageCarousel extends Toggle {
constructor(images: UIEventSource<{ key: string, url: string, provider: ImageProvider }[]>,
constructor(images: UIEventSource<{ key: string, url: string, provider: ImageProvider }[]>,
tags: UIEventSource<any>,
keys: string[]) {
const uiElements = images.map((imageURLS: { key: string, url: string, provider: ImageProvider }[]) => {

View file

@ -16,13 +16,13 @@ import {VariableUiElement} from "../Base/VariableUIElement";
export class ImageUploadFlow extends Toggle {
private static readonly uploadCountsPerId = new Map<string, UIEventSource<number>>()
constructor(tagsSource: UIEventSource<any>, imagePrefix: string = "image", text: string = undefined) {
const perId = ImageUploadFlow.uploadCountsPerId
const id = tagsSource.data.id
if(!perId.has(id)){
if (!perId.has(id)) {
perId.set(id, new UIEventSource<number>(0))
}
const uploadedCount = perId.get(id)
@ -39,7 +39,7 @@ export class ImageUploadFlow extends Toggle {
key = imagePrefix + ":" + freeIndex;
}
console.log("Adding image:" + key, url);
uploadedCount.data ++
uploadedCount.data++
uploadedCount.ping()
Promise.resolve(State.state.changes
.applyAction(new ChangeTagAction(
@ -50,17 +50,17 @@ export class ImageUploadFlow extends Toggle {
}
)))
})
const licensePicker = new LicensePicker()
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 ")
}else{
labelContent = new FixedUiElement(text).SetClass("block align-middle mt-1 ml-3 text-2xl ")
}
let labelContent: BaseUIElement
if (text === undefined) {
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 ")
}
const label = new Combine([
Svg.camera_plus_ui().SetClass("block w-12 h-12 p-1 text-4xl "),
labelContent
@ -74,17 +74,17 @@ export class ImageUploadFlow extends Toggle {
for (var i = 0; i < filelist.length; i++) {
const sizeInBytes= filelist[i].size
const sizeInBytes = filelist[i].size
console.log(filelist[i].name + " has a size of " + sizeInBytes + " Bytes");
if(sizeInBytes > uploader.maxFileSizeInMegabytes * 1000000){
if (sizeInBytes > uploader.maxFileSizeInMegabytes * 1000000) {
alert(Translations.t.image.toBig.Subs({
actual_size: (Math.floor(sizeInBytes / 1000000)) + "MB",
max_size: uploader.maxFileSizeInMegabytes+"MB"
max_size: uploader.maxFileSizeInMegabytes + "MB"
}).txt)
return;
}
}
console.log("Received images from the user, starting upload")
const license = licensePicker.GetValue()?.data ?? "CC0"
@ -114,31 +114,31 @@ export class ImageUploadFlow extends Toggle {
const uploadFlow: BaseUIElement = new Combine([
new VariableUiElement(uploader.queue.map(q => q.length).map(l => {
if(l == 0){
if (l == 0) {
return undefined;
}
if(l == 1){
return t.uploadingPicture.Clone().SetClass("alert")
}else{
if (l == 1) {
return t.uploadingPicture.Clone().SetClass("alert")
} else {
return t.uploadingMultiple.Subs({count: "" + l}).SetClass("alert")
}
})),
new VariableUiElement(uploader.failed.map(q => q.length).map(l => {
if(l==0){
if (l == 0) {
return undefined
}
return t.uploadFailed.Clone().SetClass("alert");
})),
new VariableUiElement(uploadedCount.map(l => {
if(l == 0){
return undefined;
if (l == 0) {
return undefined;
}
if(l == 1){
if (l == 1) {
return t.uploadDone.Clone().SetClass("thanks");
}
return t.uploadMultipleDone.Subs({count: l}).SetClass("thanks")
})),
fileSelector,
Translations.t.image.respectPrivacy.Clone().SetStyle("font-size:small;"),
licensePicker

View file

@ -15,9 +15,9 @@ export class FixedInputElement<T> extends InputElement<T> {
comparator: ((t0: T, t1: T) => boolean) = undefined) {
super();
this._comparator = comparator ?? ((t0, t1) => t0 == t1);
if(value instanceof UIEventSource){
if (value instanceof UIEventSource) {
this.value = value
}else{
} else {
this.value = new UIEventSource<T>(value);
}

View file

@ -25,8 +25,8 @@ export default class InputElementMap<T, X> extends InputElement<X> {
const self = this;
this._value = inputElement.GetValue().map(
(t => {
const currentX = self.GetValue()?.data;
const newX = toX(t);
const currentX = self.GetValue()?.data;
if (isSame(currentX, newX)) {
return currentX;
}

View file

@ -45,6 +45,7 @@ export default class LengthInput extends InputElement<string> {
background: this.background,
allowMoving: false,
location: this._location,
attribution: true,
leafletOptions: {
tap: true
}

View file

@ -6,7 +6,6 @@ import BaseLayer from "../../Models/BaseLayer";
import Combine from "../Base/Combine";
import Svg from "../../Svg";
import State from "../../State";
import AvailableBaseLayers from "../../Logic/Actors/AvailableBaseLayers";
import {GeoOperations} from "../../Logic/GeoOperations";
import ShowDataMultiLayer from "../ShowDataLayer/ShowDataMultiLayer";
import StaticFeatureSource from "../../Logic/FeatureSource/Sources/StaticFeatureSource";
@ -16,7 +15,6 @@ import {FixedUiElement} from "../Base/FixedUiElement";
import ShowDataLayer from "../ShowDataLayer/ShowDataLayer";
import BaseUIElement from "../BaseUIElement";
import Toggle from "./Toggle";
import {start} from "repl";
export default class LocationInput extends InputElement<Loc> implements MinimapObj {
@ -25,12 +23,17 @@ export default class LocationInput extends InputElement<Loc> implements MinimapO
id: "matchpoint", source: {
osmTags: {and: []}
},
icon: "./assets/svg/crosshair-empty.svg"
mapRendering: [{
location: ["point","centroid"],
icon: "./assets/svg/crosshair-empty.svg"
}]
}, "matchpoint icon", true
)
IsSelected: UIEventSource<boolean> = new UIEventSource<boolean>(false);
public readonly snappedOnto: UIEventSource<any> = new UIEventSource<any>(undefined)
public readonly _matching_layer: LayerConfig;
public readonly leafletMap: UIEventSource<any>
private _centerLocation: UIEventSource<Loc>;
private readonly mapBackground: UIEventSource<BaseLayer>;
/**
@ -43,10 +46,7 @@ export default class LocationInput extends InputElement<Loc> implements MinimapO
private readonly _maxSnapDistance: number
private readonly _snappedPointTags: any;
private readonly _bounds: UIEventSource<BBox>;
public readonly _matching_layer: LayerConfig;
private readonly map: BaseUIElement & MinimapObj;
public readonly leafletMap: UIEventSource<any>
private readonly clickLocation: UIEventSource<Loc>;
private readonly _minZoom: number;
@ -83,7 +83,7 @@ export default class LocationInput extends InputElement<Loc> implements MinimapO
}
this._matching_layer = matchingLayer;
} else {
this._matching_layer = LocationInput.matchLayer
this._matching_layer = LocationInput.matchLayer
}
this._snappedPoint = options.centerLocation.map(loc => {
@ -96,17 +96,22 @@ export default class LocationInput extends InputElement<Loc> implements MinimapO
let min = undefined;
let matchedWay = undefined;
for (const feature of self._snapTo.data ?? []) {
const nearestPointOnLine = GeoOperations.nearestPoint(feature.feature, [loc.lon, loc.lat])
if (min === undefined) {
min = nearestPointOnLine
matchedWay = feature.feature;
continue;
}
try {
if (min.properties.dist > nearestPointOnLine.properties.dist) {
min = nearestPointOnLine
matchedWay = feature.feature;
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)
}
}
@ -158,25 +163,33 @@ export default class LocationInput extends InputElement<Loc> implements MinimapO
IsValid(t: Loc): boolean {
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 hasMoved = new UIEventSource(false)
const startLocation = { ...this._centerLocation.data }
this._centerLocation. addCallbackD(newLocation => {
const startLocation = {...this._centerLocation.data}
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 ))
if(diff < 1){
const diff = (Math.abs(newLocation.lon * f - startLocation.lon * f) + Math.abs(newLocation.lat * f - startLocation.lat * f))
if (diff < 1) {
return;
}
hasMoved.setData(true)
return true;
})
this.clickLocation.addCallbackAndRunD(location => this._centerLocation.setData(location))
if (this._snapTo !== undefined) {
if (this._snapTo !== undefined) {
// Show the lines to snap to
new ShowDataMultiLayer({
features: new StaticFeatureSource(this._snapTo, true),
@ -184,7 +197,7 @@ export default class LocationInput extends InputElement<Loc> implements MinimapO
zoomToFeatures: false,
leafletMap: this.map.leafletMap,
layers: State.state.filteredLayers,
allElements: State.state.allElements
allElements: State.state.allElements
}
)
// Show the central point
@ -194,16 +207,16 @@ export default class LocationInput extends InputElement<Loc> implements MinimapO
}
return [{feature: loc}];
})
new ShowDataLayer({
features: new StaticFeatureSource(matchPoint, true),
enablePopups: false,
zoomToFeatures: false,
leafletMap: this.map.leafletMap,
layerToShow: this._matching_layer,
allElements: State.state.allElements,
selectedElement: State.state.selectedElement
})
new ShowDataLayer({
features: new StaticFeatureSource(matchPoint, true),
enablePopups: false,
zoomToFeatures: false,
leafletMap: this.map.leafletMap,
layerToShow: this._matching_layer,
allElements: State.state.allElements,
selectedElement: State.state.selectedElement
})
}
this.mapBackground.map(layer => {
const leaflet = this.map.leafletMap.data
@ -216,19 +229,19 @@ export default class LocationInput extends InputElement<Loc> implements MinimapO
leaflet.setZoom(layer.max_zoom - 1)
}, [this.map.leafletMap])
const animatedHand = Svg.hand_ui()
.SetStyle("width: 2rem; height: unset;")
.SetClass("hand-drag-animation block pointer-events-none")
return new Combine([
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")
]).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)
.SetClass("block w-0 h-0 z-10 relative")
@ -244,9 +257,4 @@ export default class LocationInput extends InputElement<Loc> implements MinimapO
}
}
installBounds(factor: number | BBox, showRange?: boolean): void {
this.map.installBounds(factor, showRange)
}
}

View file

@ -18,16 +18,8 @@ export default class Toggle extends VariableUiElement {
this.isEnabled = isEnabled
}
public ToggleOnClick(): Toggle {
const self = this;
this.onClick(() => {
self.isEnabled.setData(!self.isEnabled.data);
})
return this;
}
public static If(condition: UIEventSource<boolean>, constructor: () => BaseUIElement): BaseUIElement {
if(constructor === undefined){
public static If(condition: UIEventSource<boolean>, constructor: () => BaseUIElement): BaseUIElement {
if (constructor === undefined) {
return undefined
}
return new Toggle(
@ -35,6 +27,14 @@ export default class Toggle extends VariableUiElement {
undefined,
condition
)
}
public ToggleOnClick(): Toggle {
const self = this;
this.onClick(() => {
self.isEnabled.setData(!self.isEnabled.data);
})
return this;
}
}

View file

@ -19,6 +19,9 @@ import {FixedInputElement} from "./FixedInputElement";
import WikidataSearchBox from "../Wikipedia/WikidataSearchBox";
import Wikidata from "../../Logic/Web/Wikidata";
import AvailableBaseLayers from "../../Logic/Actors/AvailableBaseLayers";
import Table from "../Base/Table";
import Combine from "../Base/Combine";
import Title from "../Base/Title";
interface TextFieldDef {
name: string,
@ -28,21 +31,172 @@ interface TextFieldDef {
inputHelper?: (value: UIEventSource<string>, options?: {
location: [number, number],
mapBackgroundLayer?: UIEventSource<any>,
args: (string | number | boolean)[]
args: (string | number | boolean | any)[]
feature?: any
}) => InputElement<string>,
inputmode?: string
}
class WikidataTextField implements TextFieldDef {
name = "wikidata"
explanation =
new Combine([
"A wikidata identifier, e.g. Q42.",
new Title("Helper arguments"),
new Table(["name", "doc"],
[
["key", "the value of this tag will initialize search (default: name)"],
["options", new Combine(["A JSON-object of type `{ removePrefixes: string[], removePostfixes: string[] }`.",
new Table(
["subarg", "doc"],
[["removePrefixes", "remove these snippets of text from the start of the passed string to search"],
["removePostfixes", "remove these snippets of text from the end of the passed string to search"],
]
)])
]]),
new Title("Example usage"),
`The following is the 'freeform'-part of a layer config which will trigger a search for the wikidata item corresponding with the name of the selected feature. It will also remove '-street', '-square', ... if found at the end of the name
\`\`\`
"freeform": {
"key": "name:etymology:wikidata",
"type": "wikidata",
"helperArgs": [
"name",
{
"removePostfixes": [
"street",
"boulevard",
"path",
"square",
"plaza",
]
}
]
}
\`\`\``
]).AsMarkdown()
public isValid(str) {
if (str === undefined) {
return false;
}
if (str.length <= 2) {
return false;
}
return !str.split(";").some(str => Wikidata.ExtractKey(str) === undefined)
}
public reformat(str) {
if (str === undefined) {
return undefined;
}
let out = str.split(";").map(str => Wikidata.ExtractKey(str)).join("; ")
if (str.endsWith(";")) {
out = out + ";"
}
return out;
}
public inputHelper(currentValue, inputHelperOptions) {
const args = inputHelperOptions.args ?? []
const searchKey = args[0] ?? "name"
let searchFor = <string>inputHelperOptions.feature?.properties[searchKey]?.toLowerCase()
const options = args[1]
if (searchFor !== undefined && options !== undefined) {
const prefixes = <string[]>options["removePrefixes"]
const postfixes = <string[]>options["removePostfixes"]
for (const postfix of postfixes ?? []) {
if (searchFor.endsWith(postfix)) {
searchFor = searchFor.substring(0, searchFor.length - postfix.length)
break;
}
}
for (const prefix of prefixes ?? []) {
if (searchFor.startsWith(prefix)) {
searchFor = searchFor.substring(prefix.length)
break;
}
}
}
return new WikidataSearchBox({
value: currentValue,
searchText: new UIEventSource<string>(searchFor)
})
}
}
class OpeningHoursTextField implements TextFieldDef {
name = "opening_hours"
explanation =
new Combine([
"Has extra elements to easily input when a POI is opened.",
new Title("Helper arguments"),
new Table(["name", "doc"],
[
["options", new Combine([
"A JSON-object of type `{ prefix: string, postfix: string }`. ",
new Table(["subarg", "doc"],
[
["prefix", "Piece of text that will always be added to the front of the generated opening hours. If the OSM-data does not start with this, it will fail to parse"],
["postfix", "Piece of text that will always be added to the end of the generated opening hours"],
])
])
]
]),
new Title("Example usage"),
"To add a conditional (based on time) access restriction:\n\n```\n" + `
"freeform": {
"key": "access:conditional",
"type": "opening_hours",
"helperArgs": [
{
"prefix":"no @ (",
"postfix":")"
}
]
}` + "\n```\n\n*Don't forget to pass the prefix and postfix in the rendering as well*: `{opening_hours_table(opening_hours,yes @ &LPARENS, &RPARENS )`"]).AsMarkdown()
isValid() {
return true
}
reformat(str) {
return str
}
inputHelper(value: UIEventSource<string>, inputHelperOptions: {
location: [number, number],
mapBackgroundLayer?: UIEventSource<any>,
args: (string | number | boolean | any)[]
feature?: any
}) {
const args = (inputHelperOptions.args ?? [])[0]
const prefix = <string>args?.prefix ?? ""
const postfix = <string>args?.postfix ?? ""
return new OpeningHoursInput(value, prefix, postfix)
}
}
export default class ValidatedTextField {
public static tpList: TextFieldDef[] = [
ValidatedTextField.tp(
"string",
"A basic string"),
ValidatedTextField.tp(
"text",
"A string, but allows input of longer strings more comfortably (a text area)",
"A string, but allows input of longer strings more comfortably and supports newlines (a text area)",
undefined,
undefined,
undefined,
@ -117,7 +271,8 @@ export default class ValidatedTextField {
if (args[0]) {
zoom = Number(args[0])
if (isNaN(zoom)) {
throw "Invalid zoom level for argument at 'length'-input"
console.error("Invalid zoom level for argument at 'length'-input. The offending argument is: ", args[0], " (using 19 instead)")
zoom = 19
}
}
@ -146,60 +301,7 @@ export default class ValidatedTextField {
},
"decimal"
),
ValidatedTextField.tp(
"wikidata",
"A wikidata identifier, e.g. Q42. Input helper arguments: [ key: the value of this tag will initialize search (default: name), options: { removePrefixes: string[], removePostfixes: string[] } these prefixes and postfixes will be removed from the initial search value]",
(str) => {
if (str === undefined) {
return false;
}
if(str.length <= 2){
return false;
}
return !str.split(";").some(str => Wikidata.ExtractKey(str) === undefined)
},
(str) => {
if (str === undefined) {
return undefined;
}
let out = str.split(";").map(str => Wikidata.ExtractKey(str)).join("; ")
if(str.endsWith(";")){
out = out + ";"
}
return out;
},
(currentValue, inputHelperOptions) => {
const args = inputHelperOptions.args ?? []
const searchKey = args[0] ?? "name"
let searchFor = <string>inputHelperOptions.feature?.properties[searchKey]?.toLowerCase()
const options = args[1]
if (searchFor !== undefined && options !== undefined) {
const prefixes = <string[]>options["removePrefixes"]
const postfixes = <string[]>options["removePostfixes"]
for (const postfix of postfixes ?? []) {
if (searchFor.endsWith(postfix)) {
searchFor = searchFor.substring(0, searchFor.length - postfix.length)
break;
}
}
for (const prefix of prefixes ?? []) {
if (searchFor.startsWith(prefix)) {
searchFor = searchFor.substring(prefix.length)
break;
}
}
}
return new WikidataSearchBox({
value: currentValue,
searchText: new UIEventSource<string>(searchFor)
})
}
),
new WikidataTextField(),
ValidatedTextField.tp(
"int",
@ -299,15 +401,7 @@ export default class ValidatedTextField {
undefined,
"tel"
),
ValidatedTextField.tp(
"opening_hours",
"Has extra elements to easily input when a POI is opened",
() => true,
str => str,
(value) => {
return new OpeningHoursInput(value);
}
),
new OpeningHoursTextField(),
ValidatedTextField.tp(
"color",
"Shows a color picker",
@ -379,6 +473,9 @@ export default class ValidatedTextField {
options.inputMode = tp.inputmode;
if(tp.inputmode === "text") {
options.htmlType = "area"
}
let input: InputElement<string> = new TextField(options);
@ -458,7 +555,11 @@ export default class ValidatedTextField {
public static HelpText(): string {
const explanations = ValidatedTextField.tpList.map(type => ["## " + type.name, "", type.explanation].join("\n")).join("\n\n")
return "# Available types for text fields\n\nThe listed types here trigger a special input element. Use them in `tagrendering.freeform.type` of your tagrendering to activate them\n\n" + explanations
return new Combine([
new Title("Available types for text fields", 1),
"The listed types here trigger a special input element. Use them in `tagrendering.freeform.type` of your tagrendering to activate them",
explanations
]).SetClass("flex flex-col").AsMarkdown()
}
private static tp(name: string,

View file

@ -5,9 +5,9 @@ import {VariableUiElement} from "../Base/VariableUIElement";
export default class VariableInputElement<T> extends InputElement<T> {
public readonly IsSelected: UIEventSource<boolean>;
private readonly value: UIEventSource<T>;
private readonly element: BaseUIElement
public readonly IsSelected: UIEventSource<boolean>;
private readonly upstream: UIEventSource<InputElement<T>>;
constructor(upstream: UIEventSource<InputElement<T>>) {
@ -23,13 +23,12 @@ export default class VariableInputElement<T> extends InputElement<T> {
return this.value;
}
protected InnerConstructElement(): HTMLElement {
return this.element.ConstructElement();
}
IsValid(t: T): boolean {
return this.upstream.data.IsValid(t);
}
protected InnerConstructElement(): HTMLElement {
return this.element.ConstructElement();
}
}

View file

@ -0,0 +1,184 @@
import {UIEventSource} from "../../Logic/UIEventSource";
import {OsmConnection} from "../../Logic/Osm/OsmConnection";
import FeaturePipeline from "../../Logic/FeatureSource/FeaturePipeline";
import BaseUIElement from "../BaseUIElement";
import LocationInput from "../Input/LocationInput";
import AvailableBaseLayers from "../../Logic/Actors/AvailableBaseLayers";
import {BBox} from "../../Logic/BBox";
import {TagUtils} from "../../Logic/Tags/TagUtils";
import {SubtleButton} from "../Base/SubtleButton";
import Combine from "../Base/Combine";
import Translations from "../i18n/Translations";
import Svg from "../../Svg";
import Toggle from "../Input/Toggle";
import SimpleAddUI, {PresetInfo} from "../BigComponents/SimpleAddUI";
export default class ConfirmLocationOfPoint extends Combine {
constructor(
state: {
osmConnection: OsmConnection,
featurePipeline: FeaturePipeline
},
filterViewIsOpened: UIEventSource<boolean>,
preset: PresetInfo,
confirmText: BaseUIElement,
loc: { lon: number, lat: number },
confirm: (tags: any[], location: { lat: number, lon: number }, snapOntoWayId: string) => void,
cancel: () => void,
) {
let preciseInput: LocationInput = undefined
if (preset.preciseInput !== undefined) {
// We uncouple the event source
const zloc = {...loc, zoom: 19}
const locationSrc = new UIEventSource(zloc);
let backgroundLayer = undefined;
if (preset.preciseInput.preferredBackground) {
backgroundLayer = AvailableBaseLayers.SelectBestLayerAccordingTo(locationSrc, new UIEventSource<string | string[]>(preset.preciseInput.preferredBackground))
}
let snapToFeatures: UIEventSource<{ feature: any }[]> = undefined
let mapBounds: UIEventSource<BBox> = undefined
if (preset.preciseInput.snapToLayers && preset.preciseInput.snapToLayers.length > 0) {
snapToFeatures = new UIEventSource<{ feature: any }[]>([])
mapBounds = new UIEventSource<BBox>(undefined)
}
const tags = TagUtils.KVtoProperties(preset.tags ?? []);
preciseInput = new LocationInput({
mapBackground: backgroundLayer,
centerLocation: locationSrc,
snapTo: snapToFeatures,
snappedPointTags: tags,
maxSnapDistance: preset.preciseInput.maxSnapDistance,
bounds: mapBounds
})
preciseInput.installBounds(0.15, true)
preciseInput.SetClass("h-32 rounded-xl overflow-hidden border border-gray").SetStyle("height: 12rem;")
if (preset.preciseInput.snapToLayers && preset.preciseInput.snapToLayers.length > 0) {
// We have to snap to certain layers.
// Lets fetch them
let loadedBbox: BBox = undefined
mapBounds?.addCallbackAndRunD(bbox => {
if (loadedBbox !== undefined && bbox.isContainedIn(loadedBbox)) {
// All is already there
// return;
}
bbox = bbox.pad(2);
loadedBbox = bbox;
const allFeatures: { feature: any }[] = []
preset.preciseInput.snapToLayers.forEach(layerId => {
console.log("Snapping to", layerId)
state.featurePipeline.GetFeaturesWithin(layerId, bbox)?.forEach(feats => allFeatures.push(...feats.map(f => ({feature: f}))))
})
console.log("Snapping to", allFeatures)
snapToFeatures.setData(allFeatures)
})
}
}
let confirmButton: BaseUIElement = new SubtleButton(preset.icon(),
new Combine([
confirmText,
Translations.t.general.add.warnVisibleForEveryone.Clone().SetClass("alert")
]).SetClass("flex flex-col")
).SetClass("font-bold break-words")
.onClick(() => {
confirm(preset.tags, (preciseInput?.GetValue()?.data ?? loc), preciseInput?.snappedOnto?.data?.properties?.id);
});
if (preciseInput !== undefined) {
confirmButton = new Combine([preciseInput, confirmButton])
}
const openLayerControl =
new SubtleButton(
Svg.layers_ui(),
new Combine([
Translations.t.general.add.layerNotEnabled
.Subs({layer: preset.layerToAddTo.layerDef.name})
.SetClass("alert"),
Translations.t.general.add.openLayerControl
])
)
.onClick(() => filterViewIsOpened.setData(true))
const openLayerOrConfirm = new Toggle(
confirmButton,
openLayerControl,
preset.layerToAddTo.isDisplayed
)
const disableFilter = new SubtleButton(
new Combine([
Svg.filter_ui().SetClass("absolute w-full"),
Svg.cross_bottom_right_svg().SetClass("absolute red-svg")
]).SetClass("relative"),
new Combine(
[
Translations.t.general.add.disableFiltersExplanation.Clone(),
Translations.t.general.add.disableFilters.Clone().SetClass("text-xl")
]
).SetClass("flex flex-col")
).onClick(() => {
preset.layerToAddTo.appliedFilters.setData([])
cancel()
})
const disableFiltersOrConfirm = new Toggle(
openLayerOrConfirm,
disableFilter,
preset.layerToAddTo.appliedFilters.map(filters => {
if (filters === undefined || filters.length === 0) {
return true;
}
for (const filter of filters) {
if (filter.selected === 0 && filter.filter.options.length === 1) {
return false;
}
if (filter.selected !== undefined) {
const tags = filter.filter.options[filter.selected].osmTags
if (tags !== undefined && tags["and"]?.length !== 0) {
// This actually doesn't filter anything at all
return false;
}
}
}
return true
})
)
const tagInfo = SimpleAddUI.CreateTagInfoFor(preset, state.osmConnection);
const cancelButton = new SubtleButton(Svg.close_ui(),
Translations.t.general.cancel
).onClick(cancel)
super([
state.osmConnection.userDetails.data.dryRun ?
Translations.t.general.testing.Clone().SetClass("alert") : undefined,
disableFiltersOrConfirm,
cancelButton,
preset.description,
tagInfo
])
this.SetClass("flex flex-col")
}
}

View file

@ -23,11 +23,39 @@ export default class OpeningHoursInput extends InputElement<string> {
private readonly _value: UIEventSource<string>;
private readonly _element: BaseUIElement;
constructor(value: UIEventSource<string> = new UIEventSource<string>("")) {
constructor(value: UIEventSource<string> = new UIEventSource<string>(""), prefix = "", postfix = "") {
super();
this._value = value;
let valueWithoutPrefix = value
if (prefix !== "" && postfix !== "") {
const leftoverRules = value.map<string[]>(str => {
valueWithoutPrefix = value.map(str => {
if (str === undefined) {
return undefined;
}
if (str === "") {
return ""
}
if (str.startsWith(prefix) && str.endsWith(postfix)) {
return str.substring(prefix.length, str.length - postfix.length)
}
return str
}, [], noPrefix => {
if (noPrefix === undefined) {
return undefined;
}
if (noPrefix === "") {
return ""
}
if (noPrefix.startsWith(prefix) && noPrefix.endsWith(postfix)) {
return noPrefix
}
return prefix + noPrefix + postfix
})
}
const leftoverRules = valueWithoutPrefix.map<string[]>(str => {
if (str === undefined) {
return []
}
@ -45,9 +73,9 @@ export default class OpeningHoursInput extends InputElement<string> {
return leftOvers;
})
// Note: MUST be bound AFTER the leftover rules!
const rulesFromOhPicker = value.map(OH.Parse);
const rulesFromOhPicker = valueWithoutPrefix.map(OH.Parse);
const ph = value.map<string>(str => {
const ph = valueWithoutPrefix.map<string>(str => {
if (str === undefined) {
return ""
}
@ -68,7 +96,7 @@ export default class OpeningHoursInput extends InputElement<string> {
...leftoverRules.data,
ph.data
]
value.setData(Utils.NoEmpty(rules).join(";"));
valueWithoutPrefix.setData(Utils.NoEmpty(rules).join(";"));
}
rulesFromOhPicker.addCallback(update);

View file

@ -23,11 +23,23 @@ export default class OpeningHoursVisualization extends Toggle {
Translations.t.general.weekdays.abbreviations.sunday,
]
constructor(tags: UIEventSource<any>, key: string) {
constructor(tags: UIEventSource<any>, key: string, prefix = "", postfix = "") {
const tagsDirect = tags.data;
const ohTable = new VariableUiElement(tags
.map(tags => tags[key]) // This mapping will absorb all other changes to tags in order to prevent regeneration
.map(tags => {
const value: string = tags[key];
if (value === undefined) {
return undefined
}
if (value.startsWith(prefix) && value.endsWith(postfix)) {
return value.substring(prefix.length, value.length - postfix.length).trim()
}
return value;
}) // This mapping will absorb all other changes to tags in order to prevent regeneration
.map(ohtext => {
if (ohtext === undefined) {
return new FixedUiElement("No opening hours defined with key " + key).SetClass("alert")
}
try {
// noinspection JSPotentiallyInvalidConstructorUsage
const oh = new opening_hours(ohtext, {
@ -35,12 +47,12 @@ export default class OpeningHoursVisualization extends Toggle {
lon: tagsDirect._lon,
address: {
country_code: tagsDirect._country
}
}, {tag_key: key});
},
}, {tag_key: "opening_hours"});
return OpeningHoursVisualization.CreateFullVisualisation(oh)
} catch (e) {
console.log(e);
console.warn(e, e.stack);
return new Combine([Translations.t.general.opening_hours.error_loading,
new Toggle(
new FixedUiElement(e).SetClass("subtle"),

View file

@ -264,7 +264,7 @@ export default class DeleteWizard extends Toggle {
]
}, undefined, "Delete wizard"
}, "Delete wizard"
)
}

View file

@ -16,7 +16,10 @@ export default class EditableTagRendering extends Toggle {
constructor(tags: UIEventSource<any>,
configuration: TagRenderingConfig,
units: Unit [],
editMode = new UIEventSource<boolean>(false)
options: {
editMode?: UIEventSource<boolean>,
innerElementClasses?: string
}
) {
// The tagrendering is hidden if:
@ -25,15 +28,20 @@ export default class EditableTagRendering extends Toggle {
const renderingIsShown = tags.map(tags =>
configuration.IsKnown(tags) &&
(configuration?.condition?.matchesProperties(tags) ?? true))
super(
new Lazy(() => EditableTagRendering.CreateRendering(tags, configuration, units, editMode)),
new Lazy(() => {
const editMode = options.editMode ?? new UIEventSource<boolean>(false)
const rendering = EditableTagRendering.CreateRendering(tags, configuration, units, editMode);
rendering.SetClass(options.innerElementClasses)
return rendering
}),
undefined,
renderingIsShown
)
}
private static CreateRendering(tags: UIEventSource<any>, configuration: TagRenderingConfig, units: Unit[], editMode: UIEventSource<boolean>) : BaseUIElement{
private static CreateRendering(tags: UIEventSource<any>, configuration: TagRenderingConfig, units: Unit[], editMode: UIEventSource<boolean>): BaseUIElement {
const answer: BaseUIElement = new TagRenderingAnswer(tags, configuration)
answer.SetClass("w-full")
let rendering = answer;
@ -71,7 +79,6 @@ export default class EditableTagRendering extends Toggle {
editMode
)
}
rendering.SetClass("block w-full break-word text-default m-1 p-1 border-b border-gray-200 mb-2 pb-2")
return rendering;
}

View file

@ -5,7 +5,6 @@ import Combine from "../Base/Combine";
import TagRenderingAnswer from "./TagRenderingAnswer";
import State from "../../State";
import ScrollableFullScreen from "../Base/ScrollableFullScreen";
import {Tag} from "../../Logic/Tags/Tag";
import Constants from "../../Models/Constants";
import SharedTagRenderings from "../../Customizations/SharedTagRenderings";
import BaseUIElement from "../BaseUIElement";
@ -18,6 +17,8 @@ import {Translation} from "../i18n/Translation";
import {Utils} from "../../Utils";
import {SubstitutedTranslation} from "../SubstitutedTranslation";
import MoveWizard from "./MoveWizard";
import Toggle from "../Input/Toggle";
import {FixedUiElement} from "../Base/FixedUiElement";
export default class FeatureInfoBox extends ScrollableFullScreen {
@ -37,7 +38,7 @@ export default class FeatureInfoBox extends ScrollableFullScreen {
private static GenerateTitleBar(tags: UIEventSource<any>,
layerConfig: LayerConfig): BaseUIElement {
const title = new TagRenderingAnswer(tags, layerConfig.title ?? new TagRenderingConfig("POI", undefined))
const title = new TagRenderingAnswer(tags, layerConfig.title ?? new TagRenderingConfig("POI"))
.SetClass("break-words font-bold sm:p-0.5 md:p-1 sm:p-1.5 md:p-2");
const titleIcons = new Combine(
layerConfig.titleIcons.map(icon => new TagRenderingAnswer(tags, icon,
@ -52,26 +53,79 @@ export default class FeatureInfoBox extends ScrollableFullScreen {
private static GenerateContent(tags: UIEventSource<any>,
layerConfig: LayerConfig): BaseUIElement {
let questionBox: BaseUIElement = undefined;
let questionBoxes: Map<string, QuestionBox> = new Map<string, QuestionBox>();
const allGroupNames = Utils.Dedup(layerConfig.tagRenderings.map(tr => tr.group))
if (State.state.featureSwitchUserbadge.data) {
questionBox = new QuestionBox(tags, layerConfig.tagRenderings, layerConfig.units);
const questionSpecs = layerConfig.tagRenderings.filter(tr => tr.id === "questions")
for (const groupName of allGroupNames) {
const questions = layerConfig.tagRenderings.filter(tr => tr.group === groupName)
const questionSpec = questionSpecs.filter(tr => tr.group === groupName)[0]
const questionBox = new QuestionBox({
tagsSource: tags,
tagRenderings: questions,
units: layerConfig.units,
showAllQuestionsAtOnce: questionSpec?.freeform?.helperArgs["showAllQuestions"] ?? State.state.featureSwitchShowAllQuestions
});
questionBoxes.set(groupName, questionBox)
}
}
let questionBoxIsUsed = false;
const renderings: BaseUIElement[] = layerConfig.tagRenderings.map(tr => {
if (tr.question === null) {
// This is the question box!
questionBoxIsUsed = true;
return questionBox;
const allRenderings = []
for (let i = 0; i < allGroupNames.length; i++) {
const groupName = allGroupNames[i];
const trs = layerConfig.tagRenderings.filter(tr => tr.group === groupName)
const renderingsForGroup: (EditableTagRendering | BaseUIElement)[] = []
const innerClasses = "block w-full break-word text-default m-1 p-1 border-b border-gray-200 mb-2 pb-2";
for (const tr of trs) {
if (tr.question === null || tr.id === "questions") {
// This is a question box!
const questionBox = questionBoxes.get(tr.group)
questionBoxes.delete(tr.group)
if (tr.render !== undefined) {
questionBox.SetClass("text-sm")
const renderedQuestion = new TagRenderingAnswer(tags, tr, tr.group + " questions", "", {
specialViz: new Map<string, BaseUIElement>([["questions", questionBox]])
})
const possiblyHidden = new Toggle(
renderedQuestion,
undefined,
questionBox.restingQuestions.map(ls => ls?.length > 0)
)
renderingsForGroup.push(possiblyHidden)
} else {
renderingsForGroup.push(questionBox)
}
} else {
let classes = innerClasses
let isHeader = renderingsForGroup.length === 0 && i > 0
if (isHeader) {
// This is the first element of a group!
// It should act as header and be sticky
classes = ""
}
const etr = new EditableTagRendering(tags, tr, layerConfig.units, {
innerElementClasses: innerClasses
})
if (isHeader) {
etr.SetClass("sticky top-0")
}
renderingsForGroup.push(etr)
}
}
return new EditableTagRendering(tags, tr, layerConfig.units);
});
allRenderings.push(...renderingsForGroup)
}
let editElements: BaseUIElement[] = []
if (!questionBoxIsUsed) {
questionBoxes.forEach(questionBox => {
editElements.push(questionBox);
}
})
if (layerConfig.allowMove) {
editElements.push(
@ -107,7 +161,7 @@ export default class FeatureInfoBox extends ScrollableFullScreen {
const hasMinimap = layerConfig.tagRenderings.some(tr => FeatureInfoBox.hasMinimap(tr))
if (!hasMinimap) {
renderings.push(new TagRenderingAnswer(tags, SharedTagRenderings.SharedTagRendering.get("minimap")))
allRenderings.push(new TagRenderingAnswer(tags, SharedTagRenderings.SharedTagRendering.get("minimap")))
}
editElements.push(
@ -132,7 +186,7 @@ export default class FeatureInfoBox extends ScrollableFullScreen {
new VariableUiElement(
State.state.featureSwitchIsDebugging.map(isDebugging => {
if (isDebugging) {
const config: TagRenderingConfig = new TagRenderingConfig({render: "{all_tags()}"}, new Tag("id", ""), "");
const config: TagRenderingConfig = new TagRenderingConfig({render: "{all_tags()}"}, "");
return new TagRenderingAnswer(tags, config, "all_tags")
}
})
@ -147,10 +201,9 @@ export default class FeatureInfoBox extends ScrollableFullScreen {
return new Combine(editElements).SetClass("flex flex-col")
}
))
renderings.push(editors)
allRenderings.push(editors)
return new Combine(renderings).SetClass("block")
return new Combine(allRenderings).SetClass("block")
}
/**

View file

@ -42,7 +42,7 @@ export default class MoveWizard extends Toggle {
changes: Changes,
layoutToUse: LayoutConfig,
allElements: ElementStorage
}, options : MoveConfig) {
}, options: MoveConfig) {
const t = Translations.t.move
const loginButton = new Toggle(
@ -64,7 +64,7 @@ export default class MoveWizard extends Toggle {
minZoom: 6
})
}
if(options.enableImproveAccuracy){
if (options.enableImproveAccuracy) {
reasons.push({
text: t.reasons.reasonInaccurate,
invitingText: t.inviteToMove.reasonInaccurate,
@ -79,8 +79,8 @@ export default class MoveWizard extends Toggle {
const currentStep = new UIEventSource<"start" | "reason" | "pick_location" | "moved">("start")
const moveReason = new UIEventSource<MoveReason>(undefined)
let moveButton : BaseUIElement;
if(reasons.length === 1){
let moveButton: BaseUIElement;
if (reasons.length === 1) {
const reason = reasons[0]
moveReason.setData(reason)
moveButton = new SubtleButton(
@ -89,7 +89,7 @@ export default class MoveWizard extends Toggle {
).onClick(() => {
currentStep.setData("pick_location")
})
}else{
} else {
moveButton = new SubtleButton(
Svg.move_ui().SetStyle("height: 1.5rem; width: auto"),
t.inviteToMove.generic
@ -97,7 +97,7 @@ export default class MoveWizard extends Toggle {
currentStep.setData("reason")
})
}
const moveAgainButton = new SubtleButton(
Svg.move_ui(),
@ -107,8 +107,6 @@ export default class MoveWizard extends Toggle {
})
const selectReason = new Combine(reasons.map(r => new SubtleButton(r.icon, r.text).onClick(() => {
moveReason.setData(r)
currentStep.setData("pick_location")
@ -129,16 +127,16 @@ export default class MoveWizard extends Toggle {
})
let background: string[]
if(typeof reason.background == "string"){
if (typeof reason.background == "string") {
background = [reason.background]
}else{
} else {
background = reason.background
}
const locationInput = new LocationInput({
minZoom: reason.minZoom,
centerLocation: loc,
mapBackground:AvailableBaseLayers.SelectBestLayerAccordingTo(loc, new UIEventSource(background))
mapBackground: AvailableBaseLayers.SelectBestLayerAccordingTo(loc, new UIEventSource(background))
})
if (reason.lockBounds) {
@ -198,8 +196,8 @@ export default class MoveWizard extends Toggle {
moveDisallowedReason.setData(t.isWay)
} else if (id.startsWith("relation")) {
moveDisallowedReason.setData(t.isRelation)
} else if(id.indexOf("-") < 0) {
} else if (id.indexOf("-") < 0) {
OsmObject.DownloadReferencingWays(id).then(referencing => {
if (referencing.length > 0) {
console.log("Got a referencing way, move not allowed")
@ -207,7 +205,7 @@ export default class MoveWizard extends Toggle {
}
})
OsmObject.DownloadReferencingRelations(id).then(partOf => {
if(partOf.length > 0){
if (partOf.length > 0) {
moveDisallowedReason.setData(t.partOfRelation)
}
})

View file

@ -32,6 +32,7 @@ export interface MultiApplyParams {
class MultiApplyExecutor {
private static executorCache = new Map<string, MultiApplyExecutor>()
private readonly originalValues = new Map<string, string>()
private readonly params: MultiApplyParams;
@ -48,7 +49,7 @@ class MultiApplyExecutor {
const self = this;
const relevantValues = p.tagsSource.map(tags => {
const currentValues = p.keysToApply.map(key => tags[key])
// By stringifying, we have a very clear ping when they changec
// By stringifying, we have a very clear ping when they changec
return JSON.stringify(currentValues);
})
relevantValues.addCallbackD(_ => {
@ -57,6 +58,15 @@ class MultiApplyExecutor {
}
}
public static GetApplicator(id: string, params: MultiApplyParams): MultiApplyExecutor {
if (MultiApplyExecutor.executorCache.has(id)) {
return MultiApplyExecutor.executorCache.get(id)
}
const applicator = new MultiApplyExecutor(params)
MultiApplyExecutor.executorCache.set(id, applicator)
return applicator
}
public applyTaggingOnOtherFeatures() {
console.log("Multi-applying changes...")
const featuresToChange = this.params.featureIds.data
@ -103,17 +113,6 @@ class MultiApplyExecutor {
}
}
private static executorCache = new Map<string, MultiApplyExecutor>()
public static GetApplicator(id: string, params: MultiApplyParams): MultiApplyExecutor {
if (MultiApplyExecutor.executorCache.has(id)) {
return MultiApplyExecutor.executorCache.get(id)
}
const applicator = new MultiApplyExecutor(params)
MultiApplyExecutor.executorCache.set(id, applicator)
return applicator
}
}
export default class MultiApply extends Toggle {

View file

@ -1,7 +1,6 @@
import {UIEventSource} from "../../Logic/UIEventSource";
import TagRenderingQuestion from "./TagRenderingQuestion";
import Translations from "../i18n/Translations";
import State from "../../State";
import Combine from "../Base/Combine";
import BaseUIElement from "../BaseUIElement";
import {VariableUiElement} from "../Base/VariableUIElement";
@ -14,70 +13,119 @@ import Lazy from "../Base/Lazy";
* Generates all the questions, one by one
*/
export default class QuestionBox extends VariableUiElement {
public readonly skippedQuestions: UIEventSource<number[]>;
public readonly restingQuestions: UIEventSource<BaseUIElement[]>;
constructor(options: {
tagsSource: UIEventSource<any>,
tagRenderings: TagRenderingConfig[], units: Unit[],
showAllQuestionsAtOnce?: boolean | UIEventSource<boolean>
}) {
constructor(tagsSource: UIEventSource<any>, tagRenderings: TagRenderingConfig[], units: Unit[]) {
const skippedQuestions: UIEventSource<number[]> = new UIEventSource<number[]>([])
tagRenderings = tagRenderings
const tagsSource = options.tagsSource
const units = options.units
options.showAllQuestionsAtOnce = options.showAllQuestionsAtOnce ?? false
const tagRenderings = options.tagRenderings
.filter(tr => tr.question !== undefined)
.filter(tr => tr.question !== null);
.filter(tr => tr.question !== null)
super(tagsSource.map(tags => {
if (tags === undefined) {
return undefined;
const tagRenderingQuestions = tagRenderings
.map((tagRendering, i) =>
new Lazy(() => new TagRenderingQuestion(tagsSource, tagRendering,
{
units: units,
afterSave: () => {
// We save and indicate progress by pinging and recalculating
skippedQuestions.ping();
},
cancelButton: Translations.t.general.skip.Clone()
.SetClass("btn btn-secondary mr-3")
.onClick(() => {
skippedQuestions.data.push(i);
skippedQuestions.ping();
})
}
)));
const skippedQuestionsButton = Translations.t.general.skippedQuestions
.onClick(() => {
skippedQuestions.setData([]);
})
tagsSource.map(tags => {
if (tags === undefined) {
return undefined;
}
for (let i = 0; i < tagRenderingQuestions.length; i++) {
let tagRendering = tagRenderings[i];
if (skippedQuestions.data.indexOf(i) >= 0) {
continue;
}
if (tagRendering.IsKnown(tags)) {
continue;
}
if (tagRendering.condition &&
!tagRendering.condition.matchesProperties(tags)) {
// Filtered away by the condition, so it is kindof known
continue;
}
const tagRenderingQuestions = tagRenderings
.map((tagRendering, i) =>
new Lazy(() => new TagRenderingQuestion(tagsSource, tagRendering,
{
units: units,
afterSave: () => {
// We save
skippedQuestions.ping();
},
cancelButton: Translations.t.general.skip.Clone()
.SetClass("btn btn-secondary mr-3")
.onClick(() => {
skippedQuestions.data.push(i);
skippedQuestions.ping();
})
}
)));
// this value is NOT known - this is the question we have to show!
return i
}
return undefined; // The questions are depleted
}, [skippedQuestions]);
const questionsToAsk: UIEventSource<BaseUIElement[]> = tagsSource.map(tags => {
if (tags === undefined) {
return [];
}
const qs = []
for (let i = 0; i < tagRenderingQuestions.length; i++) {
let tagRendering = tagRenderings[i];
const skippedQuestionsButton = Translations.t.general.skippedQuestions.Clone()
.onClick(() => {
skippedQuestions.setData([]);
})
if (skippedQuestions.data.indexOf(i) >= 0) {
continue;
}
if (tagRendering.IsKnown(tags)) {
continue;
}
if (tagRendering.condition &&
!tagRendering.condition.matchesProperties(tags)) {
// Filtered away by the condition, so it is kindof known
continue;
}
// this value is NOT known - this is the question we have to show!
qs.push(tagRenderingQuestions[i])
}
return qs
}, [skippedQuestions])
const allQuestions: BaseUIElement[] = []
for (let i = 0; i < tagRenderingQuestions.length; i++) {
let tagRendering = tagRenderings[i];
if (tagRendering.IsKnown(tags)) {
continue;
}
if (skippedQuestions.data.indexOf(i) >= 0) {
continue;
}
// this value is NOT known - we show the questions for it
if (State.state.featureSwitchShowAllQuestions.data || allQuestions.length == 0) {
allQuestions.push(tagRenderingQuestions[i])
}
super(questionsToAsk.map(allQuestions => {
const els: BaseUIElement[] = []
if (options.showAllQuestionsAtOnce === true || options.showAllQuestionsAtOnce["data"]) {
els.push(...questionsToAsk.data)
} else {
els.push(allQuestions[0])
}
if (skippedQuestions.data.length > 0) {
allQuestions.push(skippedQuestionsButton)
els.push(skippedQuestionsButton)
}
return new Combine(allQuestions).SetClass("block mb-8")
}, [skippedQuestions])
return new Combine(els).SetClass("block mb-8")
})
)
this.skippedQuestions = skippedQuestions;
this.restingQuestions = questionsToAsk
}
}

View file

@ -21,8 +21,13 @@ export default class SplitRoadWizard extends Toggle {
private static splitLayerStyling = new LayerConfig({
id: "splitpositions",
source: {osmTags: "_cutposition=yes"},
icon: {render: "circle:white;./assets/svg/scissors.svg"},
iconSize: {render: "30,30,center"},
mapRendering: [
{
location: ["point", "centroid"],
icon: {render: "circle:white;./assets/svg/scissors.svg"},
iconSize: {render: "30,30,center"}
}
],
}, "(BUILTIN) SplitRoadWizard.ts", true)
public dialogIsOpened: UIEventSource<boolean>
@ -61,7 +66,7 @@ export default class SplitRoadWizard extends Toggle {
miniMap.installBounds(BBox.get(roadElement).pad(0.25), false)
// Define how a cut is displayed on the map
// Datalayer displaying the road and the cut points (if any)
new ShowDataLayer({
features: new StaticFeatureSource(splitPoints, true),
@ -90,7 +95,7 @@ export default class SplitRoadWizard extends Toggle {
const points = splitPoints.data.map((f, i) => [f.feature, i])
.filter(p => GeoOperations.distanceBetween(p[0].geometry.coordinates, coordinates) * 1000 < 5)
.map(p => p[1])
.sort()
.sort((a, b) => a - b)
.reverse()
if (points.length > 0) {
for (const point of points) {

View file

@ -11,10 +11,16 @@ import TagRenderingConfig from "../../Models/ThemeConfig/TagRenderingConfig";
*/
export default class TagRenderingAnswer extends VariableUiElement {
constructor(tagsSource: UIEventSource<any>, configuration: TagRenderingConfig, contentClasses: string = "", contentStyle: string = "") {
constructor(tagsSource: UIEventSource<any>, configuration: TagRenderingConfig,
contentClasses: string = "", contentStyle: string = "", options?:{
specialViz: Map<string, BaseUIElement>
}) {
if (configuration === undefined) {
throw "Trying to generate a tagRenderingAnswer without configuration..."
}
if (tagsSource === undefined) {
throw "Trying to generate a tagRenderingAnswer without tagSource..."
}
super(tagsSource.map(tags => {
if (tags === undefined) {
return undefined;
@ -31,7 +37,7 @@ export default class TagRenderingAnswer extends VariableUiElement {
return undefined;
}
const valuesToRender: BaseUIElement[] = trs.map(tr => new SubstitutedTranslation(tr, tagsSource))
const valuesToRender: BaseUIElement[] = trs.map(tr => new SubstitutedTranslation(tr, tagsSource, options?.specialViz))
if (valuesToRender.length === 1) {
return valuesToRender[0];
} else if (valuesToRender.length > 1) {

View file

@ -9,7 +9,6 @@ import CheckBoxes from "../Input/Checkboxes";
import InputElementMap from "../Input/InputElementMap";
import {SaveButton} from "./SaveButton";
import State from "../../State";
import {Changes} from "../../Logic/Osm/Changes";
import {VariableUiElement} from "../Base/VariableUIElement";
import Translations from "../i18n/Translations";
import {FixedUiElement} from "../Base/FixedUiElement";
@ -85,7 +84,7 @@ export default class TagRenderingQuestion extends Combine {
const save = () => {
const selection = inputElement.GetValue().data;
if (selection) {
(State.state?.changes ?? new Changes())
(State.state?.changes)
.applyAction(new ChangeTagAction(
tags.data.id, selection, tags.data, {
theme: State.state?.layoutToUse?.id ?? "unkown",

View file

@ -1,25 +1,35 @@
/**
* The data layer shows all the given geojson elements with the appropriate icon etc
*/
import {UIEventSource} from "../../Logic/UIEventSource";
import LayerConfig from "../../Models/ThemeConfig/LayerConfig";
import FeatureInfoBox from "../Popup/FeatureInfoBox";
import {ShowDataLayerOptions} from "./ShowDataLayerOptions";
import {ElementStorage} from "../../Logic/ElementStorage";
import RenderingMultiPlexerFeatureSource from "../../Logic/FeatureSource/Sources/RenderingMultiPlexerFeatureSource";
/*
// import 'leaflet-polylineoffset';
We don't actually import it here. It is imported in the 'MinimapImplementation'-class, which'll result in a patched 'L' object.
Even though actually importing this here would seem cleaner, we don't do this as this breaks some scripts:
- Scripts are ran in ts-node
- ts-node doesn't define the 'window'-object
- Importing this will execute some code which needs the window object
*/
/**
* The data layer shows all the given geojson elements with the appropriate icon etc
*/
export default class ShowDataLayer {
private static dataLayerIds = 0
private readonly _leafletMap: UIEventSource<L.Map>;
private readonly _enablePopups: boolean;
private readonly _features: UIEventSource<{ feature: any }[]>
private readonly _features: RenderingMultiPlexerFeatureSource
private readonly _layerToShow: LayerConfig;
private readonly _selectedElement: UIEventSource<any>
private readonly allElements : ElementStorage
private readonly allElements: ElementStorage
// Used to generate a fresh ID when needed
private _cleanCount = 0;
private geoLayer = undefined;
private isDirty = false;
/**
* If the selected element triggers, this is used to lookup the correct layer and to open the popup
* Used to avoid a lot of callbacks on the selected element
@ -28,9 +38,7 @@ export default class ShowDataLayer {
* @private
*/
private readonly leafletLayersPerId = new Map<string, { feature: any, leafletlayer: any }>()
private readonly showDataLayerid : number;
private static dataLayerIds = 0
private readonly showDataLayerid: number;
constructor(options: ShowDataLayerOptions & { layerToShow: LayerConfig }) {
this._leafletMap = options.leafletMap;
@ -41,8 +49,7 @@ export default class ShowDataLayer {
console.error("Invalid ShowDataLayer invocation: options.features is undefed")
throw "Invalid ShowDataLayer invocation: options.features is undefed"
}
const features = options.features.features.map(featFreshes => featFreshes.map(ff => ff.feature));
this._features = features;
this._features = new RenderingMultiPlexerFeatureSource(options.features, options.layerToShow);
this._layerToShow = options.layerToShow;
this._selectedElement = options.selectedElement
this.allElements = options.allElements;
@ -53,7 +60,7 @@ export default class ShowDataLayer {
}
);
features.addCallback(_ => self.update(options));
this._features.features.addCallback(_ => self.update(options));
options.doShowLayer?.addCallback(doShow => {
const mp = options.leafletMap.data;
if (mp == undefined) {
@ -103,13 +110,13 @@ export default class ShowDataLayer {
leafletLayer.openPopup()
}
})
this.update(options)
}
private update(options: ShowDataLayerOptions) {
if (this._features.data === undefined) {
if (this._features.features.data === undefined) {
return;
}
this.isDirty = true;
@ -139,13 +146,40 @@ export default class ShowDataLayer {
onEachFeature: (feature, leafletLayer) => self.postProcessFeature(feature, leafletLayer)
});
const allFeats = this._features.data;
const allFeats = this._features.features.data;
for (const feat of allFeats) {
if (feat === undefined) {
continue
}
try {
this.geoLayer.addData(feat);
if (feat.geometry.type === "LineString") {
const self = this;
const coords = L.GeoJSON.coordsToLatLngs(feat.geometry.coordinates)
const tagsSource = this.allElements?.addOrGetElement(feat) ?? new UIEventSource<any>(feat.properties);
let offsettedLine;
tagsSource
.map(tags => this._layerToShow.lineRendering[feat.lineRenderingIndex].GenerateLeafletStyle(tags))
.withEqualityStabilized((a, b) => {
if (a === b) {
return true
}
if (a === undefined || b === undefined) {
return false
}
return a.offset === b.offset && a.color === b.color && a.weight === b.weight && a.dashArray === b.dashArray
})
.addCallbackAndRunD(lineStyle => {
if (offsettedLine !== undefined) {
self.geoLayer.removeLayer(offsettedLine)
}
// @ts-ignore
offsettedLine = L.polyline(coords, lineStyle);
this.postProcessFeature(feat, offsettedLine)
offsettedLine.addTo(this.geoLayer)
})
} else {
this.geoLayer.addData(feat);
}
} catch (e) {
console.error("Could not add ", feat, "to the geojson layer in leaflet due to", e, e.stack)
}
@ -153,9 +187,10 @@ export default class ShowDataLayer {
if (options.zoomToFeatures ?? false) {
try {
mp.fitBounds(this.geoLayer.getBounds(), {animate: false})
const bounds = this.geoLayer.getBounds()
mp.fitBounds(bounds, {animate: false})
} catch (e) {
console.error(e)
console.debug("Invalid bounds", e)
}
}
@ -170,7 +205,21 @@ export default class ShowDataLayer {
const tagsSource = this.allElements?.addOrGetElement(feature) ?? new UIEventSource<any>(feature.properties);
// Every object is tied to exactly one layer
const layer = this._layerToShow
return layer?.GenerateLeafletStyle(tagsSource, true);
const pointRenderingIndex = feature.pointRenderingIndex
const lineRenderingIndex = feature.lineRenderingIndex
if (pointRenderingIndex !== undefined) {
const style = layer.mapRendering[pointRenderingIndex].GenerateLeafletStyle(tagsSource, this._enablePopups)
return {
icon: style
}
}
if (lineRenderingIndex !== undefined) {
return layer.lineRendering[lineRenderingIndex].GenerateLeafletStyle(tagsSource.data)
}
throw "Neither lineRendering nor mapRendering defined for " + feature
}
private pointToLayer(feature, latLng): L.Layer {
@ -182,23 +231,16 @@ export default class ShowDataLayer {
if (layer === undefined) {
return;
}
let tagSource = this.allElements?.getEventSourceById(feature.properties.id) ?? new UIEventSource<any>(feature.properties)
const clickable = !(layer.title === undefined && (layer.tagRenderings ?? []).length === 0)
const style = layer.GenerateLeafletStyle(tagSource, clickable);
const baseElement = style.icon.html;
const clickable = !(layer.title === undefined && (layer.tagRenderings ?? []).length === 0) && this._enablePopups
let style: any = layer.mapRendering[feature.pointRenderingIndex].GenerateLeafletStyle(tagSource, clickable);
const baseElement = style.html;
if (!this._enablePopups) {
baseElement.SetStyle("cursor: initial !important")
}
style.html = style.html.ConstructElement()
return L.marker(latLng, {
icon: L.divIcon({
html: baseElement.ConstructElement(),
className: style.icon.className,
iconAnchor: style.icon.iconAnchor,
iconUrl: style.icon.iconUrl ?? "./assets/svg/bug.svg",
popupAnchor: style.icon.popupAnchor,
iconSize: style.icon.iconSize
})
icon: L.divIcon(style)
});
}
@ -228,7 +270,7 @@ export default class ShowDataLayer {
let infobox: FeatureInfoBox = undefined;
const id = `popup-${feature.properties.id}-${feature.geometry.type}-${this.showDataLayerid}-${this._cleanCount}`
const id = `popup-${feature.properties.id}-${feature.geometry.type}-${this.showDataLayerid}-${this._cleanCount}-${feature.pointRenderingIndex ?? feature.lineRenderingIndex}-${feature.multiLineStringIndex ?? ""}`
popup.setContent(`<div style='height: 65vh' id='${id}'>Popup for ${feature.properties.id} ${feature.geometry.type} ${id} is loading</div>`)
leafletLayer.on("popupopen", () => {
if (infobox === undefined) {
@ -237,7 +279,6 @@ export default class ShowDataLayer {
infobox.isShown.addCallback(isShown => {
if (!isShown) {
this._selectedElement?.setData(undefined);
leafletLayer.closePopup()
}
});
@ -256,7 +297,7 @@ export default class ShowDataLayer {
feature: feature,
leafletlayer: leafletLayer
})
}

View file

@ -1,19 +1,18 @@
import TilesourceConfig from "../../Models/ThemeConfig/TilesourceConfig";
import {UIEventSource} from "../../Logic/UIEventSource";
import * as L from "leaflet";
export default class ShowOverlayLayer {
public static implementation: (config: TilesourceConfig,
leafletMap: UIEventSource<any>,
isShown?: UIEventSource<boolean>) => void;
constructor(config: TilesourceConfig,
leafletMap: UIEventSource<any>,
isShown: UIEventSource<boolean> = undefined) {
if(ShowOverlayLayer.implementation === undefined){
if (ShowOverlayLayer.implementation === undefined) {
throw "Call ShowOverlayLayerImplemenation.initialize() first before using this"
}
ShowOverlayLayer.implementation(config, leafletMap, isShown)
ShowOverlayLayer.implementation(config, leafletMap, isShown)
}
}

View file

@ -4,14 +4,14 @@ import {UIEventSource} from "../../Logic/UIEventSource";
import ShowOverlayLayer from "./ShowOverlayLayer";
export default class ShowOverlayLayerImplementation {
public static Implement(){
public static Implement() {
ShowOverlayLayer.implementation = ShowOverlayLayerImplementation.AddToMap
}
public static AddToMap(config: TilesourceConfig,
leafletMap: UIEventSource<any>,
isShown: UIEventSource<boolean> = undefined){
isShown: UIEventSource<boolean> = undefined) {
leafletMap.map(leaflet => {
if (leaflet === undefined) {
return;
@ -41,5 +41,5 @@ export default class ShowOverlayLayerImplementation {
})
}
}

View file

@ -6,6 +6,7 @@ import StaticFeatureSource from "../../Logic/FeatureSource/Sources/StaticFeature
import {GeoOperations} from "../../Logic/GeoOperations";
import {Tiles} from "../../Models/TileRange";
import * as clusterstyle from "../../assets/layers/cluster_style/cluster_style.json"
export default class ShowTileInfo {
public static readonly styling = new LayerConfig(
clusterstyle, "tileinfo", true)

View file

@ -10,6 +10,12 @@ import FilteredLayer from "../../Models/FilteredLayer";
* A feature source containing but a single feature, which keeps stats about a tile
*/
export class TileHierarchyAggregator implements FeatureSource {
private static readonly empty = []
public totalValue: number = 0
public showCount: number = 0
public hiddenCount: number = 0
public readonly features = new UIEventSource<{ feature: any, freshness: Date }[]>(TileHierarchyAggregator.empty)
public readonly name;
private _parent: TileHierarchyAggregator;
private _root: TileHierarchyAggregator;
private _z: number;
@ -17,21 +23,12 @@ export class TileHierarchyAggregator implements FeatureSource {
private _y: number;
private _tileIndex: number
private _counter: SingleTileCounter
private _subtiles: [TileHierarchyAggregator, TileHierarchyAggregator, TileHierarchyAggregator, TileHierarchyAggregator] = [undefined, undefined, undefined, undefined]
public totalValue: number = 0
public showCount: number = 0
public hiddenCount: number = 0
private static readonly empty = []
public readonly features = new UIEventSource<{ feature: any, freshness: Date }[]>(TileHierarchyAggregator.empty)
public readonly name;
private readonly featuresStatic = []
private readonly featureProperties: { count: string, kilocount: string, tileId: string, id: string, showCount: string, totalCount: string };
private readonly _state: { filteredLayers: UIEventSource<FilteredLayer[]> };
private readonly updateSignal = new UIEventSource<any>(undefined)
private constructor(parent: TileHierarchyAggregator,
state: {
filteredLayers: UIEventSource<FilteredLayer[]>
@ -45,7 +42,7 @@ export class TileHierarchyAggregator implements FeatureSource {
this._y = y;
this._tileIndex = Tiles.tile_index(z, x, y)
this.name = "Count(" + this._tileIndex + ")"
const totals = {
id: "" + this._tileIndex,
tileId: "" + this._tileIndex,
@ -87,6 +84,10 @@ export class TileHierarchyAggregator implements FeatureSource {
this.featuresStatic.push({feature: box, freshness: now})
}
public static createHierarchy(state: { filteredLayers: UIEventSource<FilteredLayer[]> }) {
return new TileHierarchyAggregator(undefined, state, 0, 0, 0)
}
public getTile(tileIndex): TileHierarchyAggregator {
if (tileIndex === this._tileIndex) {
return this;
@ -103,6 +104,61 @@ export class TileHierarchyAggregator implements FeatureSource {
return this._subtiles[subtileIndex]?.getTile(tileIndex)
}
public addTile(source: FeatureSourceForLayer & Tiled) {
const self = this;
if (source.tileIndex === this._tileIndex) {
if (this._counter === undefined) {
this._counter = new SingleTileCounter(this._tileIndex)
this._counter.countsPerLayer.addCallbackAndRun(_ => self.update())
}
this._counter.addTileCount(source)
} else {
// We have to give it to one of the subtiles
let [tileZ, tileX, tileY] = Tiles.tile_from_index(source.tileIndex)
while (tileZ - 1 > this._z) {
tileX = Math.floor(tileX / 2)
tileY = Math.floor(tileY / 2)
tileZ--
}
const xDiff = tileX - (2 * this._x)
const yDiff = tileY - (2 * this._y)
const subtileIndex = yDiff * 2 + xDiff;
if (this._subtiles[subtileIndex] === undefined) {
this._subtiles[subtileIndex] = new TileHierarchyAggregator(this, this._state, tileZ, tileX, tileY)
}
this._subtiles[subtileIndex].addTile(source)
}
this.updateSignal.setData(source)
}
getCountsForZoom(clusteringConfig: { maxZoom: number }, locationControl: UIEventSource<{ zoom: number }>, cutoff: number = 0): FeatureSource {
const self = this
const empty = []
const features = locationControl.map(loc => loc.zoom).map(targetZoom => {
if (targetZoom - 1 > clusteringConfig.maxZoom) {
return empty
}
const features = []
self.visitSubTiles(aggr => {
if (aggr.showCount < cutoff) {
return false
}
if (aggr._z === targetZoom) {
features.push(...aggr.features.data)
return false
}
return aggr._z <= targetZoom;
})
return features
}, [this.updateSignal.stabilized(500)])
return new StaticFeatureSource(features, true);
}
private update() {
const newMap = new Map<string, number>()
let total = 0
@ -162,71 +218,12 @@ export class TileHierarchyAggregator implements FeatureSource {
}
}
public addTile(source: FeatureSourceForLayer & Tiled) {
const self = this;
if (source.tileIndex === this._tileIndex) {
if (this._counter === undefined) {
this._counter = new SingleTileCounter(this._tileIndex)
this._counter.countsPerLayer.addCallbackAndRun(_ => self.update())
}
this._counter.addTileCount(source)
} else {
// We have to give it to one of the subtiles
let [tileZ, tileX, tileY] = Tiles.tile_from_index(source.tileIndex)
while (tileZ - 1 > this._z) {
tileX = Math.floor(tileX / 2)
tileY = Math.floor(tileY / 2)
tileZ--
}
const xDiff = tileX - (2 * this._x)
const yDiff = tileY - (2 * this._y)
const subtileIndex = yDiff * 2 + xDiff;
if (this._subtiles[subtileIndex] === undefined) {
this._subtiles[subtileIndex] = new TileHierarchyAggregator(this, this._state, tileZ, tileX, tileY)
}
this._subtiles[subtileIndex].addTile(source)
}
this.updateSignal.setData(source)
}
public static createHierarchy(state: { filteredLayers: UIEventSource<FilteredLayer[]> }) {
return new TileHierarchyAggregator(undefined, state, 0, 0, 0)
}
private visitSubTiles(f: (aggr: TileHierarchyAggregator) => boolean) {
const visitFurther = f(this)
if (visitFurther) {
this._subtiles.forEach(tile => tile?.visitSubTiles(f))
}
}
getCountsForZoom(clusteringConfig: { maxZoom: number }, locationControl: UIEventSource<{ zoom: number }>, cutoff: number = 0): FeatureSource {
const self = this
const empty = []
const features = locationControl.map(loc => loc.zoom).map(targetZoom => {
if (targetZoom - 1 > clusteringConfig.maxZoom) {
return empty
}
const features = []
self.visitSubTiles(aggr => {
if (aggr.showCount < cutoff) {
return false
}
if (aggr._z === targetZoom) {
features.push(...aggr.features.data)
return false
}
return aggr._z <= targetZoom;
})
return features
}, [this.updateSignal.stabilized(500)])
return new StaticFeatureSource(features, true);
}
}
/**
@ -236,11 +233,10 @@ class SingleTileCounter implements Tiled {
public readonly bbox: BBox;
public readonly tileIndex: number;
public readonly countsPerLayer: UIEventSource<Map<string, number>> = new UIEventSource<Map<string, number>>(new Map<string, number>())
private readonly registeredLayers: Map<string, LayerConfig> = new Map<string, LayerConfig>();
public readonly z: number
public readonly x: number
public readonly y: number
private readonly registeredLayers: Map<string, LayerConfig> = new Map<string, LayerConfig>();
constructor(tileIndex: number) {
this.tileIndex = tileIndex

View file

@ -20,7 +20,7 @@ import Histogram from "./BigComponents/Histogram";
import Loc from "../Models/Loc";
import {Utils} from "../Utils";
import LayerConfig from "../Models/ThemeConfig/LayerConfig";
import ImportButton from "./BigComponents/ImportButton";
import {ImportButtonSpecialViz} from "./BigComponents/ImportButton";
import {Tag} from "../Logic/Tags/Tag";
import StaticFeatureSource from "../Logic/FeatureSource/Sources/StaticFeatureSource";
import ShowDataMultiLayer from "./ShowDataLayer/ShowDataMultiLayer";
@ -29,10 +29,20 @@ import AllImageProviders from "../Logic/ImageProviders/AllImageProviders";
import WikipediaBox from "./Wikipedia/WikipediaBox";
import SimpleMetaTagger from "../Logic/SimpleMetaTagger";
import MultiApply from "./Popup/MultiApply";
import AllKnownLayers from "../Customizations/AllKnownLayers";
import ShowDataLayer from "./ShowDataLayer/ShowDataLayer";
import Link from "./Base/Link";
import List from "./Base/List";
import {SubtleButton} from "./Base/SubtleButton";
import ChangeTagAction from "../Logic/Osm/Actions/ChangeTagAction";
import {And} from "../Logic/Tags/And";
import Toggle from "./Input/Toggle";
import {DefaultGuiState} from "./DefaultGuiState";
import {GeoOperations} from "../Logic/GeoOperations";
export interface SpecialVisualization {
funcName: string,
constr: ((state: State, tagSource: UIEventSource<any>, argument: string[]) => BaseUIElement),
constr: ((state: State, tagSource: UIEventSource<any>, argument: string[], guistate: DefaultGuiState, ) => BaseUIElement),
docs: string,
example?: string,
args: { name: string, defaultValue?: string, doc: string }[]
@ -40,6 +50,7 @@ export interface SpecialVisualization {
export default class SpecialVisualizations {
static tagsToApplyHelpText = Utils.Special_visualizations_tagsToApplyHelpText
public static specialVisualizations: SpecialVisualization[] =
[
{
@ -49,7 +60,7 @@ export default class SpecialVisualizations {
constr: ((state: State, tags: UIEventSource<any>) => {
const calculatedTags = [].concat(
SimpleMetaTagger.lazyTags,
... state.layoutToUse.layers.map(l => l.calculatedTags?.map(c => c[0]) ?? []))
...state.layoutToUse.layers.map(l => l.calculatedTags?.map(c => c[0]) ?? []))
return new VariableUiElement(tags.map(tags => {
const parts = [];
for (const key in tags) {
@ -57,20 +68,20 @@ export default class SpecialVisualizations {
continue
}
let v = tags[key]
if(v === ""){
if (v === "") {
v = "<b>empty string</b>"
}
parts.push([key, v ?? "<b>undefined</b>"]);
}
for(const key of calculatedTags){
for (const key of calculatedTags) {
const value = tags[key]
if(value === undefined){
if (value === undefined) {
continue
}
parts.push([ "<i>"+key+"</i>", value ])
parts.push(["<i>" + key + "</i>", value])
}
return new Table(
["key", "value"],
parts
@ -88,7 +99,7 @@ export default class SpecialVisualizations {
}],
constr: (state: State, tags, args) => {
let imagePrefixes: string[] = undefined;
if(args.length > 0){
if (args.length > 0) {
imagePrefixes = [].concat(...args.map(a => a.split(",")));
}
return new ImageCarousel(AllImageProviders.LoadImagesFor(tags, imagePrefixes), tags, imagePrefixes);
@ -101,9 +112,9 @@ export default class SpecialVisualizations {
name: "image-key",
doc: "Image tag to add the URL to (or image-tag:0, image-tag:1 when multiple images are added)",
defaultValue: "image"
},{
name:"label",
doc:"The text to show on the button",
}, {
name: "label",
doc: "The text to show on the button",
defaultValue: "Add image"
}],
constr: (state: State, tags, args) => {
@ -125,17 +136,16 @@ export default class SpecialVisualizations {
new VariableUiElement(
tagsSource.map(tags => tags[args[0]])
.map(wikidata => {
const wikidatas : string[] =
const wikidatas: string[] =
Utils.NoEmpty(wikidata?.split(";")?.map(wd => wd.trim()) ?? [])
return new WikipediaBox(wikidatas)
})
)
},
{
funcName: "minimap",
docs: "A small map showing the selected feature. Note that no styling is applied, wrap this in a div",
docs: "A small map showing the selected feature.",
args: [
{
doc: "The (maximum) zoomlevel: the target zoomlevel after fitting the entire feature. The minimap will fit the entire feature, then zoom out to this zoom level. The higher, the more zoomed in with 1 being the entire world and 19 being really close",
@ -149,7 +159,7 @@ export default class SpecialVisualizations {
}
],
example: "`{minimap()}`, `{minimap(17, id, _list_of_embedded_feature_ids_calculated_by_calculated_tag):height:10rem; border: 2px solid black}`",
constr: (state, tagSource, args) => {
constr: (state, tagSource, args, defaultGuiState) => {
const keys = [...args]
keys.splice(0, 1)
@ -164,6 +174,7 @@ export default class SpecialVisualizations {
idList = JSON.parse(value)
}
for (const id of idList) {
features.push({
freshness: new Date(),
@ -214,6 +225,53 @@ export default class SpecialVisualizations {
)
minimap.SetStyle("overflow: hidden; pointer-events: none;")
return minimap;
}
},
{
funcName: "sided_minimap",
docs: "A small map showing _only one side_ the selected feature. *This features requires to have linerenderings with offset* as only linerenderings with a postive or negative offset will be shown. Note: in most cases, this map will be automatically introduced",
args: [
{
doc: "The side to show, either `left` or `right`",
name: "side",
}
],
example: "`{sided_minimap(left)}`",
constr: (state, tagSource, args) => {
const properties = tagSource.data;
const locationSource = new UIEventSource<Loc>({
lat: Number(properties._lat),
lon: Number(properties._lon),
zoom: 18
})
const minimap = Minimap.createMiniMap(
{
background: state.backgroundLayer,
location: locationSource,
allowMoving: false
}
)
const side = args[0]
const feature = state.allElements.ContainingFeatures.get(tagSource.data.id)
const copy = {...feature}
copy.properties = {
id: side
}
new ShowDataLayer(
{
leafletMap: minimap["leafletMap"],
enablePopups: false,
zoomToFeatures: true,
layerToShow: AllKnownLayers.sharedLayers.get("left_right_style"),
features: new StaticFeatureSource([copy], false),
allElements: State.state.allElements
}
)
minimap.SetStyle("overflow: hidden; pointer-events: none;")
return minimap;
}
@ -253,9 +311,18 @@ export default class SpecialVisualizations {
name: "key",
defaultValue: "opening_hours",
doc: "The tagkey from which the table is constructed."
}, {
name: "prefix",
defaultValue: "",
doc: "Remove this string from the start of the value before parsing. __Note: use `&LPARENs` to indicate `(` if needed__"
}, {
name: "postfix",
defaultValue: "",
doc: "Remove this string from the end of the value before parsing. __Note: use `&RPARENs` to indicate `)` if needed__"
}],
example: "A normal opening hours table can be invoked with `{opening_hours_table()}`. A table for e.g. conditional access with opening hours can be `{opening_hours_table(access:conditional, no @ &LPARENS, &RPARENS)}`",
constr: (state: State, tagSource: UIEventSource<any>, args) => {
return new OpeningHoursVisualization(tagSource, args[0])
return new OpeningHoursVisualization(tagSource, args[0], args[1], args[2])
}
},
{
@ -359,12 +426,7 @@ export default class SpecialVisualizations {
const title = state?.layoutToUse?.title?.txt ?? "MapComplete";
let matchingLayer: LayerConfig = undefined;
for (const layer of (state?.layoutToUse?.layers ?? [])) {
if (layer.source.osmTags.matchesProperties(tagSource?.data)) {
matchingLayer = layer
}
}
let matchingLayer: LayerConfig = state?.layoutToUse?.getMatchingLayer(tagSource?.data);
let name = matchingLayer?.title?.GetRenderValue(tagSource.data)?.txt ?? tagSource.data?.name ?? "POI";
if (name) {
name = `${name} (${title})`
@ -415,86 +477,25 @@ export default class SpecialVisualizations {
)
}
},
new ImportButtonSpecialViz(),
{
funcName: "import_button",
args: [
{
name: "tags",
doc: "Tags to copy-specification. This contains one or more pairs (seperated by a `;`), e.g. `amenity=fast_food; addr:housenumber=$number`. This new point will then have the tags `amenity=fast_food` and `addr:housenumber` with the value that was saved in `number` in the original feature. (Hint: prepare these values, e.g. with calculatedTags)"
},
{
name: "text",
doc: "The text to show on the button",
defaultValue: "Import this data into OpenStreetMap"
},
{
name: "icon",
doc: "A nice icon to show in the button",
defaultValue: "./assets/svg/addSmall.svg"
},
{name:"minzoom",
doc: "How far the contributor must zoom in before being able to import the point",
defaultValue: "18"}],
docs: `This button will copy the data from an external dataset into OpenStreetMap. It is only functional in official themes but can be tested in unofficial themes.
If you want to import a dataset, make sure that:
1. The dataset to import has a suitable license
2. The community has been informed of the import
3. All other requirements of the [import guidelines](https://wiki.openstreetmap.org/wiki/Import/Guidelines) have been followed
There are also some technicalities in your theme to keep in mind:
1. The new point will be added and will flow through the program as any other new point as if it came from OSM.
This means that there should be a layer which will match the new tags and which will display it.
2. The original point from your geojson layer will gain the tag '_imported=yes'.
This should be used to change the appearance or even to hide it (eg by changing the icon size to zero)
3. There should be a way for the theme to detect previously imported points, even after reloading.
A reference number to the original dataset is an excellen way to do this
`,
constr: (state, tagSource, args) => {
if (!state.layoutToUse.official && !state.featureSwitchIsTesting.data) {
return new Combine([new FixedUiElement("The import button is disabled for unofficial themes to prevent accidents.").SetClass("alert"),
new FixedUiElement("To test, add 'test=true' to the URL. The changeset will be printed in the console. Please open a PR to officialize this theme to actually enable the import button.")])
}
const tgsSpec = args[0].split(";").map(spec => {
const kv = spec.split("=").map(s => s.trim());
if (kv.length != 2) {
throw "Invalid key spec: multiple '=' found in " + spec
}
return kv
})
const rewrittenTags: UIEventSource<Tag[]> = tagSource.map(tags => {
const newTags: Tag [] = []
for (const [key, value] of tgsSpec) {
if (value.startsWith('$')) {
const origKey = value.substring(1)
newTags.push(new Tag(key, tags[origKey]))
} else {
newTags.push(new Tag(key, value))
}
}
return newTags
})
const id = tagSource.data.id;
const feature = state.allElements.ContainingFeatures.get(id)
if (feature.geometry.type !== "Point") {
return new FixedUiElement("Error: can only import point objects").SetClass("alert")
}
const [lon, lat] = feature.geometry.coordinates;
return new ImportButton(
args[2], args[1], tagSource, rewrittenTags, lat, lon, Number(args[3]), state
)
}
},
{funcName: "multi_apply",
funcName: "multi_apply",
docs: "A button to apply the tagging of this object onto a list of other features. This is an advanced feature for which you'll need calculatedTags",
args:[
args: [
{name: "feature_ids", doc: "A JSOn-serialized list of IDs of features to apply the tagging on"},
{name: "keys", doc: "One key (or multiple keys, seperated by ';') of the attribute that should be copied onto the other features." },
{
name: "keys",
doc: "One key (or multiple keys, seperated by ';') of the attribute that should be copied onto the other features."
},
{name: "text", doc: "The text to show on the button"},
{name:"autoapply",doc:"A boolean indicating wether this tagging should be applied automatically if the relevant tags on this object are changed. A visual element indicating the multi_apply is still shown"},
{name:"overwrite",doc:"If set to 'true', the tags on the other objects will always be overwritten. The default behaviour will be to only change the tags on other objects if they are either undefined or had the same value before the change"}
{
name: "autoapply",
doc: "A boolean indicating wether this tagging should be applied automatically if the relevant tags on this object are changed. A visual element indicating the multi_apply is still shown"
},
{
name: "overwrite",
doc: "If set to 'true', the tags on the other objects will always be overwritten. The default behaviour will be to only change the tags on other objects if they are either undefined or had the same value before the change"
}
],
example: "{multi_apply(_features_with_the_same_name_within_100m, name:etymology:wikidata;name:etymology, Apply etymology information on all nearby objects with the same name)}",
constr: (state, tagsSource, args) => {
@ -503,14 +504,14 @@ There are also some technicalities in your theme to keep in mind:
const text = args[2]
const autoapply = args[3]?.toLowerCase() === "true"
const overwrite = args[4]?.toLowerCase() === "true"
const featureIds : UIEventSource<string[]> = tagsSource.map(tags => {
const ids = tags[featureIdsKey]
try{
if(ids === undefined){
const featureIds: UIEventSource<string[]> = tagsSource.map(tags => {
const ids = tags[featureIdsKey]
try {
if (ids === undefined) {
return []
}
return JSON.parse(ids);
}catch(e){
} catch (e) {
console.warn("Could not parse ", ids, "as JSON to extract IDS which should be shown on the map.")
return []
}
@ -526,14 +527,140 @@ There are also some technicalities in your theme to keep in mind:
state
}
);
}
},
{
funcName: "tag_apply",
docs: "Shows a big button; clicking this button will apply certain tags onto the feature.\n\nThe first argument takes a specification of which tags to add.\n" + SpecialVisualizations.tagsToApplyHelpText,
args: [
{
name: "tags_to_apply",
doc: "A specification of the tags to apply"
},
{
name: "message",
doc: "The text to show to the contributor"
},
{
name: "image",
doc: "An image to show to the contributor on the button"
},
{
name: "id_of_object_to_apply_this_one",
defaultValue: undefined,
doc: "If specified, applies the the tags onto _another_ object. The id will be read from properties[id_of_object_to_apply_this_one] of the selected object. The tags are still calculated based on the tags of the _selected_ element"
}
],
example: "`{tag_apply(survey_date:=$_now:date, Surveyed today!)}`",
constr: (state, tags, args) => {
const tagsToApply = SpecialVisualizations.generateTagsToApply(args[0], tags)
const msg = args[1]
let image = args[2]?.trim()
if (image === "" || image === "undefined") {
image = undefined
}
const targetIdKey = args[3]
const t = Translations.t.general.apply_button
const tagsExplanation = new VariableUiElement(tagsToApply.map(tagsToApply => {
const tagsStr = tagsToApply.map(t => t.asHumanString(false, true)).join("&");
let el: BaseUIElement = new FixedUiElement(tagsStr)
if (targetIdKey !== undefined) {
const targetId = tags.data[targetIdKey] ?? tags.data.id
el = t.appliedOnAnotherObject.Subs({tags: tagsStr, id: targetId})
}
return el;
}
)).SetClass("subtle")
const applied = new UIEventSource(false)
const applyButton = new SubtleButton(image, new Combine([msg, tagsExplanation]).SetClass("flex flex-col"))
.onClick(() => {
const targetId = tags.data[targetIdKey] ?? tags.data.id
const changeAction = new ChangeTagAction(targetId,
new And(tagsToApply.data),
tags.data, // We pass in the tags of the selected element, not the tags of the target element!
{
theme: state.layoutToUse.id,
changeType: "answer"
}
)
state.changes.applyAction(changeAction)
applied.setData(true)
})
return new Toggle(
new Toggle(
t.isApplied.SetClass("thanks"),
applyButton,
applied
)
, undefined, state.osmConnection.isLoggedIn)
}
},
{
funcName: "export_as_gpx",
docs: "Exports the selected feature as GPX-file",
args: [],
constr: (state, tagSource, args) => {
const t = Translations.t.general.download;
return new SubtleButton(Svg.download_ui(),
new Combine([t.downloadGpx.SetClass("font-bold text-lg"),
t.downloadGpxHelper.SetClass("subtle")]).SetClass("flex flex-col")
).onClick(() => {
console.log("Exporting as GPX!")
const tags = tagSource.data
const feature = state.allElements.ContainingFeatures.get(tags.id)
const matchingLayer = state?.layoutToUse?.getMatchingLayer(tags)
const gpx = GeoOperations.AsGpx(feature, matchingLayer)
const title = matchingLayer.title?.GetRenderValue(tags)?.Subs(tags)?.txt ?? "gpx_track"
Utils.offerContentsAsDownloadableFile(gpx, title+"_mapcomplete_export.gpx", {
mimetype: "{gpx=application/gpx+xml}"
})
})
}
}
]
static HelpMessage: BaseUIElement = SpecialVisualizations.GenHelpMessage();
static generateTagsToApply(spec: string, tagSource: UIEventSource<any>): UIEventSource<Tag[]> {
private static GenHelpMessage() {
const tgsSpec = spec.split(";").map(spec => {
const kv = spec.split("=").map(s => s.trim());
if (kv.length != 2) {
throw "Invalid key spec: multiple '=' found in " + spec
}
return kv
})
return tagSource.map(tags => {
const newTags: Tag [] = []
for (const [key, value] of tgsSpec) {
if (value.indexOf('$') >= 0) {
let parts = value.split("$")
// THe first of the split won't start with a '$', so no substitution needed
let actualValue = parts[0]
parts.shift()
for (const part of parts) {
const [_, varName, leftOver] = part.match(/([a-zA-Z0-9_:]*)(.*)/)
actualValue += (tags[varName] ?? "") + leftOver
}
newTags.push(new Tag(key, actualValue))
} else {
newTags.push(new Tag(key, value))
}
}
return newTags
})
}
public static HelpMessage() {
const helpTexts =
SpecialVisualizations.specialVisualizations.map(viz => new Combine(
@ -541,7 +668,13 @@ There are also some technicalities in your theme to keep in mind:
new Title(viz.funcName, 3),
viz.docs,
viz.args.length > 0 ? new Table(["name", "default", "description"],
viz.args.map(arg => [arg.name, arg.defaultValue ?? "undefined", arg.doc])
viz.args.map(arg => {
let defaultArg = arg.defaultValue ?? "_undefined_"
if (defaultArg == "") {
defaultArg = "_empty string_"
}
return [arg.name, defaultArg, arg.doc];
})
) : undefined,
new Title("Example usage", 4),
new FixedUiElement(
@ -552,12 +685,18 @@ There are also some technicalities in your theme to keep in mind:
));
const toc = new List(
SpecialVisualizations.specialVisualizations.map(viz => new Link(viz.funcName, "#" + viz.funcName))
)
return new Combine([
new Title("Special tag renderings", 3),
"In a tagrendering, some special values are substituted by an advanced UI-element. This allows advanced features and visualizations to be reused by custom themes or even to query third-party API's.",
"General usage is `{func_name()}`, `{func_name(arg, someotherarg)}` or `{func_name(args):cssStyle}`. Note that you _do not_fcs need to use quotes around your arguments, the comma is enough to separate them. This also implies you cannot use a comma in your args",
"General usage is `{func_name()}`, `{func_name(arg, someotherarg)}` or `{func_name(args):cssStyle}`. Note that you _do not_ need to use quotes around your arguments, the comma is enough to separate them. This also implies you cannot use a comma in your args",
toc,
...helpTexts
]
);
).SetClass("flex flex-col");
}
}

View file

@ -8,6 +8,7 @@ import {Utils} from "../Utils";
import {VariableUiElement} from "./Base/VariableUIElement";
import Combine from "./Base/Combine";
import BaseUIElement from "./BaseUIElement";
import {DefaultGuiState} from "./DefaultGuiState";
export class SubstitutedTranslation extends VariableUiElement {
@ -49,7 +50,7 @@ export class SubstitutedTranslation extends VariableUiElement {
}
const viz = proto.special;
try {
return viz.func.constr(State.state, tagsSource, proto.special.args).SetStyle(proto.special.style);
return viz.func.constr(State.state, tagsSource, proto.special.args, DefaultGuiState.state).SetStyle(proto.special.style);
} catch (e) {
console.error("SPECIALRENDERING FAILED for", tagsSource.data?.id, e)
return new FixedUiElement(`Could not generate special rendering for ${viz.func}(${viz.args.join(", ")}) ${e}`).SetStyle("alert")
@ -62,7 +63,6 @@ export class SubstitutedTranslation extends VariableUiElement {
this.SetClass("w-full")
}
public static ExtractSpecialComponents(template: string, extraMappings: SpecialVisualization[] = []): {
fixed?: string,
special?: {
@ -85,7 +85,9 @@ export class SubstitutedTranslation extends VariableUiElement {
const partAfter = SubstitutedTranslation.ExtractSpecialComponents(matched[4], extraMappings);
const args = knownSpecial.args.map(arg => arg.defaultValue ?? "");
if (argument.length > 0) {
const realArgs = argument.split(",").map(str => str.trim());
const realArgs = argument.split(",").map(str => str.trim()
.replace(/&LPARENS/g, '(')
.replace(/&RPARENS/g, ')'));
for (let i = 0; i < realArgs.length; i++) {
if (args.length <= i) {
args.push(realArgs[i]);

View file

@ -4,7 +4,6 @@ import Wikidata, {WikidataResponse} from "../../Logic/Web/Wikidata";
import {Translation} from "../i18n/Translation";
import {FixedUiElement} from "../Base/FixedUiElement";
import Loading from "../Base/Loading";
import {Transform} from "stream";
import Translations from "../i18n/Translations";
import Combine from "../Base/Combine";
import Img from "../Base/Img";
@ -16,6 +15,43 @@ import {Utils} from "../../Utils";
export default class WikidataPreviewBox extends VariableUiElement {
private static isHuman = [
{p: 31/*is a*/, q: 5 /* human */},
]
// @ts-ignore
private static extraProperties: {
requires?: { p: number, q?: number }[],
property: string,
display: Translation | Map<string, string | (() => BaseUIElement) /*If translation: Subs({value: * }) */>
}[] = [
{
requires: WikidataPreviewBox.isHuman,
property: "P21",
display: new Map([
['Q6581097', () => Svg.gender_male_ui().SetStyle("width: 1rem; height: auto")],
['Q6581072', () => Svg.gender_female_ui().SetStyle("width: 1rem; height: auto")],
['Q1097630', () => Svg.gender_inter_ui().SetStyle("width: 1rem; height: auto")],
['Q1052281', () => Svg.gender_trans_ui().SetStyle("width: 1rem; height: auto")/*'transwomen'*/],
['Q2449503', () => Svg.gender_trans_ui().SetStyle("width: 1rem; height: auto")/*'transmen'*/],
['Q48270', () => Svg.gender_queer_ui().SetStyle("width: 1rem; height: auto")]
])
},
{
property: "P569",
requires: WikidataPreviewBox.isHuman,
display: new Translation({
"*": "Born: {value}"
})
},
{
property: "P570",
requires: WikidataPreviewBox.isHuman,
display: new Translation({
"*": "Died: {value}"
})
}
]
constructor(wikidataId: UIEventSource<string>) {
let inited = false;
const wikidata = wikidataId
@ -45,6 +81,7 @@ export default class WikidataPreviewBox extends VariableUiElement {
}))
}
// @ts-ignore
public static WikidataResponsePreview(wikidata: WikidataResponse): BaseUIElement {
let link = new Link(
@ -57,7 +94,7 @@ export default class WikidataPreviewBox extends VariableUiElement {
let info = new Combine([
new Combine(
[Translation.fromMap(wikidata.labels)?.SetClass("font-bold"),
link]).SetClass("flex justify-between"),
link]).SetClass("flex justify-between"),
Translation.fromMap(wikidata.descriptions),
WikidataPreviewBox.QuickFacts(wikidata)
]).SetClass("flex flex-col link-underline")
@ -80,87 +117,49 @@ export default class WikidataPreviewBox extends VariableUiElement {
return info
}
private static isHuman = [
{p: 31/*is a*/, q: 5 /* human */},
]
// @ts-ignore
// @ts-ignore
private static extraProperties: {
requires?: { p: number, q?: number }[],
property: string,
display: Translation | Map<string, string | (() => BaseUIElement) /*If translation: Subs({value: * }) */>
}[] = [
{
requires: WikidataPreviewBox.isHuman,
property: "P21",
display: new Map([
['Q6581097', () => Svg.gender_male_ui().SetStyle("width: 1rem; height: auto")],
['Q6581072', () => Svg.gender_female_ui().SetStyle("width: 1rem; height: auto")],
['Q1097630',() => Svg.gender_inter_ui().SetStyle("width: 1rem; height: auto")],
['Q1052281',() => Svg.gender_trans_ui().SetStyle("width: 1rem; height: auto")/*'transwomen'*/],
['Q2449503',() => Svg.gender_trans_ui().SetStyle("width: 1rem; height: auto")/*'transmen'*/],
['Q48270',() => Svg.gender_queer_ui().SetStyle("width: 1rem; height: auto")]
])
},
{
property: "P569",
requires: WikidataPreviewBox.isHuman,
display: new Translation({
"*":"Born: {value}"
})
},
{
property: "P570",
requires: WikidataPreviewBox.isHuman,
display: new Translation({
"*":"Died: {value}"
})
}
]
public static QuickFacts(wikidata: WikidataResponse): BaseUIElement {
const els : BaseUIElement[] = []
const els: BaseUIElement[] = []
for (const extraProperty of WikidataPreviewBox.extraProperties) {
let hasAllRequirements = true
for (const requirement of extraProperty.requires) {
if(!wikidata.claims?.has("P"+requirement.p)){
if (!wikidata.claims?.has("P" + requirement.p)) {
hasAllRequirements = false;
break
}
if(!wikidata.claims?.get("P"+requirement.p).has("Q"+requirement.q)){
if (!wikidata.claims?.get("P" + requirement.p).has("Q" + requirement.q)) {
hasAllRequirements = false;
break
}
}
if(!hasAllRequirements){
if (!hasAllRequirements) {
continue
}
const key = extraProperty.property
const display = extraProperty.display
const value: string[] = Array.from(wikidata.claims.get(key))
if(value === undefined){
if (value === undefined) {
continue
}
if(display instanceof Translation){
if (display instanceof Translation) {
els.push(display.Subs({value: value.join(", ")}).SetClass("m-2"))
continue
}
const constructors = Utils.NoNull(value.map(property => display.get(property)))
const elems = constructors.map(v => {
if(typeof v === "string"){
if (typeof v === "string") {
return new FixedUiElement(v)
}else{
} else {
return v();
}
})
els.push(new Combine(elems).SetClass("flex m-2"))
}
if(els.length === 0){
if (els.length === 0) {
return undefined;
}
return new Combine(els).SetClass("flex")
}

View file

@ -13,6 +13,8 @@ import Svg from "../../Svg";
export default class WikidataSearchBox extends InputElement<string> {
private static readonly _searchCache = new Map<string, Promise<WikidataResponse[]>>()
IsSelected: UIEventSource<boolean> = new UIEventSource<boolean>(false);
private readonly wikidataId: UIEventSource<string>
private readonly searchText: UIEventSource<string>
@ -29,6 +31,10 @@ export default class WikidataSearchBox extends InputElement<string> {
return this.wikidataId;
}
IsValid(t: string): boolean {
return t.startsWith("Q") && !isNaN(Number(t.substring(1)));
}
protected InnerConstructElement(): HTMLElement {
const searchField = new TextField({
@ -46,12 +52,20 @@ export default class WikidataSearchBox extends InputElement<string> {
return;
}
searchFailMessage.setData(undefined)
lastSearchResults.WaitForPromise(
Wikidata.searchAndFetch(searchText, {
lang: Locale.language.data,
const lang = Locale.language.data
const key = lang + ":" + searchText
let promise = WikidataSearchBox._searchCache.get(key)
if (promise === undefined) {
promise = Wikidata.searchAndFetch(searchText, {
lang,
maxCount: 5
}
), err => searchFailMessage.setData(err))
)
WikidataSearchBox._searchCache.set(key, promise)
}
lastSearchResults.WaitForPromise(promise, err => searchFailMessage.setData(err))
})
@ -61,10 +75,10 @@ export default class WikidataSearchBox extends InputElement<string> {
return new Combine([Translations.t.general.wikipedia.failed.Clone().SetClass("alert"), searchFailMessage.data])
}
if(searchResults.length === 0){
if (searchResults.length === 0) {
return Translations.t.general.wikipedia.noResults.Subs({search: searchField.GetValue().data ?? ""})
}
if (searchResults.length === 0) {
return Translations.t.general.wikipedia.doSearch
}
@ -88,7 +102,6 @@ export default class WikidataSearchBox extends InputElement<string> {
}, [searchFailMessage]))
//
const full = new Combine([
new Title(Translations.t.general.wikipedia.searchWikidata, 3).SetClass("m-2"),
new Combine([
@ -108,10 +121,4 @@ export default class WikidataSearchBox extends InputElement<string> {
]).ConstructElement();
}
IsSelected: UIEventSource<boolean> = new UIEventSource<boolean>(false);
IsValid(t: string): boolean {
return t.startsWith("Q") && !isNaN(Number(t.substring(1)));
}
}

View file

@ -88,7 +88,7 @@ export default class WikipediaBox extends Combine {
}
const wikidata = <WikidataResponse>maybewikidata["success"]
if(wikidata === undefined){
if (wikidata === undefined) {
return "failed"
}
if (wikidata.wikisites.size === 0) {
@ -118,13 +118,13 @@ export default class WikipediaBox extends Combine {
return wp.failed.Clone().SetClass("alert p-4")
}
if (status[0] == "no page") {
const [_, wd] = <[string, WikidataResponse]> status
const [_, wd] = <[string, WikidataResponse]>status
return new Combine([
WikidataPreviewBox.WikidataResponsePreview(wd),
wp.noWikipediaPage.Clone().SetClass("subtle")]).SetClass("flex flex-col p-4")
}
const [pagetitle, language, wd] = <[string, string, WikidataResponse]> status
const [pagetitle, language, wd] = <[string, string, WikidataResponse]>status
return WikipediaBox.createContents(pagetitle, language, wd)
})
@ -134,27 +134,27 @@ export default class WikipediaBox extends Combine {
const titleElement = new VariableUiElement(wikiLink.map(state => {
if (typeof state !== "string") {
const [pagetitle, _] = state
if(pagetitle === "no page"){
const wd = <WikidataResponse> state[1]
return new Title( Translation.fromMap(wd.labels),3)
if (pagetitle === "no page") {
const wd = <WikidataResponse>state[1]
return new Title(Translation.fromMap(wd.labels), 3)
}
return new Title(pagetitle, 3)
}
//return new Title(Translations.t.general.wikipedia.wikipediaboxTitle.Clone(), 2)
return new Title(wikidataId,3)
return new Title(wikidataId, 3)
}))
const linkElement = new VariableUiElement(wikiLink.map(state => {
if (typeof state !== "string") {
const [pagetitle, language] = state
if(pagetitle === "no page"){
const wd = <WikidataResponse> state[1]
return new Link(Svg.pop_out_ui().SetStyle("width: 1.2rem").SetClass("block "),
"https://www.wikidata.org/wiki/"+wd.id
if (pagetitle === "no page") {
const wd = <WikidataResponse>state[1]
return new Link(Svg.pop_out_ui().SetStyle("width: 1.2rem").SetClass("block "),
"https://www.wikidata.org/wiki/" + wd.id
, true)
}
const url = `https://${language}.wikipedia.org/wiki/${pagetitle}`
return new Link(Svg.pop_out_ui().SetStyle("width: 1.2rem").SetClass("block "), url, true)
}
@ -202,7 +202,7 @@ export default class WikipediaBox extends Combine {
return new Combine([
quickFacts?.SetClass("border-2 border-grey rounded-lg m-1 mb-0"),
new VariableUiElement(contents)
.SetClass("block pl-6 pt-2")])
.SetClass("block pl-6 pt-2")])
}
}

View file

@ -34,6 +34,38 @@ export class Translation extends BaseUIElement {
return this.textFor(Translation.forcedLanguage ?? Locale.language.data)
}
static ExtractAllTranslationsFrom(object: any, context = ""): { context: string, tr: Translation }[] {
const allTranslations: { context: string, tr: Translation }[] = []
for (const key in object) {
const v = object[key]
if (v === undefined || v === null) {
continue
}
if (v instanceof Translation) {
allTranslations.push({context: context + "." + key, tr: v})
continue
}
if (typeof v === "object") {
allTranslations.push(...Translation.ExtractAllTranslationsFrom(v, context + "." + key))
}
}
return allTranslations
}
static fromMap(transl: Map<string, string>) {
const translations = {}
let hasTranslation = false;
transl?.forEach((value, key) => {
translations[key] = value
hasTranslation = true
})
if (!hasTranslation) {
return undefined
}
return new Translation(translations);
}
public textFor(language: string): string {
if (this.translations["*"]) {
return this.translations["*"];
@ -195,36 +227,8 @@ export class Translation extends BaseUIElement {
}
return allIcons.filter(icon => icon != undefined)
}
static ExtractAllTranslationsFrom(object: any, context = ""): { context: string, tr: Translation }[] {
const allTranslations: { context: string, tr: Translation }[] = []
for (const key in object) {
const v = object[key]
if (v === undefined || v === null) {
continue
}
if (v instanceof Translation) {
allTranslations.push({context: context +"." + key, tr: v})
continue
}
if (typeof v === "object") {
allTranslations.push(...Translation.ExtractAllTranslationsFrom(v, context + "." + key))
continue
}
}
return allTranslations
}
static fromMap(transl: Map<string, string>) {
const translations = {}
let hasTranslation = false;
transl?.forEach((value, key) => {
translations[key] = value
hasTranslation = true
})
if(!hasTranslation){
return undefined
}
return new Translation(translations);
AsMarkdown(): string {
return this.txt
}
}

View file

@ -25,6 +25,9 @@ export default class Translations {
if (t === undefined || t === null) {
return undefined;
}
if (typeof t === "number") {
t = "" + t
}
if (typeof t === "string") {
return new Translation({"*": t}, context);
}
@ -44,7 +47,7 @@ export default class Translations {
return undefined;
}
if (typeof (s) === "string") {
return new Translation({en: s});
return new Translation({'*': s});
}
if (s instanceof Translation) {
return s.Clone() /* MUST CLONE HERE! */;