More work on splitting roads, WIP; refactoring tests

This commit is contained in:
Pieter Vander Vennet 2021-09-22 05:02:09 +02:00
parent e374bb355c
commit 1f93923820
62 changed files with 1163 additions and 823 deletions

View file

@ -16,6 +16,11 @@ export interface MinimapOptions {
lastClickLocation?: UIEventSource<{ lat: number, lon: number }>
}
export interface MinimapObj {
readonly leafletMap: UIEventSource<any>,
installBounds(factor: number | BBox, showRange?: boolean) : void
}
export default class Minimap {
/**
* A stub implementation. The actual implementation is injected later on, but only in the browser.
@ -25,6 +30,6 @@ export default class Minimap {
/**
* Construct a minimap
*/
public static createMiniMap: (options: MinimapOptions) => BaseUIElement & { readonly leafletMap: UIEventSource<any> }
public static createMiniMap: (options: MinimapOptions) => (BaseUIElement & MinimapObj)
}

View file

@ -7,9 +7,9 @@ import AvailableBaseLayers from "../../Logic/Actors/AvailableBaseLayers";
import {BBox} from "../../Logic/GeoOperations";
import * as L from "leaflet";
import {Map} from "leaflet";
import Minimap, {MinimapOptions} from "./Minimap";
import Minimap, {MinimapObj, MinimapOptions} from "./Minimap";
export default class MinimapImplementation extends BaseUIElement {
export default class MinimapImplementation extends BaseUIElement implements MinimapObj {
private static _nextId = 0;
public readonly leafletMap: UIEventSource<Map>
private readonly _id: string;
@ -44,6 +44,65 @@ export default class MinimapImplementation extends BaseUIElement {
Minimap.createMiniMap = options => new MinimapImplementation(options)
}
public installBounds(factor: number | BBox, showRange?: boolean) {
this.leafletMap.addCallbackD(leaflet => {
console.log("Installing max bounds")
let bounds;
if (typeof factor === "number") {
bounds = leaflet.getBounds()
leaflet.setMaxBounds(bounds.pad(factor))
}else{
// @ts-ignore
leaflet.setMaxBounds(factor.toLeaflet())
bounds = leaflet.getBounds()
}
if (showRange) {
const data = {
type: "FeatureCollection",
features: [{
"type": "Feature",
"geometry": {
"type": "LineString",
"coordinates": [
[
bounds.getEast(),
bounds.getNorth()
],
[
bounds.getWest(),
bounds.getNorth()
],
[
bounds.getWest(),
bounds.getSouth()
],
[
bounds.getEast(),
bounds.getSouth()
],
[
bounds.getEast(),
bounds.getNorth()
]
]
}
}]
}
// @ts-ignore
L.geoJSON(data, {
style: {
color: "#f00",
weight: 2,
opacity: 0.4
}
}).addTo(leaflet)
}
})
}
protected InnerConstructElement(): HTMLElement {
const div = document.createElement("div")
div.id = this._id;

View file

@ -65,7 +65,8 @@ export default class FullWelcomePaneWithTabs extends ScrollableFullScreen {
const tabsWithAboutMc = [...FullWelcomePaneWithTabs.ConstructBaseTabs(layoutToUse, isShown)]
const now = new Date()
const date = now.getFullYear()+"-"+Utils.TwoDigits(now.getMonth()+1)+"-"+Utils.TwoDigits(now.getDate())
const lastWeek = new Date(now.getDate() - 7 * 24 * 60 * 60 * 1000)
const date = lastWeek.getFullYear()+"-"+Utils.TwoDigits(lastWeek.getMonth()+1)+"-"+Utils.TwoDigits(lastWeek.getDate())
const osmcha_link = `https://osmcha.org/?filters=%7B%22date__gte%22%3A%5B%7B%22label%22%3A%22${date}%22%2C%22value%22%3A%222021-01-01%22%7D%5D%2C%22editor%22%3A%5B%7B%22label%22%3A%22mapcomplete%22%2C%22value%22%3A%22mapcomplete%22%7D%5D%7D`
tabsWithAboutMc.push({

View file

@ -20,6 +20,7 @@ import {OsmObject, OsmWay} from "../../Logic/Osm/OsmObject";
import PresetConfig from "../../Models/ThemeConfig/PresetConfig";
import FilteredLayer from "../../Models/FilteredLayer";
import {And} from "../../Logic/Tags/And";
import {BBox} from "../../Logic/GeoOperations";
/*
* The SimpleAddUI is a single panel, which can have multiple states:
@ -39,8 +40,6 @@ interface PresetInfo extends PresetConfig {
export default class SimpleAddUI extends Toggle {
constructor(isShown: UIEventSource<boolean>) {
const loginButton = new SubtleButton(Svg.osm_logo_ui(), Translations.t.general.add.pleaseLogin.Clone())
.onClick(() => State.state.osmConnection.AttemptLogin());
const readYourMessages = new Combine([
@ -52,7 +51,8 @@ export default class SimpleAddUI extends Toggle {
const selectedPreset = new UIEventSource<PresetInfo>(undefined);
isShown.addCallback(_ => selectedPreset.setData(undefined)) // Clear preset selection when the UI is closed/opened
State.state.LastClickLocation.addCallback( _ => selectedPreset.setData(undefined))
const presetsOverview = SimpleAddUI.CreateAllPresetsPanel(selectedPreset)
@ -82,11 +82,7 @@ export default class SimpleAddUI extends Toggle {
return true;
})
}
},
() => {
selectedPreset.setData(undefined)
})
@ -98,9 +94,9 @@ export default class SimpleAddUI extends Toggle {
new Toggle(
new Toggle(
new Toggle(
Translations.t.general.add.stillLoading.Clone().SetClass("alert"),
addUi,
State.state.featurePipeline.runningQuery
Translations.t.general.add.stillLoading.Clone().SetClass("alert"),
State.state.featurePipeline.somethingLoaded
),
Translations.t.general.add.zoomInFurther.Clone().SetClass("alert"),
State.state.locationControl.map(loc => loc.zoom >= Constants.userJourney.minZoomLevelToAddNewPoints)
@ -126,6 +122,7 @@ export default class SimpleAddUI extends Toggle {
let location = State.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,
@ -137,24 +134,48 @@ export default class SimpleAddUI extends Toggle {
backgroundLayer = AvailableBaseLayers.SelectBestLayerAccordingTo(locationSrc, new UIEventSource<string | string[]>(preset.preciseInput.preferredBackground))
}
let features: UIEventSource<{ feature: any }[]> = undefined
let snapToFeatures: UIEventSource<{ feature: any }[]> = undefined
let mapBounds: UIEventSource<BBox> = undefined
if (preset.preciseInput.snapToLayers) {
// We have to snap to certain layers.
// Lets fetch tehm
const asSet = new Set(preset.preciseInput.snapToLayers)
features = State.state.featurePipeline.features.map(f => f.filter(feat => asSet.has(feat.feature._matching_layer_id)))
snapToFeatures = new UIEventSource<{ feature: any }[]>([])
mapBounds = new UIEventSource<BBox>(undefined)
}
const tags = TagUtils.KVtoProperties(preset.tags ?? []);
preciseInput = new LocationInput({
mapBackground: backgroundLayer,
centerLocation: locationSrc,
snapTo: features,
snapTo: snapToFeatures,
snappedPointTags: tags,
maxSnapDistance: preset.preciseInput.maxSnapDistance
maxSnapDistance: preset.preciseInput.maxSnapDistance,
bounds: mapBounds
})
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.state.featurePipeline.GetFeaturesWithin(layerId, bbox).forEach(feats => allFeatures.push(...feats.map(f => ({feature :f}))))
})
snapToFeatures.setData(allFeatures)
})
}
}

View file

@ -7,7 +7,7 @@ import Combine from "../Base/Combine";
import Svg from "../../Svg";
import State from "../../State";
import AvailableBaseLayers from "../../Logic/Actors/AvailableBaseLayers";
import {GeoOperations} from "../../Logic/GeoOperations";
import {BBox, GeoOperations} from "../../Logic/GeoOperations";
import ShowDataLayer from "../ShowDataLayer/ShowDataLayer";
import * as L from "leaflet";
import ShowDataMultiLayer from "../ShowDataLayer/ShowDataMultiLayer";
@ -38,6 +38,8 @@ export default class LocationInput extends InputElement<Loc> {
private readonly _snappedPoint: UIEventSource<any>
private readonly _maxSnapDistance: number
private readonly _snappedPointTags: any;
private readonly _bounds: UIEventSource<BBox>;
public readonly _matching_layer: UIEventSource<LayerConfig>;
constructor(options: {
mapBackground?: UIEventSource<BaseLayer>,
@ -46,32 +48,33 @@ export default class LocationInput extends InputElement<Loc> {
snappedPointTags?: any,
requiresSnapping?: boolean,
centerLocation: UIEventSource<Loc>,
bounds?: UIEventSource<BBox>
}) {
super();
this._snapTo = options.snapTo?.map(features => features?.filter(feat => feat.feature.geometry.type !== "Point"))
this._maxSnapDistance = options.maxSnapDistance
this._centerLocation = options.centerLocation;
this._snappedPointTags = options.snappedPointTags
this._bounds = options.bounds;
if (this._snapTo === undefined) {
this._value = this._centerLocation;
} else {
const self = this;
let matching_layer: UIEventSource<string>
if (self._snappedPointTags !== undefined) {
matching_layer = State.state.layoutToUse.map(layout => {
this._matching_layer = State.state.layoutToUse.map(layout => {
for (const layer of layout.layers) {
if (layer.source.osmTags.matchesProperties(self._snappedPointTags)) {
return layer.id
return layer
}
}
console.error("No matching layer found for tags ", self._snappedPointTags)
return "matchpoint"
return LocationInput.matchLayer
})
} else {
matching_layer = new UIEventSource<string>("matchpoint")
this._matching_layer = new UIEventSource<LayerConfig>(LocationInput.matchLayer)
}
this._snappedPoint = options.centerLocation.map(loc => {
@ -83,7 +86,7 @@ export default class LocationInput extends InputElement<Loc> {
let min = undefined;
let matchedWay = undefined;
for (const feature of self._snapTo.data) {
for (const feature of self._snapTo.data ?? []) {
const nearestPointOnLine = GeoOperations.nearestPoint(feature.feature, [loc.lon, loc.lat])
if (min === undefined) {
min = nearestPointOnLine
@ -98,19 +101,17 @@ export default class LocationInput extends InputElement<Loc> {
}
}
if (min.properties.dist * 1000 > self._maxSnapDistance) {
if (min === undefined || min.properties.dist * 1000 > self._maxSnapDistance) {
if (options.requiresSnapping) {
return undefined
} else {
return {
"type": "Feature",
"_matching_layer_id": matching_layer.data,
"properties": options.snappedPointTags ?? min.properties,
"geometry": {"type": "Point", "coordinates": [loc.lon, loc.lat]}
}
}
}
min._matching_layer_id = matching_layer?.data ?? "matchpoint"
min.properties = options.snappedPointTags ?? min.properties
self.snappedOnto.setData(matchedWay)
return min
@ -144,84 +145,40 @@ export default class LocationInput extends InputElement<Loc> {
location: this._centerLocation,
background: this.mapBackground,
attribution: this.mapBackground !== State.state.backgroundLayer,
lastClickLocation: clickLocation
lastClickLocation: clickLocation,
bounds: this._bounds
}
)
clickLocation.addCallbackAndRunD(location => this._centerLocation.setData(location))
map.leafletMap.addCallbackAndRunD(leaflet => {
const bounds = leaflet.getBounds()
leaflet.setMaxBounds(bounds.pad(0.15))
const data = {
type: "FeatureCollection",
features: [{
"type": "Feature",
"geometry": {
"type": "LineString",
"coordinates": [
[
bounds.getEast(),
bounds.getNorth()
],
[
bounds.getWest(),
bounds.getNorth()
],
[
bounds.getWest(),
bounds.getSouth()
],
[
bounds.getEast(),
bounds.getSouth()
],
[
bounds.getEast(),
bounds.getNorth()
]
]
}
}]
}
// @ts-ignore
L.geoJSON(data, {
style: {
color: "#f00",
weight: 2,
opacity: 0.4
}
}).addTo(leaflet)
})
map.installBounds(0.15, true);
if (this._snapTo !== undefined) {
// Show the lines to snap to
new ShowDataMultiLayer({
features: new StaticFeatureSource(this._snapTo, true),
enablePopups: false,
zoomToFeatures: false,
leafletMap: map.leafletMap,
layers: State.state.filteredLayers
}
)
// Show the central point
const matchPoint = this._snappedPoint.map(loc => {
if (loc === undefined) {
return []
}
return [{feature: loc}];
})
if (this._snapTo) {
if (this._snappedPointTags === undefined) {
// No special tags - we show a default crosshair
new ShowDataLayer({
features: new StaticFeatureSource(matchPoint),
enablePopups: false,
zoomToFeatures: false,
leafletMap: map.leafletMap,
layerToShow: LocationInput.matchLayer
})
}else{
new ShowDataMultiLayer({
features: new StaticFeatureSource(matchPoint),
enablePopups: false,
zoomToFeatures: false,
leafletMap: map.leafletMap,
layers: State.state.filteredLayers
}
)
}
}
new ShowDataLayer({
features: new StaticFeatureSource(matchPoint, true),
enablePopups: false,
zoomToFeatures: false,
leafletMap: map.leafletMap,
layerToShow: this._matching_layer.data
})
}
this.mapBackground.map(layer => {

View file

@ -130,7 +130,7 @@ export default class FeatureInfoBox extends ScrollableFullScreen {
if (!userbadge) {
return undefined
}
return new Combine(editElements)
return new Combine(editElements).SetClass("flex flex-col")
}
))
renderings.push(editors)

View file

@ -5,13 +5,12 @@ import {SubtleButton} from "../Base/SubtleButton";
import Minimap from "../Base/Minimap";
import State from "../../State";
import ShowDataLayer from "../ShowDataLayer/ShowDataLayer";
import {GeoOperations} from "../../Logic/GeoOperations";
import {BBox, GeoOperations} from "../../Logic/GeoOperations";
import {LeafletMouseEvent} from "leaflet";
import Combine from "../Base/Combine";
import {Button} from "../Base/Button";
import Translations from "../i18n/Translations";
import SplitAction from "../../Logic/Osm/Actions/SplitAction";
import {OsmObject, OsmWay} from "../../Logic/Osm/OsmObject";
import Title from "../Base/Title";
import StaticFeatureSource from "../../Logic/FeatureSource/Sources/StaticFeatureSource";
import ShowDataMultiLayer from "../ShowDataLayer/ShowDataMultiLayer";
@ -21,9 +20,12 @@ export default class SplitRoadWizard extends Toggle {
private static splitLayerStyling = new LayerConfig({
id: "splitpositions",
source: {osmTags: "_cutposition=yes"},
icon: "./assets/svg/plus.svg"
icon: {render: "circle:white;./assets/svg/scissors.svg"},
iconSize: {render: "30,30,center"},
}, "(BUILTIN) SplitRoadWizard.ts", true)
public dialogIsOpened: UIEventSource<boolean>
/**
* A UI Element used for splitting roads
*
@ -40,30 +42,40 @@ export default class SplitRoadWizard extends Toggle {
// Toggle variable between show split button and map
const splitClicked = new UIEventSource<boolean>(false);
// Load the road with given id on the minimap
const roadElement = State.state.allElements.ContainingFeatures.get(id)
// Minimap on which you can select the points to be splitted
const miniMap = Minimap.createMiniMap({background: State.state.backgroundLayer, allowMoving: false});
miniMap.SetStyle("width: 100%; height: 24rem;");
const miniMap = Minimap.createMiniMap(
{
background: State.state.backgroundLayer,
allowMoving: true,
leafletOptions: {
minZoom: 14
}
});
miniMap.SetStyle("width: 100%; height: 24rem")
.SetClass("rounded-xl overflow-hidden");
miniMap.installBounds(BBox.get(roadElement))
// Define how a cut is displayed on the map
// Load the road with given id on the minimap
const roadElement = State.state.allElements.ContainingFeatures.get(id)
const roadEventSource = new UIEventSource([{feature: roadElement, freshness: new Date()}]);
// Datalayer displaying the road and the cut points (if any)
new ShowDataMultiLayer({
features: new StaticFeatureSource(roadEventSource, true),
layers: State.state.filteredLayers,
leafletMap: miniMap.leafletMap,
enablePopups: false,
zoomToFeatures: true
})
new ShowDataLayer({
features: new StaticFeatureSource(splitPoints, true),
leafletMap: miniMap.leafletMap,
zoomToFeatures: false,
enablePopups: false,
layerToShow: SplitRoadWizard.splitLayerStyling
layerToShow: SplitRoadWizard.splitLayerStyling
})
new ShowDataMultiLayer({
features: new StaticFeatureSource([roadElement]),
layers: State.state.filteredLayers,
leafletMap: miniMap.leafletMap,
enablePopups: false,
zoomToFeatures: true
})
/**
@ -72,12 +84,25 @@ export default class SplitRoadWizard extends Toggle {
* @param coordinates Clicked location, [lon, lat]
*/
function onMapClick(coordinates) {
// First, we check if there is another, already existing point nearby
const points = splitPoints.data.map((f, i) => [f.feature, i])
.filter(p => GeoOperations.distanceBetween(p[0].geometry.coordinates, coordinates) * 1000 < 5)
.map(p => p[1])
.sort()
.reverse()
if (points.length > 0) {
for (const point of points) {
splitPoints.data.splice(point, 1)
}
splitPoints.ping()
return;
}
// Get nearest point on the road
const pointOnRoad = GeoOperations.nearestPoint(roadElement, coordinates); // pointOnRoad is a geojson
// Update point properties to let it match the layer
pointOnRoad.properties._cutposition = "yes";
pointOnRoad["_matching_layer_id"] = "splitpositions";
// let the state remember the point, to be able to retrieve it later by id
State.state.allElements.addOrGetElement(pointOnRoad);
@ -94,7 +119,7 @@ export default class SplitRoadWizard extends Toggle {
}))
// Toggle between splitmap
const splitButton = new SubtleButton(Svg.scissors_ui(), t.inviteToSplit.Clone());
const splitButton = new SubtleButton(Svg.scissors_ui(), t.inviteToSplit.Clone().SetClass("text-lg font-bold"));
splitButton.onClick(
() => {
splitClicked.setData(true)
@ -110,27 +135,9 @@ export default class SplitRoadWizard extends Toggle {
// Save button
const saveButton = new Button(t.split.Clone(), () => {
hasBeenSplit.setData(true)
const way = OsmObject.DownloadObject(id)
const partOfSrc = OsmObject.DownloadReferencingRelations(id);
let hasRun = false
way.map(way => {
const partOf = partOfSrc.data
if (way === undefined || partOf === undefined) {
return;
}
if (hasRun) {
return
}
hasRun = true
const splitAction = new SplitAction(
<OsmWay>way, way.asGeoJson(), partOf, splitPoints.data.map(ff => ff.feature)
)
State.state.changes.applyAction(splitAction)
State.state.changes.applyAction(new SplitAction(id, splitPoints.data.map(ff => ff.feature.geometry.coordinates)))
})
}, [partOfSrc])
});
saveButton.SetClass("btn btn-primary mr-3");
const disabledSaveButton = new Button("Split", undefined);
disabledSaveButton.SetClass("btn btn-disabled mr-3");
@ -152,5 +159,6 @@ export default class SplitRoadWizard extends Toggle {
mapView.SetClass("question")
const confirm = new Toggle(mapView, splitToggle, splitClicked);
super(t.hasBeenSplit.Clone(), confirm, hasBeenSplit)
this.dialogIsOpened = splitClicked
}
}

View file

@ -37,8 +37,8 @@ export default class ShowDataLayer {
this._layerToShow = options.layerToShow;
const self = this;
features.addCallback(() => self.update(options));
options.leafletMap.addCallback(() => self.update(options));
features.addCallback(_ => self.update(options));
options.leafletMap.addCallback(_ => self.update(options));
this.update(options);
@ -83,13 +83,17 @@ export default class ShowDataLayer {
mp.removeLayer(this.geoLayer);
}
this.geoLayer= this.CreateGeojsonLayer()
const allFeats = this._features.data;
this.geoLayer = this.CreateGeojsonLayer();
for (const feat of allFeats) {
if (feat === undefined) {
continue
}
this.geoLayer.addData(feat);
try{
this.geoLayer.addData(feat);
}catch(e){
console.error("Could not add ", feat, "to the geojson layer in leaflet")
}
}
mp.addLayer(this.geoLayer)
@ -122,7 +126,8 @@ export default class ShowDataLayer {
}
const tagSource = feature.properties.id === undefined ? new UIEventSource<any>(feature.properties) : State.state.allElements.getEventSourceById(feature.properties.id)
const style = layer.GenerateLeafletStyle(tagSource, !(layer.title === undefined && (layer.tagRenderings ?? []).length === 0));
const clickable = !(layer.title === undefined && (layer.tagRenderings ?? []).length === 0)
const style = layer.GenerateLeafletStyle(tagSource, clickable);
const baseElement = style.icon.html;
if (!this._enablePopups) {
baseElement.SetStyle("cursor: initial !important")
@ -132,7 +137,7 @@ export default class ShowDataLayer {
html: baseElement.ConstructElement(),
className: style.icon.className,
iconAnchor: style.icon.iconAnchor,
iconUrl: style.icon.iconUrl,
iconUrl: style.icon.iconUrl ?? "./assets/svg/bug.svg",
popupAnchor: style.icon.popupAnchor,
iconSize: style.icon.iconSize
})