Merge branch 'develop' into fix-answer-with-image-style-#491

This commit is contained in:
Pieter Vander Vennet 2021-10-01 00:31:39 +02:00 committed by GitHub
commit ba2b4754a9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
262 changed files with 27237 additions and 25052 deletions

View file

@ -1,8 +1,8 @@
import BaseUIElement from "../BaseUIElement";
import Loc from "../../Models/Loc";
import BaseLayer from "../../Models/BaseLayer";
import {BBox} from "../../Logic/GeoOperations";
import {UIEventSource} from "../../Logic/UIEventSource";
import {BBox} from "../../Logic/BBox";
export interface MinimapOptions {
background?: UIEventSource<BaseLayer>,
@ -30,6 +30,8 @@ export default class Minimap {
/**
* Construct a minimap
*/
public static createMiniMap: (options: MinimapOptions) => (BaseUIElement & MinimapObj)
public static createMiniMap: (options: MinimapOptions) => (BaseUIElement & MinimapObj) = (_) => {
throw "CreateMinimap hasn't been initialized yet. Please call MinimapImplementation.initialize()"
}
}

View file

@ -4,10 +4,10 @@ import {UIEventSource} from "../../Logic/UIEventSource";
import Loc from "../../Models/Loc";
import BaseLayer from "../../Models/BaseLayer";
import AvailableBaseLayers from "../../Logic/Actors/AvailableBaseLayers";
import {BBox} from "../../Logic/GeoOperations";
import * as L from "leaflet";
import {Map} from "leaflet";
import Minimap, {MinimapObj, MinimapOptions} from "./Minimap";
import {BBox} from "../../Logic/BBox";
export default class MinimapImplementation extends BaseUIElement implements MinimapObj {
private static _nextId = 0;
@ -50,7 +50,7 @@ export default class MinimapImplementation extends BaseUIElement implements Mini
if (typeof factor === "number") {
bounds = leaflet.getBounds()
leaflet.setMaxBounds(bounds.pad(factor))
}else{
} else {
// @ts-ignore
leaflet.setMaxBounds(factor.toLeaflet())
bounds = leaflet.getBounds()
@ -114,8 +114,12 @@ export default class MinimapImplementation extends BaseUIElement implements Mini
const self = this;
// @ts-ignore
const resizeObserver = new ResizeObserver(_ => {
self.InitMap();
self.leafletMap?.data?.invalidateSize()
try {
self.InitMap();
self.leafletMap?.data?.invalidateSize()
} catch (e) {
console.error("Could not construct a minimap:", e)
}
});
resizeObserver.observe(div);
@ -141,8 +145,12 @@ export default class MinimapImplementation extends BaseUIElement implements Mini
const location = this._location;
const self = this;
let currentLayer = this._background.data.layer()
let latLon = <[number, number]>[location.data?.lat ?? 0, location.data?.lon ?? 0]
if(isNaN(latLon[0]) || isNaN(latLon[1])){
latLon = [0,0]
}
const options = {
center: <[number, number]>[location.data?.lat ?? 0, location.data?.lon ?? 0],
center: latLon,
zoom: location.data?.zoom ?? 2,
layers: [currentLayer],
zoomControl: false,

View file

@ -36,6 +36,8 @@ export default class ScrollableFullScreen extends UIElement {
this._component = this.BuildComponent(title("desktop"), content("desktop"), isShown)
.SetClass("hidden md:block");
this._fullscreencomponent = this.BuildComponent(title("mobile"), content("mobile"), isShown);
const self = this;
isShown.addCallback(isShown => {
if (isShown) {

View file

@ -31,7 +31,7 @@ export class TabbedComponent extends Combine {
tabs.push(tab)
}
const header = new Combine(tabs).SetClass("block tabs-header-bar")
const header = new Combine(tabs).SetClass("tabs-header-bar")
const actualContent = new VariableUiElement(
openedTabSrc.map(i => contentElements[i])
)

View file

@ -2,22 +2,23 @@ import {UIEventSource} from "../../Logic/UIEventSource";
import BaseUIElement from "../BaseUIElement";
export class VariableUiElement extends BaseUIElement {
private _element: HTMLElement;
private readonly _contents: UIEventSource<string | BaseUIElement | BaseUIElement[]>;
constructor(
contents: UIEventSource<string | BaseUIElement | BaseUIElement[]>
) {
constructor(contents: UIEventSource<string | BaseUIElement | BaseUIElement[]>) {
super();
this._contents = contents;
this._element = document.createElement("span");
const el = this._element;
contents.addCallbackAndRun((contents) => {
}
protected InnerConstructElement(): HTMLElement {
const el = document.createElement("span");
this._contents.addCallbackAndRun((contents) => {
while (el.firstChild) {
el.removeChild(el.lastChild);
}
if (contents === undefined) {
return el;
return
}
if (typeof contents === "string") {
el.innerHTML = contents;
@ -35,9 +36,6 @@ export class VariableUiElement extends BaseUIElement {
}
}
});
}
protected InnerConstructElement(): HTMLElement {
return this._element;
return el;
}
}

View file

@ -100,6 +100,7 @@ export default abstract class BaseUIElement {
throw "ERROR! This is not a correct baseUIElement: " + this.constructor.name
}
try {
const el = this.InnerConstructElement();

View file

@ -7,7 +7,7 @@ import Constants from "../../Models/Constants";
import Loc from "../../Models/Loc";
import {VariableUiElement} from "../Base/VariableUIElement";
import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig";
import {BBox} from "../../Logic/GeoOperations";
import {BBox} from "../../Logic/BBox";
/**
* The bottom right attribution panel in the leaflet map
@ -16,13 +16,13 @@ export default class Attribution extends Combine {
constructor(location: UIEventSource<Loc>,
userDetails: UIEventSource<UserDetails>,
layoutToUse: UIEventSource<LayoutConfig>,
layoutToUse: LayoutConfig,
currentBounds: UIEventSource<BBox>) {
const mapComplete = new Link(`Mapcomplete ${Constants.vNumber}`, 'https://github.com/pietervdvn/MapComplete', true);
const reportBug = new Link(Svg.bug_ui().SetClass("small-image"), "https://github.com/pietervdvn/MapComplete/issues", true);
const layoutId = layoutToUse?.data?.id;
const layoutId = layoutToUse?.id;
const now = new Date()
// Note: getMonth is zero-index, we want 1-index but with one substracted, so it checks out!
const startDate = now.getFullYear() + "-" + now.getMonth() + "-" + now.getDate()

View file

@ -20,11 +20,11 @@ export default class AttributionPanel extends Combine {
private static LicenseObject = AttributionPanel.GenerateLicenses();
constructor(layoutToUse: UIEventSource<LayoutConfig>, contributions: UIEventSource<Map<string, number>>) {
constructor(layoutToUse: LayoutConfig, contributions: UIEventSource<Map<string, number>>) {
super([
Translations.t.general.attribution.attributionContent,
((layoutToUse.data.maintainer ?? "") == "") ? "" : Translations.t.general.attribution.themeBy.Subs({author: layoutToUse.data.maintainer}),
layoutToUse.data.credits,
((layoutToUse.maintainer ?? "") == "") ? "" : Translations.t.general.attribution.themeBy.Subs({author: layoutToUse.maintainer}),
layoutToUse.credits,
"<br/>",
new Attribution(State.state.locationControl, State.state.osmConnection.userDetails, State.state.layoutToUse, State.state.currentBounds),
"<br/>",
@ -65,7 +65,7 @@ export default class AttributionPanel extends Combine {
"<br/>",
AttributionPanel.CodeContributors(),
"<h3>", Translations.t.general.attribution.iconAttribution.title.Clone().SetClass("pt-6 pb-3"), "</h3>",
...Utils.NoNull(Array.from(layoutToUse.data.ExtractImages()))
...Utils.NoNull(Array.from(layoutToUse.ExtractImages()))
.map(AttributionPanel.IconAttribution)
]);
this.SetClass("flex flex-col link-underline overflow-hidden")

View file

@ -25,7 +25,9 @@ export default class BackgroundSelector extends VariableUiElement {
if (baseLayers.length <= 1) {
return undefined;
}
return new DropDown(Translations.t.general.backgroundMap.Clone(), baseLayers, State.state.backgroundLayer)
return new DropDown(Translations.t.general.backgroundMap.Clone(), baseLayers, State.state.backgroundLayer, {
select_class: 'bg-indigo-100 p-1 rounded hover:bg-indigo-200 w-full'
})
}
)
)

View file

@ -5,7 +5,7 @@ import State from "../../State";
import {Utils} from "../../Utils";
import Combine from "../Base/Combine";
import CheckBoxes from "../Input/Checkboxes";
import {BBox, GeoOperations} from "../../Logic/GeoOperations";
import {GeoOperations} from "../../Logic/GeoOperations";
import Toggle from "../Input/Toggle";
import Title from "../Base/Title";
import FeaturePipeline from "../../Logic/FeatureSource/FeaturePipeline";
@ -13,19 +13,20 @@ import {UIEventSource} from "../../Logic/UIEventSource";
import SimpleMetaTagger from "../../Logic/SimpleMetaTagger";
import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig";
import {meta} from "@turf/turf";
import {BBox} from "../../Logic/BBox";
export class DownloadPanel extends Toggle {
constructor() {
const state: {
featurePipeline: FeaturePipeline,
layoutToUse: UIEventSource<LayoutConfig>,
layoutToUse: LayoutConfig,
currentBounds: UIEventSource<BBox>
} = State.state
const t = Translations.t.general.download
const name = State.state.layoutToUse.data.id;
const name = State.state.layoutToUse.id;
const includeMetaToggle = new CheckBoxes([t.includeMetaData.Clone()])
const metaisIncluded = includeMetaToggle.GetValue().map(selected => selected.length > 0)

View file

@ -7,8 +7,6 @@ import Combine from "../Base/Combine";
import Translations from "../i18n/Translations";
import {Translation} from "../i18n/Translation";
import Svg from "../../Svg";
import {TagsFilter} from "../../Logic/Tags/TagsFilter";
import {And} from "../../Logic/Tags/And";
import {UIEventSource} from "../../Logic/UIEventSource";
import BaseUIElement from "../BaseUIElement";
import State from "../../State";
@ -16,11 +14,6 @@ import FilteredLayer from "../../Models/FilteredLayer";
import BackgroundSelector from "./BackgroundSelector";
import FilterConfig from "../../Models/ThemeConfig/FilterConfig";
/**
* Shows the filter
*/
export default class FilterView extends VariableUiElement {
constructor(filteredLayer: UIEventSource<FilteredLayer[]>) {
const backgroundSelector = new Toggle(
@ -101,26 +94,52 @@ export default class FilterView extends VariableUiElement {
return undefined;
}
let listFilterElements: [BaseUIElement, UIEventSource<TagsFilter>][] = layer.filters.map(
const filterIndexes = new Map<string, number>()
layer.filters.forEach((f, i) => filterIndexes.set(f.id, i))
let listFilterElements: [BaseUIElement, UIEventSource<{ filter: FilterConfig, selected: number }>][] = layer.filters.map(
FilterView.createFilter
);
const update = () => {
let listTagsFilters = Utils.NoNull(
listFilterElements.map((input) => input[1].data)
);
flayer.appliedFilters.setData(new And(listTagsFilters));
};
listFilterElements.forEach((inputElement, i) =>
inputElement[1].addCallback((changed) => {
const oldValue = flayer.appliedFilters.data
if(changed === undefined){
// Lets figure out which filter should be removed
// We know this inputElement corresponds with layer.filters[i]
// SO, if there is a value in 'oldValue' with this filter, we have to recalculated
if(!oldValue.some(f => f.filter === layer.filters[i])){
// The filter to remove is already gone, we can stop
return;
}
}else if(oldValue.some(f => f.filter === changed.filter && f.selected === changed.selected)){
// The changed value is already there
return;
}
const listTagsFilters = Utils.NoNull(
listFilterElements.map((input) => input[1].data)
);
listFilterElements.forEach((inputElement) =>
inputElement[1].addCallback((_) => update())
console.log(listTagsFilters, oldValue)
flayer.appliedFilters.setData(listTagsFilters);
})
);
flayer.appliedFilters.addCallbackAndRun(appliedFilters => {
if (appliedFilters === undefined || appliedFilters.and.length === 0) {
listFilterElements.forEach(filter => filter[1].setData(undefined))
return
for (let i = 0; i < layer.filters.length; i++){
const filter = layer.filters[i];
let foundMatch = undefined
for (const appliedFilter of appliedFilters) {
if(appliedFilter.filter === filter){
foundMatch = appliedFilter
break;
}
}
listFilterElements[i][1].setData(foundMatch)
}
})
return new Combine(listFilterElements.map(input => input[0].SetClass("mt-3")))
@ -128,7 +147,7 @@ export default class FilterView extends VariableUiElement {
}
private static createFilter(filterConfig: FilterConfig): [BaseUIElement, UIEventSource<TagsFilter>] {
private static createFilter(filterConfig: FilterConfig): [BaseUIElement, UIEventSource<{ filter: FilterConfig, selected: number }>] {
if (filterConfig.options.length === 1) {
let option = filterConfig.options[0];
@ -136,26 +155,42 @@ export default class FilterView extends VariableUiElement {
const iconUnselected = Svg.checkbox_empty_svg().SetClass("block mr-2");
const toggle = new Toggle(
new Combine([icon, option.question.Clone()]).SetClass("flex"),
new Combine([iconUnselected, option.question.Clone()]).SetClass("flex")
new Combine([icon, option.question.Clone().SetClass("block")]).SetClass("flex"),
new Combine([iconUnselected, option.question.Clone().SetClass("block")]).SetClass("flex")
)
.ToggleOnClick()
.SetClass("block m-1")
return [toggle, toggle.isEnabled.map(enabled => enabled ? option.osmTags : undefined, [], tags => tags !== undefined)]
const selected = {
filter: filterConfig,
selected: 0
}
return [toggle, toggle.isEnabled.map(enabled => enabled ? selected : undefined, [],
f => f?.filter === filterConfig && f?.selected === 0)
]
}
let options = filterConfig.options;
const values = options.map((f, i) => ({
filter: filterConfig, selected: i
}))
const radio = new RadioButton(
options.map(
(option) =>
new FixedInputElement(option.question.Clone(), option.osmTags)
(option, i) =>
new FixedInputElement(option.question.Clone().SetClass("block"), i)
),
{
dontStyle: true
}
);
return [radio, radio.GetValue()]
return [radio,
radio.GetValue().map(
i => values[i],
[],
selected => {
return selected?.selected
}
)]
}
}

View file

@ -1,7 +1,5 @@
import State from "../../State";
import ThemeIntroductionPanel from "./ThemeIntroductionPanel";
import * as personal from "../../assets/themes/personal/personal.json";
import PersonalLayersPanel from "./PersonalLayersPanel";
import Svg from "../../Svg";
import Translations from "../i18n/Translations";
import ShareScreen from "./ShareScreen";
@ -21,7 +19,7 @@ export default class FullWelcomePaneWithTabs extends ScrollableFullScreen {
constructor(isShown: UIEventSource<boolean>) {
const layoutToUse = State.state.layoutToUse.data;
const layoutToUse = State.state.layoutToUse;
super(
() => layoutToUse.title.Clone(),
() => FullWelcomePaneWithTabs.GenerateContents(layoutToUse, State.state.osmConnection.userDetails, isShown),
@ -32,9 +30,7 @@ export default class FullWelcomePaneWithTabs extends ScrollableFullScreen {
private static ConstructBaseTabs(layoutToUse: LayoutConfig, isShown: UIEventSource<boolean>): { header: string | BaseUIElement; content: BaseUIElement }[] {
let welcome: BaseUIElement = new ThemeIntroductionPanel(isShown);
if (layoutToUse.id === personal.id) {
welcome = new PersonalLayersPanel();
}
const tabs: { header: string | BaseUIElement, content: BaseUIElement }[] = [
{header: `<img src='${layoutToUse.icon}'>`, content: welcome},
{

View file

@ -31,14 +31,14 @@ export default class ImportButton extends Toggle {
const button = new SubtleButton(imageUrl, message)
button.onClick(() => {
button.onClick(async () => {
if (isImported.data) {
return
}
originalTags.data["_imported"] = "yes"
originalTags.ping() // will set isImported as per its definition
const newElementAction = new CreateNewNodeAction(newTags.data, lat, lon)
State.state.changes.applyAction(newElementAction)
await State.state.changes.applyAction(newElementAction)
State.state.selectedElement.setData(State.state.allElements.ContainingFeatures.get(
newElementAction.newElementId
))

View file

@ -11,8 +11,8 @@ import AllDownloads from "./AllDownloads";
import FilterView from "./FilterView";
import {UIEventSource} from "../../Logic/UIEventSource";
import FeaturePipeline from "../../Logic/FeatureSource/FeaturePipeline";
import {BBox} from "../../Logic/GeoOperations";
import Loc from "../../Models/Loc";
import {BBox} from "../../Logic/BBox";
export default class LeftControls extends Combine {

View file

@ -126,7 +126,7 @@ export default class MoreScreen extends Combine {
if (layout.hideFromOverview) {
return undefined;
}
if (layout.id === State.state.layoutToUse.data?.id) {
if (layout.id === State.state.layoutToUse?.id) {
return undefined;
}

View file

@ -1,120 +0,0 @@
import {AllKnownLayouts} from "../../Customizations/AllKnownLayouts";
import Svg from "../../Svg";
import State from "../../State";
import Combine from "../Base/Combine";
import Toggle from "../Input/Toggle";
import {SubtleButton} from "../Base/SubtleButton";
import Translations from "../i18n/Translations";
import BaseUIElement from "../BaseUIElement";
import {VariableUiElement} from "../Base/VariableUIElement";
import {UIEventSource} from "../../Logic/UIEventSource";
import LayerConfig from "../../Models/ThemeConfig/LayerConfig";
export default class PersonalLayersPanel extends VariableUiElement {
constructor() {
super(
State.state.installedThemes.map(installedThemes => {
const t = Translations.t.favourite;
// Lets get all the layers
const allThemes = AllKnownLayouts.layoutsList.concat(installedThemes.map(layout => layout.layout))
.filter(theme => !theme.hideFromOverview)
const allLayers = []
{
const seenLayers = new Set<string>()
for (const layers of allThemes.map(theme => theme.layers)) {
for (const layer of layers) {
if (seenLayers.has(layer.id)) {
continue
}
seenLayers.add(layer.id)
allLayers.push(layer)
}
}
}
// Time to create a panel based on them!
const panel: BaseUIElement = new Combine(allLayers.map(PersonalLayersPanel.CreateLayerToggle));
return new Toggle(
new Combine([
t.panelIntro.Clone(),
panel
]).SetClass("flex flex-col"),
new SubtleButton(
Svg.osm_logo_ui(),
t.loginNeeded.Clone().SetClass("text-center")
).onClick(() => State.state.osmConnection.AttemptLogin()),
State.state.osmConnection.isLoggedIn
)
})
)
}
/***
* Creates a toggle for the given layer, which'll update State.state.favouriteLayers right away
* @param layer
* @constructor
* @private
*/
private static CreateLayerToggle(layer: LayerConfig): Toggle {
let icon: BaseUIElement = new Combine([layer.GenerateLeafletStyle(
new UIEventSource<any>({id: "node/-1"}),
false
).icon.html]).SetClass("relative")
let iconUnset = new Combine([layer.GenerateLeafletStyle(
new UIEventSource<any>({id: "node/-1"}),
false
).icon.html]).SetClass("relative")
iconUnset.SetStyle("opacity:0.1")
let name = layer.name;
if (name === undefined) {
return undefined;
}
const content = new Combine([
Translations.WT(name).Clone().SetClass("font-bold"),
Translations.WT(layer.description)?.Clone()
]).SetClass("flex flex-col")
const contentUnselected = new Combine([
Translations.WT(name).Clone().SetClass("font-bold"),
Translations.WT(layer.description)?.Clone()
]).SetClass("flex flex-col line-through")
return new Toggle(
new SubtleButton(
icon,
content),
new SubtleButton(
iconUnset,
contentUnselected
),
State.state.favouriteLayers.map(favLayers => {
return favLayers.indexOf(layer.id) >= 0
}, [], (selected, current) => {
if (!selected && current.indexOf(layer.id) <= 0) {
// Not selected and not contained: nothing to change: we return current as is
return current;
}
if (selected && current.indexOf(layer.id) >= 0) {
// Selected and contained: this is fine!
return current;
}
const clone = [...current]
if (selected) {
clone.push(layer.id)
} else {
clone.splice(clone.indexOf(layer.id), 1)
}
return clone
})
).ToggleOnClick();
}
}

View file

@ -17,7 +17,7 @@ import LayerConfig from "../../Models/ThemeConfig/LayerConfig";
export default class ShareScreen extends Combine {
constructor(layout: LayoutConfig = undefined, layoutDefinition: string = undefined) {
layout = layout ?? State.state?.layoutToUse?.data;
layout = layout ?? State.state?.layoutToUse;
layoutDefinition = layoutDefinition ?? State.state?.layoutDefinition;
const tr = Translations.t.general.sharescreen;

View file

@ -19,8 +19,7 @@ import CreateNewNodeAction from "../../Logic/Osm/Actions/CreateNewNodeAction";
import {OsmObject, OsmWay} from "../../Logic/Osm/OsmObject";
import PresetConfig from "../../Models/ThemeConfig/PresetConfig";
import FilteredLayer from "../../Models/FilteredLayer";
import {And} from "../../Logic/Tags/And";
import {BBox} from "../../Logic/GeoOperations";
import {BBox} from "../../Logic/BBox";
/*
* The SimpleAddUI is a single panel, which can have multiple states:
@ -56,10 +55,9 @@ export default class SimpleAddUI extends Toggle {
const presetsOverview = SimpleAddUI.CreateAllPresetsPanel(selectedPreset)
function createNewPoint(tags: any[], location: { lat: number, lon: number }, snapOntoWay?: OsmWay) {
console.trace("Creating a new point")
async function createNewPoint(tags: any[], location: { lat: number, lon: number }, snapOntoWay?: OsmWay) {
const newElementAction = new CreateNewNodeAction(tags, location.lat, location.lon, {snapOnto: snapOntoWay})
State.state.changes.applyAction(newElementAction)
await State.state.changes.applyAction(newElementAction)
selectedPreset.setData(undefined)
isShown.setData(false)
State.state.selectedElement.setData(State.state.allElements.ContainingFeatures.get(
@ -224,14 +222,32 @@ export default class SimpleAddUI extends Toggle {
]
).SetClass("flex flex-col")
).onClick(() => {
preset.layerToAddTo.appliedFilters.setData(new And([]))
preset.layerToAddTo.appliedFilters.setData([])
cancel()
})
const disableFiltersOrConfirm = new Toggle(
openLayerOrConfirm,
disableFilter,
preset.layerToAddTo.appliedFilters.map(filters => filters === undefined || filters.normalize().and.length === 0)
preset.layerToAddTo.appliedFilters.map(filters => {
if(filters === undefined || filters.length === 0){
return true;
}
for (const filter of filters) {
if(filter.selected === 0 && filter.filter.options.length === 1){
return false;
}
if(filter.selected !== undefined){
const tags = filter.filter.options[filter.selected].osmTags
if(tags !== undefined && tags["and"]?.length !== 0){
// This actually doesn't filter anything at all
return false;
}
}
}
return true
})
)

View file

@ -2,21 +2,17 @@ import State from "../../State";
import Combine from "../Base/Combine";
import LanguagePicker from "../LanguagePicker";
import Translations from "../i18n/Translations";
import {VariableUiElement} from "../Base/VariableUIElement";
import Toggle from "../Input/Toggle";
import {SubtleButton} from "../Base/SubtleButton";
import Svg from "../../Svg";
import {UIEventSource} from "../../Logic/UIEventSource";
export default class ThemeIntroductionPanel extends VariableUiElement {
export default class ThemeIntroductionPanel extends Combine {
constructor(isShown: UIEventSource<boolean>) {
const layout = State.state.layoutToUse
const languagePicker =
new VariableUiElement(
State.state.layoutToUse.map(layout => LanguagePicker.CreateLanguagePicker(layout.language, Translations.t.general.pickLanguage.Clone()))
)
;
const languagePicker = LanguagePicker.CreateLanguagePicker(layout.language, Translations.t.general.pickLanguage.Clone())
const toTheMap = new SubtleButton(
undefined,
@ -53,8 +49,7 @@ export default class ThemeIntroductionPanel extends VariableUiElement {
State.state.featureSwitchUserbadge
)
super(State.state.layoutToUse.map(layout => new Combine([
super([
layout.description.Clone(),
"<br/><br/>",
toTheMap,
@ -63,7 +58,7 @@ export default class ThemeIntroductionPanel extends VariableUiElement {
"<br/>",
languagePicker,
...layout.CustomCodeSnippets()
])))
])
this.SetClass("link-underline")
}

View file

@ -47,7 +47,7 @@ export default class UserBadge extends Toggle {
});
const linkStyle = "flex items-baseline"
const languagePicker = (LanguagePicker.CreateLanguagePicker(State.state.layoutToUse.data.language) ?? new FixedUiElement(""))
const languagePicker = (LanguagePicker.CreateLanguagePicker(State.state.layoutToUse.language) ?? new FixedUiElement(""))
.SetStyle("width:min-content;");
let messageSpan =

View file

@ -5,7 +5,6 @@ import {SimpleMapScreenshoter} from "leaflet-simple-map-screenshoter";
import {UIEventSource} from "../Logic/UIEventSource";
import Minimap from "./Base/Minimap";
import Loc from "../Models/Loc";
import {BBox} from "../Logic/GeoOperations";
import BaseLayer from "../Models/BaseLayer";
import {FixedUiElement} from "./Base/FixedUiElement";
import Translations from "./i18n/Translations";
@ -14,6 +13,7 @@ import Constants from "../Models/Constants";
import LayoutConfig from "../Models/ThemeConfig/LayoutConfig";
import FeaturePipeline from "../Logic/FeatureSource/FeaturePipeline";
import ShowDataLayer from "./ShowDataLayer/ShowDataLayer";
import {BBox} from "../Logic/BBox";
/**
* Creates screenshoter to take png screenshot
* Creates jspdf and downloads it

View file

@ -1,30 +1,19 @@
import Combine from "../Base/Combine";
import Attribution from "./Attribution";
import Img from "../Base/Img";
import ImageAttributionSource from "../../Logic/ImageProviders/ImageAttributionSource";
import {ProvidedImage} from "../../Logic/ImageProviders/ImageProvider";
import BaseUIElement from "../BaseUIElement";
import {VariableUiElement} from "../Base/VariableUIElement";
import Loading from "../Base/Loading";
export class AttributedImage extends Combine {
constructor(urlSource: string, imgSource: ImageAttributionSource) {
const preparedUrl = imgSource.PrepareUrl(urlSource)
constructor(imageInfo: ProvidedImage) {
let img: BaseUIElement;
let attr: BaseUIElement
if (typeof preparedUrl === "string") {
img = new Img(urlSource);
attr = new Attribution(imgSource.GetAttributionFor(urlSource), imgSource.SourceIcon())
} else {
img = new VariableUiElement(preparedUrl.map(url => {
if(url === undefined){
return new Loading()
}
return new Img(url, false, {fallbackImage: './assets/svg/blocked.svg'});
}))
attr = new VariableUiElement(preparedUrl.map(_ => new Attribution(imgSource.GetAttributionFor(urlSource), imgSource.SourceIcon())))
}
img = new Img(imageInfo.url);
attr = new Attribution(imageInfo.provider.GetAttributionFor(imageInfo.url),
imageInfo.provider.SourceIcon(),
)
super([img, attr]);

View file

@ -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/ImageProviders/Wikimedia";
import {LicenseInfo} from "../../Logic/ImageProviders/LicenseInfo";
export default class Attribution extends VariableUiElement {
@ -13,17 +13,16 @@ export default class Attribution extends VariableUiElement {
}
super(
license.map((license: LicenseInfo) => {
if (license?.artist === undefined) {
return undefined;
if(license === undefined){
return undefined
}
return new Combine([
icon?.SetClass("block left").SetStyle("height: 2em; width: 2em; padding-right: 0.5em;"),
new Combine([
Translations.W(license.artist).SetClass("block font-bold"),
Translations.W((license.license ?? "") === "" ? "CC0" : (license.license ?? ""))
Translations.W(license?.artist ?? ".").SetClass("block font-bold"),
Translations.W((license?.license ?? "") === "" ? "CC0" : (license?.license ?? ""))
]).SetClass("flex flex-col")
]).SetClass("flex flex-row bg-black text-white text-sm absolute bottom-0 left-0 p-0.5 pl-5 pr-3 rounded-lg")

View file

@ -15,15 +15,15 @@ export default class DeleteImage extends Toggle {
const isDeletedBadge = Translations.t.image.isDeleted.Clone()
.SetClass("rounded-full p-1")
.SetStyle("color:white;background:#ff8c8c")
.onClick(() => {
State.state?.changes?.applyAction(new ChangeTagAction(tags.data.id, new Tag(key, oldValue), tags.data))
.onClick(async() => {
await State.state?.changes?.applyAction(new ChangeTagAction(tags.data.id, new Tag(key, oldValue), tags.data))
});
const deleteButton = Translations.t.image.doDelete.Clone()
.SetClass("block w-full pl-4 pr-4")
.SetStyle("color:white;background:#ff8c8c; border-top-left-radius:30rem; border-top-right-radius: 30rem;")
.onClick(() => {
State.state?.changes?.applyAction(
.onClick( async() => {
await State.state?.changes?.applyAction(
new ChangeTagAction(tags.data.id, new Tag(key, ""), tags.data)
)
});
@ -48,7 +48,7 @@ export default class DeleteImage extends Toggle {
tags.map(tags => (tags[key] ?? "") !== "")
),
undefined /*Login (and thus editing) is disabled*/,
State.state?.featureSwitchUserbadge ?? new UIEventSource<boolean>(true)
State.state.osmConnection.isLoggedIn
)
this.SetClass("cursor-pointer")
}

View file

@ -4,29 +4,35 @@ import Combine from "../Base/Combine";
import DeleteImage from "./DeleteImage";
import {AttributedImage} from "./AttributedImage";
import BaseUIElement from "../BaseUIElement";
import Img from "../Base/Img";
import Toggle from "../Input/Toggle";
import {Wikimedia} from "../../Logic/ImageProviders/Wikimedia";
import {Imgur} from "../../Logic/ImageProviders/Imgur";
import {Mapillary} from "../../Logic/ImageProviders/Mapillary";
import ImageProvider from "../../Logic/ImageProviders/ImageProvider";
export class ImageCarousel extends Toggle {
constructor(images: UIEventSource<{ key: string, url: string }[]>, tags: UIEventSource<any>) {
const uiElements = images.map((imageURLS: { key: string, url: string }[]) => {
constructor(images: UIEventSource<{ key: string, url: string, provider: ImageProvider }[]>, tags: UIEventSource<any>) {
const uiElements = images.map((imageURLS: { key: string, url: string, provider: ImageProvider }[]) => {
const uiElements: BaseUIElement[] = [];
for (const url of imageURLS) {
let image = ImageCarousel.CreateImageElement(url.url)
if (url.key !== undefined) {
image = new Combine([
image,
new DeleteImage(url.key, tags).SetClass("delete-image-marker absolute top-0 left-0 pl-3")
]).SetClass("relative");
try {
let image = new AttributedImage(url)
if (url.key !== undefined) {
image = new Combine([
image,
new DeleteImage(url.key, tags).SetClass("delete-image-marker absolute top-0 left-0 pl-3")
]).SetClass("relative");
}
image
.SetClass("w-full block")
.SetStyle("min-width: 50px; background: grey;")
uiElements.push(image);
} catch (e) {
console.error("Could not generate image element for", url.url, "due to", e)
}
image
.SetClass("w-full block")
.SetStyle("min-width: 50px; background: grey;")
uiElements.push(image);
}
return uiElements;
});
@ -38,33 +44,4 @@ export class ImageCarousel extends Toggle {
)
this.SetClass("block w-full");
}
/***
* Creates either a 'simpleimage' or a 'wikimediaimage' based on the string
* @param url
* @constructor
*/
private static CreateImageElement(url: string): BaseUIElement {
// @ts-ignore
let attrSource: ImageAttributionSource = undefined;
if (url.startsWith("File:")) {
attrSource = Wikimedia.singleton
} else if (url.toLowerCase().startsWith("https://commons.wikimedia.org/wiki/")) {
attrSource = Wikimedia.singleton;
} else if (url.toLowerCase().startsWith("https://i.imgur.com/")) {
attrSource = Imgur.singleton
} else if (url.toLowerCase().startsWith("https://www.mapillary.com/map/im/")) {
attrSource = Mapillary.singleton
} else {
return new Img(url);
}
try {
return new AttributedImage(url, attrSource)
} catch (e) {
console.error("Could not create an image: ", e)
return undefined;
}
}
}

View file

@ -29,10 +29,10 @@ export class ImageUploadFlow extends Toggle {
key = imagePrefix + ":" + freeIndex;
}
console.log("Adding image:" + key, url);
State.state.changes
Promise.resolve(State.state.changes
.applyAction(new ChangeTagAction(
tags.id, new Tag(key, url), tagsSource.data
))
)))
})
@ -55,7 +55,7 @@ export class ImageUploadFlow extends Toggle {
const tags = tagsSource.data;
const layout = State.state?.layoutToUse?.data
const layout = State.state?.layoutToUse
let matchingLayer: LayerConfig = undefined
for (const layer of layout?.layers ?? []) {
if (layer.source.osmTags.matchesProperties(tags)) {

View file

@ -7,12 +7,13 @@ import Combine from "../Base/Combine";
import Svg from "../../Svg";
import State from "../../State";
import AvailableBaseLayers from "../../Logic/Actors/AvailableBaseLayers";
import {BBox, GeoOperations} from "../../Logic/GeoOperations";
import ShowDataLayer from "../ShowDataLayer/ShowDataLayer";
import * as L from "leaflet";
import {GeoOperations} from "../../Logic/GeoOperations";
import ShowDataMultiLayer from "../ShowDataLayer/ShowDataMultiLayer";
import StaticFeatureSource from "../../Logic/FeatureSource/Sources/StaticFeatureSource";
import LayerConfig from "../../Models/ThemeConfig/LayerConfig";
import {BBox} from "../../Logic/BBox";
import {FixedUiElement} from "../Base/FixedUiElement";
import ShowDataLayer from "../ShowDataLayer/ShowDataLayer";
export default class LocationInput extends InputElement<Loc> {
@ -39,7 +40,7 @@ export default class LocationInput extends InputElement<Loc> {
private readonly _maxSnapDistance: number
private readonly _snappedPointTags: any;
private readonly _bounds: UIEventSource<BBox>;
public readonly _matching_layer: UIEventSource<LayerConfig>;
public readonly _matching_layer: LayerConfig;
constructor(options: {
mapBackground?: UIEventSource<BaseLayer>,
@ -63,18 +64,17 @@ export default class LocationInput extends InputElement<Loc> {
if (self._snappedPointTags !== undefined) {
this._matching_layer = State.state.layoutToUse.map(layout => {
const layout = State.state.layoutToUse
for (const layer of layout.layers) {
if (layer.source.osmTags.matchesProperties(self._snappedPointTags)) {
return layer
}
let matchingLayer = LocationInput.matchLayer
for (const layer of layout.layers) {
if (layer.source.osmTags.matchesProperties(self._snappedPointTags)) {
matchingLayer = layer
}
console.error("No matching layer found for tags ", self._snappedPointTags)
return LocationInput.matchLayer
})
}
this._matching_layer = matchingLayer;
} else {
this._matching_layer = new UIEventSource<LayerConfig>(LocationInput.matchLayer)
this._matching_layer = LocationInput.matchLayer
}
this._snappedPoint = options.centerLocation.map(loc => {
@ -125,7 +125,7 @@ export default class LocationInput extends InputElement<Loc> {
})
}
this.mapBackground = options.mapBackground ?? State.state.backgroundLayer ?? new UIEventSource(AvailableBaseLayers.osmCarto)
this.mapBackground = options.mapBackground ?? State.state?.backgroundLayer ?? new UIEventSource(AvailableBaseLayers.osmCarto)
this.SetClass("block h-full")
}
@ -144,7 +144,7 @@ export default class LocationInput extends InputElement<Loc> {
{
location: this._centerLocation,
background: this.mapBackground,
attribution: this.mapBackground !== State.state.backgroundLayer,
attribution: this.mapBackground !== State.state?.backgroundLayer,
lastClickLocation: clickLocation,
bounds: this._bounds
}
@ -176,11 +176,10 @@ export default class LocationInput extends InputElement<Loc> {
enablePopups: false,
zoomToFeatures: false,
leafletMap: map.leafletMap,
layerToShow: this._matching_layer.data
layerToShow: this._matching_layer
})
}
this.mapBackground.map(layer => {
const leaflet = map.leafletMap.data
if (leaflet === undefined || layer === undefined) {
@ -192,20 +191,31 @@ export default class LocationInput extends InputElement<Loc> {
leaflet.setZoom(layer.max_zoom - 1)
}, [map.leafletMap])
const animatedHand = Svg.hand_ui()
.SetStyle("width: 2rem; height: unset;")
.SetClass("hand-drag-animation block pointer-events-none")
return new Combine([
new Combine([
Svg.move_arrows_ui()
.SetClass("block relative pointer-events-none")
.SetStyle("left: -2.5rem; top: -2.5rem; width: 5rem; height: 5rem")
]).SetClass("block w-0 h-0 z-10 relative")
.SetStyle("background: rgba(255, 128, 128, 0.21); left: 50%; top: 50%"),
]).SetClass("block w-0 h-0 z-10 relative")
.SetStyle("background: rgba(255, 128, 128, 0.21); left: 50%; top: 50%; opacity: 0.5"),
new Combine([
animatedHand])
.SetClass("block w-0 h-0 z-10 relative")
.SetStyle("left: calc(50% + 3rem); top: calc(50% + 2rem); opacity: 0.7"),
map
.SetClass("z-0 relative block w-full h-full bg-gray-100")
]).ConstructElement();
} catch (e) {
console.error("Could not generate LocationInputElement:", e)
return undefined;
return new FixedUiElement("Constructing a locationInput failed due to" + e).SetClass("alert").ConstructElement();
}
}

View file

@ -51,14 +51,14 @@ export default class DeleteWizard extends Toggle {
const confirm = new UIEventSource<boolean>(false)
function softDelete(reason: string, tagsToApply: { k: string, v: string }[]) {
async function softDelete(reason: string, tagsToApply: { k: string, v: string }[]) {
if (reason !== undefined) {
tagsToApply.splice(0, 0, {
k: "fixme",
v: `A mapcomplete user marked this feature to be deleted (${reason})`
})
}
(State.state?.changes ?? new Changes())
await (State.state?.changes ?? new Changes())
.applyAction(new ChangeTagAction(
id, new And(tagsToApply.map(kv => new Tag(kv.k, kv.v))), tagsSource.data
))

View file

@ -31,8 +31,10 @@ export default class EditableTagRendering extends Toggle {
const answerWithEditButton = new Combine([answer,
new Toggle(editButton, undefined, State.state.osmConnection.isLoggedIn)])
.SetClass("flex justify-between w-full")
new Toggle(editButton,
undefined,
State.state.osmConnection.isLoggedIn)
]).SetClass("flex justify-between w-full")
const cancelbutton =

View file

@ -5,7 +5,7 @@ import {SubtleButton} from "../Base/SubtleButton";
import Minimap from "../Base/Minimap";
import State from "../../State";
import ShowDataLayer from "../ShowDataLayer/ShowDataLayer";
import {BBox, GeoOperations} from "../../Logic/GeoOperations";
import {GeoOperations} from "../../Logic/GeoOperations";
import {LeafletMouseEvent} from "leaflet";
import Combine from "../Base/Combine";
import {Button} from "../Base/Button";
@ -15,6 +15,7 @@ import Title from "../Base/Title";
import StaticFeatureSource from "../../Logic/FeatureSource/Sources/StaticFeatureSource";
import ShowDataMultiLayer from "../ShowDataLayer/ShowDataMultiLayer";
import LayerConfig from "../../Models/ThemeConfig/LayerConfig";
import {BBox} from "../../Logic/BBox";
export default class SplitRoadWizard extends Toggle {
private static splitLayerStyling = new LayerConfig({
@ -71,7 +72,7 @@ export default class SplitRoadWizard extends Toggle {
})
new ShowDataMultiLayer({
features: new StaticFeatureSource([roadElement]),
features: new StaticFeatureSource([roadElement], false),
layers: State.state.filteredLayers,
leafletMap: miniMap.leafletMap,
enablePopups: false,

View file

@ -58,10 +58,10 @@ export default class TagRenderingQuestion extends Combine {
console.error("MultiAnswer failed - probably not a single option was possible", configuration)
throw "MultiAnswer failed - probably not a single option was possible"
}
const save = () => {
const save = async () => {
const selection = inputElement.GetValue().data;
if (selection) {
(State.state?.changes ?? new Changes())
await (State.state?.changes ?? new Changes())
.applyAction(new ChangeTagAction(
tags.data.id, selection, tags.data
))

View file

@ -17,6 +17,7 @@ export default class ShowDataLayer {
// Used to generate a fresh ID when needed
private _cleanCount = 0;
private geoLayer = undefined;
private isDirty = false;
/**
* If the selected element triggers, this is used to lookup the correct layer and to open the popup
@ -37,41 +38,72 @@ export default class ShowDataLayer {
this._layerToShow = options.layerToShow;
const self = this;
options.leafletMap.addCallbackAndRunD(_ => {
self.update(options)
}
);
features.addCallback(_ => self.update(options));
options.leafletMap.addCallback(_ => self.update(options));
this.update(options);
options.doShowLayer?.addCallbackAndRun(doShow => {
const mp = options.leafletMap.data;
if (mp == undefined) {
return;
}
if (doShow) {
if (self.isDirty) {
self.update(options)
} else {
mp.addLayer(this.geoLayer)
}
} else {
if(this.geoLayer !== undefined){
mp.removeLayer(this.geoLayer)
}
}
})
State.state.selectedElement.addCallbackAndRunD(selected => {
if (self._leafletMap.data === undefined) {
return;
}
const v = self.leafletLayersPerId.get(selected.properties.id)
if(v === undefined){return;}
if (v === undefined) {
return;
}
const leafletLayer = v.leafletlayer
const feature = v.feature
if (leafletLayer.getPopup().isOpen()) {
return;
}
if (selected.properties.id === feature.properties.id) {
// A small sanity check to prevent infinite loops:
if (selected.geometry.type === feature.geometry.type // If a feature is rendered both as way and as point, opening one popup might trigger the other to open, which might trigger the one to open again
&& feature.id === feature.properties.id // the feature might have as id 'node/-1' and as 'feature.properties.id' = 'the newly assigned id'. That is no good too
) {
leafletLayer.openPopup()
}
if (feature.id !== feature.properties.id) {
console.trace("Not opening the popup for", feature)
}
if (selected.properties.id !== feature.properties.id) {
return;
}
if (feature.id !== feature.properties.id) {
// Probably a feature which has renamed
console.trace("Not opening the popup for", feature)
return;
}
if (selected.geometry.type === feature.geometry.type // If a feature is rendered both as way and as point, opening one popup might trigger the other to open, which might trigger the one to open again
&& feature.id === feature.properties.id // the feature might have as id 'node/-1' and as 'feature.properties.id' = 'the newly assigned id'. That is no good too
) {
console.log("Opening popup of feature", feature)
leafletLayer.openPopup()
}
})
}
private update(options) {
private update(options: ShowDataLayerOptions) {
if (this._features.data === undefined) {
return;
}
this.isDirty = true;
if (options?.doShowLayer?.data === false) {
return;
}
const mp = options.leafletMap.data;
if (mp === undefined) {
@ -83,21 +115,30 @@ export default class ShowDataLayer {
mp.removeLayer(this.geoLayer);
}
this.geoLayer= this.CreateGeojsonLayer()
const self = this;
const data = {
type: "FeatureCollection",
features: []
}
// @ts-ignore
this.geoLayer = L.geoJSON(data, {
style: feature => self.createStyleFor(feature),
pointToLayer: (feature, latLng) => self.pointToLayer(feature, latLng),
onEachFeature: (feature, leafletLayer) => self.postProcessFeature(feature, leafletLayer)
});
const allFeats = this._features.data;
for (const feat of allFeats) {
if (feat === undefined) {
continue
}
try{
try {
this.geoLayer.addData(feat);
}catch(e){
} catch (e) {
console.error("Could not add ", feat, "to the geojson layer in leaflet")
}
}
mp.addLayer(this.geoLayer)
if (options.zoomToFeatures ?? false) {
try {
mp.fitBounds(this.geoLayer.getBounds(), {animate: false})
@ -105,6 +146,11 @@ export default class ShowDataLayer {
console.error(e)
}
}
if (options.doShowLayer?.data ?? true) {
mp.addLayer(this.geoLayer)
}
this.isDirty = false;
}
@ -125,7 +171,10 @@ export default class ShowDataLayer {
return;
}
const tagSource = feature.properties.id === undefined ? new UIEventSource<any>(feature.properties) : State.state.allElements.getEventSourceById(feature.properties.id)
let tagSource = State.state.allElements.getEventSourceById(feature.properties.id)
if(tagSource === undefined){
tagSource = new UIEventSource<any>(feature.properties)
}
const clickable = !(layer.title === undefined && (layer.tagRenderings ?? []).length === 0)
const style = layer.GenerateLeafletStyle(tagSource, clickable);
const baseElement = style.icon.html;
@ -193,22 +242,9 @@ export default class ShowDataLayer {
infobox.Activate();
});
// Add the feature to the index to open the popup when needed
this.leafletLayersPerId.set(feature.properties.id, {feature: feature, leafletlayer: leafletLayer})
}
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),
onEachFeature: (feature, leafletLayer) => self.postProcessFeature(feature, leafletLayer)
});
}

View file

@ -6,4 +6,5 @@ export interface ShowDataLayerOptions {
leafletMap: UIEventSource<L.Map>,
enablePopups?: true | boolean,
zoomToFeatures?: false | boolean,
doShowLayer?: UIEventSource<boolean>
}

View file

@ -0,0 +1,61 @@
import FeatureSource, {Tiled} from "../../Logic/FeatureSource/FeatureSource";
import {UIEventSource} from "../../Logic/UIEventSource";
import LayerConfig from "../../Models/ThemeConfig/LayerConfig";
import ShowDataLayer from "./ShowDataLayer";
import StaticFeatureSource from "../../Logic/FeatureSource/Sources/StaticFeatureSource";
import {GeoOperations} from "../../Logic/GeoOperations";
import {Tiles} from "../../Models/TileRange";
import * as clusterstyle from "../../assets/layers/cluster_style/cluster_style.json"
export default class ShowTileInfo {
public static readonly styling = new LayerConfig(
clusterstyle, "tileinfo", true)
constructor(options: {
source: FeatureSource & Tiled, leafletMap: UIEventSource<any>, layer?: LayerConfig,
doShowLayer?: UIEventSource<boolean>
}) {
const source = options.source
const metaFeature: UIEventSource<any[]> =
source.features.map(features => {
const bbox = source.bbox
const [z, x, y] = Tiles.tile_from_index(source.tileIndex)
const box = {
"type": "Feature",
"properties": {
"z": z,
"x": x,
"y": y,
"tileIndex": source.tileIndex,
"source": source.name,
"count": features.length,
tileId: source.name + "/" + source.tileIndex
},
"geometry": {
"type": "Polygon",
"coordinates": [
[
[bbox.minLon, bbox.minLat],
[bbox.minLon, bbox.maxLat],
[bbox.maxLon, bbox.maxLat],
[bbox.maxLon, bbox.minLat],
[bbox.minLon, bbox.minLat]
]
]
}
}
const center = GeoOperations.centerpoint(box)
return [box, center]
})
new ShowDataLayer({
layerToShow: ShowTileInfo.styling,
features: new StaticFeatureSource(metaFeature, false),
leafletMap: options.leafletMap,
doShowLayer: options.doShowLayer
})
}
}

View file

@ -0,0 +1,217 @@
import FeatureSource, {FeatureSourceForLayer, Tiled} from "../../Logic/FeatureSource/FeatureSource";
import LayerConfig from "../../Models/ThemeConfig/LayerConfig";
import {UIEventSource} from "../../Logic/UIEventSource";
import {Tiles} from "../../Models/TileRange";
import StaticFeatureSource from "../../Logic/FeatureSource/Sources/StaticFeatureSource";
import {BBox} from "../../Logic/BBox";
export class TileHierarchyAggregator implements FeatureSource {
private _parent: TileHierarchyAggregator;
private _root: TileHierarchyAggregator;
private _z: number;
private _x: number;
private _y: number;
private _tileIndex: number
private _counter: SingleTileCounter
private _subtiles: [TileHierarchyAggregator, TileHierarchyAggregator, TileHierarchyAggregator, TileHierarchyAggregator] = [undefined, undefined, undefined, undefined]
public totalValue: number = 0
private static readonly empty = []
public readonly features = new UIEventSource<{ feature: any, freshness: Date }[]>(TileHierarchyAggregator.empty)
public readonly name;
private readonly featuresStatic = []
private readonly featureProperties: { count: string, tileId: string, id: string };
private constructor(parent: TileHierarchyAggregator, z: number, x: number, y: number) {
this._parent = parent;
this._root = parent?._root ?? this
this._z = z;
this._x = x;
this._y = y;
this._tileIndex = Tiles.tile_index(z, x, y)
this.name = "Count(" + this._tileIndex + ")"
const totals = {
id: ""+this._tileIndex,
tileId: ""+this._tileIndex,
count: ""+0
}
this.featureProperties = totals
const now = new Date()
const feature = {
"type": "Feature",
"properties": totals,
"geometry": {
"type": "Point",
"coordinates": Tiles.centerPointOf(z, x, y)
}
}
this.featuresStatic.push({feature: feature, freshness: now})
const bbox = BBox.fromTile(z, x, y)
const box = {
"type": "Feature",
"properties": totals,
"geometry": {
"type": "Polygon",
"coordinates": [
[
[bbox.minLon, bbox.minLat],
[bbox.minLon, bbox.maxLat],
[bbox.maxLon, bbox.maxLat],
[bbox.maxLon, bbox.minLat],
[bbox.minLon, bbox.minLat]
]
]
}
}
this.featuresStatic.push({feature: box, freshness: now})
}
public getTile(tileIndex): TileHierarchyAggregator {
if (tileIndex === this._tileIndex) {
return this;
}
let [tileZ, tileX, tileY] = Tiles.tile_from_index(tileIndex)
while (tileZ - 1 > this._z) {
tileX = Math.floor(tileX / 2)
tileY = Math.floor(tileY / 2)
tileZ--
}
const xDiff = tileX - (2 * this._x)
const yDiff = tileY - (2 * this._y)
const subtileIndex = yDiff * 2 + xDiff;
return this._subtiles[subtileIndex]?.getTile(tileIndex)
}
private update() {
const newMap = new Map<string, number>()
let total = 0
this?._counter?.countsPerLayer?.data?.forEach((count, layerId) => {
newMap.set(layerId, count)
total += count
})
for (const tile of this._subtiles) {
if (tile === undefined) {
continue;
}
total += tile.totalValue
}
this.totalValue = total
this._parent?.update()
if (total === 0) {
this.features.setData(TileHierarchyAggregator.empty)
} else {
this.featureProperties.count = "" + total;
this.features.data = this.featuresStatic
this.features.ping()
}
}
public addTile(source: FeatureSourceForLayer & Tiled) {
const self = this;
if (source.tileIndex === this._tileIndex) {
if (this._counter === undefined) {
this._counter = new SingleTileCounter(this._tileIndex)
this._counter.countsPerLayer.addCallbackAndRun(_ => self.update())
}
this._counter.addTileCount(source)
} else {
// We have to give it to one of the subtiles
let [tileZ, tileX, tileY] = Tiles.tile_from_index(source.tileIndex)
while (tileZ - 1 > this._z) {
tileX = Math.floor(tileX / 2)
tileY = Math.floor(tileY / 2)
tileZ--
}
const xDiff = tileX - (2 * this._x)
const yDiff = tileY - (2 * this._y)
const subtileIndex = yDiff * 2 + xDiff;
if (this._subtiles[subtileIndex] === undefined) {
this._subtiles[subtileIndex] = new TileHierarchyAggregator(this, tileZ, tileX, tileY)
}
this._subtiles[subtileIndex].addTile(source)
}
}
public static createHierarchy() {
return new TileHierarchyAggregator(undefined, 0, 0, 0)
}
private visitSubTiles(f : (aggr: TileHierarchyAggregator) => boolean){
const visitFurther = f(this)
if(visitFurther){
this._subtiles.forEach(tile => tile?.visitSubTiles(f))
}
}
getCountsForZoom(locationControl: UIEventSource<{ zoom : number }>, cutoff: number = 0) : FeatureSource{
const self = this
return new StaticFeatureSource(
locationControl.map(loc => {
const features = []
const targetZoom = loc.zoom
self.visitSubTiles(aggr => {
if(aggr.totalValue < cutoff) {
return false
}
if(aggr._z === targetZoom){
features.push(...aggr.features.data)
return false
}
return aggr._z <= targetZoom;
})
return features
})
, true);
}
}
/**
* Keeps track of a single tile
*/
class SingleTileCounter implements Tiled {
public readonly bbox: BBox;
public readonly tileIndex: number;
public readonly countsPerLayer: UIEventSource<Map<string, number>> = new UIEventSource<Map<string, number>>(new Map<string, number>())
private readonly registeredLayers: Map<string, LayerConfig> = new Map<string, LayerConfig>();
public readonly z: number
public readonly x: number
public readonly y: number
constructor(tileIndex: number) {
this.tileIndex = tileIndex
this.bbox = BBox.fromTileIndex(tileIndex)
const [z, x, y] = Tiles.tile_from_index(tileIndex)
this.z = z;
this.x = x;
this.y = y
}
public addTileCount(source: FeatureSourceForLayer) {
const layer = source.layer.layerDef
this.registeredLayers.set(layer.id, layer)
const self = this
source.features.map(f => {
const isDisplayed = source.layer.isDisplayed.data
self.countsPerLayer.data.set(layer.id, isDisplayed ? f.length : 0)
self.countsPerLayer.ping()
}, [source.layer.isDisplayed])
}
}

View file

@ -13,20 +13,19 @@ import Translations from "./i18n/Translations";
import ReviewForm from "./Reviews/ReviewForm";
import OpeningHoursVisualization from "./OpeningHours/OpeningHoursVisualization";
import State from "../State";
import {ImageSearcher} from "../Logic/Actors/ImageSearcher";
import BaseUIElement from "./BaseUIElement";
import Title from "./Base/Title";
import Table from "./Base/Table";
import Histogram from "./BigComponents/Histogram";
import Loc from "../Models/Loc";
import {Utils} from "../Utils";
import BaseLayer from "../Models/BaseLayer";
import LayerConfig from "../Models/ThemeConfig/LayerConfig";
import ImportButton from "./BigComponents/ImportButton";
import {Tag} from "../Logic/Tags/Tag";
import StaticFeatureSource from "../Logic/FeatureSource/Sources/StaticFeatureSource";
import ShowDataMultiLayer from "./ShowDataLayer/ShowDataMultiLayer";
import Minimap from "./Base/Minimap";
import AllImageProviders from "../Logic/ImageProviders/AllImageProviders";
export interface SpecialVisualization {
funcName: string,
@ -67,18 +66,10 @@ export default class SpecialVisualizations {
name: "image key/prefix",
defaultValue: "image",
doc: "The keys given to the images, e.g. if <span class='literal-code'>image</span> is given, the first picture URL will be added as <span class='literal-code'>image</span>, the second as <span class='literal-code'>image:0</span>, the third as <span class='literal-code'>image:1</span>, etc... "
},
{
name: "smart search",
defaultValue: "true",
doc: "Also include images given via 'Wikidata', 'wikimedia_commons' and 'mapillary"
}],
}],
constr: (state: State, tags, args) => {
const imagePrefix = args[0];
const loadSpecial = args[1].toLowerCase() === "true";
const searcher: UIEventSource<{ key: string, url: string }[]> = ImageSearcher.construct(tags, imagePrefix, loadSpecial);
return new ImageCarousel(searcher, tags);
return new ImageCarousel(AllImageProviders.LoadImagesFor(tags, imagePrefix), tags);
}
},
{
@ -316,10 +307,10 @@ export default class SpecialVisualizations {
const generateShareData = () => {
const title = state?.layoutToUse?.data?.title?.txt ?? "MapComplete";
const title = state?.layoutToUse?.title?.txt ?? "MapComplete";
let matchingLayer: LayerConfig = undefined;
for (const layer of (state?.layoutToUse?.data?.layers ?? [])) {
for (const layer of (state?.layoutToUse?.layers ?? [])) {
if (layer.source.osmTags.matchesProperties(tagSource?.data)) {
matchingLayer = layer
}
@ -337,7 +328,7 @@ export default class SpecialVisualizations {
return {
title: name,
url: url,
text: state?.layoutToUse?.data?.shortDescription?.txt ?? "MapComplete"
text: state?.layoutToUse?.shortDescription?.txt ?? "MapComplete"
}
}
@ -363,15 +354,14 @@ export default class SpecialVisualizations {
if (value === undefined) {
return undefined
}
const allUnits = [].concat(...state.layoutToUse.data.layers.map(lyr => lyr.units))
const allUnits = [].concat(...state.layoutToUse.layers.map(lyr => lyr.units))
const unit = allUnits.filter(unit => unit.isApplicableToKey(key))[0]
if (unit === undefined) {
return value;
}
return unit.asHumanLongValue(value);
},
[state.layoutToUse])
})
)
}
},
@ -410,7 +400,7 @@ There are also some technicalities in your theme to keep in mind:
A reference number to the original dataset is an excellen way to do this
`,
constr: (state, tagSource, args) => {
if (!state.layoutToUse.data.official && !state.featureSwitchIsTesting.data) {
if (!state.layoutToUse.official && !state.featureSwitchIsTesting.data) {
return new Combine([new FixedUiElement("The import button is disabled for unofficial themes to prevent accidents.").SetClass("alert"),
new FixedUiElement("To test, add 'test=true' to the URL. The changeset will be printed in the console. Please open a PR to officialize this theme to actually enable the import button.")])
}