Add minimap baseElement; add this as special rendering, add minimap beneath direction input element

This commit is contained in:
Pieter Vander Vennet 2021-06-23 02:15:28 +02:00
parent 89df28ae06
commit eba1772ab9
16 changed files with 411 additions and 153 deletions

144
UI/Base/Minimap.ts Normal file
View file

@ -0,0 +1,144 @@
import BaseUIElement from "../BaseUIElement";
import * as L from "leaflet";
import {UIEventSource} from "../../Logic/UIEventSource";
import Loc from "../../Models/Loc";
import BaseLayer from "../../Models/BaseLayer";
import AvailableBaseLayers from "../../Logic/Actors/AvailableBaseLayers";
import {Map} from "leaflet";
export default class Minimap extends BaseUIElement {
private static _nextId = 0;
public readonly leafletMap: UIEventSource<Map> = new UIEventSource<Map>(undefined)
private readonly _id: string;
private readonly _background: UIEventSource<BaseLayer>;
private readonly _location: UIEventSource<Loc>;
private _isInited = false;
private _allowMoving: boolean;
constructor(options?: {
background?: UIEventSource<BaseLayer>,
location?: UIEventSource<Loc>,
allowMoving?: boolean
}
) {
super()
options = options ?? {}
this._background = options?.background ?? new UIEventSource<BaseLayer>(AvailableBaseLayers.osmCarto)
this._location = options?.location ?? new UIEventSource<Loc>(undefined)
this._id = "minimap" + Minimap._nextId;
this._allowMoving = options.allowMoving ?? true;
Minimap._nextId++
}
protected InnerConstructElement(): HTMLElement {
const div = document.createElement("div")
div.id = this._id;
div.style.height = "100%"
div.style.width = "100%"
div.style.minWidth = "40px"
div.style.minHeight = "40px"
const wrapper = document.createElement("div")
wrapper.appendChild(div)
const self = this;
// @ts-ignore
const resizeObserver = new ResizeObserver(_ => {
console.log("Change in size detected!")
self.InitMap();
self.leafletMap?.data?.invalidateSize()
});
resizeObserver.observe(div);
return wrapper;
}
private InitMap() {
if (this._constructedHtmlElement === undefined) {
// This element isn't initialized yet
return;
}
if (document.getElementById(this._id) === null) {
// not yet attached, we probably got some other event
return;
}
if (this._isInited) {
return;
}
this._isInited = true;
const location = this._location;
let currentLayer = this._background.data.layer()
const map = L.map(this._id, {
center: [location.data?.lat ?? 0, location.data?.lon ?? 0],
zoom: location.data?.zoom ?? 2,
layers: [currentLayer],
zoomControl: false,
attributionControl: false,
dragging: this._allowMoving,
scrollWheelZoom: this._allowMoving,
doubleClickZoom: this._allowMoving,
keyboard: this._allowMoving,
touchZoom: this._allowMoving
});
map.setMaxBounds(
[[-100, -200], [100, 200]]
);
this._background.addCallbackAndRun(layer => {
const newLayer = layer.layer()
if (currentLayer !== undefined) {
map.removeLayer(currentLayer);
}
currentLayer = newLayer;
map.addLayer(newLayer);
})
let isRecursing = false;
map.on("moveend", function () {
if (isRecursing) {
return
}
if (map.getZoom() === location.data.zoom &&
map.getCenter().lat === location.data.lat &&
map.getCenter().lng === location.data.lon) {
return;
}
console.trace(map.getZoom(), map.getCenter(), location.data)
location.data.zoom = map.getZoom();
location.data.lat = map.getCenter().lat;
location.data.lon = map.getCenter().lng;
isRecursing = true;
location.ping();
isRecursing = false; // This is ugly, I know
})
location.addCallback(loc => {
const mapLoc = map.getCenter()
const dlat = Math.abs(loc.lat - mapLoc[0])
const dlon = Math.abs(loc.lon - mapLoc[1])
if (dlat < 0.000001 && dlon < 0.000001 && map.getZoom() === loc.zoom) {
return;
}
map.setView([loc.lat, loc.lon], loc.zoom)
})
location.map(loc => loc.zoom)
.addCallback(zoom => {
if (Math.abs(map.getZoom() - zoom) > 0.1) {
map.setZoom(zoom, {});
}
})
this.leafletMap.setData(map)
}
}

