Add level selector and global filters

This commit is contained in:
Pieter Vander Vennet 2023-04-26 18:04:42 +02:00
parent 5504d49d59
commit 7fd7a3722e
19 changed files with 401 additions and 253 deletions

View 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}

View file

@ -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])
}
}

View file

@ -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")
}
}

View file

@ -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")

View 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>

View file

@ -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}

View file

@ -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()
}
}

View file

@ -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();

View file

@ -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>