forked from MapComplete/MapComplete
Add level selector and global filters
This commit is contained in:
parent
5504d49d59
commit
7fd7a3722e
19 changed files with 401 additions and 253 deletions
|
@ -1,8 +1,7 @@
|
|||
import { FeatureSource, FeatureSourceForLayer } from "./FeatureSource"
|
||||
import { FeatureSource, IndexedFeatureSource } from "./FeatureSource"
|
||||
import FilteredLayer from "../../Models/FilteredLayer"
|
||||
import SimpleFeatureSource from "./Sources/SimpleFeatureSource"
|
||||
import { Feature } from "geojson"
|
||||
import { Utils } from "../../Utils"
|
||||
import { UIEventSource } from "../UIEventSource"
|
||||
|
||||
/**
|
||||
|
@ -10,9 +9,7 @@ import { UIEventSource } from "../UIEventSource"
|
|||
* If this is the case, multiple objects with a different _matching_layer_id are generated.
|
||||
* In any case, this featureSource marks the objects with _matching_layer_id
|
||||
*/
|
||||
export default class PerLayerFeatureSourceSplitter<
|
||||
T extends FeatureSourceForLayer = SimpleFeatureSource
|
||||
> {
|
||||
export default class PerLayerFeatureSourceSplitter<T extends FeatureSource = FeatureSource> {
|
||||
public readonly perLayer: ReadonlyMap<string, T>
|
||||
constructor(
|
||||
layers: FilteredLayer[],
|
||||
|
@ -23,6 +20,11 @@ export default class PerLayerFeatureSourceSplitter<
|
|||
}
|
||||
) {
|
||||
const knownLayers = new Map<string, T>()
|
||||
/**
|
||||
* Keeps track of the ids that are included per layer.
|
||||
* Used to know if the downstream feature source needs to be pinged
|
||||
*/
|
||||
let layerIndexes: ReadonlySet<string>[] = layers.map((_) => new Set<string>())
|
||||
this.perLayer = knownLayers
|
||||
const layerSources = new Map<string, UIEventSource<Feature[]>>()
|
||||
const constructStore =
|
||||
|
@ -41,6 +43,12 @@ export default class PerLayerFeatureSourceSplitter<
|
|||
// We try to figure out (for each feature) in which feature store it should be saved.
|
||||
|
||||
const featuresPerLayer = new Map<string, Feature[]>()
|
||||
/**
|
||||
* Indexed on layer-position
|
||||
* Will be true if a new id pops up
|
||||
*/
|
||||
const hasChanged: boolean[] = layers.map((_) => false)
|
||||
const newIndices: Set<string>[] = layers.map((_) => new Set<string>())
|
||||
const noLayerFound: Feature[] = []
|
||||
|
||||
for (const layer of layers) {
|
||||
|
@ -49,9 +57,14 @@ export default class PerLayerFeatureSourceSplitter<
|
|||
|
||||
for (const f of features) {
|
||||
let foundALayer = false
|
||||
for (const layer of layers) {
|
||||
for (let i = 0; i < layers.length; i++) {
|
||||
const layer = layers[i]
|
||||
if (layer.layerDef.source.osmTags.matchesProperties(f.properties)) {
|
||||
const id = f.properties.id
|
||||
// We have found our matching layer!
|
||||
const previousIndex = layerIndexes[i]
|
||||
hasChanged[i] = hasChanged[i] || !previousIndex.has(id)
|
||||
newIndices[i].add(id)
|
||||
featuresPerLayer.get(layer.layerDef.id).push(f)
|
||||
foundALayer = true
|
||||
if (!layer.layerDef.passAllFeatures) {
|
||||
|
@ -67,7 +80,8 @@ export default class PerLayerFeatureSourceSplitter<
|
|||
|
||||
// At this point, we have our features per layer as a list
|
||||
// We assign them to the correct featureSources
|
||||
for (const layer of layers) {
|
||||
for (let i = 0; i < layers.length; i++) {
|
||||
const layer = layers[i]
|
||||
const id = layer.layerDef.id
|
||||
const features = featuresPerLayer.get(id)
|
||||
if (features === undefined) {
|
||||
|
@ -75,14 +89,17 @@ export default class PerLayerFeatureSourceSplitter<
|
|||
continue
|
||||
}
|
||||
|
||||
const src = layerSources.get(id)
|
||||
|
||||
if (Utils.sameList(src.data, features)) {
|
||||
if (!hasChanged[i] && layerIndexes[i].size === newIndices[i].size) {
|
||||
// No new id has been added and the sizes are the same (thus: nothing has been removed as well)
|
||||
// We can safely assume that no changes were made
|
||||
continue
|
||||
}
|
||||
src.setData(features)
|
||||
|
||||
layerSources.get(id).setData(features)
|
||||
}
|
||||
|
||||
layerIndexes = newIndices
|
||||
|
||||
// AT last, the leftovers are handled
|
||||
if (options?.handleLeftovers !== undefined && noLayerFound.length > 0) {
|
||||
options.handleLeftovers(noLayerFound)
|
||||
|
@ -90,7 +107,7 @@ export default class PerLayerFeatureSourceSplitter<
|
|||
})
|
||||
}
|
||||
|
||||
public forEach(f: (featureSource: FeatureSourceForLayer) => void) {
|
||||
public forEach(f: (featureSource: FeatureSource) => void) {
|
||||
for (const fs of this.perLayer.values()) {
|
||||
f(fs)
|
||||
}
|
||||
|
|
|
@ -122,7 +122,7 @@ export default class OsmFeatureSource extends FeatureSourceMerger {
|
|||
throw "This is an absurd high zoom level"
|
||||
}
|
||||
|
||||
if (z < 14) {
|
||||
if (z < 15) {
|
||||
throw `Zoom ${z} is too much for OSM to handle! Use a higher zoom level!`
|
||||
}
|
||||
const index = Tiles.tile_index(z, x, y)
|
||||
|
|
|
@ -35,7 +35,18 @@ export default class MetaTagging {
|
|||
continue
|
||||
}
|
||||
const featureSource = state.perLayer.get(layer.id)
|
||||
featureSource.features?.addCallbackAndRunD((features) => {
|
||||
featureSource.features?.stabilized(1000)?.addCallbackAndRunD((features) => {
|
||||
if (!(features?.length > 0)) {
|
||||
// No features to handle
|
||||
return
|
||||
}
|
||||
console.trace(
|
||||
"Recalculating metatags for layer ",
|
||||
layer.id,
|
||||
"due to a change in the upstream features. Contains ",
|
||||
features.length,
|
||||
"items"
|
||||
)
|
||||
MetaTagging.addMetatags(
|
||||
features,
|
||||
params,
|
||||
|
@ -71,7 +82,6 @@ export default class MetaTagging {
|
|||
return
|
||||
}
|
||||
|
||||
console.debug("Recalculating metatags...")
|
||||
const metatagsToApply: SimpleMetaTagger[] = []
|
||||
for (const metatag of SimpleMetaTaggers.metatags) {
|
||||
if (metatag.includesDates) {
|
||||
|
|
|
@ -3,6 +3,8 @@ import { GlobalFilter } from "../../Models/GlobalFilter"
|
|||
import FilteredLayer from "../../Models/FilteredLayer"
|
||||
import LayerConfig from "../../Models/ThemeConfig/LayerConfig"
|
||||
import { OsmConnection } from "../Osm/OsmConnection"
|
||||
import { Tag } from "../Tags/Tag"
|
||||
import Translations from "../../UI/i18n/Translations"
|
||||
|
||||
/**
|
||||
* The layer state keeps track of:
|
||||
|
@ -41,6 +43,45 @@ export default class LayerState {
|
|||
}
|
||||
this.filteredLayers = filteredLayers
|
||||
layers.forEach((l) => LayerState.linkFilterStates(l, filteredLayers))
|
||||
|
||||
this.globalFilters.data.push({
|
||||
id: "level",
|
||||
osmTags: undefined,
|
||||
state: undefined,
|
||||
onNewPoint: undefined,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the global filter which looks to the 'level'-tag.
|
||||
* Only features with the given 'level' will be shown.
|
||||
*
|
||||
* If undefined is passed, _all_ levels will be shown
|
||||
* @param level
|
||||
*/
|
||||
public setLevelFilter(level?: string) {
|
||||
// Remove all previous
|
||||
const l = this.globalFilters.data.length
|
||||
this.globalFilters.data = this.globalFilters.data.filter((f) => f.id !== "level")
|
||||
if (!level) {
|
||||
if (l !== this.globalFilters.data.length) {
|
||||
this.globalFilters.ping()
|
||||
}
|
||||
return
|
||||
}
|
||||
const t = Translations.t.general.levelSelection
|
||||
this.globalFilters.data.push({
|
||||
id: "level",
|
||||
state: level,
|
||||
osmTags: new Tag("level", level),
|
||||
onNewPoint: {
|
||||
tags: [new Tag("level", level)],
|
||||
icon: "./assets/svg/elevator.svg",
|
||||
confirmAddNew: t.confirmLevel.PartialSubs({ level }),
|
||||
safetyCheck: t.addNewOnLevel.Subs({ level }),
|
||||
},
|
||||
})
|
||||
this.globalFilters.ping()
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -186,6 +186,12 @@ export default class FilteredLayer {
|
|||
if (properties._deleted === "yes") {
|
||||
return false
|
||||
}
|
||||
for (const globalFilter of globalFilters ?? []) {
|
||||
const neededTags = globalFilter.osmTags
|
||||
if (neededTags !== undefined && !neededTags.matchesProperties(properties)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
{
|
||||
const isShown: TagsFilter = this.layerDef.isShown
|
||||
if (isShown !== undefined && !isShown.matchesProperties(properties)) {
|
||||
|
@ -200,12 +206,6 @@ export default class FilteredLayer {
|
|||
}
|
||||
}
|
||||
|
||||
for (const globalFilter of globalFilters ?? []) {
|
||||
const neededTags = globalFilter.osmTags
|
||||
if (neededTags !== undefined && !neededTags.matchesProperties(properties)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
|
|
|
@ -8,6 +8,7 @@ export interface GlobalFilter {
|
|||
id: string
|
||||
onNewPoint: {
|
||||
safetyCheck: Translation
|
||||
icon: string
|
||||
confirmAddNew: TypedTranslation<{ preset: Translation }>
|
||||
tags: Tag[]
|
||||
}
|
||||
|
|
|
@ -92,6 +92,10 @@ export default class ThemeViewState implements SpecialVisualizationState {
|
|||
string,
|
||||
{ readonly isDisplayed: UIEventSource<boolean> }
|
||||
>
|
||||
/**
|
||||
* All 'level'-tags that are available with the current features
|
||||
*/
|
||||
readonly floors: Store<string[]>
|
||||
|
||||
constructor(layout: LayoutConfig) {
|
||||
this.layout = layout
|
||||
|
@ -214,17 +218,29 @@ export default class ThemeViewState implements SpecialVisualizationState {
|
|||
this.featureProperties
|
||||
)
|
||||
|
||||
const doShowLayer = this.mapProperties.zoom.map(
|
||||
(z) =>
|
||||
(fs.layer.isDisplayed?.data ?? true) && z >= (fs.layer.layerDef?.minzoom ?? 0),
|
||||
[fs.layer.isDisplayed]
|
||||
)
|
||||
|
||||
if (
|
||||
!doShowLayer.data &&
|
||||
(this.featureSwitches.featureSwitchFilter.data === false || !fs.layer.layerDef.name)
|
||||
) {
|
||||
/* This layer is hidden and there is no way to enable it (filterview is disabled or this layer doesn't show up in the filter view as the name is not defined)
|
||||
*
|
||||
* This means that we don't have to filter it, nor do we have to display it
|
||||
* */
|
||||
return
|
||||
}
|
||||
|
||||
const filtered = new FilteringFeatureSource(
|
||||
fs.layer,
|
||||
fs,
|
||||
(id) => this.featureProperties.getStore(id),
|
||||
this.layerState.globalFilters
|
||||
)
|
||||
const doShowLayer = this.mapProperties.zoom.map(
|
||||
(z) =>
|
||||
(fs.layer.isDisplayed?.data ?? true) && z >= (fs.layer.layerDef?.minzoom ?? 0),
|
||||
[fs.layer.isDisplayed]
|
||||
)
|
||||
|
||||
new ShowDataLayer(this.map, {
|
||||
layer: fs.layer.layerDef,
|
||||
|
@ -236,6 +252,33 @@ export default class ThemeViewState implements SpecialVisualizationState {
|
|||
})
|
||||
})
|
||||
|
||||
this.floors = this.indexedFeatures.features.stabilized(500).map((features) => {
|
||||
if (!features) {
|
||||
return []
|
||||
}
|
||||
const floors = new Set<string>()
|
||||
for (const feature of features) {
|
||||
const level = feature.properties["level"]
|
||||
if (level) {
|
||||
floors.add(level)
|
||||
}
|
||||
}
|
||||
const sorted = Array.from(floors)
|
||||
// Sort alphabetically first, to deal with floor "A", "B" and "C"
|
||||
sorted.sort()
|
||||
sorted.sort((a, b) => {
|
||||
// We use the laxer 'parseInt' to deal with floor '1A'
|
||||
const na = parseInt(a)
|
||||
const nb = parseInt(b)
|
||||
if (isNaN(na) || isNaN(nb)) {
|
||||
return 0
|
||||
}
|
||||
return na - nb
|
||||
})
|
||||
sorted.reverse(/* new list, no side-effects */)
|
||||
return sorted
|
||||
})
|
||||
|
||||
const lastClick = (this.lastClickObject = new LastClickFeatureSource(
|
||||
this.mapProperties.lastClickLocation,
|
||||
this.layout
|
||||
|
@ -443,7 +486,6 @@ export default class ThemeViewState implements SpecialVisualizationState {
|
|||
}
|
||||
|
||||
const found = this.indexedFeatures.featuresById.data?.get(hash)
|
||||
console.log("Found:", found)
|
||||
if (!found) {
|
||||
return
|
||||
}
|
||||
|
@ -451,7 +493,7 @@ export default class ThemeViewState implements SpecialVisualizationState {
|
|||
this.selectedElement.setData(found)
|
||||
this.selectedLayer.setData(layer)
|
||||
},
|
||||
[this.indexedFeatures.featuresById]
|
||||
[this.indexedFeatures.featuresById.stabilized(250)]
|
||||
)
|
||||
|
||||
new MetaTagging(this)
|
||||
|
|
29
UI/BigComponents/LevelSelector.svelte
Normal file
29
UI/BigComponents/LevelSelector.svelte
Normal file
|
@ -0,0 +1,29 @@
|
|||
<script lang="ts">
|
||||
/**
|
||||
* Shows a 'floorSelector' and maps the selected floor onto a global filter
|
||||
*/
|
||||
import LayerState from "../../Logic/State/LayerState";
|
||||
import FloorSelector from "../InputElement/Helpers/FloorSelector.svelte";
|
||||
import { Store, UIEventSource } from "../../Logic/UIEventSource";
|
||||
|
||||
export let layerState: LayerState;
|
||||
export let floors: Store<string[]>;
|
||||
export let zoom: Store<number>;
|
||||
const maxZoom = 16
|
||||
|
||||
let selectedFloor: UIEventSource<string> = new UIEventSource<string>(undefined);
|
||||
|
||||
selectedFloor.stabilized(5).map(floor => {
|
||||
if(floors.data === undefined || floors.data.length <= 1 || zoom.data < maxZoom){
|
||||
// Only a single floor is visible -> disable the 'level' global filter
|
||||
// OR we might have zoomed out to much ant want to show all
|
||||
layerState.setLevelFilter(undefined)
|
||||
}else{
|
||||
layerState.setLevelFilter(floor)
|
||||
}
|
||||
}, [floors, zoom])
|
||||
|
||||
</script>
|
||||
{#if $zoom >= maxZoom}
|
||||
<FloorSelector {floors} value={selectedFloor} />
|
||||
{/if}
|
|
@ -1,151 +0,0 @@
|
|||
import FloorLevelInputElement from "../Input/FloorLevelInputElement"
|
||||
import MapState from "../../Logic/State/MapState"
|
||||
import { TagsFilter } from "../../Logic/Tags/TagsFilter"
|
||||
import { RegexTag } from "../../Logic/Tags/RegexTag"
|
||||
import { Or } from "../../Logic/Tags/Or"
|
||||
import { Tag } from "../../Logic/Tags/Tag"
|
||||
import Translations from "../i18n/Translations"
|
||||
import Combine from "../Base/Combine"
|
||||
import { OsmFeature } from "../../Models/OsmFeature"
|
||||
import { BBox } from "../../Logic/BBox"
|
||||
import { TagUtils } from "../../Logic/Tags/TagUtils"
|
||||
import FeaturePipeline from "../../Logic/FeatureSource/FeaturePipeline"
|
||||
import { Store } from "../../Logic/UIEventSource"
|
||||
import { GlobalFilter } from "../../Logic/State/GlobalFilter"
|
||||
|
||||
/***
|
||||
* The element responsible for the level input element and picking the right level, showing and hiding at the right time, ...
|
||||
*/
|
||||
export default class LevelSelector extends Combine {
|
||||
constructor(state: MapState & { featurePipeline: FeaturePipeline }) {
|
||||
const levelsInView: Store<Record<string, number>> = 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 levels: Record<string, number> = { "0": 0 }
|
||||
for (const levelDescription of allLevelsRaw) {
|
||||
if (levelDescription === undefined) {
|
||||
levels["0"]++
|
||||
}
|
||||
for (const level of TagUtils.LevelsParser(levelDescription)) {
|
||||
levels[level] = (levels[level] ?? 0) + 1
|
||||
}
|
||||
}
|
||||
|
||||
return levels
|
||||
})
|
||||
|
||||
const levelSelect = new FloorLevelInputElement(levelsInView)
|
||||
|
||||
state.globalFilters.data.push({
|
||||
filter: {
|
||||
currentFilter: undefined,
|
||||
state: undefined,
|
||||
},
|
||||
id: "level",
|
||||
onNewPoint: undefined,
|
||||
})
|
||||
const isShown = levelsInView.map(
|
||||
(levelsInView) => {
|
||||
if (state.locationControl.data.zoom <= 16) {
|
||||
return false
|
||||
}
|
||||
if (Object.keys(levelsInView).length == 1) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
},
|
||||
[state.locationControl]
|
||||
)
|
||||
|
||||
function setLevelFilter() {
|
||||
console.log(
|
||||
"Updating levels filter to ",
|
||||
levelSelect.GetValue().data,
|
||||
" is shown:",
|
||||
isShown.data
|
||||
)
|
||||
const filter: GlobalFilter = state.globalFilters.data.find((gf) => gf.id === "level")
|
||||
if (!isShown.data) {
|
||||
filter.filter = {
|
||||
state: "*",
|
||||
currentFilter: undefined,
|
||||
}
|
||||
filter.onNewPoint = undefined
|
||||
state.globalFilters.ping()
|
||||
return
|
||||
}
|
||||
|
||||
const l = levelSelect.GetValue().data
|
||||
if (l === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
})
|
||||
|
||||
levelsInView.addCallbackAndRun((levels) => {
|
||||
if (!isShown.data) {
|
||||
return
|
||||
}
|
||||
const value = levelSelect.GetValue()
|
||||
if (!(levels[value.data] === undefined || levels[value.data] === 0)) {
|
||||
return
|
||||
}
|
||||
// Nothing in view. Lets switch to a different level (the level with the most features)
|
||||
let mostElements = 0
|
||||
let mostElementsLevel = undefined
|
||||
for (const level in levels) {
|
||||
const count = levels[level]
|
||||
if (mostElementsLevel === undefined || mostElements < count) {
|
||||
mostElementsLevel = level
|
||||
mostElements = count
|
||||
}
|
||||
}
|
||||
console.log(
|
||||
"Force switching to a different level:",
|
||||
mostElementsLevel,
|
||||
"as it has",
|
||||
mostElements,
|
||||
"elements on that floor",
|
||||
levels,
|
||||
"(old level: " + value.data + ")"
|
||||
)
|
||||
value.setData(mostElementsLevel)
|
||||
})
|
||||
levelSelect.GetValue().addCallback((_) => setLevelFilter())
|
||||
super([levelSelect])
|
||||
}
|
||||
}
|
|
@ -1,11 +0,0 @@
|
|||
import Combine from "../Base/Combine"
|
||||
import MapState from "../../Logic/State/MapState"
|
||||
import LevelSelector from "./LevelSelector"
|
||||
|
||||
export default class RightControls extends Combine {
|
||||
constructor(state: MapState & { featurePipeline: FeaturePipeline }) {
|
||||
const levelSelector = new LevelSelector(state)
|
||||
super([levelSelector].map((el) => el.SetClass("m-0.5 md:m-1")))
|
||||
this.SetClass("flex flex-col items-center")
|
||||
}
|
||||
}
|
|
@ -1,6 +1,5 @@
|
|||
import Toggle from "./Input/Toggle"
|
||||
import LeftControls from "./BigComponents/LeftControls"
|
||||
import RightControls from "./BigComponents/RightControls"
|
||||
import CenterMessageBox from "./CenterMessageBox"
|
||||
import { DefaultGuiState } from "./DefaultGuiState"
|
||||
import Combine from "./Base/Combine"
|
||||
|
@ -42,7 +41,6 @@ export default class DefaultGUI {
|
|||
|
||||
const guiState = this.guiState
|
||||
new LeftControls(state, guiState).AttachTo("bottom-left")
|
||||
new RightControls(state, this.geolocationHandler).AttachTo("bottom-right")
|
||||
|
||||
new CenterMessageBox(state).AttachTo("centermessage")
|
||||
document?.getElementById("centermessage")?.classList?.add("pointer-events-none")
|
||||
|
|
140
UI/InputElement/Helpers/FloorSelector.svelte
Normal file
140
UI/InputElement/Helpers/FloorSelector.svelte
Normal file
|
@ -0,0 +1,140 @@
|
|||
<script lang="ts">
|
||||
import { Store, Stores, UIEventSource } from "../../../Logic/UIEventSource";
|
||||
|
||||
/**
|
||||
* Given the available floors, shows an elevator to pick a single one
|
||||
*
|
||||
* This is but the input element, the logic of handling the filter is in 'LevelSelector'
|
||||
*/
|
||||
export let floors: Store<string[]>;
|
||||
export let value: UIEventSource<string>;
|
||||
|
||||
const HEIGHT = 40;
|
||||
|
||||
let initialIndex = Math.max(0, floors?.data?.findIndex(f => f === value?.data) ?? 0);
|
||||
let index: UIEventSource<number> = new UIEventSource<number>(initialIndex);
|
||||
let forceIndex: number | undefined = undefined;
|
||||
let top = Math.max(0, initialIndex) * HEIGHT;
|
||||
let elevator: HTMLImageElement;
|
||||
|
||||
let mouseDown = false;
|
||||
|
||||
let container: HTMLElement;
|
||||
|
||||
$:{
|
||||
if (top > 0 || forceIndex !== undefined) {
|
||||
index.setData(closestFloorIndex());
|
||||
value.setData(floors.data[forceIndex ?? closestFloorIndex()]);
|
||||
}
|
||||
}
|
||||
|
||||
function unclick() {
|
||||
mouseDown = false;
|
||||
}
|
||||
|
||||
function click() {
|
||||
mouseDown = true;
|
||||
}
|
||||
|
||||
function closestFloorIndex() {
|
||||
return Math.min(floors.data.length - 1, Math.max(0, Math.round(top / HEIGHT)));
|
||||
}
|
||||
|
||||
function onMove(e: { movementY: number }) {
|
||||
if (mouseDown) {
|
||||
forceIndex = undefined;
|
||||
const containerY = container.clientTop;
|
||||
const containerMax = containerY + (floors.data.length - 1) * HEIGHT;
|
||||
top = Math.min(Math.max(0, top + e.movementY), containerMax);
|
||||
}
|
||||
}
|
||||
|
||||
let momentum = 0;
|
||||
|
||||
function stabilize() {
|
||||
// Automatically move the elevator to the closes floor
|
||||
if (mouseDown) {
|
||||
return;
|
||||
}
|
||||
const target = (forceIndex ?? index.data) * HEIGHT;
|
||||
let diff = target - top;
|
||||
if (diff > 1) {
|
||||
diff /= 3;
|
||||
}
|
||||
const sign = Math.sign(diff);
|
||||
momentum = momentum + sign;
|
||||
let diffR = Math.min(Math.abs(momentum), forceIndex !== undefined ? 9 : 3, Math.abs(diff));
|
||||
momentum = Math.sign(momentum) * Math.min(diffR, Math.abs(momentum));
|
||||
top += sign * diffR;
|
||||
if (index.data === forceIndex) {
|
||||
forceIndex = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
Stores.Chronic(50).addCallback(_ => stabilize());
|
||||
|
||||
let image: HTMLImageElement;
|
||||
$:{
|
||||
if (image) {
|
||||
let lastY = 0;
|
||||
image.ontouchstart = (e: TouchEvent) => {
|
||||
mouseDown = true;
|
||||
lastY = e.changedTouches[0].clientY;
|
||||
};
|
||||
image.ontouchmove = e => {
|
||||
const y = e.changedTouches[0].clientY;
|
||||
console.log(y)
|
||||
const movementY = y - lastY;
|
||||
lastY = y;
|
||||
onMove({ movementY });
|
||||
};
|
||||
image.ontouchend = unclick;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<div bind:this={container} class="relative"
|
||||
style={`height: calc(${HEIGHT}px * ${$floors.length}); width: 96px`}>
|
||||
<div class="h-full absolute w-min right-0">
|
||||
{#each $floors as floor, i}
|
||||
<button style={`height: ${HEIGHT}px; width: ${HEIGHT}px`}
|
||||
class={"border-2 border-gray-300 flex content-box justify-center items-center "+(i === (forceIndex ?? $index) ? "selected": "normal-background" )
|
||||
}
|
||||
on:click={() => {forceIndex = i}}
|
||||
> {floor}</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div style={`width: ${HEIGHT}px`}>
|
||||
<img bind:this={image} class="draggable" draggable="false" on:mousedown={click} src="./assets/svg/elevator.svg"
|
||||
style={" top: "+top+"px;"} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<svelte:window on:mousemove={onMove} on:mouseup={unclick} />
|
||||
|
||||
<style>
|
||||
.selected {
|
||||
background: var(--subtle-detail-color);
|
||||
font-weight: bold;
|
||||
border-color: black;
|
||||
}
|
||||
|
||||
.draggable {
|
||||
user-select: none;
|
||||
cursor: move;
|
||||
position: absolute;
|
||||
user-drag: none;
|
||||
|
||||
height: 72px;
|
||||
margin-top: -15px;
|
||||
margin-bottom: -15px;
|
||||
margin-left: -18px;
|
||||
-webkit-user-drag: none;
|
||||
-moz-user-select: none;
|
||||
-webkit-user-select: none;
|
||||
-ms-user-select: none;
|
||||
}
|
||||
</style>
|
|
@ -25,22 +25,36 @@
|
|||
import { Tag } from "../../../Logic/Tags/Tag";
|
||||
import type { WayId } from "../../../Models/OsmFeature";
|
||||
import Loading from "../../Base/Loading.svelte";
|
||||
import type { GlobalFilter } from "../../../Models/GlobalFilter";
|
||||
import { onDestroy } from "svelte";
|
||||
|
||||
export let coordinate: { lon: number, lat: number };
|
||||
export let state: SpecialVisualizationState;
|
||||
|
||||
let selectedPreset: { preset: PresetConfig, layer: LayerConfig, icon: string, tags: Record<string, string> } = undefined;
|
||||
|
||||
let selectedPreset: {
|
||||
preset: PresetConfig,
|
||||
layer: LayerConfig,
|
||||
icon: string,
|
||||
tags: Record<string, string>
|
||||
} = undefined;
|
||||
let checkedOfGlobalFilters : number = 0
|
||||
let confirmedCategory = false;
|
||||
$: if (selectedPreset === undefined) {
|
||||
confirmedCategory = false;
|
||||
creating = false;
|
||||
checkedOfGlobalFilters = 0
|
||||
|
||||
}
|
||||
|
||||
let flayer: FilteredLayer = undefined;
|
||||
let layerIsDisplayed: UIEventSource<boolean> | undefined = undefined;
|
||||
let layerHasFilters: Store<boolean> | undefined = undefined;
|
||||
|
||||
let globalFilter: UIEventSource<GlobalFilter[]> = state.layerState.globalFilters;
|
||||
let _globalFilter: GlobalFilter[];
|
||||
onDestroy(globalFilter.addCallbackAndRun(globalFilter => {
|
||||
console.log("Global filters are", globalFilter);
|
||||
_globalFilter = globalFilter ?? [];
|
||||
}));
|
||||
$:{
|
||||
flayer = state.layerState.filteredLayers.get(selectedPreset?.layer?.id);
|
||||
layerIsDisplayed = flayer?.isDisplayed;
|
||||
|
@ -71,38 +85,38 @@
|
|||
creating = true;
|
||||
const location: { lon: number; lat: number } = preciseCoordinate.data;
|
||||
const snapTo: WayId | undefined = <WayId>snappedToObject.data;
|
||||
const tags: Tag[] = selectedPreset.preset.tags;
|
||||
const tags: Tag[] = selectedPreset.preset.tags.concat(..._globalFilter.map(f => f.onNewPoint.tags));
|
||||
console.log("Creating new point at", location, "snapped to", snapTo, "with tags", tags);
|
||||
|
||||
let snapToWay: undefined | OsmWay = undefined
|
||||
if(snapTo !== undefined){
|
||||
let snapToWay: undefined | OsmWay = undefined;
|
||||
if (snapTo !== undefined) {
|
||||
const downloaded = await state.osmObjectDownloader.DownloadObjectAsync(snapTo, 0);
|
||||
if(downloaded !== "deleted"){
|
||||
snapToWay = downloaded
|
||||
if (downloaded !== "deleted") {
|
||||
snapToWay = downloaded;
|
||||
}
|
||||
}
|
||||
|
||||
const newElementAction = new CreateNewNodeAction(tags, location.lat, location.lon,
|
||||
{
|
||||
theme: state.layout?.id ?? "unkown",
|
||||
changeType: "create",
|
||||
snapOnto: snapToWay
|
||||
});
|
||||
await state.changes.applyAction(newElementAction)
|
||||
state.newFeatures.features.ping()
|
||||
theme: state.layout?.id ?? "unkown",
|
||||
changeType: "create",
|
||||
snapOnto: snapToWay
|
||||
});
|
||||
await state.changes.applyAction(newElementAction);
|
||||
state.newFeatures.features.ping();
|
||||
// The 'changes' should have created a new point, which added this into the 'featureProperties'
|
||||
const newId = newElementAction.newElementId;
|
||||
console.log("Applied pending changes, fetching store for", newId)
|
||||
console.log("Applied pending changes, fetching store for", newId);
|
||||
const tagsStore = state.featureProperties.getStore(newId);
|
||||
{
|
||||
// Set some metainfo
|
||||
const properties = tagsStore.data;
|
||||
if (snapTo) {
|
||||
// metatags (starting with underscore) are not uploaded, so we can safely mark this
|
||||
delete properties["_referencing_ways"]
|
||||
delete properties["_referencing_ways"];
|
||||
properties["_referencing_ways"] = `["${snapTo}"]`;
|
||||
}
|
||||
properties["_backend"] = state.osmConnection.Backend()
|
||||
properties["_backend"] = state.osmConnection.Backend();
|
||||
properties["_last_edit:timestamp"] = new Date().toISOString();
|
||||
const userdetails = state.osmConnection.userDetails.data;
|
||||
properties["_last_edit:contributor"] = userdetails.name;
|
||||
|
@ -113,13 +127,17 @@
|
|||
abort();
|
||||
state.selectedLayer.setData(selectedPreset.layer);
|
||||
state.selectedElement.setData(feature);
|
||||
tagsStore.ping()
|
||||
tagsStore.ping();
|
||||
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<LoginToggle ignoreLoading={true} {state}>
|
||||
<!-- This component is basically one big if/then/else flow checking for many conditions and edge cases that (in some cases) have to be handled;
|
||||
1. the first (and outermost) is of course: are we logged in?
|
||||
2. What do we want to add?
|
||||
3. Are all elements of this category visible? (i.e. there are no filters possibly hiding this, is the data still loading, ...) -->
|
||||
<LoginButton osmConnection={state.osmConnection} slot="not-logged-in">
|
||||
<Tr slot="message" t={Translations.t.general.add.pleaseLogin} />
|
||||
</LoginButton>
|
||||
|
@ -163,7 +181,7 @@
|
|||
|
||||
|
||||
{:else if $layerHasFilters}
|
||||
<!-- Some filters are enabled. The feature to add might already be mapped, but hiddne -->
|
||||
<!-- Some filters are enabled. The feature to add might already be mapped, but hidden -->
|
||||
<div class="alert flex justify-center items-center">
|
||||
<EyeOffIcon class="w-8" />
|
||||
<Tr t={Translations.t.general.add.disableFiltersExplanation} />
|
||||
|
@ -231,6 +249,16 @@
|
|||
<Tr t={t.backToSelect} />
|
||||
</div>
|
||||
</SubtleButton>
|
||||
{:else if _globalFilter.length > checkedOfGlobalFilters}
|
||||
<Tr t={_globalFilter[checkedOfGlobalFilters].onNewPoint?.safetyCheck} />
|
||||
<SubtleButton on:click={() => {checkedOfGlobalFilters = checkedOfGlobalFilters + 1}}>
|
||||
<img slot="image" src={_globalFilter[checkedOfGlobalFilters].onNewPoint?.icon ?? "./assets/svg/confirm.svg"} class="w-12 h-12">
|
||||
<Tr slot="message" t={_globalFilter[checkedOfGlobalFilters].onNewPoint?.confirmAddNew.Subs({preset: selectedPreset.preset})} />
|
||||
</SubtleButton>
|
||||
<SubtleButton on:click={() => {globalFilter.setData([]); abort()}}>
|
||||
<img slot="image" src="./assets/svg/close.svg" class="w-8 h-8"/>
|
||||
<Tr slot="message" t={Translations.t.general.cancel}/>
|
||||
</SubtleButton>
|
||||
{:else if !creating}
|
||||
<NewPointLocationInput value={preciseCoordinate} snappedTo={snappedToObject} {state} {coordinate}
|
||||
targetLayer={selectedPreset.layer}
|
||||
|
|
|
@ -13,9 +13,6 @@ import { OsmServiceState } from "../../Logic/Osm/OsmConnection"
|
|||
* Generates all the questions, one by one
|
||||
*/
|
||||
export default class QuestionBox extends VariableUiElement {
|
||||
public readonly skippedQuestions: UIEventSource<number[]>
|
||||
public readonly restingQuestions: Store<BaseUIElement[]>
|
||||
|
||||
constructor(
|
||||
state,
|
||||
options: {
|
||||
|
@ -29,10 +26,6 @@ export default class QuestionBox extends VariableUiElement {
|
|||
|
||||
const tagsSource = options.tagsSource
|
||||
const units = options.units
|
||||
options.showAllQuestionsAtOnce = options.showAllQuestionsAtOnce ?? false
|
||||
const tagRenderings = options.tagRenderings
|
||||
.filter((tr) => tr.question !== undefined)
|
||||
.filter((tr) => tr.question !== null)
|
||||
|
||||
let focus: () => void = () => {}
|
||||
|
||||
|
@ -59,9 +52,6 @@ export default class QuestionBox extends VariableUiElement {
|
|||
)
|
||||
)
|
||||
|
||||
const skippedQuestionsButton = Translations.t.general.skippedQuestions.onClick(() => {
|
||||
skippedQuestions.setData([])
|
||||
})
|
||||
tagsSource.map(
|
||||
(tags) => {
|
||||
if (tags === undefined) {
|
||||
|
@ -136,18 +126,12 @@ export default class QuestionBox extends VariableUiElement {
|
|||
els.push(allQuestions[0])
|
||||
}
|
||||
|
||||
if (skippedQuestions.data.length > 0) {
|
||||
els.push(skippedQuestionsButton)
|
||||
}
|
||||
|
||||
return new Combine(els).SetClass("block mb-8")
|
||||
},
|
||||
[state.osmConnection.apiIsOnline]
|
||||
)
|
||||
)
|
||||
|
||||
this.skippedQuestions = skippedQuestions
|
||||
this.restingQuestions = questionsToAsk
|
||||
focus = () => this.ScrollIntoView()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -72,6 +72,9 @@
|
|||
let answered: number = 0;
|
||||
let skipped: number = 0;
|
||||
|
||||
function focus(){
|
||||
|
||||
}
|
||||
function skip(question: TagRenderingConfig, didAnswer: boolean = false) {
|
||||
skippedQuestions.data.add(question.id);
|
||||
skippedQuestions.ping();
|
||||
|
|
|
@ -5,7 +5,6 @@
|
|||
import FeatureSwitchState from "../Logic/State/FeatureSwitchState";
|
||||
import MapControlButton from "./Base/MapControlButton.svelte";
|
||||
import ToSvelte from "./Base/ToSvelte.svelte";
|
||||
import Svg from "../Svg";
|
||||
import If from "./Base/If.svelte";
|
||||
import { GeolocationControl } from "./BigComponents/GeolocationControl";
|
||||
import type { Feature } from "geojson";
|
||||
|
@ -35,6 +34,7 @@
|
|||
import { VariableUiElement } from "./Base/VariableUIElement";
|
||||
import SvelteUIElement from "./Base/SvelteUIElement";
|
||||
import OverlayToggle from "./BigComponents/OverlayToggle.svelte";
|
||||
import LevelSelector from "./BigComponents/LevelSelector.svelte";
|
||||
|
||||
export let state: ThemeViewState;
|
||||
let layout = state.layout;
|
||||
|
@ -71,14 +71,14 @@
|
|||
<div class="absolute top-0 left-0 w-full ">
|
||||
<!-- Top components -->
|
||||
<If condition={state.featureSwitches.featureSwitchSearch}>
|
||||
<div class="sm:w-min float-right mt-1 px-1 sm:m-2 max-[320px]:w-full">
|
||||
<div class="max-[480px]:w-full float-right mt-1 px-1 sm:m-2">
|
||||
<Geosearch bounds={state.mapProperties.bounds} perLayer={state.perLayer} {selectedElement}
|
||||
{selectedLayer}></Geosearch>
|
||||
</div>
|
||||
</If>
|
||||
<div class="float-left m-1 sm:mt-2">
|
||||
<MapControlButton on:click={() => state.guistate.themeIsOpened.setData(true)}>
|
||||
<div class="flex m-0.5 mx-1 sm:mx-1 md:mx-2 items-center cursor-pointer">
|
||||
<div class="flex m-0.5 mx-1 sm:mx-1 md:mx-2 items-center cursor-pointer max-[480px]:w-full">
|
||||
<img class="w-4 h-4 sm:w-6 sm:h-6 md:w-8 md:h-8 block mr-0.5 sm:mr-1 md:mr-2" src={layout.icon}>
|
||||
<b class="mr-1">
|
||||
<Tr t={layout.title}></Tr>
|
||||
|
@ -101,7 +101,12 @@
|
|||
|
||||
</div>
|
||||
|
||||
<div class="absolute bottom-0 right-0 mb-4 mr-4">
|
||||
<div class="absolute bottom-0 right-0 mb-4 mr-4 flex flex-col items-end">
|
||||
<If condition={state.floors.map(f => f.length > 1)}>
|
||||
<div class="mr-0.5">
|
||||
<LevelSelector floors={state.floors} layerState={state.layerState} zoom={state.mapProperties.zoom}/>
|
||||
</div>
|
||||
</If>
|
||||
<MapControlButton on:click={() => mapproperties.zoom.update(z => z+1)}>
|
||||
<img src="./assets/svg/plus.svg" class="w-6 h-6 md:w-8 md:h-8"/>
|
||||
</MapControlButton>
|
||||
|
|
|
@ -787,14 +787,14 @@ video {
|
|||
margin: 0.5rem;
|
||||
}
|
||||
|
||||
.m-4 {
|
||||
margin: 1rem;
|
||||
}
|
||||
|
||||
.m-1 {
|
||||
margin: 0.25rem;
|
||||
}
|
||||
|
||||
.m-4 {
|
||||
margin: 1rem;
|
||||
}
|
||||
|
||||
.m-6 {
|
||||
margin: 1.5rem;
|
||||
}
|
||||
|
@ -857,10 +857,6 @@ video {
|
|||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.ml-1 {
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
|
||||
.mr-0\.5 {
|
||||
margin-right: 0.125rem;
|
||||
}
|
||||
|
@ -885,6 +881,10 @@ video {
|
|||
margin-bottom: 6rem;
|
||||
}
|
||||
|
||||
.ml-1 {
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
|
||||
.ml-2 {
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
@ -1146,6 +1146,11 @@ video {
|
|||
width: 12rem;
|
||||
}
|
||||
|
||||
.w-min {
|
||||
width: -webkit-min-content;
|
||||
width: min-content;
|
||||
}
|
||||
|
||||
.w-auto {
|
||||
width: auto;
|
||||
}
|
||||
|
@ -1260,6 +1265,10 @@ video {
|
|||
align-content: flex-start;
|
||||
}
|
||||
|
||||
.items-end {
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.items-center {
|
||||
align-items: center;
|
||||
}
|
||||
|
@ -2297,8 +2306,8 @@ input {
|
|||
color: var(--unsubtle-detail-color-contrast);
|
||||
}
|
||||
|
||||
@media (max-width: 320px) {
|
||||
.max-\[320px\]\:w-full {
|
||||
@media (max-width: 480px) {
|
||||
.max-\[480px\]\:w-full {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
@ -2322,6 +2331,10 @@ input {
|
|||
margin-right: auto;
|
||||
}
|
||||
|
||||
.sm\:mt-2 {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.sm\:mr-1 {
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
|
@ -2350,11 +2363,6 @@ input {
|
|||
width: 6rem;
|
||||
}
|
||||
|
||||
.sm\:w-min {
|
||||
width: -webkit-min-content;
|
||||
width: min-content;
|
||||
}
|
||||
|
||||
.sm\:w-6 {
|
||||
width: 1.5rem;
|
||||
}
|
||||
|
@ -2415,10 +2423,6 @@ input {
|
|||
margin: 0.25rem;
|
||||
}
|
||||
|
||||
.md\:m-4 {
|
||||
margin: 1rem;
|
||||
}
|
||||
|
||||
.md\:m-8 {
|
||||
margin: 2rem;
|
||||
}
|
||||
|
|
|
@ -17,7 +17,7 @@
|
|||
</head>
|
||||
<body>
|
||||
|
||||
<div id="maindiv" class="w-full h-full">'maindiv' not attached</div>
|
||||
<div id="maindiv" class="w-full">'maindiv' not attached</div>
|
||||
<div id="extradiv">'extradiv' not attached</div>
|
||||
|
||||
<script type="module" src="./test.ts"></script>
|
||||
|
|
12
test.ts
12
test.ts
|
@ -5,12 +5,13 @@ import Combine from "./UI/Base/Combine"
|
|||
import SpecialVisualizations from "./UI/SpecialVisualizations"
|
||||
import InputHelpers from "./UI/InputElement/InputHelpers"
|
||||
import BaseUIElement from "./UI/BaseUIElement"
|
||||
import { UIEventSource } from "./Logic/UIEventSource"
|
||||
import { ImmutableStore, UIEventSource } from "./Logic/UIEventSource"
|
||||
import { VariableUiElement } from "./UI/Base/VariableUIElement"
|
||||
import { FixedUiElement } from "./UI/Base/FixedUiElement"
|
||||
import Title from "./UI/Base/Title"
|
||||
import SvelteUIElement from "./UI/Base/SvelteUIElement"
|
||||
import ValidatedInput from "./UI/InputElement/ValidatedInput.svelte"
|
||||
import LevelSelector from "./UI/InputElement/Helpers/LevelSelector.svelte"
|
||||
|
||||
function testspecial() {
|
||||
const layout = new LayoutConfig(<any>theme, true) // qp.data === "" ? : new AllKnownLayoutsLazy().get(qp.data)
|
||||
|
@ -47,7 +48,14 @@ function testinput() {
|
|||
new Combine(els).SetClass("flex flex-col").AttachTo("maindiv")
|
||||
}
|
||||
|
||||
testinput()
|
||||
function testElevator() {
|
||||
const floors = new ImmutableStore(["0", "1", "1.5", "2"])
|
||||
const value = new UIEventSource<string>(undefined)
|
||||
new SvelteUIElement(LevelSelector, { floors, value }).AttachTo("maindiv")
|
||||
new VariableUiElement(value).AttachTo("extradiv")
|
||||
}
|
||||
testElevator()
|
||||
//testinput()
|
||||
/*/
|
||||
testspecial()
|
||||
//*/
|
||||
|
|
Loading…
Reference in a new issue