forked from MapComplete/MapComplete
Merge master
This commit is contained in:
commit
aa50d33b81
53 changed files with 1094 additions and 411 deletions
144
UI/Base/Minimap.ts
Normal file
144
UI/Base/Minimap.ts
Normal 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)
|
||||
}
|
||||
}
|
|
@ -3,25 +3,21 @@ import {UIEventSource} from "../Logic/UIEventSource";
|
|||
|
||||
/**
|
||||
* A thin wrapper around a html element, which allows to generate a HTML-element.
|
||||
*
|
||||
*
|
||||
* Assumes a read-only configuration, so it has no 'ListenTo'
|
||||
*/
|
||||
export default abstract class BaseUIElement {
|
||||
|
||||
protected _constructedHtmlElement: HTMLElement;
|
||||
private clss: Set<string> = new Set<string>();
|
||||
private style: string;
|
||||
private _onClick: () => void;
|
||||
private _onHover: UIEventSource<boolean>;
|
||||
|
||||
protected _constructedHtmlElement: HTMLElement;
|
||||
|
||||
|
||||
protected abstract InnerConstructElement(): HTMLElement;
|
||||
|
||||
public onClick(f: (() => void)) {
|
||||
this._onClick = f;
|
||||
this.SetClass("clickable")
|
||||
if(this._constructedHtmlElement !== undefined){
|
||||
if (this._constructedHtmlElement !== undefined) {
|
||||
this._constructedHtmlElement.onclick = f;
|
||||
}
|
||||
return this;
|
||||
|
@ -38,12 +34,13 @@ export default abstract class BaseUIElement {
|
|||
element.removeChild(element.firstChild);
|
||||
}
|
||||
const el = this.ConstructElement();
|
||||
if(el !== undefined){
|
||||
if (el !== undefined) {
|
||||
element.appendChild(el)
|
||||
}
|
||||
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds all the relevant classes, space seperated
|
||||
*/
|
||||
|
@ -55,7 +52,7 @@ export default abstract class BaseUIElement {
|
|||
if (this.clss.has(clss)) {
|
||||
continue;
|
||||
}
|
||||
if(c === undefined || c === ""){
|
||||
if (c === undefined || c === "") {
|
||||
continue;
|
||||
}
|
||||
this.clss.add(c);
|
||||
|
@ -74,19 +71,19 @@ export default abstract class BaseUIElement {
|
|||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
public HasClass(clss: string): boolean{
|
||||
|
||||
public HasClass(clss: string): boolean {
|
||||
return this.clss.has(clss)
|
||||
}
|
||||
|
||||
public SetStyle(style: string): BaseUIElement {
|
||||
this.style = style;
|
||||
if(this._constructedHtmlElement !== undefined){
|
||||
if (this._constructedHtmlElement !== undefined) {
|
||||
this._constructedHtmlElement.style.cssText = style;
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* The same as 'Render', but creates a HTML element instead of the HTML representation
|
||||
*/
|
||||
|
@ -99,68 +96,71 @@ export default abstract class BaseUIElement {
|
|||
return this._constructedHtmlElement
|
||||
}
|
||||
|
||||
if(this.InnerConstructElement === undefined){
|
||||
throw "ERROR! This is not a correct baseUIElement: "+this.constructor.name
|
||||
if (this.InnerConstructElement === undefined) {
|
||||
throw "ERROR! This is not a correct baseUIElement: " + this.constructor.name
|
||||
}
|
||||
try{
|
||||
|
||||
try {
|
||||
|
||||
const el = this.InnerConstructElement();
|
||||
|
||||
if(el === undefined){
|
||||
return undefined;
|
||||
}
|
||||
const el = this.InnerConstructElement();
|
||||
|
||||
this._constructedHtmlElement = el;
|
||||
const style = this.style
|
||||
if (style !== undefined && style !== "") {
|
||||
el.style.cssText = style
|
||||
}
|
||||
if (this.clss.size > 0) {
|
||||
try{
|
||||
el.classList.add(...Array.from(this.clss))
|
||||
}catch(e){
|
||||
console.error("Invalid class name detected in:", Array.from(this.clss).join(" "),"\nErr msg is ",e)
|
||||
if (el === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
if (this._onClick !== undefined) {
|
||||
const self = this;
|
||||
el.onclick = (e) => {
|
||||
// @ts-ignore
|
||||
if (e.consumed) {
|
||||
return;
|
||||
this._constructedHtmlElement = el;
|
||||
const style = this.style
|
||||
if (style !== undefined && style !== "") {
|
||||
el.style.cssText = style
|
||||
}
|
||||
if (this.clss.size > 0) {
|
||||
try {
|
||||
el.classList.add(...Array.from(this.clss))
|
||||
} catch (e) {
|
||||
console.error("Invalid class name detected in:", Array.from(this.clss).join(" "), "\nErr msg is ", e)
|
||||
}
|
||||
self._onClick();
|
||||
// @ts-ignore
|
||||
e.consumed = true;
|
||||
}
|
||||
el.style.pointerEvents = "all";
|
||||
el.style.cursor = "pointer";
|
||||
}
|
||||
|
||||
if (this._onHover !== undefined) {
|
||||
const self = this;
|
||||
el.addEventListener('mouseover', () => self._onHover.setData(true));
|
||||
el.addEventListener('mouseout', () => self._onHover.setData(false));
|
||||
}
|
||||
if (this._onClick !== undefined) {
|
||||
const self = this;
|
||||
el.onclick = (e) => {
|
||||
// @ts-ignore
|
||||
if (e.consumed) {
|
||||
return;
|
||||
}
|
||||
self._onClick();
|
||||
// @ts-ignore
|
||||
e.consumed = true;
|
||||
}
|
||||
el.style.pointerEvents = "all";
|
||||
el.style.cursor = "pointer";
|
||||
}
|
||||
|
||||
if (this._onHover !== undefined) {
|
||||
const self = this;
|
||||
el.addEventListener('mouseover', () => self._onHover.setData(true));
|
||||
el.addEventListener('mouseout', () => self._onHover.setData(false));
|
||||
}
|
||||
if (this._onHover !== undefined) {
|
||||
const self = this;
|
||||
el.addEventListener('mouseover', () => self._onHover.setData(true));
|
||||
el.addEventListener('mouseout', () => self._onHover.setData(false));
|
||||
}
|
||||
|
||||
return el}catch(e){
|
||||
if (this._onHover !== undefined) {
|
||||
const self = this;
|
||||
el.addEventListener('mouseover', () => self._onHover.setData(true));
|
||||
el.addEventListener('mouseout', () => self._onHover.setData(false));
|
||||
}
|
||||
|
||||
return el
|
||||
} catch (e) {
|
||||
const domExc = e as DOMException;
|
||||
if(domExc){
|
||||
console.log("An exception occured", domExc.code, domExc.message, domExc.name )
|
||||
if (domExc) {
|
||||
console.log("An exception occured", domExc.code, domExc.message, domExc.name)
|
||||
}
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public AsMarkdown(): string{
|
||||
throw "AsMarkdown is not implemented by "+this.constructor.name
|
||||
|
||||
public AsMarkdown(): string {
|
||||
throw "AsMarkdown is not implemented by " + this.constructor.name
|
||||
}
|
||||
|
||||
protected abstract InnerConstructElement(): HTMLElement;
|
||||
}
|
|
@ -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);
|
||||
})
|
||||
|
||||
|
||||
|
|
|
@ -29,7 +29,8 @@ export default class MoreScreen extends Combine {
|
|||
LanguagePicker.CreateLanguagePicker(Translations.t.index.title.SupportedLanguages())
|
||||
.SetClass("absolute top-2 right-3"),
|
||||
new IndexText()
|
||||
])
|
||||
]);
|
||||
|
||||
themeButtonStyle = "h-32 min-h-32 max-h-32 overflow-ellipsis overflow-hidden"
|
||||
themeListStyle = "md:grid md:grid-flow-row md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-g4 gap-4"
|
||||
}
|
||||
|
@ -59,10 +60,23 @@ export default class MoreScreen extends Combine {
|
|||
|
||||
private static createOfficialThemesList(state: State, buttonClass: string): BaseUIElement {
|
||||
let officialThemes = AllKnownLayouts.layoutsList
|
||||
if (State.state.osmConnection.userDetails.data.csCount < Constants.userJourney.personalLayoutUnlock) {
|
||||
officialThemes = officialThemes.filter(theme => theme.id !== personal.id)
|
||||
}
|
||||
let buttons = officialThemes.map((layout) => MoreScreen.createLinkButton(layout)?.SetClass(buttonClass))
|
||||
|
||||
let buttons = officialThemes.map((layout) => {
|
||||
const button = MoreScreen.createLinkButton(layout)?.SetClass(buttonClass);
|
||||
if(layout.id === personal.id){
|
||||
return new VariableUiElement(
|
||||
State.state.osmConnection.userDetails.map(userdetails => userdetails.csCount)
|
||||
.map(csCount => {
|
||||
if(csCount < Constants.userJourney.personalLayoutUnlock){
|
||||
return undefined
|
||||
}else{
|
||||
return button
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
return button;
|
||||
})
|
||||
|
||||
let customGeneratorLink = MoreScreen.createCustomGeneratorButton(state)
|
||||
buttons.splice(0, 0, customGeneratorLink);
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import Combine from "../Base/Combine";
|
||||
import Attribution from "./Attribution";
|
||||
import Img from "../Base/Img";
|
||||
import ImageAttributionSource from "../../Logic/Web/ImageAttributionSource";
|
||||
import ImageAttributionSource from "../../Logic/ImageProviders/ImageAttributionSource";
|
||||
|
||||
|
||||
export class AttributedImage extends Combine {
|
||||
|
|
|
@ -3,7 +3,7 @@ import Translations from "../i18n/Translations";
|
|||
import BaseUIElement from "../BaseUIElement";
|
||||
import {VariableUiElement} from "../Base/VariableUIElement";
|
||||
import {UIEventSource} from "../../Logic/UIEventSource";
|
||||
import {LicenseInfo} from "../../Logic/Web/Wikimedia";
|
||||
import {LicenseInfo} from "../../Logic/ImageProviders/Wikimedia";
|
||||
|
||||
export default class Attribution extends VariableUiElement {
|
||||
|
||||
|
|
|
@ -6,10 +6,9 @@ import {AttributedImage} from "./AttributedImage";
|
|||
import BaseUIElement from "../BaseUIElement";
|
||||
import Img from "../Base/Img";
|
||||
import Toggle from "../Input/Toggle";
|
||||
import ImageAttributionSource from "../../Logic/Web/ImageAttributionSource";
|
||||
import {Wikimedia} from "../../Logic/Web/Wikimedia";
|
||||
import {Mapillary} from "../../Logic/Web/Mapillary";
|
||||
import {Imgur} from "../../Logic/Web/Imgur";
|
||||
import {Wikimedia} from "../../Logic/ImageProviders/Wikimedia";
|
||||
import {Imgur} from "../../Logic/ImageProviders/Imgur";
|
||||
import {Mapillary} from "../../Logic/ImageProviders/Mapillary";
|
||||
|
||||
export class ImageCarousel extends Toggle {
|
||||
|
||||
|
|
|
@ -8,7 +8,7 @@ import BaseUIElement from "../BaseUIElement";
|
|||
import LicensePicker from "../BigComponents/LicensePicker";
|
||||
import Toggle from "../Input/Toggle";
|
||||
import FileSelectorButton from "../Input/FileSelectorButton";
|
||||
import ImgurUploader from "../../Logic/Web/ImgurUploader";
|
||||
import ImgurUploader from "../../Logic/ImageProviders/ImgurUploader";
|
||||
import UploadFlowStateUI from "../BigComponents/UploadFlowStateUI";
|
||||
import LayerConfig from "../../Customizations/JSON/LayerConfig";
|
||||
|
||||
|
|
|
@ -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()
|
||||
|
||||
|
||||
|
|
|
@ -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"
|
||||
),
|
||||
|
@ -216,16 +221,6 @@ export default class ValidatedTextField {
|
|||
* {string (typename) --> TextFieldDef}
|
||||
*/
|
||||
public static AllTypes = ValidatedTextField.allTypesDict();
|
||||
|
||||
public static TypeDropdown(): DropDown<string> {
|
||||
const values: { value: string, shown: string }[] = [];
|
||||
const expl = ValidatedTextField.tpList;
|
||||
for (const key in expl) {
|
||||
values.push({value: expl[key].name, shown: `${expl[key].name} - ${expl[key].explanation}`})
|
||||
}
|
||||
return new DropDown<string>("", values)
|
||||
}
|
||||
|
||||
public static InputForType(type: string, options?: {
|
||||
placeholder?: string | UIElement,
|
||||
value?: UIEventSource<string>,
|
||||
|
@ -235,7 +230,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,90 +265,16 @@ export default class ValidatedTextField {
|
|||
|
||||
if (tp.inputHelper) {
|
||||
input = new CombinedInputElement(input, tp.inputHelper(input.GetValue(), {
|
||||
location: options.location
|
||||
location: options.location,
|
||||
mapBackgroundLayer: options.mapBackgroundLayer
|
||||
|
||||
}),
|
||||
(a, b) => a, // We can ignore b, as they are linked earlier
|
||||
(a, _) => a, // We can ignore b, as they are linked earlier
|
||||
a => [a, a]
|
||||
);
|
||||
}
|
||||
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
|
||||
|
@ -363,7 +285,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 {
|
||||
|
||||
|
|
|
@ -331,7 +331,8 @@ export default class TagRenderingQuestion extends UIElement {
|
|||
let input: InputElement<string> = 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
|
||||
});
|
||||
|
||||
if (this._applicableUnit) {
|
||||
|
|
|
@ -15,13 +15,18 @@ export default class ShowDataLayer {
|
|||
private _layerDict;
|
||||
private readonly _leafletMap: UIEventSource<L.Map>;
|
||||
private _cleanCount = 0;
|
||||
private readonly _enablePopups: boolean;
|
||||
private readonly _features : UIEventSource<{ feature: any, freshness: Date }[]>
|
||||
|
||||
constructor(features: UIEventSource<{ feature: any, freshness: Date }[]>,
|
||||
leafletMap: UIEventSource<L.Map>,
|
||||
layoutToUse: UIEventSource<LayoutConfig>) {
|
||||
layoutToUse: UIEventSource<LayoutConfig>,
|
||||
enablePopups= true,
|
||||
zoomToFeatures = false) {
|
||||
this._leafletMap = leafletMap;
|
||||
this._enablePopups = enablePopups;
|
||||
this._features = features;
|
||||
const self = this;
|
||||
const mp = leafletMap.data;
|
||||
self._layerDict = {};
|
||||
|
||||
layoutToUse.addCallbackAndRun(layoutToUse => {
|
||||
|
@ -39,7 +44,9 @@ export default class ShowDataLayer {
|
|||
if (features.data === undefined) {
|
||||
return;
|
||||
}
|
||||
if (leafletMap.data === undefined) {
|
||||
const mp = leafletMap.data;
|
||||
|
||||
if(mp === undefined){
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -68,6 +75,11 @@ export default class ShowDataLayer {
|
|||
mp.addLayer(geoLayer)
|
||||
}
|
||||
|
||||
if(zoomToFeatures){
|
||||
mp.fitBounds(geoLayer.getBounds())
|
||||
}
|
||||
|
||||
|
||||
State.state.selectedElement.ping();
|
||||
}
|
||||
|
||||
|
@ -77,6 +89,7 @@ export default class ShowDataLayer {
|
|||
}
|
||||
|
||||
|
||||
|
||||
private createStyleFor(feature) {
|
||||
const tagsSource = State.state.allElements.addOrGetElement(feature);
|
||||
// Every object is tied to exactly one layer
|
||||
|
@ -97,9 +110,13 @@ export default class ShowDataLayer {
|
|||
}
|
||||
|
||||
const style = layer.GenerateLeafletStyle(tagSource, !(layer.title === undefined && (layer.tagRenderings ?? []).length === 0));
|
||||
const baseElement = style.icon.html;
|
||||
if(!this._enablePopups){
|
||||
baseElement.SetStyle("cursor: initial !important")
|
||||
}
|
||||
return L.marker(latLng, {
|
||||
icon: L.divIcon({
|
||||
html: style.icon.html.ConstructElement(),
|
||||
html: baseElement.ConstructElement(),
|
||||
className: style.icon.className,
|
||||
iconAnchor: style.icon.iconAnchor,
|
||||
iconUrl: style.icon.iconUrl,
|
||||
|
@ -115,10 +132,12 @@ export default class ShowDataLayer {
|
|||
console.warn("No layer found for object (probably a now disabled layer)", feature, this._layerDict)
|
||||
return;
|
||||
}
|
||||
if (layer.title === undefined) {
|
||||
if (layer.title === undefined || !this._enablePopups) {
|
||||
// No popup action defined -> Don't do anything
|
||||
// or probably a map in the popup - no popups needed!
|
||||
return;
|
||||
}
|
||||
|
||||
const popup = L.popup({
|
||||
autoPan: true,
|
||||
closeOnEscapeKey: true,
|
||||
|
@ -171,15 +190,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)
|
||||
});
|
||||
|
||||
|
|
|
@ -21,6 +21,10 @@ 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";
|
||||
import {Utils} from "../Utils";
|
||||
|
||||
export default class SpecialVisualizations {
|
||||
|
||||
|
@ -32,7 +36,6 @@ export default class SpecialVisualizations {
|
|||
example?: string,
|
||||
args: { name: string, defaultValue?: string, doc: string }[]
|
||||
}[] =
|
||||
|
||||
[
|
||||
{
|
||||
funcName: "all_tags",
|
||||
|
@ -86,7 +89,80 @@ 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"
|
||||
},
|
||||
{
|
||||
doc: "(Matches all resting arguments) This argument should be the key of a property of the feature. The corresponding value is interpreted as either the id or the a list of ID's. The features with these ID's will be shown on this minimap.",
|
||||
name: "idKey",
|
||||
defaultValue: "id"
|
||||
}
|
||||
],
|
||||
example: "`{minimap()}`, `{minimap(17, id, _list_of_embedded_feature_ids_calculated_by_calculated_tag):height:10rem; border: 2px solid black}`",
|
||||
constr: (state, tagSource, args) => {
|
||||
|
||||
const keys = [...args]
|
||||
keys.splice(0, 1)
|
||||
const featureStore = state.allElements.ContainingFeatures
|
||||
const featuresToShow: UIEventSource<{ freshness: Date, feature: any }[]> = tagSource.map(properties => {
|
||||
const values: string[] = Utils.NoNull(keys.map(key => properties[key]))
|
||||
const features: { freshness: Date, feature: any }[] = []
|
||||
for (const value of values) {
|
||||
let idList = [value]
|
||||
if (value.startsWith("[")) {
|
||||
// This is a list of values
|
||||
idList = JSON.parse(value)
|
||||
}
|
||||
for (const id of idList) {
|
||||
features.push({
|
||||
freshness: new Date(),
|
||||
feature: featureStore.get(id)
|
||||
})
|
||||
}
|
||||
}
|
||||
return features
|
||||
})
|
||||
const properties = tagSource.data;
|
||||
|
||||
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(
|
||||
featuresToShow,
|
||||
minimap.leafletMap,
|
||||
State.state.layoutToUse,
|
||||
false,
|
||||
true
|
||||
)
|
||||
|
||||
|
||||
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",
|
||||
|
@ -169,7 +245,7 @@ export default class SpecialVisualizations {
|
|||
defaultValue: ""
|
||||
},
|
||||
{
|
||||
name: "colors",
|
||||
name: "colors*",
|
||||
doc: "(Matches all resting arguments - optional) Matches a regex onto a color value, e.g. `3[a-zA-Z+-]*:#33cc33`"
|
||||
|
||||
}
|
||||
|
@ -260,33 +336,33 @@ export default class SpecialVisualizations {
|
|||
|
||||
}
|
||||
},
|
||||
{funcName: "canonical",
|
||||
docs: "Converts a short, canonical value into the long, translated text",
|
||||
example: "{canonical(length)} will give 42 metre (in french)",
|
||||
args:[{
|
||||
name:"key",
|
||||
doc: "The key of the tag to give the canonical text for"
|
||||
}],
|
||||
constr: (state, tagSource, args) => {
|
||||
const key = args [0]
|
||||
return new VariableUiElement(
|
||||
tagSource.map(tags => tags[key]).map(value => {
|
||||
if(value === undefined){
|
||||
return undefined
|
||||
}
|
||||
const unit = state.layoutToUse.data.units.filter(unit => unit.isApplicableToKey(key))[0]
|
||||
if(unit === undefined){
|
||||
return value;
|
||||
}
|
||||
|
||||
return unit.asHumanLongValue(value);
|
||||
|
||||
},
|
||||
[ state.layoutToUse])
|
||||
|
||||
|
||||
)
|
||||
}}
|
||||
{
|
||||
funcName: "canonical",
|
||||
docs: "Converts a short, canonical value into the long, translated text",
|
||||
example: "{canonical(length)} will give 42 metre (in french)",
|
||||
args: [{
|
||||
name: "key",
|
||||
doc: "The key of the tag to give the canonical text for"
|
||||
}],
|
||||
constr: (state, tagSource, args) => {
|
||||
const key = args [0]
|
||||
return new VariableUiElement(
|
||||
tagSource.map(tags => tags[key]).map(value => {
|
||||
if (value === undefined) {
|
||||
return undefined
|
||||
}
|
||||
const unit = state.layoutToUse.data.units.filter(unit => unit.isApplicableToKey(key))[0]
|
||||
if (unit === undefined) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return unit.asHumanLongValue(value);
|
||||
|
||||
},
|
||||
[state.layoutToUse])
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
]
|
||||
static HelpMessage: BaseUIElement = SpecialVisualizations.GenHelpMessage();
|
||||
|
@ -313,7 +389,7 @@ export default class SpecialVisualizations {
|
|||
return new Combine([
|
||||
new Title("Special tag renderings", 3),
|
||||
"In a tagrendering, some special values are substituted by an advanced UI-element. This allows advanced features and visualizations to be reused by custom themes or even to query third-party API's.",
|
||||
"General usage is <b>{func_name()}</b> or <b>{func_name(arg, someotherarg)}</b>. Note that you <i>do not</i> need to use quotes around your arguments, the comma is enough to seperate them. This also implies you cannot use a comma in your args",
|
||||
"General usage is <b>{func_name()}</b>, <b>{func_name(arg, someotherarg)}</b> or <b>{func_name(args):cssStyle}</b>. Note that you <i>do not</i> need to use quotes around your arguments, the comma is enough to seperate them. This also implies you cannot use a comma in your args",
|
||||
...helpTexts
|
||||
]
|
||||
);
|
||||
|
|
|
@ -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]?.substring(1) ?? ""
|
||||
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);
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue