From 23ae9d39c88bb368dc359906793e3b8d9a0edec2 Mon Sep 17 00:00:00 2001 From: pietervdvn Date: Sun, 31 Oct 2021 02:08:39 +0100 Subject: [PATCH] Add the possibility to snap onto another layer with imports, add location confirm on input, add metalayer exporting all nodes, various fixes --- Logic/FeatureSource/FeaturePipeline.ts | 14 + .../Sources/SimpleFeatureSource.ts | 2 - .../FullNodeDatabaseSource.ts | 70 +++++ .../TiledFeatureSource/OsmFeatureSource.ts | 8 +- Logic/GeoOperations.ts | 7 + Logic/Osm/Actions/CreateNewWayAction.ts | 39 ++- Logic/Osm/OsmObject.ts | 2 +- Models/ThemeConfig/LayerConfig.ts | 17 +- Models/ThemeConfig/LayoutConfig.ts | 27 +- UI/BigComponents/FilterView.ts | 17 +- UI/BigComponents/ImportButton.ts | 271 +++++++++++++++--- UI/BigComponents/SimpleAddUI.ts | 213 ++------------ UI/DefaultGUI.ts | 1 + UI/Input/LocationInput.ts | 5 + UI/NewPoint/ConfirmLocationOfPoint.ts | 184 ++++++++++++ UI/SpecialVisualizations.ts | 95 +----- UI/SubstitutedTranslation.ts | 3 +- Utils.ts | 12 + assets/layers/type_node/type_node.json | 12 + assets/themes/grb_import/grb.json | 17 +- assets/themes/uk_addresses/uk_addresses.json | 2 +- index.ts | 2 + scripts/generateLayerOverview.ts | 31 +- test.ts | 146 +++++++++- 24 files changed, 807 insertions(+), 390 deletions(-) create mode 100644 Logic/FeatureSource/TiledFeatureSource/FullNodeDatabaseSource.ts create mode 100644 UI/NewPoint/ConfirmLocationOfPoint.ts create mode 100644 assets/layers/type_node/type_node.json diff --git a/Logic/FeatureSource/FeaturePipeline.ts b/Logic/FeatureSource/FeaturePipeline.ts index 0f2fa19ef..ecec7cfde 100644 --- a/Logic/FeatureSource/FeaturePipeline.ts +++ b/Logic/FeatureSource/FeaturePipeline.ts @@ -26,6 +26,7 @@ import {OsmConnection} from "../Osm/OsmConnection"; import {Tiles} from "../../Models/TileRange"; import TileFreshnessCalculator from "./TileFreshnessCalculator"; import {ElementStorage} from "../ElementStorage"; +import FullNodeDatabaseSource from "./TiledFeatureSource/FullNodeDatabaseSource"; /** @@ -146,6 +147,11 @@ export default class FeaturePipeline { this.freshnesses.set(id, new TileFreshnessCalculator()) + if(id === "type_node"){ + // Handles by the 'FullNodeDatabaseSource' + continue; + } + if (source.geojsonSource === undefined) { // This is an OSM layer // We load the cached values and register them @@ -220,6 +226,14 @@ export default class FeaturePipeline { self.freshnesses.get(flayer.layerDef.id).addTileLoad(tileId, new Date()) }) }) + + if(state.layoutToUse.trackAllNodes){ + new FullNodeDatabaseSource(state, osmFeatureSource, tile => { + new RegisteringAllFromFeatureSourceActor(tile) + perLayerHierarchy.get(tile.layer.layerDef.id).registerTile(tile) + tile.features.addCallbackAndRunD(_ => self.newDataLoadedSignal.setData(tile)) + }) + } const updater = this.initOverpassUpdater(state, useOsmApi) diff --git a/Logic/FeatureSource/Sources/SimpleFeatureSource.ts b/Logic/FeatureSource/Sources/SimpleFeatureSource.ts index b06aae6d2..fd98ad92e 100644 --- a/Logic/FeatureSource/Sources/SimpleFeatureSource.ts +++ b/Logic/FeatureSource/Sources/SimpleFeatureSource.ts @@ -1,8 +1,6 @@ import {UIEventSource} from "../../UIEventSource"; import FilteredLayer from "../../../Models/FilteredLayer"; import {FeatureSourceForLayer, Tiled} from "../FeatureSource"; -import {Utils} from "../../../Utils"; -import {Tiles} from "../../../Models/TileRange"; import {BBox} from "../../BBox"; export default class SimpleFeatureSource implements FeatureSourceForLayer, Tiled { diff --git a/Logic/FeatureSource/TiledFeatureSource/FullNodeDatabaseSource.ts b/Logic/FeatureSource/TiledFeatureSource/FullNodeDatabaseSource.ts new file mode 100644 index 000000000..20d7bee07 --- /dev/null +++ b/Logic/FeatureSource/TiledFeatureSource/FullNodeDatabaseSource.ts @@ -0,0 +1,70 @@ +import TileHierarchy from "./TileHierarchy"; +import FeatureSource, {FeatureSourceForLayer, Tiled} from "../FeatureSource"; +import {OsmNode, OsmObject, OsmWay} from "../../Osm/OsmObject"; +import SimpleFeatureSource from "../Sources/SimpleFeatureSource"; +import {UIEventSource} from "../../UIEventSource"; +import FilteredLayer from "../../../Models/FilteredLayer"; + + +export default class FullNodeDatabaseSource implements TileHierarchy { + public readonly loadedTiles = new Map() + private readonly onTileLoaded: (tile: (Tiled & FeatureSourceForLayer)) => void; + private readonly layer : FilteredLayer + + constructor( + state: { + readonly filteredLayers: UIEventSource}, + osmFeatureSource: { rawDataHandlers: ((data: any, tileId: number) => void)[] }, + onTileLoaded: ((tile: Tiled & FeatureSourceForLayer) => void)) { + this.onTileLoaded = onTileLoaded + this.layer = state.filteredLayers.data.filter(l => l.layerDef.id === "type_node")[0] + if(this.layer === undefined){ + throw "Weird: tracking all nodes, but layer 'type_node' is not defined" + } + const self = this + osmFeatureSource.rawDataHandlers.push((osmJson, tileId) => self.handleOsmXml(osmJson, tileId)) + } + + private handleOsmXml(osmJson: any, tileId: number) { + + const allObjects = OsmObject.ParseObjects(osmJson.elements) + const nodesById = new Map() + + for (const osmObj of allObjects) { + if (osmObj.type !== "node") { + continue + } + const osmNode = osmObj; + nodesById.set(osmNode.id, osmNode) + } + + const parentWaysByNodeId = new Map() + for (const osmObj of allObjects) { + if (osmObj.type !== "way") { + continue + } + const osmWay = osmObj; + for (const nodeId of osmWay.nodes) { + + if (!parentWaysByNodeId.has(nodeId)) { + parentWaysByNodeId.set(nodeId, []) + } + parentWaysByNodeId.get(nodeId).push(osmWay) + } + } + parentWaysByNodeId.forEach((allWays, nodeId) => { + nodesById.get(nodeId).tags["parent_ways"] = JSON.stringify(allWays.map(w => w.tags)) + }) + const now = new Date() + const asGeojsonFeatures = Array.from(nodesById.values()).map(osmNode => ({ + feature: osmNode.asGeoJson(),freshness: now + })) + + const featureSource = new SimpleFeatureSource(this.layer, tileId) + featureSource.features.setData(asGeojsonFeatures) + this.loadedTiles.set(tileId, featureSource) + this.onTileLoaded(featureSource) + + } + +} \ No newline at end of file diff --git a/Logic/FeatureSource/TiledFeatureSource/OsmFeatureSource.ts b/Logic/FeatureSource/TiledFeatureSource/OsmFeatureSource.ts index a4bdc8ca5..29e26bd90 100644 --- a/Logic/FeatureSource/TiledFeatureSource/OsmFeatureSource.ts +++ b/Logic/FeatureSource/TiledFeatureSource/OsmFeatureSource.ts @@ -30,6 +30,8 @@ export default class OsmFeatureSource { }; public readonly downloadedTiles = new Set() private readonly allowedTags: TagsFilter; + + public rawDataHandlers: ((osmJson: any, tileId: number) => void)[] = [] constructor(options: { handleTile: (tile: FeatureSourceForLayer & Tiled) => void; @@ -94,11 +96,11 @@ export default class OsmFeatureSource { try { console.log("Attempting to get tile", z, x, y, "from the osm api") - const osmXml = await Utils.download(url, {"accept": "application/xml"}) + const osmJson = await Utils.downloadJson(url) try { - const parsed = new DOMParser().parseFromString(osmXml, "text/xml"); console.log("Got tile", z, x, y, "from the osm api") - const geojson = OsmToGeoJson.default(parsed, + this.rawDataHandlers.forEach(handler => handler(osmJson, Tiles.tile_index(z, x, y))) + const geojson = OsmToGeoJson.default(osmJson, // @ts-ignore { flatProperties: true diff --git a/Logic/GeoOperations.ts b/Logic/GeoOperations.ts index 09a288117..f94f9944a 100644 --- a/Logic/GeoOperations.ts +++ b/Logic/GeoOperations.ts @@ -235,6 +235,13 @@ export class GeoOperations { * @param point Point defined as [lon, lat] */ public static nearestPoint(way, point: [number, number]) { + if(way.geometry.type === "Polygon"){ + way = {...way} + way.geometry = {...way.geometry} + way.geometry.type = "LineString" + way.geometry.coordinates = way.geometry.coordinates[0] + } + return turf.nearestPointOnLine(way, point, {units: "kilometers"}); } diff --git a/Logic/Osm/Actions/CreateNewWayAction.ts b/Logic/Osm/Actions/CreateNewWayAction.ts index 098115636..ec5486121 100644 --- a/Logic/Osm/Actions/CreateNewWayAction.ts +++ b/Logic/Osm/Actions/CreateNewWayAction.ts @@ -4,23 +4,40 @@ import {Changes} from "../Changes"; import {Tag} from "../../Tags/Tag"; import CreateNewNodeAction from "./CreateNewNodeAction"; import {And} from "../../Tags/And"; +import {TagsFilter} from "../../Tags/TagsFilter"; export default class CreateNewWayAction extends OsmChangeAction { public newElementId: string = undefined private readonly coordinates: ({ nodeId?: number, lat: number, lon: number })[]; private readonly tags: Tag[]; - private readonly _options: { theme: string }; + private readonly _options: { + theme: string, existingPointHandling?: { + withinRangeOfM: number, + ifMatches?: TagsFilter, + mode: "reuse_osm_point" | "move_osm_point" + } [] + }; /*** * Creates a new way to upload to OSM * @param tags: the tags to apply to the wya * @param coordinates: the coordinates. Might have a nodeId, in this case, this node will be used - * @param options + * @param options */ - constructor(tags: Tag[], coordinates: ({ nodeId?: number, lat: number, lon: number })[], options: { - theme: string - }) { + constructor(tags: Tag[], coordinates: ({ nodeId?: number, lat: number, lon: number })[], + options: { + theme: string, + /** + * IF specified, an existing OSM-point within this range and satisfying the condition 'ifMatches' will be used instead of a new coordinate. + * If multiple points are possible, only the closest point is considered + */ + existingPointHandling?: { + withinRangeOfM: number, + ifMatches?: TagsFilter, + mode: "reuse_osm_point" | "move_osm_point" + } [] + }) { super() this.coordinates = coordinates; this.tags = tags; @@ -49,14 +66,14 @@ export default class CreateNewWayAction extends OsmChangeAction { // We have all created (or reused) all the points! // Time to create the actual way - - + + const id = changes.getNewID() - - const newWay = { + + const newWay = { id, type: "way", - meta:{ + meta: { theme: this._options.theme, changeType: "import" }, @@ -67,7 +84,7 @@ export default class CreateNewWayAction extends OsmChangeAction { } } newElements.push(newWay) - this.newElementId = "way/"+id + this.newElementId = "way/" + id return newElements } diff --git a/Logic/Osm/OsmObject.ts b/Logic/Osm/OsmObject.ts index faf1ee79d..cfc9c5234 100644 --- a/Logic/Osm/OsmObject.ts +++ b/Logic/Osm/OsmObject.ts @@ -206,7 +206,7 @@ export abstract class OsmObject { return result; } - private static ParseObjects(elements: any[]): OsmObject[] { + public static ParseObjects(elements: any[]): OsmObject[] { const objects: OsmObject[] = []; const allNodes: Map = new Map() diff --git a/Models/ThemeConfig/LayerConfig.ts b/Models/ThemeConfig/LayerConfig.ts index e30f0a20e..3ef796cd7 100644 --- a/Models/ThemeConfig/LayerConfig.ts +++ b/Models/ThemeConfig/LayerConfig.ts @@ -15,6 +15,8 @@ import LineRenderingConfig from "./LineRenderingConfig"; import PointRenderingConfigJson from "./Json/PointRenderingConfigJson"; import LineRenderingConfigJson from "./Json/LineRenderingConfigJson"; import {TagRenderingConfigJson} from "./Json/TagRenderingConfigJson"; +import {UIEventSource} from "../../Logic/UIEventSource"; +import BaseUIElement from "../../UI/BaseUIElement"; export default class LayerConfig extends WithContextLoader { @@ -59,11 +61,11 @@ export default class LayerConfig extends WithContextLoader { this.id = json.id; if (json.source === undefined) { - throw "Layer " + this.id + " does not define a source section ("+context+")" + throw "Layer " + this.id + " does not define a source section (" + context + ")" } if (json.source.osmTags === undefined) { - throw "Layer " + this.id + " does not define a osmTags in the source section - these should always be present, even for geojson layers ("+context+")" + throw "Layer " + this.id + " does not define a osmTags in the source section - these should always be present, even for geojson layers (" + context + ")" } @@ -262,6 +264,15 @@ export default class LayerConfig extends WithContextLoader { } } + public defaultIcon() : BaseUIElement | undefined{ + const mapRendering = this.mapRendering.filter(r => r.location.has("point"))[0] + if (mapRendering === undefined) { + return undefined + } + const defaultTags = new UIEventSource(TagUtils.changeAsProperties(this.source.osmTags.asChange({id: "node/-1"}))) + return mapRendering.GenerateLeafletStyle(defaultTags, false, {noSize: true}).html + } + public ExtractLayerTagRenderings(json: LayerConfigJson): TagRenderingConfig[] { if (json.tagRenderings === undefined) { @@ -358,7 +369,6 @@ export default class LayerConfig extends WithContextLoader { } - public CustomCodeSnippets(): string[] { if (this.calculatedTags === undefined) { return []; @@ -366,7 +376,6 @@ export default class LayerConfig extends WithContextLoader { return this.calculatedTags.map((code) => code[1]); } - public ExtractImages(): Set { const parts: Set[] = []; parts.push(...this.tagRenderings?.map((tr) => tr.ExtractImages(false))); diff --git a/Models/ThemeConfig/LayoutConfig.ts b/Models/ThemeConfig/LayoutConfig.ts index bb492e047..8702a3cbf 100644 --- a/Models/ThemeConfig/LayoutConfig.ts +++ b/Models/ThemeConfig/LayoutConfig.ts @@ -1,7 +1,5 @@ import {Translation} from "../../UI/i18n/Translation"; -import TagRenderingConfig from "./TagRenderingConfig"; import {LayoutConfigJson} from "./Json/LayoutConfigJson"; -import SharedTagRenderings from "../../Customizations/SharedTagRenderings"; import AllKnownLayers from "../../Customizations/AllKnownLayers"; import {Utils} from "../../Utils"; import LayerConfig from "./LayerConfig"; @@ -54,6 +52,7 @@ export default class LayoutConfig { public readonly overpassMaxZoom: number public readonly osmApiTileSize: number public readonly official: boolean; + public readonly trackAllNodes : boolean; constructor(json: LayoutConfigJson, official = true, context?: string) { this.official = official; @@ -63,6 +62,8 @@ export default class LayoutConfig { this.credits = json.credits; this.version = json.version; this.language = []; + this.trackAllNodes = false + if (typeof json.language === "string") { this.language = [json.language]; } else { @@ -92,12 +93,16 @@ export default class LayoutConfig { if(json.widenFactor > 20){ throw "Widenfactor is very big, use a value between 1 and 5 (current value is "+json.widenFactor+") at "+context } + this.widenFactor = json.widenFactor ?? 1.5; this.defaultBackgroundId = json.defaultBackgroundId; this.tileLayerSources = (json.tileLayerSources??[]).map((config, i) => new TilesourceConfig(config, `${this.id}.tileLayerSources[${i}]`)) - this.layers = LayoutConfig.ExtractLayers(json, official, context); - + const layerInfo = LayoutConfig.ExtractLayers(json, official, context); + this.layers = layerInfo.layers + this.trackAllNodes = layerInfo.extractAllNodes + + this.clustering = { maxZoom: 16, minNeededElements: 25, @@ -147,10 +152,11 @@ export default class LayoutConfig { } - private static ExtractLayers(json: LayoutConfigJson, official: boolean, context: string): LayerConfig[] { + private static ExtractLayers(json: LayoutConfigJson, official: boolean, context: string): {layers: LayerConfig[], extractAllNodes: boolean} { const result: LayerConfig[] = [] - + let exportAllNodes = false json.layers.forEach((layer, i) => { + if (typeof layer === "string") { if (AllKnownLayers.sharedLayersJson.get(layer) !== undefined) { if (json.overrideAll !== undefined) { @@ -177,12 +183,19 @@ export default class LayoutConfig { result.push(newLayer) return } + // @ts-ignore let names = layer.builtin; if (typeof names === "string") { names = [names] } names.forEach(name => { + + if(name === "type_node"){ + // This is a very special layer which triggers special behaviour + exportAllNodes = true; + } + const shared = AllKnownLayers.sharedLayersJson.get(name); if (shared === undefined) { throw `Unknown shared/builtin layer ${name} at ${context}.layers[${i}]. Available layers are ${Array.from(AllKnownLayers.sharedLayersJson.keys()).join(", ")}`; @@ -199,7 +212,7 @@ export default class LayoutConfig { }); - return result + return {layers: result, extractAllNodes: exportAllNodes} } public CustomCodeSnippets(): string[] { diff --git a/UI/BigComponents/FilterView.ts b/UI/BigComponents/FilterView.ts index c44a61c70..3bbfccb90 100644 --- a/UI/BigComponents/FilterView.ts +++ b/UI/BigComponents/FilterView.ts @@ -14,7 +14,6 @@ import FilteredLayer from "../../Models/FilteredLayer"; import BackgroundSelector from "./BackgroundSelector"; import FilterConfig from "../../Models/ThemeConfig/FilterConfig"; import TilesourceConfig from "../../Models/ThemeConfig/TilesourceConfig"; -import {TagUtils} from "../../Logic/Tags/TagUtils"; export default class FilterView extends VariableUiElement { constructor(filteredLayer: UIEventSource, tileLayers: { config: TilesourceConfig, isDisplayed: UIEventSource }[]) { @@ -83,7 +82,7 @@ export default class FilterView extends VariableUiElement { const icon = new Combine([Svg.checkbox_filled]).SetStyle(iconStyle); const layer = filteredLayer.layerDef - + const iconUnselected = new Combine([Svg.checkbox_empty]).SetStyle( iconStyle ); @@ -113,18 +112,8 @@ export default class FilterView extends VariableUiElement { const style = "display:flex;align-items:center;padding:0.5rem 0;"; - const mapRendering = layer.mapRendering.filter(r => r.location.has("point"))[0] - let layerIcon = undefined - let layerIconUnchecked = undefined - try { - if (mapRendering !== undefined) { - const defaultTags = new UIEventSource( TagUtils.changeAsProperties(layer.source.osmTags.asChange({id: "node/-1"}))) - layerIcon = mapRendering.GenerateLeafletStyle(defaultTags, false, {noSize: true}).html.SetClass("w-8 h-8 ml-2") - layerIconUnchecked = mapRendering.GenerateLeafletStyle(defaultTags, false, {noSize: true}).html.SetClass("opacity-50 w-8 h-8 ml-2") - } - } catch (e) { - console.error(e) - } + const layerIcon = layer.defaultIcon()?.SetClass("w-8 h-8 ml-2") + const layerIconUnchecked = layer.defaultIcon()?.SetClass("opacity-50 w-8 h-8 ml-2") const layerChecked = new Combine([icon, layerIcon, styledNameChecked, zoomStatus]) .SetStyle(style) diff --git a/UI/BigComponents/ImportButton.ts b/UI/BigComponents/ImportButton.ts index 362237e00..9c206f249 100644 --- a/UI/BigComponents/ImportButton.ts +++ b/UI/BigComponents/ImportButton.ts @@ -16,29 +16,165 @@ import {OsmConnection} from "../../Logic/Osm/OsmConnection"; import {Changes} from "../../Logic/Osm/Changes"; import {ElementStorage} from "../../Logic/ElementStorage"; import FeaturePipeline from "../../Logic/FeatureSource/FeaturePipeline"; +import Lazy from "../Base/Lazy"; +import ConfirmLocationOfPoint from "../NewPoint/ConfirmLocationOfPoint"; +import {PresetInfo} from "./SimpleAddUI"; +import Img from "../Base/Img"; +import {Translation} from "../i18n/Translation"; +import FilteredLayer from "../../Models/FilteredLayer"; +import SpecialVisualizations, {SpecialVisualization} from "../SpecialVisualizations"; +import {FixedUiElement} from "../Base/FixedUiElement"; +import Svg from "../../Svg"; +import {Utils} from "../../Utils"; + + +export interface ImportButtonState { + description?: Translation; + image: () => BaseUIElement, + message: string | BaseUIElement, + originalTags: UIEventSource, + newTags: UIEventSource, + targetLayer: FilteredLayer, + feature: any, + minZoom: number, + state: { + featureSwitchUserbadge: UIEventSource; + featurePipeline: FeaturePipeline; + allElements: ElementStorage; + selectedElement: UIEventSource; + layoutToUse: LayoutConfig, + osmConnection: OsmConnection, + changes: Changes, + locationControl: UIEventSource<{ zoom: number }> + }, + guiState: { filterViewIsOpened: UIEventSource }, + snapToLayers?: string[], + snapToLayersMaxDist?: number +} + +export class ImportButtonSpecialViz implements SpecialVisualization { + funcName = "import_button" + docs = `This button will copy the data from an external dataset into OpenStreetMap. It is only functional in official themes but can be tested in unofficial themes. + +#### Importing a dataset into OpenStreetMap: requirements + +If you want to import a dataset, make sure that: + +1. The dataset to import has a suitable license +2. The community has been informed of the import +3. All other requirements of the [import guidelines](https://wiki.openstreetmap.org/wiki/Import/Guidelines) have been followed + +There are also some technicalities in your theme to keep in mind: + +1. The new feature will be added and will flow through the program as any other new point as if it came from OSM. + This means that there should be a layer which will match the new tags and which will display it. +2. The original feature from your geojson layer will gain the tag '_imported=yes'. + This should be used to change the appearance or even to hide it (eg by changing the icon size to zero) +3. There should be a way for the theme to detect previously imported points, even after reloading. + A reference number to the original dataset is an excellent way to do this +4. When importing ways, the theme creator is also responsible of avoiding overlapping ways. + +#### Disabled in unofficial themes + +The import button can be tested in an unofficial theme by adding \`test=true\` or \`backend=osm-test\` as [URL-paramter](URL_Parameters.md). +The import button will show up then. If in testmode, you can read the changeset-XML directly in the web console. +In the case that MapComplete is pointed to the testing grounds, the edit will be made on ${OsmConnection.oauth_configs["osm-test"].url} + + +#### Specifying which tags to copy or add + +The first argument of the import button takes a \`;\`-seperated list of tags to add. + +${Utils.Special_visualizations_tagsToApplyHelpText} + + +` + args = [ + { + name: "targetLayer", + doc: "The id of the layer where this point should end up. This is not very strict, it will simply result in checking that this layer is shown preventing possible duplicate elements" + }, + { + name: "tags", + doc: "The tags to add onto the new object - see specification above" + }, + { + name: "text", + doc: "The text to show on the button", + defaultValue: "Import this data into OpenStreetMap" + }, + { + name: "icon", + doc: "A nice icon to show in the button", + defaultValue: "./assets/svg/addSmall.svg" + }, + { + name: "minzoom", + doc: "How far the contributor must zoom in before being able to import the point", + defaultValue: "18" + }, { + name: "Snap onto layer(s)", + doc: "If a way of the given layer is closeby, will snap the new point onto this way (similar as preset might snap). To show multiple layers to snap onto, use a `;`-seperated list", + }, { + name: "snap max distance", + doc: "The maximum distance that this point will move to snap onto a layer (in meters)", + defaultValue: "5" + }] + + constr(state, tagSource, args, guiState) { + if (!state.layoutToUse.official && !(state.featureSwitchIsTesting.data || state.osmConnection._oauth_config.url === OsmConnection.oauth_configs["osm-test"].url)) { + return new Combine([new FixedUiElement("The import button is disabled for unofficial themes to prevent accidents.").SetClass("alert"), + new FixedUiElement("To test, add test=true or backend=osm-test to the URL. The changeset will be printed in the console. Please open a PR to officialize this theme to actually enable the import button.")]) + } + const newTags = SpecialVisualizations.generateTagsToApply(args[1], tagSource) + const id = tagSource.data.id; + const feature = state.allElements.ContainingFeatures.get(id) + let minZoom = args[4] == "" ? 18 : Number(args[4]) + if(isNaN(minZoom)){ + console.warn("Invalid minzoom:", minZoom) + minZoom = 18 + } + const message = args[2] + const imageUrl = args[3] + let img: () => BaseUIElement + const targetLayer: FilteredLayer = state.filteredLayers.data.filter(fl => fl.layerDef.id === args[0])[0] + + if (imageUrl !== undefined && imageUrl !== "") { + img = () => new Img(imageUrl) + } else { + img = () => Svg.add_ui() + } + + const snapToLayers = args[5]?.split(";").filter(s => s !== "") + const snapToLayersMaxDist = Number(args[6] ?? 6) + + if (targetLayer === undefined) { + const e = "Target layer not defined: error in import button for theme: " + state.layoutToUse.id + ": layer " + args[0] + " not found" + console.error(e) + return new FixedUiElement(e).SetClass("alert") + } + + return new ImportButton( + { + state, guiState, image: img, + feature, newTags, message, minZoom, + originalTags: tagSource, + targetLayer, + snapToLayers, + snapToLayersMaxDist + } + ); + } +} export default class ImportButton extends Toggle { - constructor(imageUrl: string | BaseUIElement, - message: string | BaseUIElement, - originalTags: UIEventSource, - newTags: UIEventSource, - feature: any, - minZoom: number, - state: { - featureSwitchUserbadge: UIEventSource; - featurePipeline: FeaturePipeline; - allElements: ElementStorage; - selectedElement: UIEventSource; - layoutToUse: LayoutConfig, - osmConnection: OsmConnection, - changes: Changes, - locationControl: UIEventSource<{ zoom: number }> - }) { + + constructor(o: ImportButtonState) { const t = Translations.t.general.add; - const isImported = originalTags.map(tags => tags._imported === "yes") + const isImported = o.originalTags.map(tags => tags._imported === "yes") const appliedTags = new Toggle( new VariableUiElement( - newTags.map(tgs => { + o.newTags.map(tgs => { const parts = [] for (const tag of tgs) { parts.push(tag.key + "=" + tag.value) @@ -46,63 +182,106 @@ export default class ImportButton extends Toggle { const txt = parts.join(" & ") return t.presetInfo.Subs({tags: txt}).SetClass("subtle") })), undefined, - state.osmConnection.userDetails.map(ud => ud.csCount >= Constants.userJourney.tagsVisibleAt) + o.state.osmConnection.userDetails.map(ud => ud.csCount >= Constants.userJourney.tagsVisibleAt) ) - const button = new SubtleButton(imageUrl, message) + const button = new SubtleButton(o.image(), o.message) - minZoom = Math.max(16, minZoom ?? 19) + o.minZoom = Math.max(16, o.minZoom ?? 19) - button.onClick(async () => { - if (isImported.data) { - return - } - originalTags.data["_imported"] = "yes" - originalTags.ping() // will set isImported as per its definition - const newElementAction = ImportButton.createAddActionForFeature(newTags.data, feature, state.layoutToUse.id) - await state.changes.applyAction(newElementAction) - state.selectedElement.setData(state.allElements.ContainingFeatures.get( - newElementAction.newElementId - )) - console.log("Did set selected element to", state.allElements.ContainingFeatures.get( - newElementAction.newElementId - )) - - - }) const withLoadingCheck = new Toggle(new Toggle( new Loading(t.stillLoading.Clone()), new Combine([button, appliedTags]).SetClass("flex flex-col"), - state.featurePipeline.runningQuery + o.state.featurePipeline.runningQuery ), t.zoomInFurther.Clone(), - state.locationControl.map(l => l.zoom >= minZoom) + o.state.locationControl.map(l => l.zoom >= o.minZoom) ) const importButton = new Toggle(t.hasBeenImported, withLoadingCheck, isImported) + + const importClicked = new UIEventSource(false); + const importFlow = new Toggle( + new Lazy(() => ImportButton.createConfirmPanel(o, isImported, importClicked)), + importButton, + importClicked + ) + + button.onClick(() => { + importClicked.setData(true); + }) + + const pleaseLoginButton = new Toggle(t.pleaseLogin.Clone() - .onClick(() => state.osmConnection.AttemptLogin()) + .onClick(() => o.state.osmConnection.AttemptLogin()) .SetClass("login-button-friendly"), undefined, - state.featureSwitchUserbadge) + o.state.featureSwitchUserbadge) - super(new Toggle(importButton, + super(new Toggle(importFlow, pleaseLoginButton, - state.osmConnection.isLoggedIn + o.state.osmConnection.isLoggedIn ), t.wrongType, - new UIEventSource(ImportButton.canBeImported(feature)) + new UIEventSource(ImportButton.canBeImported(o.feature)) ) } + public static createConfirmPanel( + o: ImportButtonState, + isImported: UIEventSource, + importClicked: UIEventSource): BaseUIElement { + + async function confirm() { + if (isImported.data) { + return + } + o.originalTags.data["_imported"] = "yes" + o.originalTags.ping() // will set isImported as per its definition + const newElementAction = ImportButton.createAddActionForFeature(o.newTags.data, o.feature, o.state.layoutToUse.id) + await o.state.changes.applyAction(newElementAction) + o.state.selectedElement.setData(o.state.allElements.ContainingFeatures.get( + newElementAction.newElementId + )) + console.log("Did set selected element to", o.state.allElements.ContainingFeatures.get( + newElementAction.newElementId + )) + } + + function cancel() { + importClicked.setData(false) + } + + if (o.feature.geometry.type === "Point") { + const presetInfo = { + tags: o.newTags.data, + icon: o.image, + description: o.description, + layerToAddTo: o.targetLayer, + name: o.message, + title: o.message, + preciseInput: { snapToLayers: o.snapToLayers, + maxSnapDistance: o.snapToLayersMaxDist} + } + + const [lon, lat] = o.feature.geometry.coordinates + console.log("Creating an import dialog at location", lon, lat) + return new ConfirmLocationOfPoint(o.state, o.guiState.filterViewIsOpened, presetInfo, Translations.W(o.message), { + lon, + lat + }, confirm, cancel) + } + } + private static canBeImported(feature: any) { const type = feature.geometry.type return type === "Point" || type === "LineString" || (type === "Polygon" && feature.geometry.coordinates.length === 1) } - private static createAddActionForFeature(newTags: Tag[], feature: any, theme: string): OsmChangeAction & { newElementId: string } { + private static createAddActionForFeature(newTags: Tag[], feature: any, theme: string): + OsmChangeAction & { newElementId: string } { const geometry = feature.geometry const type = geometry.type if (type === "Point") { diff --git a/UI/BigComponents/SimpleAddUI.ts b/UI/BigComponents/SimpleAddUI.ts index 6a5b37cb9..f7931b853 100644 --- a/UI/BigComponents/SimpleAddUI.ts +++ b/UI/BigComponents/SimpleAddUI.ts @@ -12,18 +12,16 @@ import BaseUIElement from "../BaseUIElement"; import {VariableUiElement} from "../Base/VariableUIElement"; import Toggle from "../Input/Toggle"; import UserDetails, {OsmConnection} from "../../Logic/Osm/OsmConnection"; -import LocationInput from "../Input/LocationInput"; -import AvailableBaseLayers from "../../Logic/Actors/AvailableBaseLayers"; import CreateNewNodeAction from "../../Logic/Osm/Actions/CreateNewNodeAction"; import {OsmObject, OsmWay} from "../../Logic/Osm/OsmObject"; import PresetConfig from "../../Models/ThemeConfig/PresetConfig"; import FilteredLayer from "../../Models/FilteredLayer"; -import {BBox} from "../../Logic/BBox"; import Loc from "../../Models/Loc"; import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig"; import {Changes} from "../../Logic/Osm/Changes"; import FeaturePipeline from "../../Logic/FeatureSource/FeaturePipeline"; import {ElementStorage} from "../../Logic/ElementStorage"; +import ConfirmLocationOfPoint from "../NewPoint/ConfirmLocationOfPoint"; /* * The SimpleAddUI is a single panel, which can have multiple states: @@ -33,8 +31,7 @@ import {ElementStorage} from "../../Logic/ElementStorage"; * - A 'read your unread messages before adding a point' */ -/*private*/ -interface PresetInfo extends PresetConfig { +export interface PresetInfo extends PresetConfig { name: string | BaseUIElement, icon: () => BaseUIElement, layerToAddTo: FilteredLayer @@ -91,20 +88,29 @@ export default class SimpleAddUI extends Toggle { if (preset === undefined) { return presetsOverview } - return SimpleAddUI.CreateConfirmButton(state, filterViewIsOpened, preset, - (tags, location, snapOntoWayId?: string) => { - if (snapOntoWayId === undefined) { - createNewPoint(tags, location, undefined) - } else { - OsmObject.DownloadObject(snapOntoWayId).addCallbackAndRunD(way => { - createNewPoint(tags, location, way) - return true; - }) - } - }, - () => { - selectedPreset.setData(undefined) - }) + + + function confirm(tags, location, snapOntoWayId?: string) { + if (snapOntoWayId === undefined) { + createNewPoint(tags, location, undefined) + } else { + OsmObject.DownloadObject(snapOntoWayId).addCallbackAndRunD(way => { + createNewPoint(tags, location, way) + return true; + }) + } + } + + function cancel() { + selectedPreset.setData(undefined) + } + + const message =Translations.t.general.add.addNew.Subs({category: preset.name}); + return new ConfirmLocationOfPoint(state, filterViewIsOpened, preset, + message, + state.LastClickLocation.data, + confirm, + cancel) } )) @@ -134,170 +140,7 @@ export default class SimpleAddUI extends Toggle { } - private static CreateConfirmButton( - state: { - LastClickLocation: UIEventSource<{ lat: number, lon: number }>, - osmConnection: OsmConnection, - featurePipeline: FeaturePipeline - }, - filterViewIsOpened: UIEventSource, - preset: PresetInfo, - confirm: (tags: any[], location: { lat: number, lon: number }, snapOntoWayId: string) => void, - cancel: () => void): BaseUIElement { - - let location = state.LastClickLocation; - let preciseInput: LocationInput = undefined - if (preset.preciseInput !== undefined) { - // We uncouple the event source - const locationSrc = new UIEventSource({ - lat: location.data.lat, - lon: location.data.lon, - zoom: 19 - }); - - let backgroundLayer = undefined; - if (preset.preciseInput.preferredBackground) { - backgroundLayer = AvailableBaseLayers.SelectBestLayerAccordingTo(locationSrc, new UIEventSource(preset.preciseInput.preferredBackground)) - } - - let snapToFeatures: UIEventSource<{ feature: any }[]> = undefined - let mapBounds: UIEventSource = undefined - if (preset.preciseInput.snapToLayers) { - snapToFeatures = new UIEventSource<{ feature: any }[]>([]) - mapBounds = new UIEventSource(undefined) - } - - - const tags = TagUtils.KVtoProperties(preset.tags ?? []); - preciseInput = new LocationInput({ - mapBackground: backgroundLayer, - centerLocation: locationSrc, - snapTo: snapToFeatures, - snappedPointTags: tags, - maxSnapDistance: preset.preciseInput.maxSnapDistance, - bounds: mapBounds - }) - preciseInput.installBounds(0.15, true) - preciseInput.SetClass("h-32 rounded-xl overflow-hidden border border-gray").SetStyle("height: 12rem;") - - - if (preset.preciseInput.snapToLayers) { - // We have to snap to certain layers. - // Lets fetch them - - let loadedBbox: BBox = undefined - mapBounds?.addCallbackAndRunD(bbox => { - if (loadedBbox !== undefined && bbox.isContainedIn(loadedBbox)) { - // All is already there - // return; - } - - bbox = bbox.pad(2); - loadedBbox = bbox; - const allFeatures: { feature: any }[] = [] - preset.preciseInput.snapToLayers.forEach(layerId => { - state.featurePipeline.GetFeaturesWithin(layerId, bbox).forEach(feats => allFeatures.push(...feats.map(f => ({feature: f})))) - }) - snapToFeatures.setData(allFeatures) - }) - } - - } - - - let confirmButton: BaseUIElement = new SubtleButton(preset.icon(), - new Combine([ - Translations.t.general.add.addNew.Subs({category: preset.name}), - Translations.t.general.add.warnVisibleForEveryone.Clone().SetClass("alert") - ]).SetClass("flex flex-col") - ).SetClass("font-bold break-words") - .onClick(() => { - confirm(preset.tags, (preciseInput?.GetValue() ?? location).data, preciseInput?.snappedOnto?.data?.properties?.id); - }); - - if (preciseInput !== undefined) { - confirmButton = new Combine([preciseInput, confirmButton]) - } - - const openLayerControl = - new SubtleButton( - Svg.layers_ui(), - new Combine([ - Translations.t.general.add.layerNotEnabled - .Subs({layer: preset.layerToAddTo.layerDef.name}) - .SetClass("alert"), - Translations.t.general.add.openLayerControl - ]) - ) - .onClick(() => filterViewIsOpened.setData(true)) - - - const openLayerOrConfirm = new Toggle( - confirmButton, - openLayerControl, - preset.layerToAddTo.isDisplayed - ) - - const disableFilter = new SubtleButton( - new Combine([ - Svg.filter_ui().SetClass("absolute w-full"), - Svg.cross_bottom_right_svg().SetClass("absolute red-svg") - ]).SetClass("relative"), - new Combine( - [ - Translations.t.general.add.disableFiltersExplanation.Clone(), - Translations.t.general.add.disableFilters.Clone().SetClass("text-xl") - ] - ).SetClass("flex flex-col") - ).onClick(() => { - preset.layerToAddTo.appliedFilters.setData([]) - cancel() - }) - - const disableFiltersOrConfirm = new Toggle( - openLayerOrConfirm, - disableFilter, - preset.layerToAddTo.appliedFilters.map(filters => { - if (filters === undefined || filters.length === 0) { - return true; - } - for (const filter of filters) { - if (filter.selected === 0 && filter.filter.options.length === 1) { - return false; - } - if (filter.selected !== undefined) { - const tags = filter.filter.options[filter.selected].osmTags - if (tags !== undefined && tags["and"]?.length !== 0) { - // This actually doesn't filter anything at all - return false; - } - } - } - return true - - }) - ) - - - const tagInfo = SimpleAddUI.CreateTagInfoFor(preset, state.osmConnection); - - const cancelButton = new SubtleButton(Svg.close_ui(), - Translations.t.general.cancel - ).onClick(cancel) - - return new Combine([ - state.osmConnection.userDetails.data.dryRun ? - Translations.t.general.testing.Clone().SetClass("alert") : undefined, - disableFiltersOrConfirm, - cancelButton, - preset.description, - tagInfo - - ]).SetClass("flex flex-col") - - } - - private static CreateTagInfoFor(preset: PresetInfo, osmConnection: OsmConnection, optionallyLinkToWiki = true) { + public static CreateTagInfoFor(preset: PresetInfo, osmConnection: OsmConnection, optionallyLinkToWiki = true) { const csCount = osmConnection.userDetails.data.csCount; return new Toggle( Translations.t.general.add.presetInfo.Subs({ @@ -329,7 +172,7 @@ export default class SimpleAddUI extends Toggle { private static CreatePresetSelectButton(preset: PresetInfo, osmConnection: OsmConnection) { - const tagInfo = SimpleAddUI.CreateTagInfoFor(preset, osmConnection ,false); + const tagInfo = SimpleAddUI.CreateTagInfoFor(preset, osmConnection, false); return new SubtleButton( preset.icon(), new Combine([ @@ -368,7 +211,7 @@ export default class SimpleAddUI extends Toggle { for (const preset of presets) { const tags = TagUtils.KVtoProperties(preset.tags ?? []); - let icon: () => BaseUIElement = () => layer.layerDef.mapRendering[0]. GenerateLeafletStyle(new UIEventSource(tags), false).html + let icon: () => BaseUIElement = () => layer.layerDef.mapRendering[0].GenerateLeafletStyle(new UIEventSource(tags), false).html .SetClass("w-12 h-12 block relative"); const presetInfo: PresetInfo = { tags: preset.tags, diff --git a/UI/DefaultGUI.ts b/UI/DefaultGUI.ts index a4de2d460..8ee02fdd4 100644 --- a/UI/DefaultGUI.ts +++ b/UI/DefaultGUI.ts @@ -32,6 +32,7 @@ export class DefaultGuiState { public readonly copyrightViewIsOpened: UIEventSource; public readonly welcomeMessageOpenedTab: UIEventSource public readonly allFullScreenStates: UIEventSource[] = [] + static state: DefaultGuiState; constructor() { diff --git a/UI/Input/LocationInput.ts b/UI/Input/LocationInput.ts index 3ff7fe5da..65ecd604c 100644 --- a/UI/Input/LocationInput.ts +++ b/UI/Input/LocationInput.ts @@ -96,6 +96,8 @@ export default class LocationInput extends InputElement implements MinimapO let min = undefined; let matchedWay = undefined; for (const feature of self._snapTo.data ?? []) { + try{ + const nearestPointOnLine = GeoOperations.nearestPoint(feature.feature, [loc.lon, loc.lat]) if (min === undefined) { min = nearestPointOnLine @@ -108,6 +110,9 @@ export default class LocationInput extends InputElement implements MinimapO matchedWay = feature.feature; } + }catch(e){ + console.log("Snapping to a nearest point failed for ", feature.feature,"due to ", e) + } } if (min === undefined || min.properties.dist * 1000 > self._maxSnapDistance) { diff --git a/UI/NewPoint/ConfirmLocationOfPoint.ts b/UI/NewPoint/ConfirmLocationOfPoint.ts new file mode 100644 index 000000000..68e380997 --- /dev/null +++ b/UI/NewPoint/ConfirmLocationOfPoint.ts @@ -0,0 +1,184 @@ +import {UIEventSource} from "../../Logic/UIEventSource"; +import {OsmConnection} from "../../Logic/Osm/OsmConnection"; +import FeaturePipeline from "../../Logic/FeatureSource/FeaturePipeline"; +import BaseUIElement from "../BaseUIElement"; +import LocationInput from "../Input/LocationInput"; +import AvailableBaseLayers from "../../Logic/Actors/AvailableBaseLayers"; +import {BBox} from "../../Logic/BBox"; +import {TagUtils} from "../../Logic/Tags/TagUtils"; +import {SubtleButton} from "../Base/SubtleButton"; +import Combine from "../Base/Combine"; +import Translations from "../i18n/Translations"; +import Svg from "../../Svg"; +import Toggle from "../Input/Toggle"; +import SimpleAddUI, {PresetInfo} from "../BigComponents/SimpleAddUI"; + +export default class ConfirmLocationOfPoint extends Combine { + + + constructor( + state: { + osmConnection: OsmConnection, + featurePipeline: FeaturePipeline + }, + filterViewIsOpened: UIEventSource, + preset: PresetInfo, + confirmText: BaseUIElement, + loc: { lon: number, lat: number }, + confirm: (tags: any[], location: { lat: number, lon: number }, snapOntoWayId: string) => void, + cancel: () => void, + ) { + + let preciseInput: LocationInput = undefined + if (preset.preciseInput !== undefined) { + // We uncouple the event source + const zloc = {...loc, zoom: 19} + const locationSrc = new UIEventSource(zloc); + + let backgroundLayer = undefined; + if (preset.preciseInput.preferredBackground) { + backgroundLayer = AvailableBaseLayers.SelectBestLayerAccordingTo(locationSrc, new UIEventSource(preset.preciseInput.preferredBackground)) + } + + let snapToFeatures: UIEventSource<{ feature: any }[]> = undefined + let mapBounds: UIEventSource = undefined + if (preset.preciseInput.snapToLayers && preset.preciseInput.snapToLayers.length > 0) { + snapToFeatures = new UIEventSource<{ feature: any }[]>([]) + mapBounds = new UIEventSource(undefined) + } + + + const tags = TagUtils.KVtoProperties(preset.tags ?? []); + preciseInput = new LocationInput({ + mapBackground: backgroundLayer, + centerLocation: locationSrc, + snapTo: snapToFeatures, + snappedPointTags: tags, + maxSnapDistance: preset.preciseInput.maxSnapDistance, + bounds: mapBounds + }) + preciseInput.installBounds(0.15, true) + preciseInput.SetClass("h-32 rounded-xl overflow-hidden border border-gray").SetStyle("height: 12rem;") + + + if (preset.preciseInput.snapToLayers && preset.preciseInput.snapToLayers.length > 0) { + // We have to snap to certain layers. + // Lets fetch them + + let loadedBbox: BBox = undefined + mapBounds?.addCallbackAndRunD(bbox => { + if (loadedBbox !== undefined && bbox.isContainedIn(loadedBbox)) { + // All is already there + // return; + } + + bbox = bbox.pad(2); + loadedBbox = bbox; + const allFeatures: { feature: any }[] = [] + preset.preciseInput.snapToLayers.forEach(layerId => { + console.log("Snapping to", layerId) + state.featurePipeline.GetFeaturesWithin(layerId, bbox)?.forEach(feats => allFeatures.push(...feats.map(f => ({feature: f})))) + }) + console.log("Snapping to", allFeatures) + snapToFeatures.setData(allFeatures) + }) + } + + } + + + let confirmButton: BaseUIElement = new SubtleButton(preset.icon(), + new Combine([ + confirmText, + Translations.t.general.add.warnVisibleForEveryone.Clone().SetClass("alert") + ]).SetClass("flex flex-col") + ).SetClass("font-bold break-words") + .onClick(() => { + confirm(preset.tags, (preciseInput?.GetValue()?.data ?? loc), preciseInput?.snappedOnto?.data?.properties?.id); + }); + + if (preciseInput !== undefined) { + confirmButton = new Combine([preciseInput, confirmButton]) + } + + const openLayerControl = + new SubtleButton( + Svg.layers_ui(), + new Combine([ + Translations.t.general.add.layerNotEnabled + .Subs({layer: preset.layerToAddTo.layerDef.name}) + .SetClass("alert"), + Translations.t.general.add.openLayerControl + ]) + ) + .onClick(() => filterViewIsOpened.setData(true)) + + + const openLayerOrConfirm = new Toggle( + confirmButton, + openLayerControl, + preset.layerToAddTo.isDisplayed + ) + + const disableFilter = new SubtleButton( + new Combine([ + Svg.filter_ui().SetClass("absolute w-full"), + Svg.cross_bottom_right_svg().SetClass("absolute red-svg") + ]).SetClass("relative"), + new Combine( + [ + Translations.t.general.add.disableFiltersExplanation.Clone(), + Translations.t.general.add.disableFilters.Clone().SetClass("text-xl") + ] + ).SetClass("flex flex-col") + ).onClick(() => { + preset.layerToAddTo.appliedFilters.setData([]) + cancel() + }) + + const disableFiltersOrConfirm = new Toggle( + openLayerOrConfirm, + disableFilter, + preset.layerToAddTo.appliedFilters.map(filters => { + if (filters === undefined || filters.length === 0) { + return true; + } + for (const filter of filters) { + if (filter.selected === 0 && filter.filter.options.length === 1) { + return false; + } + if (filter.selected !== undefined) { + const tags = filter.filter.options[filter.selected].osmTags + if (tags !== undefined && tags["and"]?.length !== 0) { + // This actually doesn't filter anything at all + return false; + } + } + } + return true + + }) + ) + + + const tagInfo = SimpleAddUI.CreateTagInfoFor(preset, state.osmConnection); + + const cancelButton = new SubtleButton(Svg.close_ui(), + Translations.t.general.cancel + ).onClick(cancel) + + super([ + state.osmConnection.userDetails.data.dryRun ? + Translations.t.general.testing.Clone().SetClass("alert") : undefined, + disableFiltersOrConfirm, + cancelButton, + preset.description, + tagInfo + + ]) + + this.SetClass("flex flex-col") + + } + +} \ No newline at end of file diff --git a/UI/SpecialVisualizations.ts b/UI/SpecialVisualizations.ts index 87af2ab7d..fb9e4b6bc 100644 --- a/UI/SpecialVisualizations.ts +++ b/UI/SpecialVisualizations.ts @@ -20,7 +20,7 @@ import Histogram from "./BigComponents/Histogram"; import Loc from "../Models/Loc"; import {Utils} from "../Utils"; import LayerConfig from "../Models/ThemeConfig/LayerConfig"; -import ImportButton from "./BigComponents/ImportButton"; +import ImportButton, {ImportButtonSpecialViz} from "./BigComponents/ImportButton"; import {Tag} from "../Logic/Tags/Tag"; import StaticFeatureSource from "../Logic/FeatureSource/Sources/StaticFeatureSource"; import ShowDataMultiLayer from "./ShowDataLayer/ShowDataMultiLayer"; @@ -38,10 +38,13 @@ import {SubtleButton} from "./Base/SubtleButton"; import ChangeTagAction from "../Logic/Osm/Actions/ChangeTagAction"; import {And} from "../Logic/Tags/And"; import Toggle from "./Input/Toggle"; +import {DefaultGuiState} from "./DefaultGUI"; +import Img from "./Base/Img"; +import FilteredLayer from "../Models/FilteredLayer"; export interface SpecialVisualization { funcName: string, - constr: ((state: State, tagSource: UIEventSource, argument: string[]) => BaseUIElement), + constr: ((state: State, tagSource: UIEventSource, argument: string[], guistate: DefaultGuiState) => BaseUIElement), docs: string, example?: string, args: { name: string, defaultValue?: string, doc: string }[] @@ -49,17 +52,7 @@ export interface SpecialVisualization { export default class SpecialVisualizations { - private static tagsToApplyHelpText = `These can either be a tag to add, such as \`amenity=fast_food\` or can use a substitution, e.g. \`addr:housenumber=$number\`. -This new point will then have the tags \`amenity=fast_food\` and \`addr:housenumber\` with the value that was saved in \`number\` in the original feature. - -If a value to substitute is undefined, empty string will be used instead. - -This supports multiple values, e.g. \`ref=$source:geometry:type/$source:geometry:ref\` - -Remark that the syntax is slightly different then expected; it uses '$' to note a value to copy, followed by a name (matched with \`[a-zA-Z0-9_:]*\`). Sadly, delimiting with \`{}\` as these already mark the boundaries of the special rendering... - -Note that these values can be prepare with javascript in the theme by using a [calculatedTag](calculatedTags.md#calculating-tags-with-javascript) - ` + static tagsToApplyHelpText = Utils.Special_visualizations_tagsToApplyHelpText public static specialVisualizations: SpecialVisualization[] = [ { @@ -490,79 +483,7 @@ Note that these values can be prepare with javascript in the theme by using a [c ) } }, - { - funcName: "import_button", - args: [ - { - name: "tags", - doc: "The tags to add onto the new object - see specification above" - }, - { - name: "text", - doc: "The text to show on the button", - defaultValue: "Import this data into OpenStreetMap" - }, - { - name: "icon", - doc: "A nice icon to show in the button", - defaultValue: "./assets/svg/addSmall.svg" - }, - { - name: "minzoom", - doc: "How far the contributor must zoom in before being able to import the point", - defaultValue: "18" - }], - docs: `This button will copy the data from an external dataset into OpenStreetMap. It is only functional in official themes but can be tested in unofficial themes. - -#### Importing a dataset into OpenStreetMap: requirements - -If you want to import a dataset, make sure that: - -1. The dataset to import has a suitable license -2. The community has been informed of the import -3. All other requirements of the [import guidelines](https://wiki.openstreetmap.org/wiki/Import/Guidelines) have been followed - -There are also some technicalities in your theme to keep in mind: - -1. The new feature will be added and will flow through the program as any other new point as if it came from OSM. - This means that there should be a layer which will match the new tags and which will display it. -2. The original feature from your geojson layer will gain the tag '_imported=yes'. - This should be used to change the appearance or even to hide it (eg by changing the icon size to zero) -3. There should be a way for the theme to detect previously imported points, even after reloading. - A reference number to the original dataset is an excellent way to do this -4. When importing ways, the theme creator is also responsible of avoiding overlapping ways. - -#### Disabled in unofficial themes - -The import button can be tested in an unofficial theme by adding \`test=true\` or \`backend=osm-test\` as [URL-paramter](URL_Parameters.md). -The import button will show up then. If in testmode, you can read the changeset-XML directly in the web console. -In the case that MapComplete is pointed to the testing grounds, the edit will be made on ${OsmConnection.oauth_configs["osm-test"].url} - - -#### Specifying which tags to copy or add - -The first argument of the import button takes a \`;\`-seperated list of tags to add. - -${SpecialVisualizations.tagsToApplyHelpText} - -`, - constr: (state, tagSource, args) => { - if (!state.layoutToUse.official && !(state.featureSwitchIsTesting.data || state.osmConnection._oauth_config.url === OsmConnection.oauth_configs["osm-test"].url)) { - return new Combine([new FixedUiElement("The import button is disabled for unofficial themes to prevent accidents.").SetClass("alert"), - new FixedUiElement("To test, add test=true or backend=osm-test to the URL. The changeset will be printed in the console. Please open a PR to officialize this theme to actually enable the import button.")]) - } - const rewrittenTags = SpecialVisualizations.generateTagsToApply(args[0], tagSource) - const id = tagSource.data.id; - const feature = state.allElements.ContainingFeatures.get(id) - const minzoom = Number(args[3]) - const message = args[1] - const image = args[2] - - return new ImportButton( - image, message, tagSource, rewrittenTags, feature, minzoom, state - ) - } - }, + new ImportButtonSpecialViz(), { funcName: "multi_apply", docs: "A button to apply the tagging of this object onto a list of other features. This is an advanced feature for which you'll need calculatedTags", @@ -687,7 +608,7 @@ ${SpecialVisualizations.tagsToApplyHelpText} } ] - private static generateTagsToApply(spec: string, tagSource: UIEventSource): UIEventSource { + static generateTagsToApply(spec: string, tagSource: UIEventSource): UIEventSource { const tgsSpec = spec.split(";").map(spec => { const kv = spec.split("=").map(s => s.trim()); diff --git a/UI/SubstitutedTranslation.ts b/UI/SubstitutedTranslation.ts index ff2d3a447..cf6e4af76 100644 --- a/UI/SubstitutedTranslation.ts +++ b/UI/SubstitutedTranslation.ts @@ -8,6 +8,7 @@ import {Utils} from "../Utils"; import {VariableUiElement} from "./Base/VariableUIElement"; import Combine from "./Base/Combine"; import BaseUIElement from "./BaseUIElement"; +import {DefaultGuiState} from "./DefaultGUI"; export class SubstitutedTranslation extends VariableUiElement { @@ -49,7 +50,7 @@ export class SubstitutedTranslation extends VariableUiElement { } const viz = proto.special; try { - return viz.func.constr(State.state, tagsSource, proto.special.args).SetStyle(proto.special.style); + return viz.func.constr(State.state, tagsSource, proto.special.args, DefaultGuiState.state).SetStyle(proto.special.style); } catch (e) { console.error("SPECIALRENDERING FAILED for", tagsSource.data?.id, e) return new FixedUiElement(`Could not generate special rendering for ${viz.func}(${viz.args.join(", ")}) ${e}`).SetStyle("alert") diff --git a/Utils.ts b/Utils.ts index 40fb518ee..e0dbf2641 100644 --- a/Utils.ts +++ b/Utils.ts @@ -15,6 +15,18 @@ export class Utils { private static injectedDownloads = {} private static _download_cache = new Map, timestamp: number }>() + public static Special_visualizations_tagsToApplyHelpText = `These can either be a tag to add, such as \`amenity=fast_food\` or can use a substitution, e.g. \`addr:housenumber=$number\`. +This new point will then have the tags \`amenity=fast_food\` and \`addr:housenumber\` with the value that was saved in \`number\` in the original feature. + +If a value to substitute is undefined, empty string will be used instead. + +This supports multiple values, e.g. \`ref=$source:geometry:type/$source:geometry:ref\` + +Remark that the syntax is slightly different then expected; it uses '$' to note a value to copy, followed by a name (matched with \`[a-zA-Z0-9_:]*\`). Sadly, delimiting with \`{}\` as these already mark the boundaries of the special rendering... + +Note that these values can be prepare with javascript in the theme by using a [calculatedTag](calculatedTags.md#calculating-tags-with-javascript) + ` + static EncodeXmlValue(str) { if (typeof str !== "string") { str = "" + str diff --git a/assets/layers/type_node/type_node.json b/assets/layers/type_node/type_node.json new file mode 100644 index 000000000..5ead02f1c --- /dev/null +++ b/assets/layers/type_node/type_node.json @@ -0,0 +1,12 @@ +{ + "id": "type_node", + "description": "This is a special meta_layer which exports _every_ point in OSM. This only works if zoomed below the point that the full tile is loaded (and not loaded via Overpass). Note that this point will also contain a property `parent_ways` which contains all the ways this node is part of as a list", + "minzoom": 18, + "source": { + "osmTags": "id~node/.*" + }, + "mapRendering": [], + "name": "All OSM Nodes", + "title": "OSM node {id}", + "tagRendering": [ ] +} \ No newline at end of file diff --git a/assets/themes/grb_import/grb.json b/assets/themes/grb_import/grb.json index 6f3cbd5db..a7ce909e6 100644 --- a/assets/themes/grb_import/grb.json +++ b/assets/themes/grb_import/grb.json @@ -28,7 +28,20 @@ "overrideAll": { "minzoom": 18 }, + "trackAllNodes": true, "layers": [ + { + "builtin": "type_node", + "isShown": { + "render": "no" + }, + "override": { + "calculatedTags": [ + "_is_part_of_building=feat.get('parent_ways')?.some(p => p.building !== undefined && p.building !== '') ?? false", + "_is_part_of_landuse=feat.get('parent_ways')?.some(p => (p.landuse !== undefined && p.landuse !== '') || (p.natural !== undefined && p.natural !== '')) ?? false" + ] + } + }, { "id": "OSM-buildings", "name": "All OSM-buildings", @@ -413,7 +426,7 @@ "all_tags", { "id": "import-button", - "render": "{import_button(addr:street=$STRAATNM; addr:housenumber=$HUISNR)}" + "render": "{import_button(OSM-buildings, addr:street=$STRAATNM; addr:housenumber=$HUISNR,Import this address,,,OSM-buildings,5)}" } ] }, @@ -657,7 +670,7 @@ }, { "id": "Import-button", - "render": "{import_button(building=$building; source:geometry:date=$_grb_date; source:geometry:ref=$_grb_ref, Upload this building to OpenStreetMap)}", + "render": "{import_button(OSM-buildings,building=$building; source:geometry:date=$_grb_date; source:geometry:ref=$_grb_ref, Upload this building to OpenStreetMap)}", "mappings": [ { "if": "_overlaps_with!=null", diff --git a/assets/themes/uk_addresses/uk_addresses.json b/assets/themes/uk_addresses/uk_addresses.json index e48f8cf7a..a9f9d0a48 100644 --- a/assets/themes/uk_addresses/uk_addresses.json +++ b/assets/themes/uk_addresses/uk_addresses.json @@ -93,7 +93,7 @@ }, { "id": "uk_addresses_import_button", - "render": "{import_button(ref:inspireid=$inspireid, Add this address, ./assets/themes/uk_addresses/housenumber_add.svg)}" + "render": "{import_button(addresses, ref:inspireid=$inspireid, Add this address, ./assets/themes/uk_addresses/housenumber_add.svg)}" } ], "calculatedTags": [ diff --git a/index.ts b/index.ts index 3ff564ca9..2ed85051e 100644 --- a/index.ts +++ b/index.ts @@ -64,9 +64,11 @@ class Init { const guiState = new DefaultGuiState() State.state = new State(layoutToUse); + DefaultGuiState.state = guiState; // This 'leaks' the global state via the window object, useful for debugging // @ts-ignore window.mapcomplete_state = State.state; + new DefaultGUI(State.state, guiState) if (encoded !== undefined && encoded.length > 10) { diff --git a/scripts/generateLayerOverview.ts b/scripts/generateLayerOverview.ts index e4c981dec..da1a984b4 100644 --- a/scripts/generateLayerOverview.ts +++ b/scripts/generateLayerOverview.ts @@ -12,8 +12,8 @@ import {Utils} from "../Utils"; // It spits out an overview of those to be used to load them interface LayersAndThemes { - themes: any[], - layers: { parsed: any, path: string }[] + themes: LayoutConfigJson[], + layers: { parsed: LayerConfigJson, path: string }[] } @@ -35,7 +35,6 @@ class LayerOverviewUtils { } } - writeFiles(lt: LayersAndThemes) { writeFileSync("./assets/generated/known_layers_and_themes.json", JSON.stringify({ "layers": lt.layers.map(l => l.parsed), @@ -43,7 +42,6 @@ class LayerOverviewUtils { })) } - validateLayer(layerJson: LayerConfigJson, path: string, knownPaths: Set, context?: string): string[] { let errorCount = []; if (layerJson["overpassTags"] !== undefined) { @@ -109,6 +107,8 @@ class LayerOverviewUtils { } let themeErrorCount = [] + // used only for the reports + let themeConfigs: LayoutConfig[] = [] for (const themeInfo of themeFiles) { const themeFile = themeInfo.parsed const themePath = themeInfo.path @@ -119,7 +119,7 @@ class LayerOverviewUtils { themeErrorCount.push("The theme " + themeFile.id + " has units defined - these should be defined on the layer instead. (Hint: use overrideAll: { '+units': ... }) ") } if (themeFile["roamingRenderings"] !== undefined) { - themeErrorCount.push("Theme " + themeFile.id + " contains an old 'roamingRenderings'. Use an 'overrideAll' instead") + themeErrorCount.push("Theme " + themeFile.id + " contains an old 'roamingRenderings'. Use an 'overrideAll' instead") } for (const layer of themeFile.layers) { if (typeof layer === "string") { @@ -144,17 +144,17 @@ class LayerOverviewUtils { } } } - + const referencedLayers = Utils.NoNull([].concat(...themeFile.layers.map(layer => { - if(typeof layer === "string"){ + if (typeof layer === "string") { return layer } - if(layer["builtin"] !== undefined){ + if (layer["builtin"] !== undefined) { return layer["builtin"] } return undefined }).map(layerName => { - if(typeof layerName === "string"){ + if (typeof layerName === "string") { return [layerName] } return layerName @@ -176,9 +176,9 @@ class LayerOverviewUtils { } const neededLanguages = themeFile["mustHaveLanguage"] if (neededLanguages !== undefined) { - console.log("Checking language requerements for ", theme.id, "as it must have", neededLanguages.join(", ")) - const allTranslations = [].concat(Translation.ExtractAllTranslationsFrom(theme, theme.id), - ...referencedLayers.map(layerId => Translation.ExtractAllTranslationsFrom(knownLayerIds.get(layerId), theme.id+"->"+layerId))) + console.log("Checking language requirements for ", theme.id, "as it must have", neededLanguages.join(", ")) + const allTranslations = [].concat(Translation.ExtractAllTranslationsFrom(theme, theme.id), + ...referencedLayers.map(layerId => Translation.ExtractAllTranslationsFrom(knownLayerIds.get(layerId), theme.id + "->" + layerId))) for (const neededLanguage of neededLanguages) { allTranslations .filter(t => t.tr.translations[neededLanguage] === undefined && t.tr.translations["*"] === undefined) @@ -189,7 +189,7 @@ class LayerOverviewUtils { } - + themeConfigs.push(theme) } catch (e) { themeErrorCount.push("Could not parse theme " + themeFile["id"] + "due to", e) } @@ -210,12 +210,11 @@ class LayerOverviewUtils { console.log(msg) console.log("!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!") - if (process.argv.indexOf("--report") >= 0) { + if (args.indexOf("--report") >= 0) { console.log("Writing report!") writeFileSync("layer_report.txt", errors) } - - if (process.argv.indexOf("--no-fail") < 0) { + if (args.indexOf("--no-fail") < 0) { throw msg; } } diff --git a/test.ts b/test.ts index a8cc94f92..653048710 100644 --- a/test.ts +++ b/test.ts @@ -1,13 +1,139 @@ -import * as wd from "wikidata-sdk" -import * as wds from "wikibase-sdk" import {Utils} from "./Utils"; +import FullNodeDatabaseSource from "./Logic/FeatureSource/TiledFeatureSource/FullNodeDatabaseSource"; -const url = wd.getEntities(["Q42"]) -console.log(url) -Utils.downloadJson(url).then(async (entities) => { - //const parsed = wd.parse.wb.entities(entities)["Q42"] - console.log(entities) - console.log(wds.simplify.entity(entities.entities["Q42"], { - timeConverter: 'simple-day' - })) + +const data = "\n" + + "\n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " " + +const url = "https://www.openstreetmap.org/api/0.6/map?bbox=3.217620849609375,51.21548639922819,3.218994140625,51.21634661126673" +Utils.downloadJson(url).then(data =>{ + const osmSource = { + rawDataHandlers : [] + } + new FullNodeDatabaseSource(osmSource) + osmSource.rawDataHandlers[0]( data, 0) }) \ No newline at end of file