Merge branch 'develop' into feature/maproulette

This commit is contained in:
Robin van der Linde 2022-07-27 09:28:42 +02:00
commit 64560b9cd2
Signed by untrusted user: Robin-van-der-Linde
GPG key ID: 53956B3252478F0D
279 changed files with 13050 additions and 4684 deletions

24
UI/Base/ChartJs.ts Normal file
View file

@ -0,0 +1,24 @@
import BaseUIElement from "../BaseUIElement";
import {Chart, ChartConfiguration, ChartType, DefaultDataPoint, registerables} from 'chart.js';
Chart.register(...registerables);
export default class ChartJs<
TType extends ChartType = ChartType,
TData = DefaultDataPoint<TType>,
TLabel = unknown
> extends BaseUIElement{
private readonly _config: ChartConfiguration<TType, TData, TLabel>;
constructor(config: ChartConfiguration<TType, TData, TLabel>) {
super();
this._config = config;
}
protected InnerConstructElement(): HTMLElement {
const canvas = document.createElement("canvas");
new Chart(canvas, this._config);
return canvas;
}
}

View file

@ -38,6 +38,9 @@ export default class Combine extends BaseUIElement {
protected InnerConstructElement(): HTMLElement {
const el = document.createElement("span")
try {
if(this.uiElements === undefined){
console.error("PANIC")
}
for (const subEl of this.uiElements) {
if (subEl === undefined || subEl === null) {
continue;

View file

@ -2,9 +2,10 @@ import {Utils} from "../../Utils";
import BaseUIElement from "../BaseUIElement";
export default class Img extends BaseUIElement {
private _src: string;
private readonly _src: string;
private readonly _rawSvg: boolean;
private _options: { fallbackImage?: string };
private readonly _options: { readonly fallbackImage?: string };
constructor(src: string, rawSvg = false, options?: {
fallbackImage?: string
@ -22,7 +23,13 @@ export default class Img extends BaseUIElement {
if (Utils.runningFromConsole) {
return source;
}
return `data:image/svg+xml;base64,${(btoa(source))}`;
try{
return `data:image/svg+xml;base64,${(btoa(source))}`;
}catch (e){
console.error("Cannot create an image for", source.slice(0, 100))
console.trace("Cannot create an image for the given source string due to ", e)
return ""
}
}
static AsImageElement(source: string, css_class: string = "", style = ""): string {

View file

@ -26,9 +26,9 @@ export default class Link extends BaseUIElement {
if (!hideKey) {
k = key + "="
}
return new Link(k + value, `https://wiki.openstreetmap.org/wiki/Tag:${key}%3D${value}`)
return new Link(k + value, `https://wiki.openstreetmap.org/wiki/Tag:${key}%3D${value}`, true)
}
return new Link(key, "https://wiki.openstreetmap.org/wiki/Key:" + key)
return new Link(key, "https://wiki.openstreetmap.org/wiki/Key:" + key, true)
}
AsMarkdown(): string {

View file

@ -26,7 +26,8 @@ export default class Toggleable extends Combine {
public readonly isVisible = new UIEventSource(false)
constructor(title: Title | Combine | BaseUIElement, content: BaseUIElement, options?: {
closeOnClick: true | boolean
closeOnClick?: true | boolean,
height?: "100vh" | string
}) {
super([title, content])
content.SetClass("animate-height border-l-4 pl-2 block")
@ -72,7 +73,7 @@ export default class Toggleable extends Combine {
this.isVisible.addCallbackAndRun(isVisible => {
if (isVisible) {
contentElement.style.maxHeight = "100vh"
contentElement.style.maxHeight = options?.height ?? "100vh"
contentElement.style.overflowY = "auto"
contentElement.style["-webkit-mask-image"] = "unset"
} else {

View file

@ -33,6 +33,7 @@ export class VariableUiElement extends BaseUIElement {
if (self.isDestroyed) {
return true;
}
while (el.firstChild) {
el.removeChild(el.lastChild);
}

View file

@ -1,4 +1,4 @@
import {Utils} from "../Utils";
import { Utils } from "../Utils";
/**
* A thin wrapper around a html element, which allows to generate a HTML-element.
@ -9,7 +9,7 @@ export default abstract class BaseUIElement {
protected _constructedHtmlElement: HTMLElement;
protected isDestroyed = false;
private clss: Set<string> = new Set<string>();
private readonly clss: Set<string> = new Set<string>();
private style: string;
private _onClick: () => void;
@ -39,9 +39,9 @@ export default abstract class BaseUIElement {
return this;
}
public ScrollToTop(){
this._constructedHtmlElement?.scrollTo(0,0)
public ScrollToTop() {
this._constructedHtmlElement?.scrollTo(0, 0)
}
/**
@ -70,10 +70,13 @@ export default abstract class BaseUIElement {
return this;
}
public RemoveClass(clss: string): BaseUIElement {
if (this.clss.has(clss)) {
this.clss.delete(clss);
this._constructedHtmlElement?.classList.remove(clss)
public RemoveClass(classes: string): BaseUIElement {
const all = classes.split(" ").map(clsName => clsName.trim());
for (let clss of all) {
if (this.clss.has(clss)) {
this.clss.delete(clss);
this._constructedHtmlElement?.classList.remove(clss)
}
}
return this;
}
@ -114,7 +117,7 @@ export default abstract class BaseUIElement {
if (style !== undefined && style !== "") {
el.style.cssText = style
}
if (this.clss.size > 0) {
if (this.clss?.size > 0) {
try {
el.classList.add(...Array.from(this.clss))
} catch (e) {

View file

@ -3,11 +3,22 @@ import Toggle from "../Input/Toggle";
import MapControlButton from "../MapControlButton";
import GeoLocationHandler from "../../Logic/Actors/GeoLocationHandler";
import Svg from "../../Svg";
import MapState from "../../Logic/State/MapState";
import MapState, {GlobalFilter} from "../../Logic/State/MapState";
import LevelSelector from "../Input/LevelSelector";
import FeaturePipeline from "../../Logic/FeatureSource/FeaturePipeline";
import {Utils} from "../../Utils";
import {TagUtils} from "../../Logic/Tags/TagUtils";
import {RegexTag} from "../../Logic/Tags/RegexTag";
import {Or} from "../../Logic/Tags/Or";
import {Tag} from "../../Logic/Tags/Tag";
import {TagsFilter} from "../../Logic/Tags/TagsFilter";
import Translations from "../i18n/Translations";
import {BBox} from "../../Logic/BBox";
import {OsmFeature} from "../../Models/OsmFeature";
export default class RightControls extends Combine {
constructor(state: MapState) {
constructor(state: MapState & { featurePipeline: FeaturePipeline }) {
const geolocatioHandler = new GeoLocationHandler(
state
@ -38,7 +49,91 @@ export default class RightControls extends Combine {
state.locationControl.ping();
});
super([plus, min, geolocationButton].map(el => el.SetClass("m-0.5 md:m-1")))
const levelsInView = state.currentBounds.map(bbox => {
if (bbox === undefined) {
return []
}
const allElementsUnfiltered: OsmFeature[] = [].concat(... state.featurePipeline.GetAllFeaturesAndMetaWithin(bbox).map(ff => ff.features))
const allElements = allElementsUnfiltered.filter(f => BBox.get(f).overlapsWith(bbox))
const allLevelsRaw: string[] = allElements.map(f => f.properties["level"])
const allLevels = [].concat(...allLevelsRaw.map(l => TagUtils.LevelsParser(l)))
if (allLevels.indexOf("0") < 0) {
allLevels.push("0")
}
allLevels.sort((a, b) => a < b ? -1 : 1)
return Utils.Dedup(allLevels)
})
state.globalFilters.data.push({
filter: {
currentFilter: undefined,
state: undefined,
},
id: "level",
onNewPoint: undefined
})
const levelSelect = new LevelSelector(levelsInView)
const isShown = levelsInView.map(levelsInView => {
if (levelsInView.length == 0) {
return false;
}
if (state.locationControl.data.zoom <= 16) {
return false;
}
if (levelsInView.length == 1 && levelsInView[0] == "0") {
return false
}
return true;
},
[state.locationControl])
function setLevelFilter() {
console.log("Updating levels filter")
const filter: GlobalFilter = state.globalFilters.data.find(gf => gf.id === "level")
if (!isShown.data) {
filter.filter = {
state: "*",
currentFilter: undefined,
}
filter.onNewPoint = undefined
} else {
const l = levelSelect.GetValue().data
let neededLevel: TagsFilter = new RegexTag("level", new RegExp("(^|;)" + l + "(;|$)"));
if (l === "0") {
neededLevel = new Or([neededLevel, new Tag("level", "")])
}
filter.filter = {
state: l,
currentFilter: neededLevel
}
const t = Translations.t.general.levelSelection
filter.onNewPoint = {
confirmAddNew: t.confirmLevel.PartialSubs({level: l}),
safetyCheck: t.addNewOnLevel.Subs({level: l}),
tags: [new Tag("level", l)]
}
}
state.globalFilters.ping();
return;
}
isShown.addCallbackAndRun(shown => {
console.log("Is level selector shown?", shown)
setLevelFilter()
if (shown) {
levelSelect.RemoveClass("invisible")
} else {
levelSelect.SetClass("invisible")
}
})
levelSelect.GetValue().addCallback(_ => setLevelFilter())
super([new Combine([levelSelect]).SetClass(""), plus, min, geolocationButton].map(el => el.SetClass("m-0.5 md:m-1")))
this.SetClass("flex flex-col items-center")
}

View file

@ -25,6 +25,7 @@ import ConfirmLocationOfPoint from "../NewPoint/ConfirmLocationOfPoint";
import BaseLayer from "../../Models/BaseLayer";
import Loading from "../Base/Loading";
import Hash from "../../Logic/Web/Hash";
import {GlobalFilter} from "../../Logic/State/MapState";
/*
* The SimpleAddUI is a single panel, which can have multiple states:
@ -43,6 +44,14 @@ export interface PresetInfo extends PresetConfig {
export default class SimpleAddUI extends Toggle {
/**
*
* @param isShown
* @param resetScrollSignal
* @param filterViewIsOpened
* @param state
* @param takeLocationFrom: defaults to state.lastClickLocation. Take this location to add the new point around
*/
constructor(isShown: UIEventSource<boolean>,
resetScrollSignal: UIEventSource<void>,
filterViewIsOpened: UIEventSource<boolean>,
@ -58,8 +67,11 @@ export default class SimpleAddUI extends Toggle {
locationControl: UIEventSource<Loc>,
filteredLayers: UIEventSource<FilteredLayer[]>,
featureSwitchFilter: UIEventSource<boolean>,
backgroundLayer: UIEventSource<BaseLayer>
}) {
backgroundLayer: UIEventSource<BaseLayer>,
globalFilters: UIEventSource<GlobalFilter[]>
},
takeLocationFrom?: UIEventSource<{lat: number, lon: number}>
) {
const loginButton = new SubtleButton(Svg.osm_logo_ui(), Translations.t.general.add.pleaseLogin.Clone())
.onClick(() => state.osmConnection.AttemptLogin());
const readYourMessages = new Combine([
@ -68,7 +80,8 @@ export default class SimpleAddUI extends Toggle {
Translations.t.general.goToInbox, {url: "https://www.openstreetmap.org/messages/inbox", newTab: false})
]);
takeLocationFrom = takeLocationFrom ?? state.LastClickLocation
const selectedPreset = new UIEventSource<PresetInfo>(undefined);
selectedPreset.addCallback(_ => {
resetScrollSignal.ping();
@ -76,7 +89,7 @@ export default class SimpleAddUI extends Toggle {
isShown.addCallback(_ => selectedPreset.setData(undefined)) // Clear preset selection when the UI is closed/opened
state.LastClickLocation.addCallback(_ => selectedPreset.setData(undefined))
takeLocationFrom.addCallback(_ => selectedPreset.setData(undefined))
const presetsOverview = SimpleAddUI.CreateAllPresetsPanel(selectedPreset, state)
@ -120,7 +133,7 @@ export default class SimpleAddUI extends Toggle {
const message = Translations.t.general.add.addNew.Subs({category: preset.name}, preset.name["context"]);
return new ConfirmLocationOfPoint(state, filterViewIsOpened, preset,
message,
state.LastClickLocation.data,
takeLocationFrom.data,
confirm,
cancel,
() => {

View file

@ -0,0 +1,51 @@
import {VariableUiElement} from "../Base/VariableUIElement";
import Loading from "../Base/Loading";
import Title from "../Base/Title";
import TagRenderingChart from "./TagRenderingChart";
import Combine from "../Base/Combine";
import Locale from "../i18n/Locale";
import {UIEventSource} from "../../Logic/UIEventSource";
import {OsmFeature} from "../../Models/OsmFeature";
import LayerConfig from "../../Models/ThemeConfig/LayerConfig";
import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig";
export default class StatisticsPanel extends VariableUiElement {
constructor(elementsInview: UIEventSource<{ element: OsmFeature, layer: LayerConfig }[]>, state: {
layoutToUse: LayoutConfig
}) {
super(elementsInview.stabilized(1000).map(features => {
if (features === undefined) {
return new Loading("Loading data")
}
if (features.length === 0) {
return "No elements in view"
}
const els = []
for (const layer of state.layoutToUse.layers) {
if(layer.name === undefined){
continue
}
const featuresForLayer = features.filter(f => f.layer === layer).map(f => f.element)
if(featuresForLayer.length === 0){
continue
}
els.push(new Title(layer.name.Clone(), 1).SetClass("mt-8"))
const layerStats = []
for (const tagRendering of (layer?.tagRenderings ?? [])) {
const chart = new TagRenderingChart(featuresForLayer, tagRendering, {
chartclasses: "w-full",
chartstyle: "height: 60rem",
includeTitle: false
})
const title = new Title(tagRendering.question?.Clone() ?? tagRendering.id, 4).SetClass("mt-8")
if(!chart.HasClass("hidden")){
layerStats.push(new Combine([title, chart]).SetClass("flex flex-col w-full lg:w-1/3"))
}
}
els.push(new Combine(layerStats).SetClass("flex flex-wrap"))
}
return new Combine(els)
}, [Locale.language]));
}
}

View file

@ -0,0 +1,179 @@
import ChartJs from "../Base/ChartJs";
import {OsmFeature} from "../../Models/OsmFeature";
import TagRenderingConfig from "../../Models/ThemeConfig/TagRenderingConfig";
import {ChartConfiguration} from 'chart.js';
import Combine from "../Base/Combine";
import {TagUtils} from "../../Logic/Tags/TagUtils";
export default class TagRenderingChart extends Combine {
private static readonly unkownColor = 'rgba(128, 128, 128, 0.2)'
private static readonly unkownBorderColor = 'rgba(128, 128, 128, 0.2)'
private static readonly otherColor = 'rgba(128, 128, 128, 0.2)'
private static readonly otherBorderColor = 'rgba(128, 128, 255)'
private static readonly notApplicableColor = 'rgba(128, 128, 128, 0.2)'
private static readonly notApplicableBorderColor = 'rgba(255, 0, 0)'
private static readonly backgroundColors = [
'rgba(255, 99, 132, 0.2)',
'rgba(54, 162, 235, 0.2)',
'rgba(255, 206, 86, 0.2)',
'rgba(75, 192, 192, 0.2)',
'rgba(153, 102, 255, 0.2)',
'rgba(255, 159, 64, 0.2)'
]
private static readonly borderColors = [
'rgba(255, 99, 132, 1)',
'rgba(54, 162, 235, 1)',
'rgba(255, 206, 86, 1)',
'rgba(75, 192, 192, 1)',
'rgba(153, 102, 255, 1)',
'rgba(255, 159, 64, 1)'
]
/**
* Creates a chart about this tagRendering for the given data
*/
constructor(features: OsmFeature[], tagRendering: TagRenderingConfig, options?: {
chartclasses?: string,
chartstyle?: string,
includeTitle?: boolean,
groupToOtherCutoff?: 3 | number
}) {
const mappings = tagRendering.mappings ?? []
if (mappings.length === 0 && tagRendering.freeform?.key === undefined) {
super([])
this.SetClass("hidden")
return;
}
let unknownCount = 0;
const categoryCounts = mappings.map(_ => 0)
const otherCounts: Record<string, number> = {}
let notApplicable = 0;
let barchartMode = tagRendering.multiAnswer;
for (const feature of features) {
const props = feature.properties
if (tagRendering.condition !== undefined && !tagRendering.condition.matchesProperties(props)) {
notApplicable++;
continue;
}
if (!tagRendering.IsKnown(props)) {
unknownCount++;
continue;
}
let foundMatchingMapping = false;
if (!tagRendering.multiAnswer) {
for (let i = 0; i < mappings.length; i++) {
const mapping = mappings[i];
if (mapping.if.matchesProperties(props)) {
categoryCounts[i]++
foundMatchingMapping = true
break;
}
}
} else {
for (let i = 0; i < mappings.length; i++) {
const mapping = mappings[i];
if (TagUtils.MatchesMultiAnswer( mapping.if, props)) {
categoryCounts[i]++
foundMatchingMapping = true
}
}
}
if (!foundMatchingMapping) {
if (tagRendering.freeform?.key !== undefined && props[tagRendering.freeform.key] !== undefined) {
const otherValue = props[tagRendering.freeform.key]
otherCounts[otherValue] = (otherCounts[otherValue] ?? 0) + 1
} else {
unknownCount++
}
}
}
if (unknownCount + notApplicable === features.length) {
super([])
this.SetClass("hidden")
return
}
let otherGrouped = 0;
const otherLabels: string[] = []
const otherData : number[] = []
for (const v in otherCounts) {
const count = otherCounts[v]
if(count >= (options.groupToOtherCutoff ?? 3)){
otherLabels.push(v)
otherData.push(otherCounts[v])
}else{
otherGrouped++;
}
}
const labels = ["Unknown", "Other", "Not applicable", ...mappings?.map(m => m.then.txt) ?? [], ...otherLabels]
const data = [unknownCount, otherGrouped, notApplicable, ...categoryCounts, ... otherData]
const borderColor = [TagRenderingChart.unkownBorderColor, TagRenderingChart.otherBorderColor, TagRenderingChart.notApplicableBorderColor]
const backgroundColor = [TagRenderingChart.unkownColor, TagRenderingChart.otherColor, TagRenderingChart.notApplicableColor]
while (borderColor.length < data.length) {
borderColor.push(...TagRenderingChart.borderColors)
backgroundColor.push(...TagRenderingChart.backgroundColors)
}
for (let i = data.length; i >= 0; i--) {
if (data[i] === 0) {
labels.splice(i, 1)
data.splice(i, 1)
borderColor.splice(i, 1)
backgroundColor.splice(i, 1)
}
}
if(labels.length > 9){
barchartMode = true;
}
const config = <ChartConfiguration>{
type: barchartMode ? 'bar' : 'doughnut',
data: {
labels,
datasets: [{
data,
backgroundColor,
borderColor,
borderWidth: 1,
label: undefined
}]
},
options: {
plugins: {
legend: {
display: !barchartMode
}
}
}
}
const chart = new ChartJs(config).SetClass(options?.chartclasses ?? "w-32 h-32");
if (options.chartstyle !== undefined) {
chart.SetStyle(options.chartstyle)
}
super([
options?.includeTitle ? (tagRendering.question.Clone() ?? tagRendering.id) : undefined,
chart])
this.SetClass("block")
}
}

242
UI/DashboardGui.ts Normal file
View file

@ -0,0 +1,242 @@
import FeaturePipelineState from "../Logic/State/FeaturePipelineState";
import {DefaultGuiState} from "./DefaultGuiState";
import {FixedUiElement} from "./Base/FixedUiElement";
import {Utils} from "../Utils";
import Combine from "./Base/Combine";
import ShowDataLayer from "./ShowDataLayer/ShowDataLayer";
import LayerConfig from "../Models/ThemeConfig/LayerConfig";
import * as home_location_json from "../assets/layers/home_location/home_location.json";
import State from "../State";
import Title from "./Base/Title";
import {MinimapObj} from "./Base/Minimap";
import BaseUIElement from "./BaseUIElement";
import {VariableUiElement} from "./Base/VariableUIElement";
import {GeoOperations} from "../Logic/GeoOperations";
import {OsmFeature} from "../Models/OsmFeature";
import SearchAndGo from "./BigComponents/SearchAndGo";
import FeatureInfoBox from "./Popup/FeatureInfoBox";
import {UIEventSource} from "../Logic/UIEventSource";
import LanguagePicker from "./LanguagePicker";
import Lazy from "./Base/Lazy";
import TagRenderingAnswer from "./Popup/TagRenderingAnswer";
import Hash from "../Logic/Web/Hash";
import FilterView from "./BigComponents/FilterView";
import Translations from "./i18n/Translations";
import Constants from "../Models/Constants";
import SimpleAddUI from "./BigComponents/SimpleAddUI";
import BackToIndex from "./BigComponents/BackToIndex";
import StatisticsPanel from "./BigComponents/StatisticsPanel";
export default class DashboardGui {
private readonly state: FeaturePipelineState;
private readonly currentView: UIEventSource<{ title: string | BaseUIElement, contents: string | BaseUIElement }> = new UIEventSource(undefined)
constructor(state: FeaturePipelineState, guiState: DefaultGuiState) {
this.state = state;
}
private viewSelector(shown: BaseUIElement, title: string | BaseUIElement, contents: string | BaseUIElement, hash?: string): BaseUIElement {
const currentView = this.currentView
const v = {title, contents}
shown.SetClass("pl-1 pr-1 rounded-md")
shown.onClick(() => {
currentView.setData(v)
})
Hash.hash.addCallbackAndRunD(h => {
if (h === hash) {
currentView.setData(v)
}
})
currentView.addCallbackAndRunD(cv => {
if (cv == v) {
shown.SetClass("bg-unsubtle")
Hash.hash.setData(hash)
} else {
shown.RemoveClass("bg-unsubtle")
}
})
return shown;
}
private singleElementCache: Record<string, BaseUIElement> = {}
private singleElementView(element: OsmFeature, layer: LayerConfig, distance: number): BaseUIElement {
if (this.singleElementCache[element.properties.id] !== undefined) {
return this.singleElementCache[element.properties.id]
}
const tags = this.state.allElements.getEventSourceById(element.properties.id)
const title = new Combine([new Title(new TagRenderingAnswer(tags, layer.title, this.state), 4),
distance < 900 ? Math.floor(distance) + "m away" :
Utils.Round(distance / 1000) + "km away"
]).SetClass("flex justify-between");
return this.singleElementCache[element.properties.id] = this.viewSelector(title,
new Lazy(() => FeatureInfoBox.GenerateTitleBar(tags, layer, this.state)),
new Lazy(() => FeatureInfoBox.GenerateContent(tags, layer, this.state)),
// element.properties.id
);
}
private mainElementsView(elements: { element: OsmFeature, layer: LayerConfig, distance: number }[]): BaseUIElement {
const self = this
if (elements === undefined) {
return new FixedUiElement("Initializing")
}
if (elements.length == 0) {
return new FixedUiElement("No elements in view")
}
return new Combine(elements.map(e => self.singleElementView(e.element, e.layer, e.distance)))
}
private documentationButtonFor(layerConfig: LayerConfig): BaseUIElement {
return this.viewSelector(Translations.W(layerConfig.name?.Clone() ?? layerConfig.id), new Combine(["Documentation about ", layerConfig.name?.Clone() ?? layerConfig.id]),
layerConfig.GenerateDocumentation([]),
"documentation-" + layerConfig.id)
}
private allDocumentationButtons(): BaseUIElement {
const layers = this.state.layoutToUse.layers.filter(l => Constants.priviliged_layers.indexOf(l.id) < 0)
.filter(l => !l.id.startsWith("note_import_"));
if (layers.length === 1) {
return this.documentationButtonFor(layers[0])
}
return this.viewSelector(new FixedUiElement("Documentation"), "Documentation",
new Combine(layers.map(l => this.documentationButtonFor(l).SetClass("flex flex-col"))))
}
public setup(): void {
const state = this.state;
if (this.state.layoutToUse.customCss !== undefined) {
if (window.location.pathname.indexOf("index") >= 0) {
Utils.LoadCustomCss(this.state.layoutToUse.customCss)
}
}
const map = this.SetupMap();
Utils.downloadJson("./service-worker-version").then(data => console.log("Service worker", data)).catch(_ => console.log("Service worker not active"))
document.getElementById("centermessage").classList.add("hidden")
const layers: Record<string, LayerConfig> = {}
for (const layer of state.layoutToUse.layers) {
layers[layer.id] = layer;
}
const self = this;
const elementsInview = new UIEventSource<{ distance: number, center: [number, number], element: OsmFeature, layer: LayerConfig }[]>([]);
function update() {
const mapCenter = <[number,number]> [self.state.locationControl.data.lon, self.state.locationControl.data.lon]
const elements = self.state.featurePipeline.getAllVisibleElementsWithmeta(self.state.currentBounds.data).map(el => {
const distance = GeoOperations.distanceBetween(el.center, mapCenter)
return {...el, distance }
})
elements.sort((e0, e1) => e0.distance - e1.distance)
elementsInview.setData(elements)
}
map.bounds.addCallbackAndRun(update)
state.featurePipeline.newDataLoadedSignal.addCallback(update);
state.filteredLayers.addCallbackAndRun(fls => {
for (const fl of fls) {
fl.isDisplayed.addCallback(update)
fl.appliedFilters.addCallback(update)
}
})
const filterView = new Lazy(() => {
return new FilterView(state.filteredLayers, state.overlayToggles)
});
const welcome = new Combine([state.layoutToUse.description, state.layoutToUse.descriptionTail])
self.currentView.setData({title: state.layoutToUse.title, contents: welcome})
const filterViewIsOpened = new UIEventSource(false)
filterViewIsOpened.addCallback(_ => self.currentView.setData({title: "filters", contents: filterView}))
const newPointIsShown = new UIEventSource(false);
const addNewPoint = new SimpleAddUI(
new UIEventSource(true),
new UIEventSource(undefined),
filterViewIsOpened,
state,
state.locationControl
);
const addNewPointTitle = "Add a missing point"
this.currentView.addCallbackAndRunD(cv => {
newPointIsShown.setData(cv.contents === addNewPoint)
})
newPointIsShown.addCallbackAndRun(isShown => {
if (isShown) {
if (self.currentView.data.contents !== addNewPoint) {
self.currentView.setData({title: addNewPointTitle, contents: addNewPoint})
}
} else {
if (self.currentView.data.contents === addNewPoint) {
self.currentView.setData(undefined)
}
}
})
new Combine([
new Combine([
this.viewSelector(new Title(state.layoutToUse.title.Clone(), 2), state.layoutToUse.title.Clone(), welcome, "welcome"),
map.SetClass("w-full h-64 shrink-0 rounded-lg"),
new SearchAndGo(state),
this.viewSelector(new Title(
new VariableUiElement(elementsInview.map(elements => "There are " + elements?.length + " elements in view"))),
"Statistics",
new StatisticsPanel(elementsInview, this.state), "statistics"),
this.viewSelector(new FixedUiElement("Filter"),
"Filters", filterView, "filters"),
this.viewSelector(new Combine(["Add a missing point"]), addNewPointTitle,
addNewPoint
),
new VariableUiElement(elementsInview.map(elements => this.mainElementsView(elements).SetClass("block m-2")))
.SetClass("block shrink-2 overflow-x-auto h-full border-2 border-subtle rounded-lg"),
this.allDocumentationButtons(),
new LanguagePicker(Object.keys(state.layoutToUse.title.translations)).SetClass("mt-2"),
new BackToIndex()
]).SetClass("w-1/2 lg:w-1/4 m-4 flex flex-col shrink-0 grow-0"),
new VariableUiElement(this.currentView.map(({title, contents}) => {
return new Combine([
new Title(Translations.W(title), 2).SetClass("shrink-0 border-b-4 border-subtle"),
Translations.W(contents).SetClass("shrink-2 overflow-y-auto block")
]).SetClass("flex flex-col h-full")
})).SetClass("w-1/2 lg:w-3/4 m-4 p-2 border-2 border-subtle rounded-xl m-4 ml-0 mr-8 shrink-0 grow-0"),
]).SetClass("flex h-full")
.AttachTo("leafletDiv")
}
private SetupMap(): MinimapObj & BaseUIElement {
const state = this.state;
new ShowDataLayer({
leafletMap: state.leafletMap,
layerToShow: new LayerConfig(home_location_json, "home_location", true),
features: state.homeLocation,
state
})
state.leafletMap.addCallbackAndRunD(_ => {
// Lets assume that all showDataLayers are initialized at this point
state.selectedElement.ping()
State.state.locationControl.ping();
return true;
})
return state.mainMapObject
}
}

View file

@ -44,14 +44,9 @@ export default class DefaultGUI {
}
public setup(){
if (this.state.layoutToUse.customCss !== undefined) {
Utils.LoadCustomCss(this.state.layoutToUse.customCss);
}
this.SetupUIElements();
this.SetupMap()
if (this.state.layoutToUse.customCss !== undefined && window.location.pathname.indexOf("index") >= 0) {
Utils.LoadCustomCss(this.state.layoutToUse.customCss)
}
@ -144,7 +139,7 @@ export default class DefaultGUI {
new ShowDataLayer({
leafletMap: state.leafletMap,
layerToShow: new LayerConfig(home_location_json, "all_known_layers", true),
layerToShow: new LayerConfig(home_location_json, "home_location", true),
features: state.homeLocation,
state
})

View file

@ -13,6 +13,11 @@ export class DropDown<T> extends InputElement<T> {
private readonly _value: UIEventSource<T>;
private readonly _values: { value: T; shown: string | BaseUIElement }[];
/**
*
* const dropdown = new DropDown<number>("test",[{value: 42, shown: "the answer"}])
* dropdown.GetValue().data // => 42
*/
constructor(label: string | BaseUIElement,
values: { value: T, shown: string | BaseUIElement }[],
value: UIEventSource<T> = undefined,
@ -21,7 +26,7 @@ export class DropDown<T> extends InputElement<T> {
}
) {
super();
value = value ?? new UIEventSource<T>(undefined)
value = value ?? new UIEventSource<T>(values[0].value)
this._value = value
this._values = values;
if (values.length <= 1) {
@ -63,7 +68,7 @@ export class DropDown<T> extends InputElement<T> {
select.onchange = (() => {
var index = select.selectedIndex;
const index = select.selectedIndex;
value.setData(values[index].value);
});

70
UI/Input/LevelSelector.ts Normal file
View file

@ -0,0 +1,70 @@
import {InputElement} from "./InputElement";
import {Store, Stores, UIEventSource} from "../../Logic/UIEventSource";
import Combine from "../Base/Combine";
import Slider from "./Slider";
import {ClickableToggle} from "./Toggle";
import {FixedUiElement} from "../Base/FixedUiElement";
import {VariableUiElement} from "../Base/VariableUIElement";
export default class LevelSelector extends VariableUiElement implements InputElement<string> {
private readonly _value: UIEventSource<string>;
constructor(currentLevels: Store<string[]>, options?: {
value?: UIEventSource<string>
}) {
const value = options?.value ?? new UIEventSource<string>(undefined)
super(Stores.ListStabilized(currentLevels).map(levels => {
console.log("CUrrent levels are", levels)
let slider = new Slider(0, levels.length - 1, {vertical: true});
const toggleClass = "flex border-2 border-blue-500 w-10 h-10 place-content-center items-center border-box"
slider.SetClass("flex elevator w-10").SetStyle(`height: ${2.5 * levels.length}rem; background: #00000000`)
const values = levels.map((data, i) => new ClickableToggle(
new FixedUiElement(data).SetClass("font-bold active bg-subtle " + toggleClass),
new FixedUiElement(data).SetClass("normal-background " + toggleClass),
slider.GetValue().sync(
(sliderVal) => {
return sliderVal === i
},
[],
(isSelected) => {
return isSelected ? i : slider.GetValue().data
}
))
.ToggleOnClick()
.SetClass("flex w-10 h-10"))
values.reverse(/* This is a new list, no side-effects */)
const combine = new Combine([new Combine(values), slider])
combine.SetClass("flex flex-row overflow-hidden");
slider.GetValue().addCallbackAndRun(i => {
if (currentLevels?.data === undefined) {
return
}
value.setData(currentLevels?.data[i]);
})
value.addCallback(level => {
const i = currentLevels?.data?.findIndex(l => l === level)
slider.GetValue().setData(i)
})
return combine
}))
this._value = value
}
GetValue(): UIEventSource<string> {
return this._value;
}
IsValid(t: string): boolean {
return false;
}
}

View file

@ -4,9 +4,10 @@ import {UIEventSource} from "../../Logic/UIEventSource";
export default class Slider extends InputElement<number> {
private readonly _value: UIEventSource<number>
private min: number;
private max: number;
private step: number;
private readonly min: number;
private readonly max: number;
private readonly step: number;
private readonly vertical: boolean;
/**
* Constructs a slider input element for natural numbers
@ -16,13 +17,15 @@ export default class Slider extends InputElement<number> {
*/
constructor(min: number, max: number, options?: {
value?: UIEventSource<number>,
step?: 1 | number
step?: 1 | number,
vertical?: false | boolean
}) {
super();
this.max = max;
this.min = min;
this._value = options?.value ?? new UIEventSource<number>(min)
this.step = options?.step ?? 1;
this.vertical = options?.vertical ?? false;
}
GetValue(): UIEventSource<number> {
@ -39,6 +42,10 @@ export default class Slider extends InputElement<number> {
el.oninput = () => {
valuestore.setData(Number(el.value))
}
if(this.vertical){
el.classList.add("vertical")
el.setAttribute('orient','vertical'); // firefox only workaround...
}
valuestore.addCallbackAndRunD(v => el.value = ""+valuestore.data)
return el;
}

View file

@ -16,6 +16,7 @@ export default class LanguagePicker extends Toggle {
if (languages === undefined || languages.length <= 1) {
super(undefined,undefined,undefined)
return undefined;
}

View file

@ -15,12 +15,16 @@ import SimpleAddUI, {PresetInfo} from "../BigComponents/SimpleAddUI";
import BaseLayer from "../../Models/BaseLayer";
import Img from "../Base/Img";
import Title from "../Base/Title";
import {GlobalFilter} from "../../Logic/State/MapState";
import {VariableUiElement} from "../Base/VariableUIElement";
import {Tag} from "../../Logic/Tags/Tag";
export default class ConfirmLocationOfPoint extends Combine {
constructor(
state: {
globalFilters: UIEventSource<GlobalFilter[]>;
featureSwitchIsTesting: UIEventSource<boolean>;
osmConnection: OsmConnection,
featurePipeline: FeaturePipeline,
@ -38,8 +42,8 @@ export default class ConfirmLocationOfPoint extends Combine {
let preciseInput: LocationInput = undefined
if (preset.preciseInput !== undefined) {
// Create location input
// We uncouple the event source
const zloc = {...loc, zoom: 19}
const locationSrc = new UIEventSource(zloc);
@ -106,7 +110,11 @@ export default class ConfirmLocationOfPoint extends Combine {
).SetClass("font-bold break-words")
.onClick(() => {
console.log("The confirmLocationPanel - precise input yielded ", preciseInput?.GetValue()?.data)
confirm(preset.tags, preciseInput?.GetValue()?.data ?? loc, preciseInput?.snappedOnto?.data?.properties?.id);
const globalFilterTagsToAdd: Tag[][] = state.globalFilters.data.filter(gf => gf.onNewPoint !== undefined)
.map(gf => gf.onNewPoint.tags)
const globalTags : Tag[] = [].concat(...globalFilterTagsToAdd)
console.log("Global tags to add are: ", globalTags)
confirm([...preset.tags, ...globalTags], preciseInput?.GetValue()?.data ?? loc, preciseInput?.snappedOnto?.data?.properties?.id);
});
if (preciseInput !== undefined) {
@ -126,7 +134,7 @@ export default class ConfirmLocationOfPoint extends Combine {
.onClick(() => filterViewIsOpened.setData(true))
const openLayerOrConfirm = new Toggle(
let openLayerOrConfirm = new Toggle(
confirmButton,
openLayerControl,
preset.layerToAddTo.isDisplayed
@ -152,6 +160,29 @@ export default class ConfirmLocationOfPoint extends Combine {
closePopup()
})
// We assume the number of global filters won't change during the run of the program
for (let i = 0; i < state.globalFilters.data.length; i++) {
const hasBeenCheckedOf = new UIEventSource(false);
const filterConfirmPanel = new VariableUiElement(
state.globalFilters.map(gfs => {
const gf = gfs[i]
const confirm = gf.onNewPoint?.confirmAddNew?.Subs({preset: preset.title})
return new Combine([
gf.onNewPoint?.safetyCheck,
new SubtleButton(Svg.confirm_svg(), confirm).onClick(() => hasBeenCheckedOf.setData(true))
])
}
))
openLayerOrConfirm = new Toggle(
openLayerOrConfirm, filterConfirmPanel,
state.globalFilters.map(f => hasBeenCheckedOf.data || f[i]?.onNewPoint === undefined, [hasBeenCheckedOf])
)
}
const hasActiveFilter = preset.layerToAddTo.appliedFilters
.map(appliedFilters => {
const activeFilters = Array.from(appliedFilters.values()).filter(f => f?.currentFilter !== undefined);
@ -171,16 +202,16 @@ export default class ConfirmLocationOfPoint extends Combine {
Translations.t.general.cancel
).onClick(cancel)
let examples : BaseUIElement = undefined;
if(preset.exampleImages !== undefined && preset.exampleImages.length > 0){
let examples: BaseUIElement = undefined;
if (preset.exampleImages !== undefined && preset.exampleImages.length > 0) {
examples = new Combine([
new Title( preset.exampleImages.length == 1 ? Translations.t.general.example : Translations.t.general.examples),
new Title(preset.exampleImages.length == 1 ? Translations.t.general.example : Translations.t.general.examples),
new Combine(preset.exampleImages.map(img => new Img(img).SetClass("h-64 m-1 w-auto rounded-lg"))).SetClass("flex flex-wrap items-stretch")
])
}
super([
new Toggle(
Translations.t.general.testing.SetClass("alert"),

View file

@ -46,7 +46,7 @@ export default class FeatureInfoBox extends ScrollableFullScreen {
}
private static GenerateTitleBar(tags: UIEventSource<any>,
public static GenerateTitleBar(tags: UIEventSource<any>,
layerConfig: LayerConfig,
state: {}): BaseUIElement {
const title = new TagRenderingAnswer(tags, layerConfig.title ?? new TagRenderingConfig("POI"), state)
@ -64,7 +64,7 @@ export default class FeatureInfoBox extends ScrollableFullScreen {
])
}
private static GenerateContent(tags: UIEventSource<any>,
public static GenerateContent(tags: UIEventSource<any>,
layerConfig: LayerConfig,
state: FeaturePipelineState): BaseUIElement {
let questionBoxes: Map<string, QuestionBox> = new Map<string, QuestionBox>();

View file

@ -58,6 +58,8 @@ import {MapillaryLink} from "./BigComponents/MapillaryLink";
import {CheckBox} from "./Input/Checkboxes";
import Slider from "./Input/Slider";
import List from "./Base/List";
import StatisticsPanel from "./BigComponents/StatisticsPanel";
import { OsmFeature } from "../Models/OsmFeature";
export interface SpecialVisualization {
funcName: string,
@ -208,7 +210,7 @@ class NearbyImageVis implements SpecialVisualization {
const nearby = new Lazy(() => {
const towardsCenter = new CheckBox(t.onlyTowards, false)
const radiusValue = state?.osmConnection?.GetPreference("nearby-images-radius","300").sync(s => Number(s), [], i => ""+i) ?? new UIEventSource(300);
const radiusValue = state?.osmConnection?.GetPreference("nearby-images-radius", "300").sync(s => Number(s), [], i => "" + i) ?? new UIEventSource(300);
const radius = new Slider(25, 500, {
value:
@ -286,7 +288,13 @@ export default class SpecialVisualizations {
public static specialVisualizations: SpecialVisualization[] = SpecialVisualizations.init()
public static DocumentationFor(viz: SpecialVisualization): BaseUIElement {
public static DocumentationFor(viz: string | SpecialVisualization): BaseUIElement | undefined {
if (typeof viz === "string") {
viz = SpecialVisualizations.specialVisualizations.find(sv => sv.funcName === viz)
}
if(viz === undefined){
return undefined;
}
return new Combine(
[
new Title(viz.funcName, 3),
@ -749,8 +757,8 @@ 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)",
docs: "Converts a short, canonical value into the long, translated text including the unit. This only works if a `unit` is defined for the corresponding value. The unit specification will be included in the text. ",
example: "If the object has `length=42`, then `{canonical(length)}` will be shown as **42 meter** (in english), **42 metre** (in french), ...",
args: [{
name: "key",
doc: "The key of the tag to give the canonical text for",
@ -1125,6 +1133,35 @@ export default class SpecialVisualizations {
return details;
},
docs: "Show details of a MapRoulette task"
},
{
funcName: "statistics",
docs: "Show general statistics about the elements currently in view. Intended to use on the `current_view`-layer",
args: [],
constr : (state, tagsSource, args, guiState) => {
const elementsInview = new UIEventSource<{ distance: number, center: [number, number], element: OsmFeature, layer: LayerConfig }[]>([]);
function update() {
const mapCenter = <[number,number]> [state.locationControl.data.lon, state.locationControl.data.lon]
const bbox = state.currentBounds.data
const elements = state.featurePipeline.getAllVisibleElementsWithmeta(bbox).map(el => {
const distance = GeoOperations.distanceBetween(el.center, mapCenter)
return {...el, distance }
})
elements.sort((e0, e1) => e0.distance - e1.distance)
elementsInview.setData(elements)
}
state.currentBounds.addCallbackAndRun(update)
state.featurePipeline.newDataLoadedSignal.addCallback(update);
state.filteredLayers.addCallbackAndRun(fls => {
for (const fl of fls) {
fl.isDisplayed.addCallback(update)
fl.appliedFilters.addCallback(update)
}
})
return new StatisticsPanel(elementsInview, state)
}
}
]

View file

@ -317,6 +317,19 @@ export class TypedTranslation<T> extends Translation {
return Utils.SubstituteKeys(template, text, lang);
}, context)
}
PartialSubs<X extends string>(text: Partial<T> & Record<X, string>): TypedTranslation<Omit<T, X>> {
const newTranslations : Record<string, string> = {}
for (const lang in this.translations) {
const template = this.translations[lang]
if(lang === "_context"){
newTranslations[lang] = template
continue
}
newTranslations[lang] = Utils.SubstituteKeys(template, text, lang)
}
return new TypedTranslation<Omit<T, X>>(newTranslations, this.context)
}
}