Refactoring: remove import flow, fix various issues, get PDF-export working (but not quite)

This commit is contained in:
Pieter Vander Vennet 2023-05-05 02:03:41 +02:00
parent 2149fc1a1d
commit f7eaec2243
36 changed files with 739 additions and 3930 deletions

View file

@ -465,7 +465,7 @@ export default class ReplaceGeometryAction extends OsmChangeAction {
changeType: "conflation",
}
)
allChanges.push(...(await addExtraTags.CreateChangeDescriptions(changes)))
allChanges.push(...(await addExtraTags.CreateChangeDescriptions()))
}
const newCoordinates = [...this.targetCoordinates]

View file

@ -1,9 +1,9 @@
import { Conversion } from "./Conversion"
import {Conversion} from "./Conversion"
import LayerConfig from "../LayerConfig"
import { LayerConfigJson } from "../Json/LayerConfigJson"
import {LayerConfigJson} from "../Json/LayerConfigJson"
import Translations from "../../../UI/i18n/Translations"
import PointRenderingConfigJson from "../Json/PointRenderingConfigJson"
import { Translation, TypedTranslation } from "../../../UI/i18n/Translation"
import {Translation, TypedTranslation} from "../../../UI/i18n/Translation"
export default class CreateNoteImportLayer extends Conversion<LayerConfigJson, LayerConfigJson> {
/**
@ -82,7 +82,7 @@ export default class CreateNoteImportLayer extends Conversion<LayerConfigJson, L
return { ...translation.translations, _context: translation.context }
}
function trs<T>(translation: TypedTranslation<T>, subs: T): object {
function trs<T>(translation: TypedTranslation<T>, subs: T): Record<string, string> {
return { ...translation.Subs(subs).translations, _context: translation.context }
}

View file

@ -6,7 +6,6 @@ import Translations from "./i18n/Translations"
import Constants from "../Models/Constants"
import LanguagePicker from "./LanguagePicker"
import IndexText from "./BigComponents/IndexText"
import { ImportViewerLinks } from "./BigComponents/UserInformation"
import { LoginToggle } from "./Popup/LoginButton"
import { ImmutableStore } from "../Logic/UIEventSource"
import { OsmConnection } from "../Logic/Osm/OsmConnection"
@ -29,7 +28,6 @@ export default class AllThemesGui {
osmConnection,
featureSwitchUserbadge: new ImmutableStore(true),
}),
new ImportViewerLinks(state.osmConnection),
Translations.t.general.aboutMapComplete.intro.SetClass("link-underline"),
new FixedUiElement("v" + Constants.vNumber).SetClass("block"),
])

View file

@ -1,31 +1,30 @@
import { UIElement } from "../UIElement"
import {UIElement} from "../UIElement"
import BaseUIElement from "../BaseUIElement"
import { UIEventSource } from "../../Logic/UIEventSource"
import {Store} from "../../Logic/UIEventSource"
import ExtraLinkConfig from "../../Models/ThemeConfig/ExtraLinkConfig"
import Img from "../Base/Img"
import { SubtleButton } from "../Base/SubtleButton"
import {SubtleButton} from "../Base/SubtleButton"
import Toggle from "../Input/Toggle"
import Loc from "../../Models/Loc"
import Locale from "../i18n/Locale"
import { Utils } from "../../Utils"
import {Utils} from "../../Utils"
import Svg from "../../Svg"
import Translations from "../i18n/Translations"
import { Translation } from "../i18n/Translation"
import {Translation} from "../i18n/Translation"
interface ExtraLinkButtonState {
layout: { id: string; title: Translation }
featureSwitches: { featureSwitchWelcomeMessage: Store<boolean> },
mapProperties: {
location: Store<{ lon: number, lat: number }>;
zoom: Store<number>
}
}
export default class ExtraLinkButton extends UIElement {
private readonly _config: ExtraLinkConfig
private readonly state: {
layoutToUse: { id: string; title: Translation }
featureSwitchWelcomeMessage: UIEventSource<boolean>
locationControl: UIEventSource<Loc>
}
private readonly state: ExtraLinkButtonState
constructor(
state: {
featureSwitchWelcomeMessage: UIEventSource<boolean>
locationControl: UIEventSource<Loc>
layoutToUse: { id: string; title: Translation }
},
state: ExtraLinkButtonState,
config: ExtraLinkConfig
) {
super()
@ -41,19 +40,18 @@ export default class ExtraLinkButton extends UIElement {
const c = this._config
const isIframe = window !== window.top
if (c.requirements?.has("iframe") && !isIframe) {
return undefined
}
if (c.requirements?.has("no-iframe") && isIframe) {
return undefined
return undefined
}
let link: BaseUIElement
const theme = this.state.layoutToUse?.id ?? ""
const theme = this.state.layout?.id ?? ""
const basepath = window.location.host
const href = this.state.locationControl.map((loc) => {
const href = this.state.mapProperties.location.map((loc) => {
const subs = {
...loc,
theme: theme,
@ -61,7 +59,7 @@ export default class ExtraLinkButton extends UIElement {
language: Locale.language.data,
}
return Utils.SubstituteKeys(c.href, subs)
})
}, [this.state.mapProperties.zoom])
let img: BaseUIElement = Svg.pop_out_ui()
if (c.icon !== undefined) {
@ -71,7 +69,7 @@ export default class ExtraLinkButton extends UIElement {
let text: Translation
if (c.text === undefined) {
text = Translations.t.general.screenToSmall.Subs({
theme: this.state.layoutToUse.title,
theme: this.state.layout.title,
})
} else {
text = c.text.Clone()
@ -83,11 +81,11 @@ export default class ExtraLinkButton extends UIElement {
})
if (c.requirements?.has("no-welcome-message")) {
link = new Toggle(undefined, link, this.state.featureSwitchWelcomeMessage)
link = new Toggle(undefined, link, this.state.featureSwitches.featureSwitchWelcomeMessage)
}
if (c.requirements?.has("welcome-message")) {
link = new Toggle(link, undefined, this.state.featureSwitchWelcomeMessage)
link = new Toggle(link, undefined, this.state.featureSwitches.featureSwitchWelcomeMessage)
}
return link

View file

@ -1,62 +0,0 @@
import Combine from "../Base/Combine"
import Toggle from "../Input/Toggle"
import MapControlButton from "../MapControlButton"
import Svg from "../../Svg"
import AllDownloads from "./AllDownloads"
import { Store, UIEventSource } from "../../Logic/UIEventSource"
import Lazy from "../Base/Lazy"
import { VariableUiElement } from "../Base/VariableUIElement"
import { DefaultGuiState } from "../DefaultGuiState"
export default class LeftControls extends Combine {
constructor(state: FeaturePipelineState, guiState: DefaultGuiState) {
const currentViewFL = state.currentView?.layer
const currentViewAction = new Toggle(
new Lazy(() => {
const feature: Store<any> = state.currentView.features.map((ffs) => ffs[0])
const icon = new VariableUiElement(
feature.map((feature) => {
const defaultIcon = Svg.checkbox_empty_svg()
if (feature === undefined) {
return defaultIcon
}
const tags = { ...feature.properties, button: "yes" }
const elem = currentViewFL.layerDef.mapRendering[0]?.GetSimpleIcon(
new UIEventSource(tags)
)
if (elem === undefined) {
return defaultIcon
}
return elem
})
).SetClass("inline-block w-full h-full")
feature.map((feature) => {
if (feature === undefined) {
return undefined
}
const tagsSource = state.allElements.getEventSourceById(feature.properties.id)
return new FeatureInfoBox(tagsSource, currentViewFL.layerDef, state, {
hashToShow: "currentview",
isShown: guiState.currentViewControlIsOpened,
})
})
return new MapControlButton(icon)
}).onClick(() => {
guiState.currentViewControlIsOpened.setData(true)
}),
undefined,
new UIEventSource<boolean>(
currentViewFL !== undefined && currentViewFL?.layerDef?.tagRenderings !== null
)
)
new AllDownloads(guiState.downloadControlIsOpened, state)
super([currentViewAction])
this.SetClass("flex flex-col")
}
}

View file

@ -1,22 +1,19 @@
import { VariableUiElement } from "../Base/VariableUIElement"
import { Translation } from "../i18n/Translation"
import {VariableUiElement} from "../Base/VariableUIElement"
import {Translation} from "../i18n/Translation"
import Svg from "../../Svg"
import Combine from "../Base/Combine"
import { Store, UIEventSource } from "../../Logic/UIEventSource"
import { Utils } from "../../Utils"
import {Store, UIEventSource} from "../../Logic/UIEventSource"
import {Utils} from "../../Utils"
import Translations from "../i18n/Translations"
import BaseUIElement from "../BaseUIElement"
import LayerConfig from "../../Models/ThemeConfig/LayerConfig"
import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig"
import Loc from "../../Models/Loc"
import FilteredLayer from "../../Models/FilteredLayer"
import { InputElement } from "../Input/InputElement"
import { CheckBox } from "../Input/Checkboxes"
import { SubtleButton } from "../Base/SubtleButton"
import {InputElement} from "../Input/InputElement"
import {CheckBox} from "../Input/Checkboxes"
import {SubtleButton} from "../Base/SubtleButton"
import LZString from "lz-string"
import { SpecialVisualizationState } from "../SpecialVisualization"
import {SpecialVisualizationState} from "../SpecialVisualization"
export default class ShareScreen extends Combine {
class ShareScreen extends Combine{
constructor(state: SpecialVisualizationState) {
const layout = state?.layout
const tr = Translations.t.general.sharescreen
@ -63,7 +60,7 @@ export default class ShareScreen extends Combine {
return "layer-" + flayer.layerDef.id + "=" + flayer.isDisplayed.data
}
const currentLayer: Store<{ id: string; name: string } | undefined> =
const currentLayer: Store<{ id: string; name: string | Record<string, string> } | undefined> =
state.mapProperties.rasterLayer.map((l) => l?.properties)
const currentBackground = new VariableUiElement(
currentLayer.map((layer) => {
@ -93,13 +90,13 @@ export default class ShareScreen extends Combine {
(includeLayerSelection) => {
if (includeLayerSelection) {
return Utils.NoNull(
state.layerState.filteredLayers.map(fLayerToParam)
Array.from( state.layerState.filteredLayers.values()).map(fLayerToParam)
).join("&")
} else {
return null
}
},
state.filteredLayers.data.map((flayer) => flayer.isDisplayed)
Array.from(state.layerState.filteredLayers.values()).map((flayer) => flayer.isDisplayed)
)
)

View file

@ -1,88 +0,0 @@
import Combine from "../Base/Combine"
import LanguagePicker from "../LanguagePicker"
import Translations from "../i18n/Translations"
import Toggle from "../Input/Toggle"
import { SubtleButton } from "../Base/SubtleButton"
import { Store, UIEventSource } from "../../Logic/UIEventSource"
import { LoginToggle } from "../Popup/LoginButton"
import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig"
import { OsmConnection } from "../../Logic/Osm/OsmConnection"
import LoggedInUserIndicator from "../LoggedInUserIndicator"
import { BBox } from "../../Logic/BBox"
import Loc from "../../Models/Loc"
import { DefaultGuiState } from "../DefaultGuiState"
export default class ThemeIntroductionPanel extends Combine {
constructor(
isShown: UIEventSource<boolean>,
currentTab: UIEventSource<number>,
state: {
featureSwitchMoreQuests: UIEventSource<boolean>
featureSwitchAddNew: UIEventSource<boolean>
featureSwitchUserbadge: UIEventSource<boolean>
layoutToUse: LayoutConfig
osmConnection: OsmConnection
currentBounds: Store<BBox>
locationControl: UIEventSource<Loc>
defaultGuiState: DefaultGuiState
},
guistate?: { userInfoIsOpened: UIEventSource<boolean> }
) {
const t = Translations.t.general
const layout = state.layoutToUse
const languagePicker = new LanguagePicker(layout.language, t.pickLanguage.Clone())
const toTheMap = new SubtleButton(
undefined,
t.openTheMap.Clone().SetClass("text-xl font-bold w-full text-center")
)
.onClick(() => {
isShown.setData(false)
})
.SetClass("only-on-mobile")
const loggedInUserInfo = new LoggedInUserIndicator(state.osmConnection, {
firstLine: Translations.t.general.welcomeBack.Clone(),
})
if (guistate?.userInfoIsOpened) {
loggedInUserInfo.onClick(() => {
guistate.userInfoIsOpened.setData(true)
})
}
const loginStatus = new Toggle(
new LoginToggle(
loggedInUserInfo,
new Combine([
Translations.t.general.loginWithOpenStreetMap.SetClass("text-xl font-bold"),
Translations.t.general.loginOnlyNeededToEdit.Clone().SetClass("font-bold"),
]).SetClass("flex flex-col"),
state
),
undefined,
state.featureSwitchUserbadge
)
const hasPresets = layout.layers.some((l) => l.presets?.length > 0)
super([
layout.description.Clone().SetClass("block mb-4"),
new Combine([
t.welcomeExplanation.general,
hasPresets
? Toggle.If(state.featureSwitchAddNew, () => t.welcomeExplanation.addNew)
: undefined,
]).SetClass("flex flex-col mt-2"),
toTheMap,
loginStatus.SetClass("block mt-6 pt-2 md:border-t-2 border-dotted border-gray-400"),
layout.descriptionTail?.Clone().SetClass("block mt-4"),
languagePicker?.SetClass("block mt-4 pb-8 border-b-2 border-dotted border-gray-400"),
...layout.CustomCodeSnippets(),
])
this.SetClass("link-underline")
}
}

View file

@ -1,68 +0,0 @@
import Translations from "../i18n/Translations"
import { OsmConnection } from "../../Logic/Osm/OsmConnection"
import Combine from "../Base/Combine"
import { SubtleButton } from "../Base/SubtleButton"
import Svg from "../../Svg"
import { VariableUiElement } from "../Base/VariableUIElement"
import Img from "../Base/Img"
import { FixedUiElement } from "../Base/FixedUiElement"
import Link from "../Base/Link"
import { UIEventSource } from "../../Logic/UIEventSource"
import Loc from "../../Models/Loc"
import BaseUIElement from "../BaseUIElement"
import Showdown from "showdown"
import LanguagePicker from "../LanguagePicker"
import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig"
import Constants from "../../Models/Constants"
export class ImportViewerLinks extends VariableUiElement {
constructor(osmConnection: OsmConnection) {
super(
osmConnection.userDetails.map((ud) => {
if (ud.csCount < Constants.userJourney.importHelperUnlock) {
return undefined
}
return new Combine([
new SubtleButton(undefined, Translations.t.importHelper.title, {
url: "import_helper.html",
}),
new SubtleButton(Svg.note_svg(), Translations.t.importInspector.title, {
url: "import_viewer.html",
}),
])
})
)
}
}
class UserInformationMainPanel extends VariableUiElement {
private readonly settings: UIEventSource<Record<string, BaseUIElement>>
private readonly userInfoFocusedQuestion?: UIEventSource<string>
constructor(
osmConnection: OsmConnection,
locationControl: UIEventSource<Loc>,
layout: LayoutConfig,
isOpened: UIEventSource<boolean>,
userInfoFocusedQuestion?: UIEventSource<string>
) {
const settings = new UIEventSource<Record<string, BaseUIElement>>({})
super()
this.settings = settings
this.userInfoFocusedQuestion = userInfoFocusedQuestion
const self = this
userInfoFocusedQuestion.addCallbackD((_) => {
self.focusOnSelectedQuestion()
})
}
public focusOnSelectedQuestion() {
const focusedId = this.userInfoFocusedQuestion.data
console.log("Focusing on", focusedId, this.settings.data[focusedId])
if (focusedId === undefined) {
return
}
this.settings.data[focusedId]?.ScrollIntoView()
}
}

View file

@ -1,48 +0,0 @@
import Toggle from "./Input/Toggle"
import LeftControls from "./BigComponents/LeftControls"
import CenterMessageBox from "./CenterMessageBox"
import { DefaultGuiState } from "./DefaultGuiState"
import Combine from "./Base/Combine"
import ExtraLinkButton from "./BigComponents/ExtraLinkButton"
import GeoLocationHandler from "../Logic/Actors/GeoLocationHandler"
/**
* The default MapComplete GUI initializer
*
* Adds a welcome pane, control buttons, ... etc to index.html
*/
export default class DefaultGUI {
private readonly guiState: DefaultGuiState
private readonly geolocationHandler: GeoLocationHandler | undefined
constructor(guiState: DefaultGuiState) {
this.guiState = guiState
}
public setup() {
const extraLink = Toggle.If(
state.featureSwitchExtraLinkEnabled,
() => new ExtraLinkButton(state, state.layoutToUse.extraLink)
)
new Combine([extraLink]).SetClass("flex flex-col").AttachTo("top-left")
new Combine([
new ExtraLinkButton(state, {
...state.layoutToUse.extraLink,
newTab: true,
requirements: new Set<
"iframe" | "no-iframe" | "welcome-message" | "no-welcome-message"
>(),
}),
])
.SetClass("flex items-center justify-center normal-background h-full")
.AttachTo("on-small-screen")
const guiState = this.guiState
new LeftControls(state, guiState).AttachTo("bottom-left")
new CenterMessageBox(state).AttachTo("centermessage")
document?.getElementById("centermessage")?.classList?.add("pointer-events-none")
}
}

View file

@ -1,56 +0,0 @@
import { UIEventSource } from "../Logic/UIEventSource"
import Hash from "../Logic/Web/Hash"
export class DefaultGuiState {
public readonly welcomeMessageIsOpened: UIEventSource<boolean> = new UIEventSource<boolean>(
false
)
public readonly menuIsOpened: UIEventSource<boolean> = new UIEventSource<boolean>(false)
public readonly downloadControlIsOpened: UIEventSource<boolean> = new UIEventSource<boolean>(
false
)
public readonly filterViewIsOpened: UIEventSource<boolean> = new UIEventSource<boolean>(false)
public readonly copyrightViewIsOpened: UIEventSource<boolean> = new UIEventSource<boolean>(
false
)
public readonly currentViewControlIsOpened: UIEventSource<boolean> = new UIEventSource<boolean>(
false
)
public readonly userInfoIsOpened: UIEventSource<boolean> = new UIEventSource<boolean>(false)
public readonly userInfoFocusedQuestion: UIEventSource<string> = new UIEventSource<string>(
undefined
)
private readonly sources: Record<string, UIEventSource<boolean>> = {
welcome: this.welcomeMessageIsOpened,
download: this.downloadControlIsOpened,
filters: this.filterViewIsOpened,
copyright: this.copyrightViewIsOpened,
currentview: this.currentViewControlIsOpened,
userinfo: this.userInfoIsOpened,
}
constructor() {
const self = this
this.userInfoIsOpened.addCallback((isOpen) => {
if (!isOpen) {
console.log("Resetting focused question")
self.userInfoFocusedQuestion.setData(undefined)
}
})
this.sources[Hash.hash.data?.toLowerCase()]?.setData(true)
if (Hash.hash.data === "" || Hash.hash.data === undefined) {
this.welcomeMessageIsOpened.setData(true)
}
}
public closeAll(except) {
for (const sourceKey in this.sources) {
this.sources[sourceKey].setData(false)
}
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,800 +0,0 @@
import Combine from "../Base/Combine"
import UserRelatedState from "../../Logic/State/UserRelatedState"
import { VariableUiElement } from "../Base/VariableUIElement"
import { Utils } from "../../Utils"
import { UIEventSource } from "../../Logic/UIEventSource"
import Title from "../Base/Title"
import Translations from "../i18n/Translations"
import Loading from "../Base/Loading"
import { FixedUiElement } from "../Base/FixedUiElement"
import Link from "../Base/Link"
import { DropDown } from "../Input/DropDown"
import BaseUIElement from "../BaseUIElement"
import { SubtleButton } from "../Base/SubtleButton"
import Svg from "../../Svg"
import Toggle, { ClickableToggle } from "../Input/Toggle"
import Table from "../Base/Table"
import LeftIndex from "../Base/LeftIndex"
import Toggleable, { Accordeon } from "../Base/Toggleable"
import TableOfContents from "../Base/TableOfContents"
import { LoginToggle } from "../Popup/LoginButton"
import { QueryParameters } from "../../Logic/Web/QueryParameters"
import Lazy from "../Base/Lazy"
import { Button } from "../Base/Button"
import ChartJs from "../Base/ChartJs"
interface NoteProperties {
id: number
url: string
date_created: string
closed_at?: string
status: "open" | "closed"
comments: {
date: string
uid: number
user: string
text: string
html: string
}[]
}
interface NoteState {
props: NoteProperties
theme: string
intro: string
dateStr: string
status:
| "imported"
| "already_mapped"
| "invalid"
| "closed"
| "not_found"
| "open"
| "has_comments"
}
class DownloadStatisticsButton extends SubtleButton {
constructor(states: NoteState[][]) {
super(Svg.statistics_svg(), "Download statistics")
this.onClick(() => {
const st: NoteState[] = [].concat(...states)
const fields = [
"id",
"status",
"theme",
"date_created",
"date_closed",
"days_open",
"intro",
"...comments",
]
const values: string[][] = st.map((note) => {
return [
note.props.id + "",
note.status,
note.theme,
note.props.date_created?.substr(0, note.props.date_created.length - 3),
note.props.closed_at?.substr(0, note.props.closed_at.length - 3) ?? "",
JSON.stringify(note.intro),
...note.props.comments.map(
(c) => JSON.stringify(c.user) + ": " + JSON.stringify(c.text)
),
]
})
Utils.offerContentsAsDownloadableFile(
[fields, ...values].map((c) => c.join(", ")).join("\n"),
"mapcomplete_import_notes_overview.csv",
{
mimetype: "text/csv",
}
)
})
}
}
class MassAction extends Combine {
constructor(state: UserRelatedState, props: NoteProperties[]) {
const textField = ValidatedTextField.ForType("text").ConstructInputElement()
const actions = new DropDown<{
predicate: (p: NoteProperties) => boolean
action: (p: NoteProperties) => Promise<void>
}>("On which notes should an action be performed?", [
{
value: undefined,
shown: <string | BaseUIElement>"Pick an option...",
},
{
value: {
predicate: (p) => p.status === "open",
action: async (p) => {
const txt = textField.GetValue().data
state.osmConnection.closeNote(p.id, txt)
},
},
shown: "Add comment to every open note and close all notes",
},
{
value: {
predicate: (p) => p.status === "open",
action: async (p) => {
const txt = textField.GetValue().data
state.osmConnection.addCommentToNote(p.id, txt)
},
},
shown: "Add comment to every open note",
},
/*
{
// This was a one-off for one of the first imports
value:{
predicate: p => p.status === "open" && p.comments[0].text.split("\n").find(l => l.startsWith("note=")) !== undefined,
action: async p => {
const note = p.comments[0].text.split("\n").find(l => l.startsWith("note=")).substr("note=".length)
state.osmConnection.addCommentToNode(p.id, note)
}
},
shown:"On every open note, read the 'note='-tag and and this note as comment. (This action ignores the textfield)"
},//*/
])
const handledNotesCounter = new UIEventSource<number>(undefined)
const apply = new SubtleButton(Svg.checkmark_svg(), "Apply action").onClick(async () => {
const { predicate, action } = actions.GetValue().data
for (let i = 0; i < props.length; i++) {
handledNotesCounter.setData(i)
const prop = props[i]
if (!predicate(prop)) {
continue
}
await action(prop)
}
handledNotesCounter.setData(props.length)
})
super([
actions,
textField.SetClass("w-full border border-black"),
new Toggle(
new Toggle(
apply,
new Toggle(
new Loading(
new VariableUiElement(
handledNotesCounter.map((state) => {
if (state === props.length) {
return "All done!"
}
return (
"Handling note " + (state + 1) + " out of " + props.length
)
})
)
),
new Combine([Svg.checkmark_svg().SetClass("h-8"), "All done!"]).SetClass(
"thanks flex p-4"
),
handledNotesCounter.map((s) => s < props.length)
),
handledNotesCounter.map((s) => s === undefined)
),
new VariableUiElement(
textField
.GetValue()
.map(
(txt) =>
"Type a text of at least 15 characters to apply the action. Currently, there are " +
(txt?.length ?? 0) +
" characters"
)
).SetClass("alert"),
actions
.GetValue()
.map(
(v) => v !== undefined && textField.GetValue()?.data?.length > 15,
[textField.GetValue()]
)
),
new Toggle(
new FixedUiElement("Testmode enable").SetClass("alert"),
undefined,
state.featureSwitchIsTesting
),
])
}
}
class Statistics extends Combine {
private static r() {
return Math.floor(Math.random() * 256)
}
private static randomColour(): string {
return "rgba(" + Statistics.r() + "," + Statistics.r() + "," + Statistics.r() + ")"
}
private static CreatePieByAuthor(closed_by: Record<string, number[]>): ChartJs {
const importers = Object.keys(closed_by)
importers.sort((a, b) => closed_by[b].at(-1) - closed_by[a].at(-1))
return new ChartJs(<any>{
type: "doughnut",
data: {
labels: importers,
datasets: [
{
label: "Closed by",
data: importers.map((k) => closed_by[k].at(-1)),
backgroundColor: importers.map((_) => Statistics.randomColour()),
},
],
},
})
}
private static CreateStatePie(noteStates: NoteState[]) {
const colors = {
imported: "#0aa323",
already_mapped: "#00bbff",
invalid: "#ff0000",
closed: "#000000",
not_found: "#ff6d00",
open: "#626262",
has_comments: "#a8a8a8",
}
const knownStates = Object.keys(colors)
const byState = knownStates.map(
(targetState) => noteStates.filter((ns) => ns.status === targetState).length
)
return new ChartJs(<any>{
type: "doughnut",
data: {
labels: knownStates.map(
(state, i) =>
state + " " + Math.floor((100 * byState[i]) / noteStates.length) + "%"
),
datasets: [
{
label: "Status by",
data: byState,
backgroundColor: knownStates.map((state) => colors[state]),
},
],
},
})
}
constructor(noteStates: NoteState[]) {
if (noteStates.length === 0) {
super([])
return
}
// We assume all notes are created at the same time
let dateOpened = new Date(noteStates[0].dateStr)
for (const noteState of noteStates) {
const openDate = new Date(noteState.dateStr)
if (openDate < dateOpened) {
dateOpened = openDate
}
}
const today = new Date()
const daysBetween = (today.getTime() - dateOpened.getTime()) / (1000 * 60 * 60 * 24)
const ranges = {
dates: [],
is_open: [],
}
const closed_by: Record<string, number[]> = {}
for (const noteState of noteStates) {
const closing_user = noteState.props.comments.at(-1).user
if (closed_by[closing_user] === undefined) {
closed_by[closing_user] = []
}
}
for (let i = -1; i < daysBetween; i++) {
const dt = new Date(dateOpened.getTime() + 24 * 60 * 60 * 1000 * i)
let open_count = 0
for (const closing_user in closed_by) {
closed_by[closing_user].push(0)
}
for (const noteState of noteStates) {
const openDate = new Date(noteState.dateStr)
if (openDate > dt) {
// Not created at this point
continue
}
if (noteState.props.closed_at === undefined) {
open_count++
} else if (
new Date(noteState.props.closed_at.substring(0, 10)).getTime() > dt.getTime()
) {
open_count++
} else {
const closing_user = noteState.props.comments.at(-1).user
const user_count = closed_by[closing_user]
user_count[user_count.length - 1] += 1
}
}
ranges.dates.push(
new Date(dateOpened.getTime() + i * 1000 * 60 * 60 * 24)
.toISOString()
.substring(0, 10)
)
ranges.is_open.push(open_count)
}
const labels = ranges.dates.map((i) => "" + i)
const data = {
labels: labels,
datasets: [
{
label: "Total open",
data: ranges.is_open,
fill: false,
borderColor: "rgb(75, 192, 192)",
tension: 0.1,
},
],
}
for (const closing_user in closed_by) {
if (closed_by[closing_user].at(-1) <= 10) {
continue
}
data.datasets.push({
label: "Closed by " + closing_user,
data: closed_by[closing_user],
fill: false,
borderColor: Statistics.randomColour(),
tension: 0.1,
})
}
super([
new ChartJs({
type: "line",
data,
options: {
scales: <any>{
yAxes: [
{
ticks: {
beginAtZero: true,
},
},
],
},
},
}),
new Combine([
Statistics.CreatePieByAuthor(closed_by),
Statistics.CreateStatePie(noteStates),
])
.SetClass("flex w-full h-32")
.SetStyle("width: 40rem"),
])
this.SetClass("block w-full h-64 border border-red")
}
}
class NoteTable extends Combine {
private static individualActions: [() => BaseUIElement, string][] = [
[Svg.not_found_svg, "This feature does not exist"],
[Svg.addSmall_svg, "imported"],
[Svg.duplicate_svg, "Already mapped"],
]
constructor(noteStates: NoteState[], state?: UserRelatedState) {
const typicalComment = noteStates[0].props.comments[0].html
const table = new Table(
["id", "status", "last comment", "last modified by", "actions"],
noteStates.map((ns) => NoteTable.noteField(ns, state)),
{ sortable: true }
).SetClass("zebra-table link-underline")
super([
new Title("Mass apply an action on " + noteStates.length + " notes below"),
state !== undefined
? new MassAction(
state,
noteStates.map((ns) => ns.props)
).SetClass("block")
: undefined,
table,
new Title("Example note", 4),
new FixedUiElement(typicalComment).SetClass("literal-code link-underline"),
])
this.SetClass("flex flex-col")
}
private static noteField(ns: NoteState, state: UserRelatedState) {
const link = new Link(
"" + ns.props.id,
"https://openstreetmap.org/note/" + ns.props.id,
true
)
let last_comment = ""
const last_comment_props = ns.props.comments[ns.props.comments.length - 1]
const before_last_comment = ns.props.comments[ns.props.comments.length - 2]
if (ns.props.comments.length > 1) {
last_comment = last_comment_props.text
if (last_comment === undefined && before_last_comment?.uid === last_comment_props.uid) {
last_comment = before_last_comment.text
}
}
const statusIcon = BatchView.icons[ns.status]().SetClass("h-4 w-4 shrink-0")
const togglestate = new UIEventSource(false)
const changed = new UIEventSource<string>(undefined)
const lazyButtons = new Lazy(() =>
new Combine(
this.individualActions.map(([img, text]) =>
img()
.onClick(async () => {
if (ns.props.status === "closed") {
await state.osmConnection.reopenNote(ns.props.id)
}
await state.osmConnection.closeNote(ns.props.id, text)
changed.setData(text)
})
.SetClass("h-8 w-8")
)
).SetClass("flex")
)
const appliedButtons = new VariableUiElement(
changed.map((currentState) => (currentState === undefined ? lazyButtons : currentState))
)
const buttons = Toggle.If(
state?.osmConnection?.isLoggedIn,
() =>
new ClickableToggle(
appliedButtons,
new Button("edit...", () => {
console.log("Enabling...")
togglestate.setData(true)
}),
togglestate
)
)
return [
link,
new Combine([statusIcon, ns.status]).SetClass("flex"),
last_comment,
new Link(
last_comment_props.user,
"https://www.openstreetmap.org/user/" + last_comment_props.user,
true
),
buttons,
]
}
}
class BatchView extends Toggleable {
public static icons = {
open: Svg.compass_svg,
has_comments: Svg.speech_bubble_svg,
imported: Svg.addSmall_svg,
already_mapped: Svg.checkmark_svg,
not_found: Svg.not_found_svg,
closed: Svg.close_svg,
invalid: Svg.invalid_svg,
}
constructor(noteStates: NoteState[], state?: UserRelatedState) {
noteStates.sort((a, b) => a.props.id - b.props.id)
const { theme, intro, dateStr } = noteStates[0]
const statusHist = new Map<string, number>()
for (const noteState of noteStates) {
const st = noteState.status
const c = statusHist.get(st) ?? 0
statusHist.set(st, c + 1)
}
const unresolvedTotal =
(statusHist.get("open") ?? 0) + (statusHist.get("has_comments") ?? 0)
const badges: BaseUIElement[] = [
new FixedUiElement(dateStr).SetClass("literal-code rounded-full"),
new FixedUiElement(noteStates.length + " total")
.SetClass("literal-code rounded-full ml-1 border-4 border-gray")
.onClick(() => filterOn.setData(undefined)),
unresolvedTotal === 0
? new Combine([Svg.party_svg().SetClass("h-6 m-1"), "All done!"]).SetClass(
"flex ml-1 mb-1 pl-1 pr-3 items-center rounded-full border border-black"
)
: new FixedUiElement(
Math.round(100 - (100 * unresolvedTotal) / noteStates.length) + "%"
).SetClass("literal-code rounded-full ml-1"),
]
const filterOn = new UIEventSource<string>(undefined)
Object.keys(BatchView.icons).forEach((status) => {
const count = statusHist.get(status)
if (count === undefined) {
return undefined
}
const normal = new Combine([
BatchView.icons[status]().SetClass("h-6 m-1"),
count + " " + status,
]).SetClass("flex ml-1 mb-1 pl-1 pr-3 items-center rounded-full border border-black")
const selected = new Combine([
BatchView.icons[status]().SetClass("h-6 m-1"),
count + " " + status,
]).SetClass(
"flex ml-1 mb-1 pl-1 pr-3 items-center rounded-full border-4 border-black animate-pulse"
)
const toggle = new ClickableToggle(
selected,
normal,
filterOn.sync(
(f) => f === status,
[],
(selected, previous) => {
if (selected) {
return status
}
if (previous === status) {
return undefined
}
return previous
}
)
).ToggleOnClick()
badges.push(toggle)
})
const fullTable = new Combine([
new NoteTable(noteStates, state),
new Statistics(noteStates),
])
super(
new Combine([
new Title(theme + ": " + intro, 2),
new Combine(badges).SetClass("flex flex-wrap"),
]),
new VariableUiElement(
filterOn.map((filter) => {
if (filter === undefined) {
return fullTable
}
const notes = noteStates.filter((ns) => ns.status === filter)
return new Combine([new NoteTable(notes, state), new Statistics(notes)])
})
),
{
closeOnClick: false,
}
)
}
}
class ImportInspector extends VariableUiElement {
constructor(
userDetails: { uid: number } | { display_name: string; search?: string },
state: UserRelatedState
) {
let url
if (userDetails["uid"] !== undefined) {
url =
"https://api.openstreetmap.org/api/0.6/notes/search.json?user=" +
userDetails["uid"] +
"&closed=730&limit=10000&sort=created_at&q=%23import"
} else {
url =
"https://api.openstreetmap.org/api/0.6/notes/search.json?display_name=" +
encodeURIComponent(userDetails["display_name"]) +
"&limit=10000&closed=730&sort=created_at&q="
if (userDetails["search"] !== "") {
url += userDetails["search"]
} else {
url += "#import"
}
}
const notes: UIEventSource<
{ error: string } | { success: { features: { properties: NoteProperties }[] } }
> = UIEventSource.FromPromiseWithErr(Utils.downloadJson(url))
super(
notes.map((notes) => {
if (notes === undefined) {
return new Loading("Loading notes which mention '#import'")
}
if (notes["error"] !== undefined) {
return new FixedUiElement("Something went wrong: " + notes["error"]).SetClass(
"alert"
)
}
// We only care about the properties here
let props: NoteProperties[] = notes["success"].features.map((f) => f.properties)
if (userDetails["uid"]) {
props = props.filter((n) => n.comments[0].uid === userDetails["uid"])
}
if (userDetails["display_name"] !== undefined) {
const display_name = <string>userDetails["display_name"]
props = props.filter((n) => n.comments[0].user === display_name)
}
const perBatch: NoteState[][] = Array.from(
ImportInspector.SplitNotesIntoBatches(props).values()
)
const els: Toggleable[] = perBatch.map(
(noteStates) => new BatchView(noteStates, state)
)
const accordeon = new Accordeon(els)
let contents = []
if (state?.osmConnection?.isLoggedIn?.data) {
contents = [
new Title(Translations.t.importInspector.title, 1),
new SubtleButton(undefined, "Create a new batch of imports", {
url: "import_helper.html",
}),
]
}
contents.push(accordeon)
contents.push(
new Combine([
new Title("Statistics for all notes"),
new Statistics([].concat(...perBatch)),
])
)
const content = new Combine(contents)
return new LeftIndex(
[
new TableOfContents(content, { noTopLevel: true, maxDepth: 1 }).SetClass(
"subtle"
),
new DownloadStatisticsButton(perBatch),
],
content
)
})
)
}
/**
* Creates distinct batches of note, where 'date', 'intro' and 'theme' are identical
*/
private static SplitNotesIntoBatches(props: NoteProperties[]): Map<string, NoteState[]> {
const perBatch = new Map<string, NoteState[]>()
const prefix = "https://mapcomplete.osm.be/"
for (const prop of props) {
const lines = prop.comments[0].text.split("\n")
const trigger = lines.findIndex((l) => l.startsWith(prefix) && l.endsWith("#import"))
if (trigger < 0) {
continue
}
let theme = lines[trigger].substr(prefix.length)
theme = theme.substr(0, theme.indexOf("."))
const date = Utils.ParseDate(prop.date_created)
const dateStr = date.getFullYear() + "-" + (date.getMonth() + 1) + "-" + date.getDate()
const key = theme + lines[0] + dateStr
if (!perBatch.has(key)) {
perBatch.set(key, [])
}
let status:
| "open"
| "closed"
| "imported"
| "invalid"
| "already_mapped"
| "not_found"
| "has_comments" = "open"
function has(keywords: string[], comment: string): boolean {
return keywords.some((keyword) => comment.toLowerCase().indexOf(keyword) >= 0)
}
if (prop.closed_at !== undefined) {
const lastComment = prop.comments[prop.comments.length - 1].text.toLowerCase()
if (has(["does not exist", "bestaat niet", "geen"], lastComment)) {
status = "not_found"
} else if (
has(
[
"already mapped",
"reeds",
"dubbele note",
"stond er al",
"stonden er al",
"staat er al",
"staan er al",
"stond al",
"stonden al",
"staat al",
"staan al",
],
lastComment
)
) {
status = "already_mapped"
} else if (
lastComment.indexOf("invalid") >= 0 ||
lastComment.indexOf("incorrect") >= 0
) {
status = "invalid"
} else if (
has(
[
"imported",
"erbij",
"toegevoegd",
"added",
"gemapped",
"gemapt",
"mapped",
"done",
"openstreetmap.org/changeset",
],
lastComment
)
) {
status = "imported"
} else {
status = "closed"
}
} else if (prop.comments.length > 1) {
status = "has_comments"
}
perBatch.get(key).push({
props: prop,
intro: lines[0],
theme,
dateStr,
status,
})
}
return perBatch
}
}
class ImportViewerGui extends LoginToggle {
constructor() {
const state = new UserRelatedState(undefined)
const displayNameParam = QueryParameters.GetQueryParameter(
"user",
"",
"The username of the person whom you want to see the notes for"
)
const searchParam = QueryParameters.GetQueryParameter(
"search",
"",
"A text that should be included in the first comment of the note to be shown"
)
super(
new VariableUiElement(
state.osmConnection.userDetails.map(
(ud) => {
const display_name = displayNameParam.data
const search = searchParam.data
if (display_name !== "" || search !== "") {
return new ImportInspector({ display_name, search }, undefined)
}
return new ImportInspector(ud, state)
},
[displayNameParam, searchParam]
)
),
"Login to inspect your import flows",
state
)
}
}
new ImportViewerGui().AttachTo("main")

View file

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

View file

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

View file

@ -1,162 +0,0 @@
import Combine from "../Base/Combine"
import { Store, UIEventSource } from "../../Logic/UIEventSource"
import { BBox } from "../../Logic/BBox"
import UserRelatedState from "../../Logic/State/UserRelatedState"
import Translations from "../i18n/Translations"
import { AllKnownLayouts } from "../../Customizations/AllKnownLayouts"
import { DropDown } from "../Input/DropDown"
import { Utils } from "../../Utils"
import LayerConfig from "../../Models/ThemeConfig/LayerConfig"
import Loc from "../../Models/Loc"
import StaticFeatureSource from "../../Logic/FeatureSource/Sources/StaticFeatureSource"
import Toggle from "../Input/Toggle"
import { VariableUiElement } from "../Base/VariableUIElement"
import { FlowStep } from "./FlowStep"
import Title from "../Base/Title"
import CheckBoxes from "../Input/Checkboxes"
import { Feature, Point } from "geojson"
import DivContainer from "../Base/DivContainer"
import { AvailableRasterLayers, RasterLayerPolygon } from "../../Models/RasterLayers"
import { MapLibreAdaptor } from "../Map/MapLibreAdaptor"
import ShowDataLayer from "../Map/ShowDataLayer"
/**
* Shows the data to import on a map, asks for the correct layer to be selected
*/
export class MapPreview
extends Combine
implements FlowStep<{ bbox: BBox; layer: LayerConfig; features: Feature<Point>[] }>
{
public readonly IsValid: Store<boolean>
public readonly Value: Store<{ bbox: BBox; layer: LayerConfig; features: any[] }>
constructor(state: UserRelatedState, geojson: { features: Feature[] }) {
const t = Translations.t.importHelper.mapPreview
const propertyKeys = new Set<string>()
for (const f of geojson.features) {
Object.keys(f.properties).forEach((key) => propertyKeys.add(key))
}
const availableLayers = AllKnownLayouts.AllPublicLayers().filter(
(l) => l.name !== undefined && l.source !== undefined
)
const layerPicker = new DropDown(
t.selectLayer,
[{ shown: t.selectLayer, value: undefined }].concat(
availableLayers.map((l) => ({
shown: l.name,
value: l,
}))
)
)
let autodetected = new UIEventSource(false)
for (const layer of availableLayers) {
const mismatched = geojson.features.some(
(f) => !layer.source.osmTags.matchesProperties(f.properties)
)
if (!mismatched) {
console.log("Autodected layer", layer.id)
layerPicker.GetValue().setData(layer)
layerPicker.GetValue().addCallback((_) => autodetected.setData(false))
autodetected.setData(true)
break
}
}
const withId = geojson.features.map((f, i) => {
const copy = Utils.Clone(f)
copy.properties.id = "to-import/" + i
return copy
})
// Create a store which has only features matching the selected layer
const matching: Store<Feature[]> = layerPicker.GetValue().map((layer: LayerConfig) => {
if (layer === undefined) {
console.log("No matching layer found")
return []
}
const matching: Feature[] = []
for (const feature of withId) {
if (layer.source.osmTags.matchesProperties(feature.properties)) {
matching.push(feature)
}
}
console.log("Matching features: ", matching)
return matching
})
const background = new UIEventSource<RasterLayerPolygon>(AvailableRasterLayers.osmCarto)
const location = new UIEventSource<Loc>({ lat: 0, lon: 0, zoom: 1 })
const currentBounds = new UIEventSource<BBox>(undefined)
const { ui, mapproperties, map } = MapLibreAdaptor.construct()
ui.SetClass("w-full").SetStyle("height: 500px")
layerPicker.GetValue().addCallbackAndRunD((layerToShow) => {
new ShowDataLayer(map, {
layer: layerToShow,
zoomToFeatures: true,
features: new StaticFeatureSource(matching),
})
})
const bbox = matching.map((feats) =>
BBox.bboxAroundAll(
feats.map((f) => new BBox([(<Feature<Point>>f).geometry.coordinates]))
)
)
const mismatchIndicator = new VariableUiElement(
matching.map((matching) => {
if (matching === undefined) {
return undefined
}
const diff = geojson.features.length - matching.length
if (diff === 0) {
return undefined
}
const obligatory = layerPicker
.GetValue()
.data?.source?.osmTags?.asHumanString(false, false, {})
return t.mismatch.Subs({ count: diff, tags: obligatory }).SetClass("alert")
})
)
const confirm = new CheckBoxes([t.confirm])
super([
new Title(t.title, 1),
layerPicker,
new Toggle(t.autodetected.SetClass("thanks"), undefined, autodetected),
mismatchIndicator,
ui,
new DivContainer("fullscreen"),
confirm,
])
this.Value = bbox.map(
(bbox) => ({
bbox,
features: matching.data,
layer: layerPicker.GetValue().data,
}),
[layerPicker.GetValue(), matching]
)
this.IsValid = matching.map(
(matching) => {
if (matching === undefined) {
return false
}
if (confirm.GetValue().data.length !== 1) {
return false
}
const diff = geojson.features.length - matching.length
return diff === 0
},
[confirm.GetValue()]
)
}
}

View file

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

View file

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

View file

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

View file

@ -196,7 +196,7 @@ export class MapLibreAdaptor implements MapProperties, ExportableMap {
async exportAsPng(): Promise<Blob> {
const map = this._maplibreMap.data
if (map === undefined) {
if (!map) {
return undefined
}
@ -317,7 +317,7 @@ export class MapLibreAdaptor implements MapProperties, ExportableMap {
private async awaitStyleIsLoaded(): Promise<void> {
const map = this._maplibreMap.data
if (map === undefined) {
if (!map) {
return
}
while (!map?.isStyleLoaded()) {
@ -335,7 +335,7 @@ export class MapLibreAdaptor implements MapProperties, ExportableMap {
private async setBackground() {
const map = this._maplibreMap.data
if (map === undefined) {
if (!map) {
return
}
const background: RasterLayerProperties = this.rasterLayer?.data?.properties
@ -381,7 +381,7 @@ export class MapLibreAdaptor implements MapProperties, ExportableMap {
private setMaxBounds(bbox: undefined | BBox) {
const map = this._maplibreMap.data
if (map === undefined) {
if (!map) {
return
}
if (bbox) {
@ -393,7 +393,7 @@ export class MapLibreAdaptor implements MapProperties, ExportableMap {
private setAllowMoving(allow: true | boolean | undefined) {
const map = this._maplibreMap.data
if (map === undefined) {
if (!map) {
return
}
if (allow === false) {
@ -409,7 +409,7 @@ export class MapLibreAdaptor implements MapProperties, ExportableMap {
private setMinzoom(minzoom: number) {
const map = this._maplibreMap.data
if (map === undefined) {
if (!map) {
return
}
map.setMinZoom(minzoom)
@ -417,7 +417,7 @@ export class MapLibreAdaptor implements MapProperties, ExportableMap {
private setMaxzoom(maxzoom: number) {
const map = this._maplibreMap.data
if (map === undefined) {
if (!map) {
return
}
map.setMaxZoom(maxzoom)
@ -425,7 +425,7 @@ export class MapLibreAdaptor implements MapProperties, ExportableMap {
private setAllowZooming(allow: true | boolean | undefined) {
const map = this._maplibreMap.data
if (map === undefined) {
if (!map) {
return
}
if (allow === false) {
@ -441,7 +441,7 @@ export class MapLibreAdaptor implements MapProperties, ExportableMap {
private setBounds(bounds: BBox) {
const map = this._maplibreMap.data
if (map === undefined || bounds === undefined) {
if (!map || bounds === undefined) {
return
}
const oldBounds = map.getBounds()

View file

@ -164,9 +164,9 @@
{#if config.freeform?.key}
<label class="flex">
<input type="radio" bind:group={selectedMapping} name={"mappings-radio-"+config.id}
value={config.mappings.length}>
value={config.mappings?.length}>
<FreeformInput {config} {tags} feature={selectedElement} value={freeformInput}
on:selected={() => selectedMapping = config.mappings.length }/>
on:selected={() => selectedMapping = config.mappings?.length }/>
</label>
{/if}
</div>
@ -182,7 +182,7 @@
{/each}
{#if config.freeform?.key}
<label class="flex">
<input type="checkbox" name={"mappings-checkbox-"+config.id+"-"+config.mappings.length}
<input type="checkbox" name={"mappings-checkbox-"+config.id+"-"+config.mappings?.length}
bind:checked={checkedMappings[config.mappings.length]}>
<FreeformInput {config} {tags} feature={selectedElement} value={freeformInput}
on:selected={() => checkedMappings[config.mappings.length] = true}/>

View file

@ -3,10 +3,9 @@ import Combine from "./Base/Combine"
import Title from "./Base/Title"
import List from "./Base/List"
import Translations from "./i18n/Translations"
import { QueryParameters } from "../Logic/Web/QueryParameters"
import {QueryParameters} from "../Logic/Web/QueryParameters"
import FeatureSwitchState from "../Logic/State/FeatureSwitchState"
import LayoutConfig from "../Models/ThemeConfig/LayoutConfig"
import { DefaultGuiState } from "./DefaultGuiState"
export default class QueryParameterDocumentation {
private static QueryParamDocsIntro = [
@ -48,7 +47,6 @@ export default class QueryParameterDocumentation {
},
],
})
new DefaultGuiState() // Init a featureSwitchState to init all the parameters
new FeatureSwitchState(dummyLayout)
QueryParameters.GetQueryParameter(

View file

@ -36,6 +36,7 @@
import OverlayToggle from "./BigComponents/OverlayToggle.svelte";
import LevelSelector from "./BigComponents/LevelSelector.svelte";
import Svg from "../Svg";
import ExtraLinkButton from "./BigComponents/ExtraLinkButton";
export let state: ThemeViewState;
let layout = state.layout;
@ -97,10 +98,11 @@
construct={() =>(currentViewLayer.defaultIcon() ?? Svg.checkbox_empty_svg()).SetClass("w-8 h-8 cursor-pointer")}/>
</MapControlButton>
{/if}
<ToSvelte construct={() => new ExtraLinkButton(state, layout.extraLink)}></ToSvelte>
<If condition={state.featureSwitchIsTesting}>
<span class="alert">
Testmode
</span>
<span class="alert">
Testmode
</span>
</If>
</div>

View file

@ -1,130 +1,53 @@
import FeaturePipelineState from "../Logic/State/FeaturePipelineState"
import MinimapImplementation from "../UI/Base/MinimapImplementation"
import { UIEventSource } from "../Logic/UIEventSource"
import Loc from "../Models/Loc"
import ShowDataLayer from "../UI/ShowDataLayer/ShowDataLayer"
import { BBox } from "../Logic/BBox"
import Minimap from "../UI/Base/Minimap"
import AvailableBaseLayers from "../Logic/Actors/AvailableBaseLayers"
import ThemeViewState from "../Models/ThemeViewState"
import SvelteUIElement from "../UI/Base/SvelteUIElement"
import MaplibreMap from "../UI/Map/MaplibreMap.svelte"
import { Utils } from "../Utils"
import { UIEventSource } from "../Logic/UIEventSource"
export interface PngMapCreatorOptions {
readonly divId: string
readonly width: number
readonly height: number
readonly scaling?: 1 | number
readonly dummyMode?: boolean
}
export class PngMapCreator {
private readonly _state: FeaturePipelineState | undefined
private static id = 0
private readonly _options: PngMapCreatorOptions
private readonly _state: ThemeViewState
constructor(state: FeaturePipelineState | undefined, options: PngMapCreatorOptions) {
constructor(state: ThemeViewState, options: PngMapCreatorOptions) {
this._state = state
this._options = { ...options, scaling: options.scaling ?? 1 }
}
/**
* Creates a minimap, waits till all needed tiles are loaded before returning
* @private
*/
private async createAndLoadMinimap(): Promise<MinimapImplementation> {
const state = this._state
const options = this._options
const baselayer =
AvailableBaseLayers.layerOverview.find(
(bl) => bl.id === state.layoutToUse.defaultBackgroundId
) ?? AvailableBaseLayers.osmCarto
return new Promise((resolve) => {
const minimap = Minimap.createMiniMap({
location: new UIEventSource<Loc>(state.locationControl.data), // 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: new UIEventSource(baselayer),
allowMoving: false,
onFullyLoaded: (_) =>
window.setTimeout(() => {
resolve(<MinimapImplementation>minimap)
}, 250),
})
const style = `width: ${options.width * options.scaling}mm; height: ${
options.height * options.scaling
}mm;`
minimap.SetStyle(style)
minimap.AttachTo(options.divId)
})
this._options = options
}
/**
* Creates a base64-encoded PNG image
* @constructor
*/
public async CreatePng(format: "image"): Promise<string>
public async CreatePng(format: "blob"): Promise<Blob>
public async CreatePng(format: "image" | "blob"): Promise<string | Blob>
public async CreatePng(format: "image" | "blob"): Promise<string | Blob> {
// Lets first init the minimap and wait for all background tiles to load
const minimap = await this.createAndLoadMinimap()
const state = this._state
const dummyMode = this._options.dummyMode ?? false
return new Promise<string | Blob>((resolve, reject) => {
// Next: we prepare the features. Only fully contained features are shown
minimap.leafletMap.addCallbackAndRunD(async (leaflet) => {
// Ping the featurepipeline to download what is needed
if (dummyMode) {
console.warn("Dummy mode is active - not loading map layers")
} else {
const bounds = BBox.fromLeafletBounds(
leaflet.getBounds().pad(0.1).pad(-state.layoutToUse.widenFactor)
)
state.currentBounds.setData(bounds)
if (!state.featurePipeline.sufficientlyZoomed.data) {
console.warn("Not sufficiently zoomed!")
}
if (state.featurePipeline.runningQuery.data) {
// A query is running!
// Let's wait for it to complete
console.log("Waiting for the query to complete")
await state.featurePipeline.runningQuery.AsPromise(
(isRunning) => !isRunning
)
console.log("Query has completeted!")
}
state.featurePipeline.GetTilesPerLayerWithin(bounds, (tile) => {
if (tile.layer.layerDef.id.startsWith("note_import")) {
// Don't export notes to import
return
}
new ShowDataLayer({
features: tile,
leafletMap: minimap.leafletMap,
layerToShow: tile.layer.layerDef,
doShowLayer: tile.layer.isDisplayed,
state: undefined,
})
})
await Utils.waitFor(2000)
}
minimap
.TakeScreenshot(format)
.then(async (result) => {
const divId = this._options.divId
await Utils.waitFor(250)
document
.getElementById(divId)
.removeChild(
/*Will fetch the cached htmlelement:*/ minimap.ConstructElement()
)
return resolve(result)
})
.catch((failreason) => {
console.error("Could no make a screenshot due to ", failreason)
reject(failreason)
})
})
state.AddAllOverlaysToMap(minimap.leafletMap)
})
public async CreatePng(status: UIEventSource<string>): Promise<Blob> {
const div = document.createElement("div")
div.id = "mapdiv-" + PngMapCreator.id
PngMapCreator.id++
const layout = this._state.layout
function setState(msg: string) {
status.setData(layout.id + ": " + msg)
}
setState("Initializing map")
const map = this._state.map
new SvelteUIElement(MaplibreMap, { map })
.SetStyle(
"width: " + this._options.width + "mm; height: " + this._options.height + "mm"
)
.AttachTo("extradiv")
setState("Waiting for the data")
await this._state.dataIsLoading.AsPromise((loading) => !loading)
setState("Waiting for styles to be fully loaded")
while (!map?.data?.isStyleLoaded()) {
await Utils.waitFor(250)
}
// Some extra buffer...
await Utils.waitFor(1000)
setState("Exporting png")
console.log("Loading for", this._state.layout.id, "is done")
return this._state.mapProperties.exportAsPng()
}
}

View file

@ -2,20 +2,21 @@ import jsPDF, { Matrix } from "jspdf"
import { Translation, TypedTranslation } from "../UI/i18n/Translation"
import { PngMapCreator } from "./pngMapCreator"
import { AllKnownLayouts } from "../Customizations/AllKnownLayouts"
import { Store } from "../Logic/UIEventSource"
import "../assets/templates/Ubuntu-M-normal.js"
import "../assets/templates/Ubuntu-L-normal.js"
import "../assets/templates/UbuntuMono-B-bold.js"
import "../assets/fonts/Ubuntu-M-normal.js"
import "../assets/fonts/Ubuntu-L-normal.js"
import "../assets/fonts/UbuntuMono-B-bold.js"
import { makeAbsolute, parseSVG } from "svg-path-parser"
import Translations from "../UI/i18n/Translations"
import { Utils } from "../Utils"
import Constants from "../Models/Constants"
import Hash from "../Logic/Web/Hash"
import ThemeViewState from "../Models/ThemeViewState"
import { Store, UIEventSource } from "../Logic/UIEventSource"
import { FixedUiElement } from "../UI/Base/FixedUiElement"
class SvgToPdfInternals {
private readonly doc: jsPDF
private static readonly dummyDoc: jsPDF = new jsPDF()
private readonly doc: jsPDF
private readonly matrices: Matrix[] = []
private readonly matricesInverted: Matrix[] = []
@ -40,26 +41,6 @@ class SvgToPdfInternals {
this.currentMatrixInverted = this.doc.unitMatrix
}
applyMatrices(): void {
let multiplied = this.doc.unitMatrix
let multipliedInv = this.doc.unitMatrix
for (const matrix of this.matrices) {
multiplied = this.doc.matrixMult(multiplied, matrix)
}
for (const matrix of this.matricesInverted) {
multipliedInv = this.doc.matrixMult(multiplied, matrix)
}
this.currentMatrix = multiplied
this.currentMatrixInverted = multipliedInv
}
addMatrix(m: Matrix) {
this.matrices.push(m)
this.matricesInverted.push(m.inversed())
this.doc.setCurrentTransformationMatrix(m)
this.applyMatrices()
}
public static extractMatrix(element: Element): Matrix {
const t = element.getAttribute("transform")
if (t === null) {
@ -107,22 +88,6 @@ class SvgToPdfInternals {
return null
}
public setTransform(element: Element): boolean {
const m = SvgToPdfInternals.extractMatrix(element)
if (m === null) {
return false
}
this.addMatrix(m)
return true
}
public undoTransform(): void {
this.matrices.pop()
const i = this.matricesInverted.pop()
this.doc.setCurrentTransformationMatrix(i)
this.applyMatrices()
}
public static parseCss(styleContent: string, separator: string = ";"): Record<string, string> {
if (styleContent === undefined || styleContent === null) {
return {}
@ -137,41 +102,36 @@ class SvgToPdfInternals {
return r
}
private drawRect(element: SVGRectElement) {
const x = Number(element.getAttribute("x"))
const y = Number(element.getAttribute("y"))
const width = Number(element.getAttribute("width"))
const height = Number(element.getAttribute("height"))
const ry = SvgToPdfInternals.attrNumber(element, "ry", false) ?? 0
const rx = SvgToPdfInternals.attrNumber(element, "rx", false) ?? 0
const css = SvgToPdfInternals.css(element)
if (css["fill-opacity"] !== "0" && css["fill"] !== "none") {
this.doc.setFillColor(css["fill"] ?? "black")
this.doc.roundedRect(x, y, width, height, rx, ry, "F")
static attrNumber(element: Element, name: string, recurseup: boolean = true): number {
const a = SvgToPdfInternals.attr(element, name, recurseup)
const n = parseFloat(a)
if (!isNaN(n)) {
return n
}
if (css["stroke"] && css["stroke"] !== "none") {
this.doc.setLineWidth(Number(css["stroke-width"] ?? 1))
this.doc.setDrawColor(css["stroke"] ?? "black")
this.doc.roundedRect(x, y, width, height, rx, ry, "S")
}
return
return undefined
}
private drawCircle(element: SVGCircleElement) {
const x = Number(element.getAttribute("cx"))
const y = Number(element.getAttribute("cy"))
const r = Number(element.getAttribute("r"))
const css = SvgToPdfInternals.css(element)
if (css["fill-opacity"] !== "0" && css["fill"] !== "none") {
this.doc.setFillColor(css["fill"] ?? "black")
this.doc.circle(x, y, r, "F")
}
if (css["stroke"] && css["stroke"] !== "none") {
this.doc.setLineWidth(Number(css["stroke-width"] ?? 1))
this.doc.setDrawColor(css["stroke"] ?? "black")
this.doc.circle(x, y, r, "S")
}
return
/**
* Helper function to calculate where the given point will end up.
* ALl the transforms of the parent elements are taking into account
* @param mapSpec
* @constructor
*/
static GetActualXY(mapSpec: SVGTSpanElement): { x: number; y: number } {
let runningM = SvgToPdfInternals.dummyDoc.unitMatrix
let e: Element = mapSpec
do {
const m = SvgToPdfInternals.extractMatrix(e)
if (m !== null) {
runningM = SvgToPdfInternals.dummyDoc.matrixMult(runningM, m)
}
e = e.parentElement
} while (e !== null && e.parentElement != e)
const x = SvgToPdfInternals.attrNumber(mapSpec, "x")
const y = SvgToPdfInternals.attrNumber(mapSpec, "y")
return runningM.applyToPoint({ x, y })
}
private static attr(
@ -214,13 +174,119 @@ class SvgToPdfInternals {
return css
}
static attrNumber(element: Element, name: string, recurseup: boolean = true): number {
const a = SvgToPdfInternals.attr(element, name, recurseup)
const n = parseFloat(a)
if (!isNaN(n)) {
return n
applyMatrices(): void {
let multiplied = this.doc.unitMatrix
let multipliedInv = this.doc.unitMatrix
for (const matrix of this.matrices) {
multiplied = this.doc.matrixMult(multiplied, matrix)
}
return undefined
for (const matrix of this.matricesInverted) {
multipliedInv = this.doc.matrixMult(multiplied, matrix)
}
this.currentMatrix = multiplied
this.currentMatrixInverted = multipliedInv
}
addMatrix(m: Matrix) {
this.matrices.push(m)
this.matricesInverted.push(m.inversed())
this.doc.setCurrentTransformationMatrix(m)
this.applyMatrices()
}
public setTransform(element: Element): boolean {
const m = SvgToPdfInternals.extractMatrix(element)
if (m === null) {
return false
}
this.addMatrix(m)
return true
}
public undoTransform(): void {
this.matrices.pop()
const i = this.matricesInverted.pop()
this.doc.setCurrentTransformationMatrix(i)
this.applyMatrices()
}
public handleElement(element: SVGSVGElement | Element): void {
const isTransformed = this.setTransform(element)
try {
if (element.tagName === "tspan") {
if (element.childElementCount == 0) {
this.drawTspan(element)
} else {
for (let child of Array.from(element.children)) {
this.handleElement(child)
}
}
}
if (element.tagName === "image") {
this.drawImage(element)
}
if (element.tagName === "path") {
this.drawPath(<any>element)
}
if (element.tagName === "g" || element.tagName === "text") {
for (let child of Array.from(element.children)) {
this.handleElement(child)
}
}
if (element.tagName === "rect") {
this.drawRect(<any>element)
}
if (element.tagName === "circle") {
this.drawCircle(<any>element)
}
} catch (e) {
console.error("Could not handle element", element, "due to", e)
}
if (isTransformed) {
this.undoTransform()
}
}
private drawRect(element: SVGRectElement) {
const x = Number(element.getAttribute("x"))
const y = Number(element.getAttribute("y"))
const width = Number(element.getAttribute("width"))
const height = Number(element.getAttribute("height"))
const ry = SvgToPdfInternals.attrNumber(element, "ry", false) ?? 0
const rx = SvgToPdfInternals.attrNumber(element, "rx", false) ?? 0
const css = SvgToPdfInternals.css(element)
if (css["fill-opacity"] !== "0" && css["fill"] !== "none") {
this.doc.setFillColor(css["fill"] ?? "black")
this.doc.roundedRect(x, y, width, height, rx, ry, "F")
}
if (css["stroke"] && css["stroke"] !== "none") {
this.doc.setLineWidth(Number(css["stroke-width"] ?? 1))
this.doc.setDrawColor(css["stroke"] ?? "black")
this.doc.roundedRect(x, y, width, height, rx, ry, "S")
}
return
}
private drawCircle(element: SVGCircleElement) {
const x = Number(element.getAttribute("cx"))
const y = Number(element.getAttribute("cy"))
const r = Number(element.getAttribute("r"))
const css = SvgToPdfInternals.css(element)
if (css["fill-opacity"] !== "0" && css["fill"] !== "none") {
this.doc.setFillColor(css["fill"] ?? "black")
this.doc.circle(x, y, r, "F")
}
if (css["stroke"] && css["stroke"] !== "none") {
this.doc.setLineWidth(Number(css["stroke-width"] ?? 1))
this.doc.setDrawColor(css["stroke"] ?? "black")
this.doc.circle(x, y, r, "S")
}
return
}
private drawTspan(tspan: Element) {
@ -427,129 +493,43 @@ class SvgToPdfInternals {
this.doc.fill()
}
}
public handleElement(element: SVGSVGElement | Element): void {
const isTransformed = this.setTransform(element)
try {
if (element.tagName === "tspan") {
if (element.childElementCount == 0) {
this.drawTspan(element)
} else {
for (let child of Array.from(element.children)) {
this.handleElement(child)
}
}
}
if (element.tagName === "image") {
this.drawImage(element)
}
if (element.tagName === "path") {
this.drawPath(<any>element)
}
if (element.tagName === "g" || element.tagName === "text") {
for (let child of Array.from(element.children)) {
this.handleElement(child)
}
}
if (element.tagName === "rect") {
this.drawRect(<any>element)
}
if (element.tagName === "circle") {
this.drawCircle(<any>element)
}
} catch (e) {
console.error("Could not handle element", element, "due to", e)
}
if (isTransformed) {
this.undoTransform()
}
}
/**
* Helper function to calculate where the given point will end up.
* ALl the transforms of the parent elements are taking into account
* @param mapSpec
* @constructor
*/
static GetActualXY(mapSpec: SVGTSpanElement): { x: number; y: number } {
let runningM = SvgToPdfInternals.dummyDoc.unitMatrix
let e: Element = mapSpec
do {
const m = SvgToPdfInternals.extractMatrix(e)
if (m !== null) {
runningM = SvgToPdfInternals.dummyDoc.matrixMult(runningM, m)
}
e = e.parentElement
} while (e !== null && e.parentElement != e)
const x = SvgToPdfInternals.attrNumber(mapSpec, "x")
const y = SvgToPdfInternals.attrNumber(mapSpec, "y")
return runningM.applyToPoint({ x, y })
}
}
export interface SvgToPdfOptions {
getFreeDiv: () => string
disableMaps?: false | true
textSubstitutions?: Record<string, string>
beforePage?: (i: number) => void
overrideLocation?: { lat: number; lon: number }
}
export class SvgToPdfPage {
class SvgToPdfPage {
public readonly _svgRoot: SVGSVGElement
private images: Record<string, HTMLImageElement> = {}
private rects: Record<string, SVGRectElement> = {}
public readonly _svgRoot: SVGSVGElement
public readonly currentState: Store<string>
private readonly importedTranslations: Record<string, string> = {}
private readonly layerTranslations: Record<string, Record<string, any>> = {}
private readonly options: SvgToPdfOptions
/**
* Small indicator for humans
* @private
*/
private readonly _state: UIEventSource<string>
private _isPrepared = false
private state: UIEventSource<string>
constructor(page: string, options?: SvgToPdfOptions) {
constructor(page: string, state: UIEventSource<string>, options?: SvgToPdfOptions) {
this._state = state
this.options = options ?? <SvgToPdfOptions>{}
const parser = new DOMParser()
const xmlDoc = parser.parseFromString(page, "image/svg+xml")
this._svgRoot = xmlDoc.getElementsByTagName("svg")[0]
}
private loadImage(element: Element): Promise<void> {
const xlink = element.getAttribute("xlink:href")
let img = document.createElement("img")
if (xlink.startsWith("data:image/svg+xml;")) {
const base64src = xlink
let svgXml = atob(
base64src.substring(base64src.indexOf(";base64,") + ";base64,".length)
)
const parser = new DOMParser()
const xmlDoc = parser.parseFromString(svgXml, "text/xml")
const svgRoot = xmlDoc.getElementsByTagName("svg")[0]
const svgWidthStr = svgRoot.getAttribute("width")
const svgHeightStr = svgRoot.getAttribute("height")
const svgWidth = parseFloat(svgWidthStr)
const svgHeight = parseFloat(svgHeightStr)
if (!svgWidthStr.endsWith("px")) {
svgRoot.setAttribute("width", svgWidth + "px")
}
if (!svgHeightStr.endsWith("px")) {
svgRoot.setAttribute("height", svgHeight + "px")
}
img.src = "data:image/svg+xml;base64," + btoa(svgRoot.outerHTML)
} else {
img.src = xlink
}
this.images[xlink] = img
return new Promise((resolve) => {
img.onload = (_) => {
resolve()
}
private static blobToBase64(blob): Promise<string> {
return new Promise((resolve, _) => {
const reader = new FileReader()
reader.onloadend = () => resolve(<string>reader.result)
reader.readAsDataURL(blob)
})
}
@ -628,166 +608,6 @@ export class SvgToPdfPage {
}
}
private _isPrepared = false
private async prepareMap(mapSpec: SVGTSpanElement): Promise<void> {
// Upper left point of the tspan
const { x, y } = SvgToPdfInternals.GetActualXY(mapSpec)
let textElement: Element = mapSpec
// We recurse up to get the actual, full specification
while (textElement.tagName !== "text") {
textElement = textElement.parentElement
}
const spec = textElement.textContent
const match = spec.match(/\$map\(([^)]+)\)$/)
if (match === null) {
throw "Invalid mapspec:" + spec
}
const params = SvgToPdfInternals.parseCss(match[1], ",")
let smallestRect: SVGRectElement = undefined
let smallestSurface: number = undefined
// We iterate over all the rectangles and pick the smallest (by surface area) that contains the upper left point of the tspan
for (const id in this.rects) {
const rect = this.rects[id]
const rx = SvgToPdfInternals.attrNumber(rect, "x")
const ry = SvgToPdfInternals.attrNumber(rect, "y")
const w = SvgToPdfInternals.attrNumber(rect, "width")
const h = SvgToPdfInternals.attrNumber(rect, "height")
const inBounds = rx <= x && x <= rx + w && ry <= y && y <= ry + h
if (!inBounds) {
continue
}
const surface = w * h
if (smallestSurface === undefined || smallestSurface > surface) {
smallestSurface = surface
smallestRect = rect
}
}
if (smallestRect === undefined) {
throw (
"No rectangle found around " +
spec +
". Draw a rectangle around it, the map will be projected on that one"
)
}
const svgImage = document.createElement("image")
svgImage.setAttribute("x", smallestRect.getAttribute("x"))
svgImage.setAttribute("y", smallestRect.getAttribute("y"))
const width = SvgToPdfInternals.attrNumber(smallestRect, "width")
const height = SvgToPdfInternals.attrNumber(smallestRect, "height")
svgImage.setAttribute("width", "" + width)
svgImage.setAttribute("height", "" + height)
let layout = AllKnownLayouts.allKnownLayouts.get(params["theme"])
if (layout === undefined) {
console.error("Could not show map with parameters", params)
throw (
"Theme not found:" + params["theme"] + ". Use theme: to define which theme to use. "
)
}
layout.widenFactor = 0
layout.overpassTimeout = 600
layout.defaultBackgroundId = params["background"] ?? layout.defaultBackgroundId
for (const paramsKey in params) {
if (paramsKey.startsWith("layer-")) {
const layerName = paramsKey.substring("layer-".length)
const key = params[paramsKey].toLowerCase().trim()
const layer = layout.layers.find((l) => l.id === layerName)
if (layer === undefined) {
throw "No layer found for " + paramsKey
}
if (key === "force") {
layer.minzoom = 0
layer.minzoomVisible = 0
}
}
}
const zoom = Number(params["zoom"] ?? params["z"] ?? 14)
Hash.hash.setData(undefined)
// QueryParameters.ClearAll()
const state = new ThemeViewState(layout)
state.mapProperties.location.setData({
lat: this.options?.overrideLocation?.lat ?? Number(params["lat"] ?? 51.05016),
lon: this.options?.overrideLocation?.lon ?? Number(params["lon"] ?? 3.717842),
})
state.mapProperties.zoom.setData(zoom)
console.log("Params are", params, params["layers"] === "none")
const fl = Array.from(state.layerState.filteredLayers.values())
for (const filteredLayer of fl) {
if (params["layer-" + filteredLayer.layerDef.id] !== undefined) {
filteredLayer.isDisplayed.setData(
params["layer-" + filteredLayer.layerDef.id].trim().toLowerCase() !== "false"
)
} else if (params["layers"] === "none") {
filteredLayer.isDisplayed.setData(false)
} else if (filteredLayer.layerDef.id.startsWith("note_import")) {
filteredLayer.isDisplayed.setData(false)
}
}
for (const paramsKey in params) {
if (paramsKey.startsWith("layer-")) {
const layerName = paramsKey.substring("layer-".length)
const key = params[paramsKey].toLowerCase().trim()
const isDisplayed = key === "true" || key === "force"
const layer = fl.find((l) => l.layerDef.id === layerName)
console.log(
"Setting ",
layer?.layerDef?.id,
" to visibility",
isDisplayed,
"(minzoom:",
layer?.layerDef?.minzoomVisible,
layer?.layerDef?.minzoom,
")"
)
layer.isDisplayed.setData(isDisplayed)
if (key === "force") {
layer.layerDef.minzoom = 0
layer.layerDef.minzoomVisible = 0
layer.isDisplayed.addCallback((isDisplayed) => {
if (!isDisplayed) {
console.warn("Forcing layer " + paramsKey + " as true")
layer.isDisplayed.setData(true)
}
})
}
}
}
const pngCreator = new PngMapCreator(state, {
width,
height,
scaling: Number(params["scaling"] ?? 1.5),
divId: this.options.getFreeDiv(),
dummyMode: this.options.disableMaps,
})
const png = await pngCreator.CreatePng("image")
svgImage.setAttribute("xlink:href", png)
smallestRect.parentElement.insertBefore(svgImage, smallestRect)
await this.prepareElement(svgImage, [])
const smallestRectCss = SvgToPdfInternals.parseCss(smallestRect.getAttribute("style"))
smallestRectCss["fill-opacity"] = "0"
smallestRect.setAttribute(
"style",
Object.keys(smallestRectCss)
.map((k) => k + ":" + smallestRectCss[k])
.join(";")
)
textElement.parentElement.removeChild(textElement)
}
public async PrepareLanguage(language: string) {
// Always fetch the remote data - it's cached anyway
this.layerTranslations[language] = await Utils.downloadJsonCached(
@ -888,31 +708,219 @@ export class SvgToPdfPage {
console.error("Could not get textFor from ", t, "for path", text)
}
}
private loadImage(element: Element): Promise<void> {
const xlink = element.getAttribute("xlink:href")
let img = document.createElement("img")
if (xlink.startsWith("data:image/svg+xml;")) {
const base64src = xlink
let svgXml = atob(
base64src.substring(base64src.indexOf(";base64,") + ";base64,".length)
)
const parser = new DOMParser()
const xmlDoc = parser.parseFromString(svgXml, "text/xml")
const svgRoot = xmlDoc.getElementsByTagName("svg")[0]
const svgWidthStr = svgRoot.getAttribute("width")
const svgHeightStr = svgRoot.getAttribute("height")
const svgWidth = parseFloat(svgWidthStr)
const svgHeight = parseFloat(svgHeightStr)
if (!svgWidthStr.endsWith("px")) {
svgRoot.setAttribute("width", svgWidth + "px")
}
if (!svgHeightStr.endsWith("px")) {
svgRoot.setAttribute("height", svgHeight + "px")
}
img.src = "data:image/svg+xml;base64," + btoa(svgRoot.outerHTML)
} else {
img.src = xlink
}
this.images[xlink] = img
return new Promise((resolve) => {
img.onload = (_) => {
resolve()
}
})
}
private async prepareMap(mapSpec: SVGTSpanElement): Promise<void> {
// Upper left point of the tspan
const { x, y } = SvgToPdfInternals.GetActualXY(mapSpec)
let textElement: Element = mapSpec
// We recurse up to get the actual, full specification
while (textElement.tagName !== "text") {
textElement = textElement.parentElement
}
const spec = textElement.textContent
const match = spec.match(/\$map\(([^)]+)\)$/)
if (match === null) {
throw "Invalid mapspec:" + spec
}
const params = SvgToPdfInternals.parseCss(match[1], ",")
let smallestRect: SVGRectElement = undefined
let smallestSurface: number = undefined
// We iterate over all the rectangles and pick the smallest (by surface area) that contains the upper left point of the tspan
for (const id in this.rects) {
const rect = this.rects[id]
const rx = SvgToPdfInternals.attrNumber(rect, "x")
const ry = SvgToPdfInternals.attrNumber(rect, "y")
const w = SvgToPdfInternals.attrNumber(rect, "width")
const h = SvgToPdfInternals.attrNumber(rect, "height")
const inBounds = rx <= x && x <= rx + w && ry <= y && y <= ry + h
if (!inBounds) {
continue
}
const surface = w * h
if (smallestSurface === undefined || smallestSurface > surface) {
smallestSurface = surface
smallestRect = rect
}
}
if (smallestRect === undefined) {
throw (
"No rectangle found around " +
spec +
". Draw a rectangle around it, the map will be projected on that one"
)
}
const svgImage = document.createElement("image")
svgImage.setAttribute("x", smallestRect.getAttribute("x"))
svgImage.setAttribute("y", smallestRect.getAttribute("y"))
const width = SvgToPdfInternals.attrNumber(smallestRect, "width")
const height = SvgToPdfInternals.attrNumber(smallestRect, "height")
svgImage.setAttribute("width", "" + width)
svgImage.setAttribute("height", "" + height)
let layout = AllKnownLayouts.allKnownLayouts.get(params["theme"])
if (layout === undefined) {
console.error("Could not show map with parameters", params)
throw (
"Theme not found:" + params["theme"] + ". Use theme: to define which theme to use. "
)
}
layout.widenFactor = 0
layout.overpassTimeout = 600
layout.defaultBackgroundId = params["background"] ?? layout.defaultBackgroundId
for (const paramsKey in params) {
if (paramsKey.startsWith("layer-")) {
const layerName = paramsKey.substring("layer-".length)
const key = params[paramsKey].toLowerCase().trim()
const layer = layout.layers.find((l) => l.id === layerName)
if (layer === undefined) {
throw "No layer found for " + paramsKey
}
if (key === "force") {
layer.minzoom = 0
layer.minzoomVisible = 0
}
}
}
const zoom = Number(params["zoom"] ?? params["z"] ?? 14)
Hash.hash.setData(undefined)
// QueryParameters.ClearAll()
const state = new ThemeViewState(layout)
state.mapProperties.location.setData({
lat: this.options?.overrideLocation?.lat ?? Number(params["lat"] ?? 51.05016),
lon: this.options?.overrideLocation?.lon ?? Number(params["lon"] ?? 3.717842),
})
state.mapProperties.zoom.setData(zoom)
console.log("Params are", params, params["layers"] === "none")
const fl = Array.from(state.layerState.filteredLayers.values())
for (const filteredLayer of fl) {
if (params["layer-" + filteredLayer.layerDef.id] !== undefined) {
filteredLayer.isDisplayed.setData(
params["layer-" + filteredLayer.layerDef.id].trim().toLowerCase() !== "false"
)
} else if (params["layers"] === "none") {
filteredLayer.isDisplayed.setData(false)
} else if (filteredLayer.layerDef.id.startsWith("note_import")) {
filteredLayer.isDisplayed.setData(false)
}
}
for (const paramsKey in params) {
if (paramsKey.startsWith("layer-")) {
const layerName = paramsKey.substring("layer-".length)
const key = params[paramsKey].toLowerCase().trim()
const isDisplayed = key === "true" || key === "force"
const layer = fl.find((l) => l.layerDef.id === layerName)
console.log(
"Setting ",
layer?.layerDef?.id,
" to visibility",
isDisplayed,
"(minzoom:",
layer?.layerDef?.minzoomVisible,
layer?.layerDef?.minzoom,
")"
)
layer.isDisplayed.setData(isDisplayed)
if (key === "force") {
layer.layerDef.minzoom = 0
layer.layerDef.minzoomVisible = 0
layer.isDisplayed.addCallback((isDisplayed) => {
if (!isDisplayed) {
console.warn("Forcing layer " + paramsKey + " as true")
layer.isDisplayed.setData(true)
}
})
}
}
}
console.log("Creating a map width ", width, height, params.scalingFactor)
const pngCreator = new PngMapCreator(state, {
width: width * 4,
height: height * 4,
})
const png = await pngCreator.CreatePng(this._state)
svgImage.setAttribute("xlink:href", await SvgToPdfPage.blobToBase64(png))
smallestRect.parentElement.insertBefore(svgImage, smallestRect)
await this.prepareElement(svgImage, [])
const smallestRectCss = SvgToPdfInternals.parseCss(smallestRect.getAttribute("style"))
smallestRectCss["fill-opacity"] = "0"
smallestRect.setAttribute(
"style",
Object.keys(smallestRectCss)
.map((k) => k + ":" + smallestRectCss[k])
.join(";")
)
textElement.parentElement.removeChild(textElement)
}
}
export class SvgToPdf {
public static readonly templates: Record<
string,
"flyer_a4" | "poster_a3" | "poster_a2",
{ pages: string[]; description: string | Translation }
> = {
flyer_a4: {
pages: [
"/assets/templates/MapComplete-flyer.svg",
"/assets/templates/MapComplete-flyer.back.svg",
"./assets/templates/MapComplete-flyer.svg",
"./assets/templates/MapComplete-flyer.back.svg",
],
description: Translations.t.flyer.description,
},
poster_a3: {
pages: ["/assets/templates/MapComplete-poster-a3.svg"],
pages: ["./assets/templates/MapComplete-poster-a3.svg"],
description: "A basic A3 poster (similar to the flyer)",
},
poster_a2: {
pages: ["/assets/templates/MapComplete-poster-a2.svg"],
pages: ["./assets/templates/MapComplete-poster-a2.svg"],
description: "A basic A2 poster (similar to the flyer); scaled up from the A3 poster",
},
}
public readonly status: Store<string>
public readonly _status: UIEventSource<string>
private readonly _title: string
private readonly _pages: SvgToPdfPage[]
constructor(title: string, pages: string[], options?: SvgToPdfOptions) {
@ -926,24 +934,34 @@ export class SvgToPdf {
).length
options.textSubstitutions["mapCount"] = mapCount
this._pages = pages.map((page) => new SvgToPdfPage(page, options))
const state = new UIEventSource<string>("Initializing...")
this.status = state
this._status = state
this._pages = pages.map((page) => new SvgToPdfPage(page, state, options))
}
public async ConvertSvg(language: string): Promise<void> {
console.log("Building svg...")
const firstPage = this._pages[0]._svgRoot
const width = SvgToPdfInternals.attrNumber(firstPage, "width")
const height = SvgToPdfInternals.attrNumber(firstPage, "height")
const mode = width > height ? "landscape" : "portrait"
await this.Prepare()
console.log("Global prepare done")
for (const page of this._pages) {
await page.Prepare()
await page.PrepareLanguage(language)
}
this._status.setData("Maps are rendered, building pdf")
new FixedUiElement("").AttachTo("extradiv")
console.log("Pages are prepared")
const doc = new jsPDF(mode, undefined, [width, height])
doc.advancedAPI((advancedApi) => {
for (let i = 0; i < this._pages.length; i++) {
console.log("Rendering page", i)
if (i > 0) {
const page = this._pages[i]._svgRoot
const width = SvgToPdfInternals.attrNumber(page, "width")
@ -967,6 +985,7 @@ export class SvgToPdf {
this._pages[i].drawPage(advancedApi, i, language)
}
})
console.log("Exporting...")
await doc.save(this._title + "." + language + ".pdf")
}

View file

@ -13,7 +13,6 @@
--popup-border: white;
--shadow-color: #00000066;
--variable-title-height: 0px; /* Set by javascript */
--return-to-the-map-height: 2em;
--image-carousel-height: 350px;
}

View file

@ -1407,10 +1407,6 @@ video {
border-bottom-width: 1px;
}
.border-b-2 {
border-bottom-width: 2px;
}
.border-solid {
border-style: solid;
}
@ -1588,10 +1584,6 @@ video {
padding-top: 0px;
}
.pb-8 {
padding-bottom: 2rem;
}
.pl-5 {
padding-left: 1.25rem;
}
@ -1852,8 +1844,8 @@ video {
:root {
/* The main colour scheme of mapcomplete is configured here.
* For a custom styling, set 'customCss' in your layoutConfig and overwrite some of these.
*/
* For a custom styling, set 'customCss' in your layoutConfig and overwrite some of these.
*/
/* Main color of the application: the background and text colours */
--background-color: white;
/* Main text colour. Also styles some elements, such as the 'close popup'-button or 'back-arrow' (in mobile) */
@ -1861,23 +1853,22 @@ video {
/* A colour to indicate an error or warning */
--alert-color: #fee4d1;
/**
* Base colour of interactive elements, mainly the 'subtle button'
*
*/
* Base colour of interactive elements, mainly the 'subtle button'
*
*/
--subtle-detail-color: #dbeafe;
--subtle-detail-color-contrast: black;
--subtle-detail-color-light-contrast: lightgrey;
/**
* A stronger variant of the 'subtle-detail-colour'
* Used as subtle button hover
*/
* A stronger variant of the 'subtle-detail-colour'
* Used as subtle button hover
*/
--unsubtle-detail-color: #bfdbfe;
--unsubtle-detail-color-contrast: black;
--catch-detail-color: #3a3aeb;
--catch-detail-color-contrast: white;
--non-active-tab-svg: var(--foreground-color);
--shadow-color: #00000066;
--return-to-the-map-height: 2em;
--image-carousel-height: 350px;
/* Technical variable to make some dynamic behaviour possible; set by javascript. */
--variable-title-height: 0px;

520
index.css
View file

@ -12,486 +12,484 @@
@tailwind utilities;
@layer utilities {
.z-above-map {
z-index: 10000;
}
.z-above-map {
z-index: 10000;
}
.z-above-controls {
z-index: 10001;
}
.z-above-controls {
z-index: 10001;
}
.w-160 {
width: 40rem;
}
.w-160 {
width: 40rem;
}
.bg-subtle {
background-color: var(--subtle-detail-color);
color: var(--subtle-detail-color-contrast);
}
.bg-subtle {
background-color: var(--subtle-detail-color);
color: var(--subtle-detail-color-contrast);
}
.bg-unsubtle {
background-color: var(--unsubtle-detail-color);
color: var(--unsubtle-detail-color-contrast);
}
.bg-unsubtle {
background-color: var(--unsubtle-detail-color);
color: var(--unsubtle-detail-color-contrast);
}
.bg-catch {
background-color: var(--catch-detail-color);
color: var(--catch-detail-color-contrast);
}
.bg-catch {
background-color: var(--catch-detail-color);
color: var(--catch-detail-color-contrast);
}
.rounded-left-full {
border-bottom-left-radius: 999rem;
border-top-left-radius: 999rem;
}
.rounded-left-full {
border-bottom-left-radius: 999rem;
border-top-left-radius: 999rem;
}
.rounded-right-full {
border-bottom-right-radius: 999rem;
border-top-right-radius: 999rem;
}
.rounded-right-full {
border-bottom-right-radius: 999rem;
border-top-right-radius: 999rem;
}
}
:root {
/* The main colour scheme of mapcomplete is configured here.
* For a custom styling, set 'customCss' in your layoutConfig and overwrite some of these.
*/
/* The main colour scheme of mapcomplete is configured here.
* For a custom styling, set 'customCss' in your layoutConfig and overwrite some of these.
*/
/* Main color of the application: the background and text colours */
--background-color: white;
/* Main text colour. Also styles some elements, such as the 'close popup'-button or 'back-arrow' (in mobile) */
--foreground-color: black;
/* Main color of the application: the background and text colours */
--background-color: white;
/* Main text colour. Also styles some elements, such as the 'close popup'-button or 'back-arrow' (in mobile) */
--foreground-color: black;
/* A colour to indicate an error or warning */
--alert-color: #fee4d1;
/* A colour to indicate an error or warning */
--alert-color: #fee4d1;
/**
* Base colour of interactive elements, mainly the 'subtle button'
*
*/
--subtle-detail-color: #dbeafe;
--subtle-detail-color-contrast: black;
--subtle-detail-color-light-contrast: lightgrey;
/**
* Base colour of interactive elements, mainly the 'subtle button'
*
*/
--subtle-detail-color: #dbeafe;
--subtle-detail-color-contrast: black;
--subtle-detail-color-light-contrast: lightgrey;
/**
* A stronger variant of the 'subtle-detail-colour'
* Used as subtle button hover
*/
--unsubtle-detail-color: #bfdbfe;
--unsubtle-detail-color-contrast: black;
/**
* A stronger variant of the 'subtle-detail-colour'
* Used as subtle button hover
*/
--unsubtle-detail-color: #bfdbfe;
--unsubtle-detail-color-contrast: black;
--catch-detail-color: #3a3aeb;
--catch-detail-color-contrast: white;
--catch-detail-color: #3a3aeb;
--catch-detail-color-contrast: white;
--non-active-tab-svg: var(--foreground-color);
--shadow-color: #00000066;
--non-active-tab-svg: var(--foreground-color);
--shadow-color: #00000066;
--return-to-the-map-height: 2em;
--image-carousel-height: 350px;
--image-carousel-height: 350px;
/* Technical variable to make some dynamic behaviour possible; set by javascript. */
--variable-title-height: 0px;
/* Technical variable to make some dynamic behaviour possible; set by javascript. */
--variable-title-height: 0px;
}
html,
body {
height: 100%;
min-height: 100vh;
min-height: -webkit-fill-available;
margin: 0;
padding: 0;
background-color: var(--background-color);
color: var(--foreground-color);
font-family: "Helvetica Neue", Arial, sans-serif;
height: 100%;
min-height: 100vh;
min-height: -webkit-fill-available;
margin: 0;
padding: 0;
background-color: var(--background-color);
color: var(--foreground-color);
font-family: "Helvetica Neue", Arial, sans-serif;
}
svg,
img {
box-sizing: content-box;
width: 100%;
height: 100%;
box-sizing: content-box;
width: 100%;
height: 100%;
}
.no-images img {
/* Used solely in 'imageAttribution' */
display: none;
/* Used solely in 'imageAttribution' */
display: none;
}
.text-white a {
/* Used solely in 'imageAttribution' */
color: var(--background-color);
/* Used solely in 'imageAttribution' */
color: var(--background-color);
}
.weblate-link {
/* Weblate-links are the little translation icon next to translatable sentences. Due to their special nature, they are exempt from some rules */
/* Weblate-links are the little translation icon next to translatable sentences. Due to their special nature, they are exempt from some rules */
}
a {
color: var(--foreground-color);
color: var(--foreground-color);
}
.btn {
line-height: 1.25rem;
--tw-text-opacity: 1;
color: var(--catch-detail-color-contrast);
--tw-bg-opacity: 1;
background-color: var(--catch-detail-color);
display: inline-flex;
border-radius: 1.5rem;
padding-top: 0.75rem;
padding-bottom: 0.75rem;
padding-left: 1.25rem;
padding-right: 1.25rem;
font-size: large;
font-weight: bold;
transition: 100ms;
/*-- invisible border: rendered on hover*/
border: 3px solid var(--unsubtle-detail-color);
line-height: 1.25rem;
--tw-text-opacity: 1;
color: var(--catch-detail-color-contrast);
--tw-bg-opacity: 1;
background-color: var(--catch-detail-color);
display: inline-flex;
border-radius: 1.5rem;
padding-top: 0.75rem;
padding-bottom: 0.75rem;
padding-left: 1.25rem;
padding-right: 1.25rem;
font-size: large;
font-weight: bold;
transition: 100ms;
/*-- invisible border: rendered on hover*/
border: 3px solid var(--unsubtle-detail-color);
}
.btn:hover {
border: 3px solid var(--catch-detail-color);
border: 3px solid var(--catch-detail-color);
}
.btn-secondary {
background-color: var(--catch-detail-color);
filter: saturate(0.5);
background-color: var(--catch-detail-color);
filter: saturate(0.5);
}
.btn-secondary:hover {
background-color: var(--catch-detail-color);
filter: unset;
background-color: var(--catch-detail-color);
filter: unset;
}
.btn-disabled {
filter: saturate(0.3);
cursor: default;
filter: saturate(0.3);
cursor: default;
}
.btn-disabled:hover {
border: 3px solid var(--unsubtle-detail-color);
border: 3px solid var(--unsubtle-detail-color);
}
/* slider */
input[type="range"].vertical {
writing-mode: bt-lr; /* IE */
-webkit-appearance: slider-vertical; /* Chromium */
cursor: pointer;
writing-mode: bt-lr; /* IE */
-webkit-appearance: slider-vertical; /* Chromium */
cursor: pointer;
}
@-moz-document url-prefix() {
input[type="range"].elevator::-moz-range-thumb {
background-color: #00000000 !important;
background-image: url("/assets/svg/elevator_wheelchair.svg");
width: 150px !important;
height: 30px !important;
border: 2px;
border-style: solid;
background-size: contain;
background-position: center center;
background-repeat: no-repeat;
cursor: pointer;
border-image: linear-gradient(to right, black 50%, transparent 50%) 100% 1;
padding-bottom: 5px;
}
input[type="range"].elevator::-moz-range-thumb {
background-color: #00000000 !important;
background-image: url("/assets/svg/elevator_wheelchair.svg");
width: 150px !important;
height: 30px !important;
border: 2px;
border-style: solid;
background-size: contain;
background-position: center center;
background-repeat: no-repeat;
cursor: pointer;
border-image: linear-gradient(to right, black 50%, transparent 50%) 100% 1;
padding-bottom: 5px;
}
}
.rounded-left-full {
border-bottom-left-radius: 999rem;
border-top-left-radius: 999rem;
border-bottom-left-radius: 999rem;
border-top-left-radius: 999rem;
}
.rounded-right-full {
border-bottom-right-radius: 999rem;
border-top-right-radius: 999rem;
border-bottom-right-radius: 999rem;
border-top-right-radius: 999rem;
}
.link-underline a {
text-decoration: underline 1px var(--foreground-color);
text-decoration: underline 1px var(--foreground-color);
}
a.link-underline {
text-decoration: underline 1px var(--foreground-color);
text-decoration: underline 1px var(--foreground-color);
}
.link-no-underline a {
text-decoration: none;
text-decoration: none;
}
li {
margin-left: 0.5em;
padding-left: 0.2em;
margin-top: 0.1em;
margin-left: 0.5em;
padding-left: 0.2em;
margin-top: 0.1em;
}
h2 {
font-size: large;
margin-top: 0.5em;
margin-bottom: 0.3em;
font-weight: bold;
font-size: large;
margin-top: 0.5em;
margin-bottom: 0.3em;
font-weight: bold;
}
h3 {
font-size: larger;
margin-top: 0.6em;
margin-bottom: 0;
font-weight: bold;
font-size: larger;
margin-top: 0.6em;
margin-bottom: 0;
font-weight: bold;
}
h3 {
font-size: larger;
margin-top: 0.6em;
margin-bottom: 0;
font-weight: bolder;
font-size: larger;
margin-top: 0.6em;
margin-bottom: 0;
font-weight: bolder;
}
p {
padding-top: 0.1em;
padding-top: 0.1em;
}
li::marker {
content: "•";
content: "•";
}
.subtle-background {
background: var(--subtle-detail-color);
color: var(--subtle-detail-color-contrast);
background: var(--subtle-detail-color);
color: var(--subtle-detail-color-contrast);
}
.normal-background {
background: var(--background-color);
color: var(--foreground-color);
background: var(--background-color);
color: var(--foreground-color);
}
.subtle-lighter {
color: var(--subtle-detail-color-light-contrast);
color: var(--subtle-detail-color-light-contrast);
}
.border-attention-catch {
border: 5px solid var(--catch-detail-color);
border: 5px solid var(--catch-detail-color);
}
.border-attention {
border-color: var(--catch-detail-color);
border-color: var(--catch-detail-color);
}
.direction-svg svg path {
fill: var(--catch-detail-color) !important;
fill: var(--catch-detail-color) !important;
}
.block-ruby {
display: block ruby;
display: block ruby;
}
.disable-links a {
pointer-events: none;
text-decoration: none !important;
color: var(--subtle-detail-color-contrast) !important;
pointer-events: none;
text-decoration: none !important;
color: var(--subtle-detail-color-contrast) !important;
}
.enable-links a {
pointer-events: unset;
text-decoration: underline !important;
color: unset !important;
pointer-events: unset;
text-decoration: underline !important;
color: unset !important;
}
.disable-links a.must-link,
.disable-links .must-link a {
/* Hide links if they are disabled */
display: none;
/* Hide links if they are disabled */
display: none;
}
.selected svg:not(.noselect *) path.selectable {
stroke: white !important;
stroke-width: 20px !important;
overflow: visible !important;
-webkit-animation: glowing-drop-shadow 1s ease-in-out infinite alternate;
-moz-animation: glowing-drop-shadow 1s ease-in-out infinite alternate;
animation: glowing-drop-shadow 1s ease-in-out infinite alternate;
stroke: white !important;
stroke-width: 20px !important;
overflow: visible !important;
-webkit-animation: glowing-drop-shadow 1s ease-in-out infinite alternate;
-moz-animation: glowing-drop-shadow 1s ease-in-out infinite alternate;
animation: glowing-drop-shadow 1s ease-in-out infinite alternate;
}
.selected svg {
overflow: visible !important;
overflow: visible !important;
}
@-webkit-keyframes glowing-drop-shadow {
from {
filter: drop-shadow(5px 5px 60px rgb(128 128 128 / 0.6));
}
to {
filter: drop-shadow(5px 5px 80px rgb(0.5 0.5 0.5 / 0.8));
}
from {
filter: drop-shadow(5px 5px 60px rgb(128 128 128 / 0.6));
}
to {
filter: drop-shadow(5px 5px 80px rgb(0.5 0.5 0.5 / 0.8));
}
}
/**************** GENERIC ****************/
.alert {
background-color: var(--alert-color);
color: var(--foreground-color);
font-weight: bold;
border-radius: 1em;
margin: 0.25em;
text-align: center;
padding: 0.15em 0.3em;
background-color: var(--alert-color);
color: var(--foreground-color);
font-weight: bold;
border-radius: 1em;
margin: 0.25em;
text-align: center;
padding: 0.15em 0.3em;
}
.invalid {
box-shadow: 0 0 10px #ff5353;
height: min-content;
box-shadow: 0 0 10px #ff5353;
height: min-content;
}
.shadow {
box-shadow: 0 0 10px var(--shadow-color);
box-shadow: 0 0 10px var(--shadow-color);
}
.title-font span {
font-size: xx-large !important;
font-weight: bold;
font-size: xx-large !important;
font-weight: bold;
}
.soft {
background-color: var(--subtle-detail-color);
color: var(--subtle-detail-color-contrast);
font-weight: bold;
border-radius: 1em;
margin: 0.25em;
text-align: center;
padding: 0.15em 0.3em;
background-color: var(--subtle-detail-color);
color: var(--subtle-detail-color-contrast);
font-weight: bold;
border-radius: 1em;
margin: 0.25em;
text-align: center;
padding: 0.15em 0.3em;
}
.subtle {
color: #999;
color: #999;
}
.link-underline .subtle a {
text-decoration: underline 1px #7193bb88;
color: #7193bb;
text-decoration: underline 1px #7193bb88;
color: #7193bb;
}
.thanks {
background-color: #43d904;
font-weight: bold;
border-radius: 1em;
margin: 0.25em;
text-align: center;
padding: 0.15em 0.3em;
background-color: #43d904;
font-weight: bold;
border-radius: 1em;
margin: 0.25em;
text-align: center;
padding: 0.15em 0.3em;
}
@keyframes slide {
/* This is the animation on the marker to add a new point - it slides through all the possible presets */
from {
transform: translateX(0%);
}
/* This is the animation on the marker to add a new point - it slides through all the possible presets */
from {
transform: translateX(0%);
}
to {
transform: translateX(calc(-100% + 42px));
}
to {
transform: translateX(calc(-100% + 42px));
}
}
/***************** Info box (box containing features and questions ******************/
input {
color: var(--foreground-color);
color: var(--foreground-color);
}
.literal-code {
display: inline-block;
background-color: lightgray;
padding: 0.5em;
word-break: break-word;
color: black;
box-sizing: border-box;
display: inline-block;
background-color: lightgray;
padding: 0.5em;
word-break: break-word;
color: black;
box-sizing: border-box;
}
/** Switch layout **/
.small-image img {
height: 1em;
max-width: 1em;
height: 1em;
max-width: 1em;
}
.small-image {
height: 1em;
max-width: 1em;
height: 1em;
max-width: 1em;
}
.slideshow-item img {
height: var(--image-carousel-height);
width: unset;
height: var(--image-carousel-height);
width: unset;
}
.animate-height {
transition: max-height 0.5s ease-in-out;
overflow-y: hidden;
transition: max-height 0.5s ease-in-out;
overflow-y: hidden;
}
.zebra-table tr:nth-child(even) {
background-color: #f2f2f2;
background-color: #f2f2f2;
}
.glowing-shadow {
-webkit-animation: glowing 1s ease-in-out infinite alternate;
-moz-animation: glowing 1s ease-in-out infinite alternate;
animation: glowing 1s ease-in-out infinite alternate;
-webkit-animation: glowing 1s ease-in-out infinite alternate;
-moz-animation: glowing 1s ease-in-out infinite alternate;
animation: glowing 1s ease-in-out infinite alternate;
}
@-webkit-keyframes glowing {
from {
box-shadow: 0 0 20px 10px #eaaf2588, inset 0 0 0px 1px #eaaf25;
}
to {
box-shadow: 0 0 20px 20px #eaaf2588, inset 0 0 5px 1px #eaaf25;
}
from {
box-shadow: 0 0 20px 10px #eaaf2588, inset 0 0 0px 1px #eaaf25;
}
to {
box-shadow: 0 0 20px 20px #eaaf2588, inset 0 0 5px 1px #eaaf25;
}
}
.mapping-icon-small-height {
/* A mapping icon type */
height: 1.5rem;
margin-right: 0.5rem;
width: unset;
/* A mapping icon type */
height: 1.5rem;
margin-right: 0.5rem;
width: unset;
}
.mapping-icon-medium-height {
/* A mapping icon type */
height: 3rem;
margin-right: 0.5rem;
width: unset;
/* A mapping icon type */
height: 3rem;
margin-right: 0.5rem;
width: unset;
}
.mapping-icon-large-height {
/* A mapping icon type */
height: 5rem;
margin-right: 0.5rem;
width: unset;
/* A mapping icon type */
height: 5rem;
margin-right: 0.5rem;
width: unset;
}
.mapping-icon-small {
/* A mapping icon type */
width: 1.5rem;
max-height: 1.5rem;
margin-right: 0.5rem;
/* A mapping icon type */
width: 1.5rem;
max-height: 1.5rem;
margin-right: 0.5rem;
}
.mapping-icon-medium {
/* A mapping icon type */
width: 3rem;
max-height: 3rem;
margin-right: 1rem;
margin-left: 1rem;
/* A mapping icon type */
width: 3rem;
max-height: 3rem;
margin-right: 1rem;
margin-left: 1rem;
}
.mapping-icon-large {
/* A mapping icon type */
width: 6rem;
max-height: 5rem;
margin-top: 0.5rem;
margin-bottom: 0.5rem;
margin-right: 1.5rem;
margin-left: 1.5rem;
/* A mapping icon type */
width: 6rem;
max-height: 5rem;
margin-top: 0.5rem;
margin-bottom: 0.5rem;
margin-right: 1.5rem;
margin-left: 1.5rem;
}

120
package-lock.json generated
View file

@ -49,7 +49,6 @@
"showdown": "^2.1.0",
"svg-path-parser": "^1.1.0",
"tailwindcss": "^3.1.8",
"togpx": "^0.5.4",
"vite-node": "^0.28.3",
"vitest": "^0.28.3",
"wikibase-sdk": "^7.14.0",
@ -4301,23 +4300,6 @@
"integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==",
"dev": true
},
"node_modules/bops": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/bops/-/bops-0.0.6.tgz",
"integrity": "sha512-EWD8/Ei9o/h/wmR3w/YL/8dGKe4rSFHlaO8VNNcuXnjXjeTgxdcmhjPf9hRCYlqTrBPZbKaht+FxZKahcob5UQ==",
"dependencies": {
"base64-js": "0.0.2",
"to-utf8": "0.0.1"
}
},
"node_modules/bops/node_modules/base64-js": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-0.0.2.tgz",
"integrity": "sha512-Pj9L87dCdGcKlSqPVUjD+q96pbIx1zQQLb2CUiWURfjiBELv84YX+0nGnKmyT/9KkC7PQk7UN1w+Al8bBozaxQ==",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/brace-expansion": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
@ -7352,14 +7334,6 @@
"safe-buffer": "^5.0.1"
}
},
"node_modules/jxon": {
"version": "2.0.0-beta.5",
"resolved": "https://registry.npmjs.org/jxon/-/jxon-2.0.0-beta.5.tgz",
"integrity": "sha512-Ot7muZ0v2cmgQ1k+e6bpNcz6E3q2zHssvzYubbKTk5nIEvBLqJfiS6/uivU2ujqKZQlORcjKqcyx6D9X6BEAkQ==",
"dependencies": {
"xmldom": "^0.1.21"
}
},
"node_modules/kdbush": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/kdbush/-/kdbush-1.0.1.tgz",
@ -10265,36 +10239,6 @@
"node": ">=0.12.0"
}
},
"node_modules/to-utf8": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/to-utf8/-/to-utf8-0.0.1.tgz",
"integrity": "sha512-zks18/TWT1iHO3v0vFp5qLKOG27m67ycq/Y7a7cTiRuUNlc4gf3HGnkRgMv0NyhnfTamtkYBJl+YeD1/j07gBQ=="
},
"node_modules/togpx": {
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/togpx/-/togpx-0.5.4.tgz",
"integrity": "sha512-1LY9ZjBrCYbcWfD63ZaqRw53U0tkigGC9fOdhRaTZH6yrmhlQGbbsEVTyCFzagvbPO36sZuBz0SrEQ+paaCPiw==",
"dependencies": {
"concat-stream": "~1.0.1",
"jxon": "~2.0.0-beta.5",
"optimist": "~0.3.5",
"xmldom": "~0.1.17"
},
"bin": {
"togpx": "togpx"
}
},
"node_modules/togpx/node_modules/concat-stream": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.0.1.tgz",
"integrity": "sha512-nAHFsgeRVVvZ+aB3S1gLeN73fQ+tdOcw075BHbXMbC6MY0h6nqAkEeqPVCw8kRuDJJZDvaUjxI4jZv2FD0Tl8A==",
"engines": [
"node >= 0.8.0"
],
"dependencies": {
"bops": "0.0.6"
}
},
"node_modules/topojson-client": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/topojson-client/-/topojson-client-3.1.0.tgz",
@ -12055,15 +11999,6 @@
"optional": true,
"peer": true
},
"node_modules/xmldom": {
"version": "0.1.31",
"resolved": "https://registry.npmjs.org/xmldom/-/xmldom-0.1.31.tgz",
"integrity": "sha512-yS2uJflVQs6n+CyjHoaBmVSqIDevTAWrzMmjG1Gc7h1qQ7uVozNhEPJAwZXWyGQ/Gafo3fCwrcaokezLPupVyQ==",
"deprecated": "Deprecated due to CVE-2021-21366 resolved in 0.5.0",
"engines": {
"node": ">=0.1"
}
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
@ -15350,22 +15285,6 @@
"integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==",
"dev": true
},
"bops": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/bops/-/bops-0.0.6.tgz",
"integrity": "sha512-EWD8/Ei9o/h/wmR3w/YL/8dGKe4rSFHlaO8VNNcuXnjXjeTgxdcmhjPf9hRCYlqTrBPZbKaht+FxZKahcob5UQ==",
"requires": {
"base64-js": "0.0.2",
"to-utf8": "0.0.1"
},
"dependencies": {
"base64-js": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-0.0.2.tgz",
"integrity": "sha512-Pj9L87dCdGcKlSqPVUjD+q96pbIx1zQQLb2CUiWURfjiBELv84YX+0nGnKmyT/9KkC7PQk7UN1w+Al8bBozaxQ=="
}
}
},
"brace-expansion": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
@ -17635,14 +17554,6 @@
"safe-buffer": "^5.0.1"
}
},
"jxon": {
"version": "2.0.0-beta.5",
"resolved": "https://registry.npmjs.org/jxon/-/jxon-2.0.0-beta.5.tgz",
"integrity": "sha512-Ot7muZ0v2cmgQ1k+e6bpNcz6E3q2zHssvzYubbKTk5nIEvBLqJfiS6/uivU2ujqKZQlORcjKqcyx6D9X6BEAkQ==",
"requires": {
"xmldom": "^0.1.21"
}
},
"kdbush": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/kdbush/-/kdbush-1.0.1.tgz",
@ -19802,32 +19713,6 @@
}
}
},
"to-utf8": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/to-utf8/-/to-utf8-0.0.1.tgz",
"integrity": "sha512-zks18/TWT1iHO3v0vFp5qLKOG27m67ycq/Y7a7cTiRuUNlc4gf3HGnkRgMv0NyhnfTamtkYBJl+YeD1/j07gBQ=="
},
"togpx": {
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/togpx/-/togpx-0.5.4.tgz",
"integrity": "sha512-1LY9ZjBrCYbcWfD63ZaqRw53U0tkigGC9fOdhRaTZH6yrmhlQGbbsEVTyCFzagvbPO36sZuBz0SrEQ+paaCPiw==",
"requires": {
"concat-stream": "~1.0.1",
"jxon": "~2.0.0-beta.5",
"optimist": "~0.3.5",
"xmldom": "~0.1.17"
},
"dependencies": {
"concat-stream": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.0.1.tgz",
"integrity": "sha512-nAHFsgeRVVvZ+aB3S1gLeN73fQ+tdOcw075BHbXMbC6MY0h6nqAkEeqPVCw8kRuDJJZDvaUjxI4jZv2FD0Tl8A==",
"requires": {
"bops": "0.0.6"
}
}
}
},
"topojson-client": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/topojson-client/-/topojson-client-3.1.0.tgz",
@ -21167,11 +21052,6 @@
"optional": true,
"peer": true
},
"xmldom": {
"version": "0.1.31",
"resolved": "https://registry.npmjs.org/xmldom/-/xmldom-0.1.31.tgz",
"integrity": "sha512-yS2uJflVQs6n+CyjHoaBmVSqIDevTAWrzMmjG1Gc7h1qQ7uVozNhEPJAwZXWyGQ/Gafo3fCwrcaokezLPupVyQ=="
},
"xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",

15
test.ts
View file

@ -5,12 +5,14 @@ import Combine from "./UI/Base/Combine"
import SpecialVisualizations from "./UI/SpecialVisualizations"
import InputHelpers from "./UI/InputElement/InputHelpers"
import BaseUIElement from "./UI/BaseUIElement"
import { ImmutableStore, UIEventSource } from "./Logic/UIEventSource"
import { UIEventSource } from "./Logic/UIEventSource"
import { VariableUiElement } from "./UI/Base/VariableUIElement"
import { FixedUiElement } from "./UI/Base/FixedUiElement"
import Title from "./UI/Base/Title"
import SvelteUIElement from "./UI/Base/SvelteUIElement"
import ValidatedInput from "./UI/InputElement/ValidatedInput.svelte"
import { SvgToPdf } from "./Utils/svgToPdf"
import { Utils } from "./Utils"
function testspecial() {
const layout = new LayoutConfig(<any>theme, true) // qp.data === "" ? : new AllKnownLayoutsLazy().get(qp.data)
@ -47,6 +49,17 @@ function testinput() {
new Combine(els).SetClass("flex flex-col").AttachTo("maindiv")
}
async function testPdf() {
const svgs = await Promise.all(
SvgToPdf.templates["flyer_a4"].pages.map((url) => Utils.download(url))
)
console.log("Building svg")
const pdf = new SvgToPdf("Test", svgs, {})
new VariableUiElement(pdf.status).AttachTo("maindiv")
await pdf.ConvertSvg("nl")
}
testPdf().then((_) => console.log("All done"))
//testinput()
/*/
testspecial()