View file

@ -14,10 +14,14 @@ export class Basemap {
currentLayer: UIEventSource<BaseLayer>,
lastClickLocation?: UIEventSource<{ lat: number, lon: number }>,
extraAttribution?: BaseUIElement) {
console.log("Currentlayer is" ,currentLayer, currentLayer.data, currentLayer.data?.id)
let previousLayer = currentLayer.data.layer();
this.map = L.map(leafletElementId, {
center: [location.data.lat ?? 0, location.data.lon ?? 0],
zoom: location.data.zoom ?? 2,
layers: [currentLayer.data.layer],
layers: [previousLayer],
zoomControl: false,
attributionControl: extraAttribution !== undefined
});
@ -42,16 +46,16 @@ export class Basemap {
extraAttribution.AttachTo('leaflet-attribution')
const self = this;
let previousLayer = currentLayer.data;
currentLayer.addCallbackAndRun(layer => {
if (layer === previousLayer) {
const newLayer = layer.layer()
if (newLayer === previousLayer) {
return;
}
if (previousLayer !== undefined) {
self.map.removeLayer(previousLayer.layer);
self.map.removeLayer(previousLayer);
}
previousLayer = layer;
self.map.addLayer(layer.layer);
previousLayer = newLayer;
self.map.addLayer(newLayer);
})

View file

@ -2,21 +2,30 @@ import {InputElement} from "./InputElement";
import {UIEventSource} from "../../Logic/UIEventSource";
import Combine from "../Base/Combine";
import Svg from "../../Svg";
import BaseUIElement from "../BaseUIElement";
import {FixedUiElement} from "../Base/FixedUiElement";
import {Utils} from "../../Utils";
import Loc from "../../Models/Loc";
/**
* Selects a direction in degrees
*/
export default class DirectionInput extends InputElement<string> {
public static constructMinimap: ((any) => BaseUIElement);
private readonly _location: UIEventSource<Loc>;
public readonly IsSelected: UIEventSource<boolean> = new UIEventSource<boolean>(false);
private readonly value: UIEventSource<string>;
private background;
constructor(value?: UIEventSource<string>) {
constructor(mapBackground: UIEventSource<any>,
location: UIEventSource<Loc>,
value?: UIEventSource<string>) {
super();
this._location = location;
this.value = value ?? new UIEventSource<string>(undefined);
this.background = mapBackground;
}
GetValue(): UIEventSource<string> {
@ -30,16 +39,23 @@ export default class DirectionInput extends InputElement<string> {
protected InnerConstructElement(): HTMLElement {
let map: BaseUIElement = new FixedUiElement("")
if (!Utils.runningFromConsole) {
map = DirectionInput.constructMinimap({
background: this.background,
allowMoving: false,
location: this._location
})
}
const element = new Combine([
new FixedUiElement("").SetClass("w-full h-full absolute top-0 left-O rounded-full"),
Svg.direction_svg().SetStyle(
Svg.direction_stroke_svg().SetStyle(
`position: absolute;top: 0;left: 0;width: 100%;height: 100%;transform:rotate(${this.value.data ?? 0}deg);`)
.SetClass("direction-svg"),
Svg.compass_svg().SetStyle(
"position: absolute;top: 0;left: 0;width: 100%;height: 100%;")
.SetClass("direction-svg relative")
.SetStyle("z-index: 1000"),
map.SetClass("w-full h-full absolute top-0 left-O rounded-full overflow-hidden"),
])
.SetStyle("position:relative;display:block;width: min(100%, 25em); padding-top: min(100% , 25em); background:white; border: 1px solid black; border-radius: 999em")
.SetStyle("position:relative;display:block;width: min(100%, 25em); height: min(100% , 25em); background:white; border: 1px solid black; border-radius: 999em")
.ConstructElement()

View file

@ -1,7 +1,6 @@
import {DropDown} from "./DropDown";
import * as EmailValidator from "email-validator";
import {parsePhoneNumberFromString} from "libphonenumber-js";
import InputElementMap from "./InputElementMap";
import {InputElement} from "./InputElement";
import {TextField} from "./TextField";
import {UIElement} from "../UIElement";
@ -12,6 +11,7 @@ import OpeningHoursInput from "../OpeningHours/OpeningHoursInput";
import DirectionInput from "./DirectionInput";
import ColorPicker from "./ColorPicker";
import {Utils} from "../../Utils";
import Loc from "../../Models/Loc";
interface TextFieldDef {
name: string,
@ -19,7 +19,8 @@ interface TextFieldDef {
isValid: ((s: string, country?: () => string) => boolean),
reformat?: ((s: string, country?: () => string) => string),
inputHelper?: (value: UIEventSource<string>, options?: {
location: [number, number]
location: [number, number],
mapBackgroundLayer?: UIEventSource<any>
}) => InputElement<string>,
inputmode?: string
@ -118,8 +119,12 @@ export default class ValidatedTextField {
str = "" + str;
return str !== undefined && str.indexOf(".") < 0 && !isNaN(Number(str)) && Number(str) >= 0 && Number(str) <= 360
}, str => str,
(value) => {
return new DirectionInput(value);
(value, options) => {
return new DirectionInput(options.mapBackgroundLayer , new UIEventSource<Loc>({
lat: options.location[0],
lon: options.location[1],
zoom: 19
}),value);
},
"numeric"
),
@ -235,7 +240,8 @@ export default class ValidatedTextField {
textAreaRows?: number,
isValid?: ((s: string, country: () => string) => boolean),
country?: () => string,
location?: [number /*lat*/, number /*lon*/]
location?: [number /*lat*/, number /*lon*/],
mapBackgroundLayer?: UIEventSource<any>
}): InputElement<string> {
options = options ?? {};
options.placeholder = options.placeholder ?? type;
@ -269,87 +275,12 @@ export default class ValidatedTextField {
if (tp.inputHelper) {
input = new CombinedInputElement(input, tp.inputHelper(input.GetValue(), {
location: options.location
location: options.location,
mapBackgroundLayer: options.mapBackgroundLayer
}));
}
return input;
}
public static NumberInput(type: string = "int", extraValidation: (number: Number) => boolean = undefined): InputElement<number> {
const isValid = ValidatedTextField.AllTypes[type].isValid;
extraValidation = extraValidation ?? (() => true)
const fromString = str => {
if (!isValid(str)) {
return undefined;
}
const n = Number(str);
if (!extraValidation(n)) {
return undefined;
}
return n;
};
const toString = num => {
if (num === undefined) {
return undefined;
}
return "" + num;
};
const textField = ValidatedTextField.InputForType(type);
return new InputElementMap(textField, (n0, n1) => n0 === n1, fromString, toString)
}
public static KeyInput(allowEmpty: boolean = false): InputElement<string> {
function fromString(str) {
if (str?.match(/^[a-zA-Z][a-zA-Z0-9:_-]*$/)) {
return str;
}
if (str === "" && allowEmpty) {
return "";
}
return undefined
}
const toString = str => str
function isSame(str0, str1) {
return str0 === str1;
}
const textfield = new TextField({
placeholder: "key",
isValid: str => fromString(str) !== undefined,
value: new UIEventSource<string>("")
});
return new InputElementMap(textfield, isSame, fromString, toString);
}
static Mapped<T>(fromString: (str) => T, toString: (T) => string, options?: {
placeholder?: string | UIElement,
type?: string,
value?: UIEventSource<string>,
startValidated?: boolean,
textArea?: boolean,
textAreaRows?: number,
isValid?: ((string: string) => boolean),
country?: () => string
}): InputElement<T> {
let textField: InputElement<string>;
if (options?.type) {
textField = ValidatedTextField.InputForType(options.type, options);
} else {
textField = new TextField(options);
}
return new InputElementMap(
textField, (a, b) => a === b,
fromString, toString
);
}
public static HelpText(): string {
const explanations = ValidatedTextField.tpList.map(type => ["## " + type.name, "", type.explanation].join("\n")).join("\n\n")
return "# Available types for text fields\n\nThe listed types here trigger a special input element. Use them in `tagrendering.freeform.type` of your tagrendering to activate them\n\n" + explanations
@ -360,7 +291,8 @@ export default class ValidatedTextField {
isValid?: ((s: string, country?: () => string) => boolean),
reformat?: ((s: string, country?: () => string) => string),
inputHelper?: (value: UIEventSource<string>, options?: {
location: [number, number]
location: [number, number],
mapBackgroundLayer: UIEventSource<any>
}) => InputElement<string>,
inputmode?: string): TextFieldDef {

View file

@ -326,7 +326,8 @@ export default class TagRenderingQuestion extends UIElement {
const textField = ValidatedTextField.InputForType(this._configuration.freeform.type, {
isValid: (str) => (str.length <= 255),
country: () => this._tags.data._country,
location: [this._tags.data._lat, this._tags.data._lon]
location: [this._tags.data._lat, this._tags.data._lon],
mapBackgroundLayer: State.state.backgroundLayer
});
textField.GetValue().setData(this._tags.data[this._configuration.freeform.key]);

View file

@ -15,13 +15,15 @@ export default class ShowDataLayer {
private _layerDict;
private readonly _leafletMap: UIEventSource<L.Map>;
private _cleanCount = 0;
private readonly _enablePopups: boolean;
constructor(features: UIEventSource<{ feature: any, freshness: Date }[]>,
leafletMap: UIEventSource<L.Map>,
layoutToUse: UIEventSource<LayoutConfig>) {
layoutToUse: UIEventSource<LayoutConfig>,
enablePopups= true) {
this._leafletMap = leafletMap;
this._enablePopups = enablePopups;
const self = this;
const mp = leafletMap.data;
self._layerDict = {};
layoutToUse.addCallbackAndRun(layoutToUse => {
@ -39,7 +41,9 @@ export default class ShowDataLayer {
if (features.data === undefined) {
return;
}
if (leafletMap.data === undefined) {
const mp = leafletMap.data;
if(mp === undefined){
return;
}
@ -119,6 +123,11 @@ export default class ShowDataLayer {
// No popup action defined -> Don't do anything
return;
}
if(!this._enablePopups){
// Probably a map in the popup - no popups needed!
return;
}
const popup = L.popup({
autoPan: true,
closeOnEscapeKey: true,
@ -171,15 +180,15 @@ export default class ShowDataLayer {
}
private CreateGeojsonLayer(): L.Layer {
const self = this;
const data = {
type: "FeatureCollection",
features: []
}
// @ts-ignore
return L.geoJSON(data, {
style: feature => self.createStyleFor(feature),
pointToLayer: (feature, latLng) => self.pointToLayer(feature, latLng),
const self = this;
const data = {
type: "FeatureCollection",
features: []
}
// @ts-ignore
return L.geoJSON(data, {
style: feature => self.createStyleFor(feature),
pointToLayer: (feature, latLng) => self.pointToLayer(feature, latLng),
onEachFeature: (feature, leafletLayer) => self.postProcessFeature(feature, leafletLayer)
});

View file

@ -21,10 +21,13 @@ import LayerConfig from "../Customizations/JSON/LayerConfig";
import Title from "./Base/Title";
import Table from "./Base/Table";
import Histogram from "./BigComponents/Histogram";
import Loc from "../Models/Loc";
import ShowDataLayer from "./ShowDataLayer";
import Minimap from "./Base/Minimap";
export default class SpecialVisualizations {
public static specialVisualizations: {
funcName: string,
constr: ((state: State, tagSource: UIEventSource<any>, argument: string[]) => BaseUIElement),
@ -32,7 +35,6 @@ export default class SpecialVisualizations {
example?: string,
args: { name: string, defaultValue?: string, doc: string }[]
}[] =
[{
funcName: "all_tags",
docs: "Prints all key-value pairs of the object - used for debugging",
@ -85,7 +87,57 @@ export default class SpecialVisualizations {
return new ImageUploadFlow(tags, args[0])
}
},
{
funcName: "minimap",
docs: "A small map showing the selected feature. Note that no styling is applied, wrap this in a div",
args: [
{
doc: "The zoomlevel: the higher, the more zoomed in with 1 being the entire world and 19 being really close",
name: "zoomlevel",
defaultValue: "18"
}
],
example: "`{minimap()}`",
constr: (state, tagSource, args) => {
const properties = tagSource.data;
const feature = state.allElements.ContainingFeatures.get(properties.id)
let zoom = 18
if(args[0] ){
const parsed = Number(args[0])
if(!isNaN(parsed) && parsed > 0 && parsed < 25){
zoom = parsed;
}
}
const minimap = new Minimap(
{
background: state.backgroundLayer,
location: new UIEventSource<Loc>({
lat: Number(properties._lat),
lon: Number(properties._lon),
zoom: zoom
}),
allowMoving: false
}
)
new ShowDataLayer(
new UIEventSource<{ feature: any; freshness: Date }[]>([
{
freshness: new Date(),
feature: feature
}
]),
minimap.leafletMap,
State.state.layoutToUse,
false
)
minimap.SetStyle("overflow: hidden; pointer-events: none;")
return minimap;
}
},
{
funcName: "reviews",
docs: "Adds an overview of the mangrove-reviews of this object. Mangrove.Reviews needs - in order to identify the reviewed object - a coordinate and a name. By default, the name of the object is given, but this can be overwritten",

View file

@ -17,14 +17,14 @@ export class SubstitutedTranslation extends VariableUiElement {
super(
tagsSource.map(tags => {
const txt = Utils.SubstituteKeys(translation.txt, tags)
if (txt === undefined) {
if (txt === undefined) {
return undefined
}
return new Combine(SubstitutedTranslation.EvaluateSpecialComponents(txt, tagsSource))
return new Combine(SubstitutedTranslation.EvaluateSpecialComponents(txt, tagsSource))
}, [Locale.language])
)
this.SetClass("w-full")
}
@ -34,13 +34,14 @@ export class SubstitutedTranslation extends VariableUiElement {
for (const knownSpecial of SpecialVisualizations.specialVisualizations) {
// Note: the '.*?' in the regex reads as 'any character, but in a non-greedy way'
const matched = template.match(`(.*){${knownSpecial.funcName}\\((.*?)\\)}(.*)`);
const matched = template.match(`(.*){${knownSpecial.funcName}\\((.*?)\\)(:.*)?}(.*)`);
if (matched != null) {
// We found a special component that should be brought to live
const partBefore = SubstitutedTranslation.EvaluateSpecialComponents(matched[1], tags);
const argument = matched[2].trim();
const partAfter = SubstitutedTranslation.EvaluateSpecialComponents(matched[3], tags);
const style = matched[3] ?? ""
const partAfter = SubstitutedTranslation.EvaluateSpecialComponents(matched[4], tags);
try {
const args = knownSpecial.args.map(arg => arg.defaultValue ?? "");
if (argument.length > 0) {
@ -56,13 +57,14 @@ export class SubstitutedTranslation extends VariableUiElement {
let element: BaseUIElement = new FixedUiElement(`Constructing ${knownSpecial}(${args.join(", ")})`)
try{
element = knownSpecial.constr(State.state, tags, args);
}catch(e){
try {
element = knownSpecial.constr(State.state, tags, args);
element.SetStyle(style)
} catch (e) {
console.error("SPECIALRENDERING FAILED for", tags.data.id, e)
element = new FixedUiElement(`Could not generate special rendering for ${knownSpecial}(${args.join(", ")}) ${e}`).SetClass("alert")
}
return [...partBefore, element, ...partAfter]
} catch (e) {
console.error(e);