Merge branch 'develop' into RobinLinde-patch-10

This commit is contained in:
Robin van der Linde 2023-07-17 22:34:16 +02:00
commit ff8442f90b
Signed by untrusted user: Robin-van-der-Linde
GPG key ID: 53956B3252478F0D
654 changed files with 17365 additions and 15965 deletions

View file

@ -0,0 +1,88 @@
<script lang="ts">
import { Store, UIEventSource } from "../../Logic/UIEventSource"
import type { RasterLayerPolygon } from "../../Models/RasterLayers"
import { AvailableRasterLayers } from "../../Models/RasterLayers"
import { createEventDispatcher, onDestroy } from "svelte"
import Svg from "../../Svg"
import { Map as MlMap } from "maplibre-gl"
import type { MapProperties } from "../../Models/MapProperties"
import OverlayMap from "../Map/OverlayMap.svelte"
import RasterLayerPicker from "../Map/RasterLayerPicker.svelte"
export let mapproperties: MapProperties
export let normalMap: UIEventSource<MlMap>
/**
* The current background (raster) layer of the polygon.
* This is undefined if a vector layer is used
*/
let rasterLayer: UIEventSource<RasterLayerPolygon | undefined> = mapproperties.rasterLayer
let name = rasterLayer.data?.properties?.name
let icon = Svg.satellite_svg()
onDestroy(
rasterLayer.addCallback((polygon) => {
name = polygon.properties?.name
})
)
/**
* The layers that this component can offer as a choice.
*/
export let availableRasterLayers: Store<RasterLayerPolygon[]>
let raster0 = new UIEventSource<RasterLayerPolygon>(undefined)
let raster1 = new UIEventSource<RasterLayerPolygon>(undefined)
let currentLayer: RasterLayerPolygon
function updatedAltLayer() {
const available = availableRasterLayers.data
const current = rasterLayer.data
const defaultLayer = AvailableRasterLayers.maplibre
const firstOther = available.find((l) => l !== defaultLayer)
const secondOther = available.find((l) => l !== defaultLayer && l !== firstOther)
raster0.setData(firstOther === current ? defaultLayer : firstOther)
raster1.setData(secondOther === current ? defaultLayer : secondOther)
}
updatedAltLayer()
onDestroy(mapproperties.rasterLayer.addCallbackAndRunD(updatedAltLayer))
onDestroy(availableRasterLayers.addCallbackAndRunD(updatedAltLayer))
function use(rasterLayer: UIEventSource<RasterLayerPolygon>): () => void {
return () => {
currentLayer = undefined
mapproperties.rasterLayer.setData(rasterLayer.data)
}
}
const dispatch = createEventDispatcher<{ copyright_clicked }>()
</script>
<div class="flex items-end opacity-50 hover:opacity-100">
<div class="flex flex-col md:flex-row">
<button class="m-0 h-12 w-16 overflow-hidden p-0 md:h-16 md:w-16" on:click={use(raster0)}>
<OverlayMap
placedOverMap={normalMap}
placedOverMapProperties={mapproperties}
rasterLayer={raster0}
/>
</button>
<button class="m-0 h-12 w-16 overflow-hidden p-0 md:h-16 md:w-16" on:click={use(raster1)}>
<OverlayMap
placedOverMap={normalMap}
placedOverMapProperties={mapproperties}
rasterLayer={raster1}
/>
</button>
</div>
<div class="ml-1 flex h-fit flex-col gap-y-1 text-sm">
<div class="low-interaction w-64 rounded p-1">
<RasterLayerPicker
availableLayers={availableRasterLayers}
value={mapproperties.rasterLayer}
/>
</div>
<button class="small" on:click={() => dispatch("copyright_clicked")}>© OpenStreetMap</button>
</div>
</div>

View file

@ -0,0 +1,45 @@
<script lang="ts">
import { Store, UIEventSource } from "../../Logic/UIEventSource"
import { Tiles } from "../../Models/TileRange"
import { Utils } from "../../Utils"
import global_community from "../../assets/community_index_global_resources.json"
import ContactLink from "./ContactLink.svelte"
import { GeoOperations } from "../../Logic/GeoOperations"
import Translations from "../i18n/Translations"
import ToSvelte from "../Base/ToSvelte.svelte"
import type { Feature, Geometry, GeometryCollection } from "@turf/turf"
export let location: Store<{ lat: number; lon: number }>
const tileToFetch: Store<string> = location.mapD((l) => {
const t = Tiles.embedded_tile(l.lat, l.lon, 6)
return `https://raw.githubusercontent.com/pietervdvn/MapComplete-data/main/community_index/tile_${t.z}_${t.x}_${t.y}.geojson`
})
const t = Translations.t.communityIndex
const resources = new UIEventSource<
Feature<Geometry | GeometryCollection, { resources; nameEn: string }>[]
>([])
tileToFetch.addCallbackAndRun(async (url) => {
const data = await Utils.downloadJsonCached(url, 24 * 60 * 60)
if (data === undefined) {
return
}
resources.setData(data.features)
})
const filteredResources = resources.map(
(features) =>
features.filter((f) => {
return GeoOperations.inside([location.data.lon, location.data.lat], f)
}),
[location]
)
</script>
<div>
<ToSvelte construct={t.intro} />
{#each $filteredResources as feature}
<ContactLink country={feature.properties} />
{/each}
<ContactLink country={{ resources: global_community, nameEn: "Global resources" }} />
</div>

View file

@ -0,0 +1,50 @@
<script lang="ts">
// A contact link indicates how a mapper can contact their local community
// The _properties_ of a community feature
import Locale from "../i18n/Locale.js"
import Translations from "../i18n/Translations"
import ToSvelte from "../Base/ToSvelte.svelte"
import * as native from "../../assets/language_native.json"
import { TypedTranslation } from "../i18n/Translation"
const availableTranslationTyped: TypedTranslation<{ native: string }> =
Translations.t.communityIndex.available
const availableTranslation = availableTranslationTyped.OnEveryLanguage((s, ln) =>
s.replace("{native}", native[ln] ?? ln)
)
export let country: { resources; nameEn: string }
let resources: {
id: string
resolved: Record<string, string>
languageCodes: string[]
type: string
}[] = []
$: resources = Array.from(Object.values(country?.resources ?? {}))
const language = Locale.language
</script>
<div>
{#if country?.nameEn}
<h3>{country?.nameEn}</h3>
{/if}
{#each resources as resource}
<div class="link-underline my-4 flex items-center">
<img
class="m-2 h-8 w-8"
src={`https://raw.githubusercontent.com/pietervdvn/MapComplete-data/main/community_index/${resource.type}.svg`}
/>
<div class="flex flex-col">
<a href={resource.resolved.url} target="_blank" rel="noreferrer nofollow" class="font-bold">
{resource.resolved.name ?? resource.resolved.url}
</a>
{resource.resolved?.description}
{#if resource.languageCodes?.indexOf($language) >= 0}
<div class="thanks w-fit">
<ToSvelte construct={() => availableTranslation.Clone()} />
</div>
{/if}
</div>
</div>
{/each}
</div>

View file

@ -0,0 +1,209 @@
import Combine from "../Base/Combine"
import Translations from "../i18n/Translations"
import { Store } from "../../Logic/UIEventSource"
import { FixedUiElement } from "../Base/FixedUiElement"
import licenses from "../../assets/generated/license_info.json"
import SmallLicense from "../../Models/smallLicense"
import { Utils } from "../../Utils"
import Link from "../Base/Link"
import { VariableUiElement } from "../Base/VariableUIElement"
import contributors from "../../assets/contributors.json"
import translators from "../../assets/translators.json"
import BaseUIElement from "../BaseUIElement"
import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig"
import Title from "../Base/Title"
import { BBox } from "../../Logic/BBox"
import { OsmConnection } from "../../Logic/Osm/OsmConnection"
import Constants from "../../Models/Constants"
import ContributorCount from "../../Logic/ContributorCount"
import Img from "../Base/Img"
import { TypedTranslation } from "../i18n/Translation"
import GeoIndexedStore from "../../Logic/FeatureSource/Actors/GeoIndexedStore"
import { RasterLayerPolygon } from "../../Models/RasterLayers"
/**
* The attribution panel in the theme menu.
*/
export default class CopyrightPanel extends Combine {
private static LicenseObject = CopyrightPanel.GenerateLicenses()
constructor(state: {
layout: LayoutConfig
mapProperties: {
readonly bounds: Store<BBox>
readonly rasterLayer: Store<RasterLayerPolygon>
}
osmConnection: OsmConnection
dataIsLoading: Store<boolean>
perLayer: ReadonlyMap<string, GeoIndexedStore>
}) {
const t = Translations.t.general.attribution
const layoutToUse = state.layout
const iconAttributions: BaseUIElement[] = Utils.Dedup(layoutToUse.usedImages).map(
CopyrightPanel.IconAttribution
)
let maintainer: BaseUIElement = undefined
if (layoutToUse.credits !== undefined && layoutToUse.credits !== "") {
maintainer = t.themeBy.Subs({ author: layoutToUse.credits })
}
const contributions = new ContributorCount(state).Contributors
const dataContributors = new VariableUiElement(
contributions.map((contributions) => {
if (contributions === undefined) {
return ""
}
const sorted = Array.from(contributions, ([name, value]) => ({
name,
value,
})).filter((x) => x.name !== undefined && x.name !== "undefined")
if (sorted.length === 0) {
return ""
}
sorted.sort((a, b) => b.value - a.value)
let hiddenCount = 0
if (sorted.length > 10) {
hiddenCount = sorted.length - 10
sorted.splice(10, sorted.length - 10)
}
const links = sorted.map(
(kv) =>
`<a href="https://openstreetmap.org/user/${kv.name}" target="_blank">${kv.name}</a>`
)
const contribs = links.join(", ")
if (hiddenCount <= 0) {
return t.mapContributionsBy.Subs({
contributors: contribs,
})
} else {
return t.mapContributionsByAndHidden.Subs({
contributors: contribs,
hiddenCount: hiddenCount,
})
}
})
)
super(
[
new Title(t.attributionTitle),
t.attributionContent,
new VariableUiElement(
state.mapProperties.rasterLayer.mapD((layer) => {
const props = layer.properties
const attrUrl = props.attribution?.url
const attrText = props.attribution?.text
let bgAttr: BaseUIElement | string = undefined
if (attrText && attrUrl) {
bgAttr =
"<a href='" + attrUrl + "' target='_blank'>" + attrText + "</a>"
} else if (attrUrl) {
bgAttr = attrUrl
} else {
bgAttr = attrText
}
if (bgAttr) {
return Translations.t.general.attribution.attributionBackgroundLayerWithCopyright.Subs(
{
name: props.name,
copyright: bgAttr,
}
)
}
return Translations.t.general.attribution.attributionBackgroundLayer.Subs(
props
)
})
),
maintainer,
dataContributors,
CopyrightPanel.CodeContributors(contributors, t.codeContributionsBy),
CopyrightPanel.CodeContributors(translators, t.translatedBy),
new FixedUiElement("MapComplete " + Constants.vNumber).SetClass("font-bold"),
new Title(t.iconAttribution.title, 3),
...iconAttributions,
].map((e) => e?.SetClass("mt-4"))
)
this.SetClass("flex flex-col link-underline overflow-hidden")
this.SetStyle("max-width:100%; width: 40rem; margin-left: 0.75rem; margin-right: 0.5rem")
}
private static CodeContributors(
contributors,
translation: TypedTranslation<{ contributors; hiddenCount }>
): BaseUIElement {
const total = contributors.contributors.length
let filtered = [...contributors.contributors]
filtered.splice(10, total - 10)
let contribsStr = filtered.map((c) => c.contributor).join(", ")
if (contribsStr === "") {
// Hmm, something went wrong loading the contributors list. Lets show nothing
return undefined
}
return translation.Subs({
contributors: contribsStr,
hiddenCount: total - 10,
})
}
private static IconAttribution(iconPath: string): BaseUIElement {
if (iconPath.startsWith("http")) {
try {
iconPath = "." + new URL(iconPath).pathname
} catch (e) {
console.warn(e)
}
}
const license: SmallLicense = CopyrightPanel.LicenseObject[iconPath]
if (license == undefined) {
return undefined
}
if (license.license.indexOf("trivial") >= 0) {
return undefined
}
const sources = Utils.NoNull(Utils.NoEmpty(license.sources))
return new Combine([
new Img(iconPath).SetClass("w-12 min-h-12 mr-2 mb-2"),
new Combine([
new FixedUiElement(license.authors.join("; ")).SetClass("font-bold"),
license.license,
new Combine([
...sources.map((lnk) => {
let sourceLinkContent = lnk
try {
sourceLinkContent = new URL(lnk).hostname
} catch {
console.error("Not a valid URL:", lnk)
}
return new Link(sourceLinkContent, lnk, true).SetClass("mr-2 mb-2")
}),
]).SetClass("flex flex-wrap"),
])
.SetClass("flex flex-col")
.SetStyle("width: calc(100% - 50px - 0.5em); min-width: 12rem;"),
]).SetClass("flex flex-wrap border-b border-gray-300 m-2 border-box")
}
private static GenerateLicenses() {
const allLicenses = {}
for (const key in licenses) {
const license: SmallLicense = licenses[key]
allLicenses[license.path] = license
}
return allLicenses
}
}

View file

@ -0,0 +1,101 @@
import { UIElement } from "../UIElement"
import BaseUIElement from "../BaseUIElement"
import { Store } from "../../Logic/UIEventSource"
import ExtraLinkConfig from "../../Models/ThemeConfig/ExtraLinkConfig"
import Img from "../Base/Img"
import { SubtleButton } from "../Base/SubtleButton"
import Toggle from "../Input/Toggle"
import Locale from "../i18n/Locale"
import { Utils } from "../../Utils"
import Svg from "../../Svg"
import Translations from "../i18n/Translations"
import { Translation } from "../i18n/Translation"
interface ExtraLinkButtonState {
layout: { id: string; title: Translation }
featureSwitches: { featureSwitchWelcomeMessage: Store<boolean> }
mapProperties: {
location: Store<{ lon: number; lat: number }>
zoom: Store<number>
}
}
export default class ExtraLinkButton extends UIElement {
private readonly _config: ExtraLinkConfig
private readonly state: ExtraLinkButtonState
constructor(state: ExtraLinkButtonState, config: ExtraLinkConfig) {
super()
this.state = state
this._config = config
}
protected InnerRender(): BaseUIElement {
if (this._config === undefined) {
return undefined
}
const c = this._config
const isIframe = window !== window.top
if (c.requirements?.has("iframe") && !isIframe) {
return undefined
}
if (c.requirements?.has("no-iframe") && isIframe) {
return undefined
}
let link: BaseUIElement
const theme = this.state.layout?.id ?? ""
const basepath = window.location.host
const href = this.state.mapProperties.location.map(
(loc) => {
const subs = {
...loc,
theme: theme,
basepath,
language: Locale.language.data,
}
return Utils.SubstituteKeys(c.href, subs)
},
[this.state.mapProperties.zoom]
)
let img: BaseUIElement = Svg.pop_out_svg()
if (c.icon !== undefined) {
img = new Img(c.icon).SetClass("h-6")
}
let text: Translation
if (c.text === undefined) {
text = Translations.t.general.screenToSmall.Subs({
theme: this.state.layout.title,
})
} else {
text = c.text.Clone()
}
link = new SubtleButton(img, text, {
url: href,
newTab: c.newTab,
})
if (c.requirements?.has("no-welcome-message")) {
link = new Toggle(
undefined,
link,
this.state.featureSwitches.featureSwitchWelcomeMessage
)
}
if (c.requirements?.has("welcome-message")) {
link = new Toggle(
link,
undefined,
this.state.featureSwitches.featureSwitchWelcomeMessage
)
}
return link
}
}

View file

@ -0,0 +1,110 @@
<script lang="ts">
/**
* The FilterView shows the various options to enable/disable a single layer or to only show a subset of the data.
*/
import type FilteredLayer from "../../Models/FilteredLayer"
import LayerConfig from "../../Models/ThemeConfig/LayerConfig"
import ToSvelte from "../Base/ToSvelte.svelte"
import Checkbox from "../Base/Checkbox.svelte"
import FilterConfig from "../../Models/ThemeConfig/FilterConfig"
import type { Writable } from "svelte/store"
import If from "../Base/If.svelte"
import Dropdown from "../Base/Dropdown.svelte"
import { onDestroy } from "svelte"
import { ImmutableStore, Store } from "../../Logic/UIEventSource"
import FilterviewWithFields from "./FilterviewWithFields.svelte"
import Tr from "../Base/Tr.svelte"
import Translations from "../i18n/Translations"
export let filteredLayer: FilteredLayer
export let highlightedLayer: Store<string | undefined> = new ImmutableStore(undefined)
export let zoomlevel: Store<number> = new ImmutableStore(22)
let layer: LayerConfig = filteredLayer.layerDef
let isDisplayed: Store<boolean> = filteredLayer.isDisplayed
/**
* Gets a UIEventSource as boolean for the given option, to be used with a checkbox
*/
function getBooleanStateFor(option: FilterConfig): Writable<boolean> {
const state = filteredLayer.appliedFilters.get(option.id)
return state.sync(
(f) => f === 0,
[],
(b) => (b ? 0 : undefined)
)
}
/**
* Gets a UIEventSource as number for the given option, to be used with a dropdown or radiobutton
*/
function getStateFor(option: FilterConfig): Writable<number> {
return filteredLayer.appliedFilters.get(option.id)
}
let mainElem: HTMLElement
$: onDestroy(
highlightedLayer.addCallbackAndRun((highlightedLayer) => {
if (highlightedLayer === filteredLayer.layerDef.id) {
mainElem?.classList?.add("glowing-shadow")
} else {
mainElem?.classList?.remove("glowing-shadow")
}
})
)
</script>
{#if filteredLayer.layerDef.name}
<div bind:this={mainElem} class="mb-1.5">
<label class="no-image-background flex gap-1">
<Checkbox selected={isDisplayed} />
<If condition={filteredLayer.isDisplayed}>
<ToSvelte
construct={() => layer.defaultIcon()?.SetClass("block h-6 w-6 no-image-background")}
/>
<ToSvelte
slot="else"
construct={() =>
layer.defaultIcon()?.SetClass("block h-6 w-6 no-image-background opacity-50")}
/>
</If>
{filteredLayer.layerDef.name}
{#if $zoomlevel < layer.minzoom}
<span class="alert">
<Tr t={Translations.t.general.layerSelection.zoomInToSeeThisLayer} />
</span>
{/if}
</label>
{#if $isDisplayed && filteredLayer.layerDef.filters?.length > 0}
<div id="subfilters" class="ml-4 flex flex-col gap-y-1">
{#each filteredLayer.layerDef.filters as filter}
<div>
<!-- There are three (and a half) modes of filters: a single checkbox, a radio button/dropdown or with searchable fields -->
{#if filter.options.length === 1 && filter.options[0].fields.length === 0}
<label>
<Checkbox selected={getBooleanStateFor(filter)} />
{filter.options[0].question}
</label>
{/if}
{#if filter.options.length === 1 && filter.options[0].fields.length > 0}
<FilterviewWithFields id={filter.id} {filteredLayer} option={filter.options[0]} />
{/if}
{#if filter.options.length > 1}
<Dropdown value={getStateFor(filter)}>
{#each filter.options as option, i}
<option value={i}>
{option.question}
</option>
{/each}
</Dropdown>
{/if}
</div>
{/each}
</div>
{/if}
</div>
{/if}

View file

@ -0,0 +1,61 @@
<script lang="ts">
import FilteredLayer from "../../Models/FilteredLayer"
import type { FilterConfigOption } from "../../Models/ThemeConfig/FilterConfig"
import Locale from "../i18n/Locale"
import ValidatedInput from "../InputElement/ValidatedInput.svelte"
import { UIEventSource } from "../../Logic/UIEventSource"
import { onDestroy } from "svelte"
import { Utils } from "../../Utils"
export let filteredLayer: FilteredLayer
export let option: FilterConfigOption
export let id: string
let parts: ({ message: string } | { subs: string })[]
let language = Locale.language
$: {
const template = option.question.textFor($language)
parts = Utils.splitIntoSubstitutionParts(template)
}
let fieldValues: Record<string, UIEventSource<string>> = {}
let fieldTypes: Record<string, string> = {}
let appliedFilter = <UIEventSource<string>>filteredLayer.appliedFilters.get(id)
let initialState: Record<string, string> = JSON.parse(appliedFilter?.data ?? "{}")
function setFields() {
const properties: Record<string, string> = {}
for (const key in fieldValues) {
const v = fieldValues[key].data
if (v === undefined) {
properties[key] = undefined
} else {
properties[key] = v
}
}
appliedFilter?.setData(FilteredLayer.fieldsToString(properties))
}
for (const field of option.fields) {
// A bit of cheating: the 'parts' will have '}' suffixed for fields
const src = new UIEventSource<string>(initialState[field.name] ?? "")
fieldTypes[field.name] = field.type
fieldValues[field.name] = src
onDestroy(
src.stabilized(200).addCallback(() => {
setFields()
})
)
}
</script>
<div>
{#each parts as part, i}
{#if part.subs}
<!-- This is a field! -->
<span class="mx-1">
<ValidatedInput value={fieldValues[part.subs]} type={fieldTypes[part.subs]} />
</span>
{:else}
{part.message}
{/if}
{/each}
</div>

View file

@ -0,0 +1,143 @@
import { VariableUiElement } from "../Base/VariableUIElement"
import Svg from "../../Svg"
import { UIEventSource } from "../../Logic/UIEventSource"
import GeoLocationHandler from "../../Logic/Actors/GeoLocationHandler"
import Hotkeys from "../Base/Hotkeys"
import Translations from "../i18n/Translations"
import Constants from "../../Models/Constants"
import { MapProperties } from "../../Models/MapProperties"
/**
* Displays an icon depending on the state of the geolocation.
* Will set the 'lock' if clicked twice
*/
export class GeolocationControl extends VariableUiElement {
constructor(geolocationHandler: GeoLocationHandler, state: MapProperties) {
const lastClick = new UIEventSource<Date>(undefined)
lastClick.addCallbackD((date) => {
geolocationHandler.geolocationState.requestMoment.setData(date)
})
const lastClickWithinThreeSecs = lastClick.map((lastClick) => {
if (lastClick === undefined) {
return false
}
const timeDiff = (new Date().getTime() - lastClick.getTime()) / 1000
return timeDiff <= 3
})
const lastRequestWithinTimeout = geolocationHandler.geolocationState.requestMoment.map(
(date) => {
if (date === undefined) {
return false
}
const timeDiff = (new Date().getTime() - date.getTime()) / 1000
return timeDiff <= Constants.zoomToLocationTimeout
}
)
const geolocationState = geolocationHandler?.geolocationState
super(
geolocationState?.permission?.map(
(permission) => {
if (permission === "denied") {
return Svg.location_refused_svg()
}
if (!geolocationState.allowMoving.data) {
return Svg.location_locked_svg()
}
if (geolocationState.currentGPSLocation.data === undefined) {
if (permission === "prompt") {
return Svg.location_empty_svg()
}
// Position not yet found, but permission is either requested or granted: we spin to indicate activity
const icon =
!geolocationHandler.mapHasMoved.data || lastRequestWithinTimeout.data
? Svg.location_svg()
: Svg.location_empty_svg()
return icon
.SetClass("cursor-wait")
.SetStyle("animation: spin 4s linear infinite;")
}
// We have a location, so we show a dot in the center
if (lastClickWithinThreeSecs.data) {
return Svg.location_unlocked_svg()
}
// We have a location, so we show a dot in the center
return Svg.location_svg()
},
[
geolocationState.currentGPSLocation,
geolocationState.allowMoving,
geolocationHandler.mapHasMoved,
lastClickWithinThreeSecs,
lastRequestWithinTimeout,
]
)
)
async function handleClick() {
if (
geolocationState.permission.data !== "granted" &&
geolocationState.currentGPSLocation.data === undefined
) {
lastClick.setData(new Date())
geolocationState.requestMoment.setData(new Date())
await geolocationState.requestPermission()
}
if (geolocationState.allowMoving.data === false) {
// Unlock
geolocationState.allowMoving.setData(true)
return
}
// A location _is_ known! Let's move to this location
const currentLocation = geolocationState.currentGPSLocation.data
if (currentLocation === undefined) {
// No location is known yet, not much we can do
lastClick.setData(new Date())
return
}
const inBounds = state.bounds.data.contains([
currentLocation.longitude,
currentLocation.latitude,
])
geolocationHandler.MoveMapToCurrentLocation()
if (inBounds) {
state.zoom.update((z) => z + 3)
}
if (lastClickWithinThreeSecs.data) {
geolocationState.allowMoving.setData(false)
lastClick.setData(undefined)
return
}
lastClick.setData(new Date())
}
this.onClick(handleClick)
Hotkeys.RegisterHotkey(
{ nomod: "L" },
Translations.t.hotkeyDocumentation.geolocate,
handleClick
)
lastClick.addCallbackAndRunD((_) => {
window.setTimeout(() => {
if (lastClickWithinThreeSecs.data) {
lastClick.ping()
}
}, 500)
})
geolocationHandler.geolocationState.requestMoment.addCallbackAndRunD((_) => {
window.setTimeout(() => {
if (lastRequestWithinTimeout.data) {
geolocationHandler.geolocationState.requestMoment.ping()
}
}, 500)
})
}
}

View file

@ -0,0 +1,116 @@
<script lang="ts">
import { UIEventSource } from "../../Logic/UIEventSource"
import type { Feature } from "geojson"
import LayerConfig from "../../Models/ThemeConfig/LayerConfig"
import ToSvelte from "../Base/ToSvelte.svelte"
import Svg from "../../Svg.js"
import Translations from "../i18n/Translations"
import Loading from "../Base/Loading.svelte"
import Hotkeys from "../Base/Hotkeys"
import { Geocoding } from "../../Logic/Osm/Geocoding"
import { BBox } from "../../Logic/BBox"
import { GeoIndexedStoreForLayer } from "../../Logic/FeatureSource/Actors/GeoIndexedStore"
import { createEventDispatcher, onDestroy } from "svelte"
export let perLayer: ReadonlyMap<string, GeoIndexedStoreForLayer> | undefined = undefined
export let bounds: UIEventSource<BBox>
export let selectedElement: UIEventSource<Feature> | undefined = undefined
export let selectedLayer: UIEventSource<LayerConfig> | undefined = undefined
export let clearAfterView: boolean = true
let searchContents: string = ""
export let triggerSearch: UIEventSource<any> = new UIEventSource<any>(undefined)
onDestroy(
triggerSearch.addCallback((_) => {
performSearch()
})
)
let isRunning: boolean = false
let inputElement: HTMLInputElement
let feedback: string = undefined
Hotkeys.RegisterHotkey({ ctrl: "F" }, Translations.t.hotkeyDocumentation.selectSearch, () => {
inputElement?.focus()
inputElement?.select()
})
const dispatch = createEventDispatcher<{ searchCompleted; searchIsValid: boolean }>()
$: {
if (!searchContents?.trim()) {
dispatch("searchIsValid", false)
} else {
dispatch("searchIsValid", true)
}
}
async function performSearch() {
try {
isRunning = true
searchContents = searchContents?.trim() ?? ""
if (searchContents === "") {
return
}
const result = await Geocoding.Search(searchContents, bounds.data)
if (result.length == 0) {
feedback = Translations.t.general.search.nothing.txt
return
}
const poi = result[0]
const [lat0, lat1, lon0, lon1] = poi.boundingbox
bounds.set(
new BBox([
[lon0, lat0],
[lon1, lat1],
]).pad(0.01)
)
if (perLayer !== undefined) {
const id = poi.osm_type + "/" + poi.osm_id
const layers = Array.from(perLayer?.values() ?? [])
for (const layer of layers) {
const found = layer.features.data.find((f) => f.properties.id === id)
selectedElement?.setData(found)
selectedLayer?.setData(layer.layer.layerDef)
}
}
if (clearAfterView) {
searchContents = ""
}
dispatch("searchIsValid", false)
dispatch("searchCompleted")
} catch (e) {
console.error(e)
feedback = Translations.t.general.search.error.txt
} finally {
isRunning = false
}
}
</script>
<div class="normal-background flex justify-between rounded-full pl-2">
<form class="w-full">
{#if isRunning}
<Loading>{Translations.t.general.search.searching}</Loading>
{:else if feedback !== undefined}
<div class="alert" on:click={() => (feedback = undefined)}>
{feedback}
</div>
{:else}
<input
type="search"
class="w-full"
bind:this={inputElement}
on:keypress={(keypr) => (keypr.key === "Enter" ? performSearch() : undefined)}
bind:value={searchContents}
placeholder={Translations.t.general.search.search}
/>
{/if}
</form>
<div class="h-6 w-6 self-end" on:click={performSearch}>
<ToSvelte construct={Svg.search_svg} />
</div>
</div>

View file

@ -0,0 +1,50 @@
<script lang="ts">
import { OsmConnection } from "../../Logic/Osm/OsmConnection"
import { UIEventSource } from "../../Logic/UIEventSource"
import * as themeOverview from "../../assets/generated/theme_overview.json"
import { Utils } from "../../Utils"
import ThemesList from "./ThemesList.svelte"
import Translations from "../i18n/Translations"
import { LayoutInformation } from "../../Models/ThemeConfig/LayoutConfig"
import LoginToggle from "../Base/LoginToggle.svelte"
export let search: UIEventSource<string>
export let state: { osmConnection: OsmConnection }
export let onMainScreen: boolean = true
const prefix = "mapcomplete-hidden-theme-"
const hiddenThemes: LayoutInformation[] =
(themeOverview["default"] ?? themeOverview)?.filter((layout) => layout.hideFromOverview) ?? []
const userPreferences = state.osmConnection.preferencesHandler.preferences
const t = Translations.t.general.morescreen
let knownThemesId: string[]
$: knownThemesId = Utils.NoNull(
Object.keys($userPreferences)
.filter((key) => key.startsWith(prefix))
.map((key) => key.substring(prefix.length, key.length - "-enabled".length))
)
$: console.log("Known theme ids:", knownThemesId)
$: knownThemes = hiddenThemes.filter((theme) => knownThemesId.includes(theme.id))
</script>
<LoginToggle {state}>
<ThemesList
hideThemes={false}
isCustom={false}
{onMainScreen}
{search}
{state}
themes={knownThemes}
>
<svelte:fragment slot="title">
<h3>{t.previouslyHiddenTitle.toString()}</h3>
<p>
{t.hiddenExplanation.Subs({
hidden_discovered: knownThemes.length.toString(),
total_hidden: hiddenThemes.length.toString(),
})}
</p>
</svelte:fragment>
</ThemesList>
</LoginToggle>

View file

@ -0,0 +1,154 @@
import { VariableUiElement } from "../Base/VariableUIElement"
import { Store, UIEventSource } from "../../Logic/UIEventSource"
import Table from "../Base/Table"
import Combine from "../Base/Combine"
import { FixedUiElement } from "../Base/FixedUiElement"
import { Utils } from "../../Utils"
import BaseUIElement from "../BaseUIElement"
import Svg from "../../Svg"
export default class Histogram<T> extends VariableUiElement {
private static defaultPalette = [
"#ff5858",
"#ffad48",
"#ffff59",
"#56bd56",
"#63a9ff",
"#9d62d9",
"#fa61fa",
]
constructor(
values: Store<string[]>,
title: string | BaseUIElement,
countTitle: string | BaseUIElement,
options?: {
assignColor?: (t0: string) => string
sortMode?: "name" | "name-rev" | "count" | "count-rev"
}
) {
const sortMode = new UIEventSource<"name" | "name-rev" | "count" | "count-rev">(
options?.sortMode ?? "name"
)
const sortName = new VariableUiElement(
sortMode.map((m) => {
switch (m) {
case "name":
return Svg.up_svg()
case "name-rev":
return Svg.up_svg().SetStyle("transform: rotate(180deg)")
default:
return Svg.circle_svg()
}
})
)
const titleHeader = new Combine([sortName.SetClass("w-4 mr-2"), title])
.SetClass("flex")
.onClick(() => {
if (sortMode.data === "name") {
sortMode.setData("name-rev")
} else {
sortMode.setData("name")
}
})
const sortCount = new VariableUiElement(
sortMode.map((m) => {
switch (m) {
case "count":
return Svg.up_svg()
case "count-rev":
return Svg.up_svg().SetStyle("transform: rotate(180deg)")
default:
return Svg.circle_svg()
}
})
)
const countHeader = new Combine([sortCount.SetClass("w-4 mr-2"), countTitle])
.SetClass("flex")
.onClick(() => {
if (sortMode.data === "count-rev") {
sortMode.setData("count")
} else {
sortMode.setData("count-rev")
}
})
const header = [titleHeader, countHeader]
super(
values.map(
(values) => {
if (values === undefined) {
return undefined
}
values = Utils.NoNull(values)
const counts = new Map<string, number>()
for (const value of values) {
const c = counts.get(value) ?? 0
counts.set(value, c + 1)
}
const keys = Array.from(counts.keys())
switch (sortMode.data) {
case "name":
keys.sort()
break
case "name-rev":
keys.sort().reverse(/*Copy of array, inplace reverse if fine*/)
break
case "count":
keys.sort((k0, k1) => counts.get(k0) - counts.get(k1))
break
case "count-rev":
keys.sort((k0, k1) => counts.get(k1) - counts.get(k0))
break
}
const max = Math.max(...Array.from(counts.values()))
const fallbackColor = (keyValue: string) => {
const index = keys.indexOf(keyValue)
return Histogram.defaultPalette[index % Histogram.defaultPalette.length]
}
let actualAssignColor = undefined
if (options?.assignColor === undefined) {
actualAssignColor = fallbackColor
} else {
actualAssignColor = (keyValue: string) => {
return options.assignColor(keyValue) ?? fallbackColor(keyValue)
}
}
return new Table(
header,
keys.map((key) => [
key,
new Combine([
new Combine([
new FixedUiElement("" + counts.get(key)).SetClass(
"font-bold rounded-full block"
),
])
.SetClass("flex justify-center rounded border border-black")
.SetStyle(
`background: ${actualAssignColor(key)}; width: ${
(100 * counts.get(key)) / max
}%`
),
]).SetClass("block w-full"),
]),
{
contentStyle: keys.map((_) => ["width: 20%"]),
}
).SetClass("w-full zebra-table")
},
[sortMode]
)
)
}
}

View file

@ -0,0 +1,29 @@
import Combine from "../Base/Combine"
import Translations from "../i18n/Translations"
import { FixedUiElement } from "../Base/FixedUiElement"
export default class IndexText extends Combine {
constructor() {
super([
new FixedUiElement(
`<img class="w-12 h-12 sm:h-24 sm:w-24" src="./assets/svg/logo.svg" alt="MapComplete Logo">`
).SetClass("flex-none m-3"),
new Combine([
Translations.t.index.title.SetClass(
"text-2xl tracking-tight font-extrabold text-gray-900 sm:text-5xl md:text-6xl block text-gray-800 xl:inline"
),
Translations.t.index.intro.SetClass(
"mt-3 text-base font-semibold text-gray-500 sm:mt-5 sm:text-lg sm:max-w-xl sm:mx-auto md:mt-5 md:text-xl lg:mx-0"
),
Translations.t.index.pickTheme.SetClass(
"mt-3 text-base sm:mt-5 sm:text-lg sm:max-w-xl sm:mx-auto md:mt-5 md:text-xl lg:mx-0"
),
]).SetClass("flex flex-col sm:text-center lg:text-left m-1 mt-2 md:m-2 md:mt-4"),
])
this.SetClass("flex flex-row")
}
}

View file

@ -0,0 +1,32 @@
<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

@ -0,0 +1,29 @@
<script lang="ts">
import Translations from "../i18n/Translations"
import Svg from "../../Svg"
import { Store } from "../../Logic/UIEventSource"
import Tr from "../Base/Tr.svelte"
import ToSvelte from "../Base/ToSvelte.svelte"
/*
A subtleButton which opens mapillary in a new tab at the current location
*/
export let mapProperties: {
readonly zoom: Store<number>
readonly location: Store<{ lon: number; lat: number }>
}
let location = mapProperties.location
let zoom = mapProperties.zoom
let mapillaryLink = `https://www.mapillary.com/app/?focus=map&lat=${$location?.lat ?? 0}&lng=${
$location?.lon ?? 0
}&z=${Math.max(($zoom ?? 2) - 1, 1)}`
</script>
<a class="button flex items-center" href={mapillaryLink} target="_blank">
<ToSvelte construct={() => Svg.mapillary_black_svg().SetClass("w-12 h-12 m-2 mr-4 shrink-0")} />
<div class="flex flex-col">
<Tr t={Translations.t.general.attribution.openMapillary} />
<Tr cls="subtle" t={Translations.t.general.attribution.mapillaryHelp} />
</div>
</a>

View file

@ -0,0 +1,183 @@
import Svg from "../../Svg"
import Combine from "../Base/Combine"
import Translations from "../i18n/Translations"
import LayoutConfig, { LayoutInformation } from "../../Models/ThemeConfig/LayoutConfig"
import { ImmutableStore, Store } from "../../Logic/UIEventSource"
import UserRelatedState from "../../Logic/State/UserRelatedState"
import { Utils } from "../../Utils"
import themeOverview from "../../assets/generated/theme_overview.json"
import { TextField } from "../Input/TextField"
import Locale from "../i18n/Locale"
import SvelteUIElement from "../Base/SvelteUIElement"
import ThemesList from "./ThemesList.svelte"
import HiddenThemeList from "./HiddenThemeList.svelte"
import UnofficialThemeList from "./UnofficialThemeList.svelte"
export default class MoreScreen extends Combine {
private static readonly officialThemes: LayoutInformation[] = themeOverview
constructor(
state: UserRelatedState & {
layoutToUse?: LayoutConfig
},
onMainScreen: boolean = false
) {
const tr = Translations.t.general.morescreen
const search = new TextField({
placeholder: tr.searchForATheme,
})
search.enterPressed.addCallbackD((searchTerm) => {
searchTerm = searchTerm.toLowerCase()
if (!searchTerm) {
return
}
if (searchTerm === "personal") {
window.location.href = MoreScreen.createUrlFor(
{ id: "personal" },
false,
state
).data
}
if (searchTerm === "bugs" || searchTerm === "issues") {
window.location.href = "https://github.com/pietervdvn/MapComplete/issues"
}
if (searchTerm === "source") {
window.location.href = "https://github.com/pietervdvn/MapComplete"
}
if (searchTerm === "docs") {
window.location.href = "https://github.com/pietervdvn/MapComplete/tree/develop/Docs"
}
if (searchTerm === "osmcha" || searchTerm === "stats") {
window.location.href = Utils.OsmChaLinkFor(7)
}
// Enter pressed -> search the first _official_ matchin theme and open it
const publicTheme = MoreScreen.officialThemes.find(
(th) =>
th.hideFromOverview == false &&
th.id !== "personal" &&
MoreScreen.MatchesLayout(th, searchTerm)
)
if (publicTheme !== undefined) {
window.location.href = MoreScreen.createUrlFor(publicTheme, false, state).data
}
const hiddenTheme = MoreScreen.officialThemes.find(
(th) => th.id !== "personal" && MoreScreen.MatchesLayout(th, searchTerm)
)
if (hiddenTheme !== undefined) {
window.location.href = MoreScreen.createUrlFor(hiddenTheme, false, state).data
}
})
if (onMainScreen) {
search.focus()
document.addEventListener("keydown", function (event) {
if (event.ctrlKey && event.code === "KeyF") {
search.focus()
event.preventDefault()
}
})
}
const searchBar = new Combine([
Svg.search_svg().SetClass("w-8"),
search.SetClass("mr-4 w-full"),
]).SetClass("flex rounded-full border-2 border-black items-center my-2 w-1/2")
super([
new Combine([searchBar]).SetClass("flex justify-center"),
new SvelteUIElement(ThemesList, {
state,
onMainScreen,
search: search.GetValue(),
themes: MoreScreen.officialThemes,
}),
new SvelteUIElement(HiddenThemeList, {
state,
onMainScreen,
search: search.GetValue(),
}),
new SvelteUIElement(UnofficialThemeList, {
state,
onMainScreen,
search: search.GetValue(),
}),
tr.streetcomplete.Clone().SetClass("block text-base mx-10 my-3 mb-10"),
])
}
public static MatchesLayout(
layout: {
id: string
title: any
shortDescription: any
keywords?: any[]
},
search: string
): boolean {
if (search === undefined) {
return true
}
search = search.toLocaleLowerCase()
if (search.length > 3 && layout.id.toLowerCase().indexOf(search) >= 0) {
return true
}
if (layout.id === "personal") {
return false
}
const entitiesToSearch = [layout.shortDescription, layout.title, ...(layout.keywords ?? [])]
for (const entity of entitiesToSearch) {
if (entity === undefined) {
continue
}
const term = entity["*"] ?? entity[Locale.language.data]
if (term?.toLowerCase()?.indexOf(search) >= 0) {
return true
}
}
return false
}
private static createUrlFor(
layout: { id: string; definition?: string },
isCustom: boolean,
state?: { layoutToUse?: { id } }
): Store<string> {
if (layout === undefined) {
return undefined
}
if (layout.id === undefined) {
console.error("ID is undefined for layout", layout)
return undefined
}
if (layout.id === state?.layoutToUse?.id) {
return undefined
}
let path = window.location.pathname
// Path starts with a '/' and contains everything, e.g. '/dir/dir/page.html'
path = path.substr(0, path.lastIndexOf("/"))
// Path will now contain '/dir/dir', or empty string in case of nothing
if (path === "") {
path = "."
}
let linkPrefix = `${path}/${layout.id.toLowerCase()}.html?`
if (location.hostname === "localhost" || location.hostname === "127.0.0.1") {
linkPrefix = `${path}/theme.html?layout=${layout.id}&`
}
if (isCustom) {
linkPrefix = `${path}/theme.html?userlayout=${layout.id}&`
}
let hash = ""
if (layout.definition !== undefined) {
hash = "#" + btoa(JSON.stringify(layout.definition))
}
return new ImmutableStore<string>(`${linkPrefix}${hash}`)
}
}

View file

@ -0,0 +1,117 @@
<script lang="ts">
import type { SpecialVisualizationState } from "../SpecialVisualization"
import LocationInput from "../InputElement/Helpers/LocationInput.svelte"
import { UIEventSource } from "../../Logic/UIEventSource"
import { Tiles } from "../../Models/TileRange"
import { Map as MlMap } from "maplibre-gl"
import { BBox } from "../../Logic/BBox"
import type { MapProperties } from "../../Models/MapProperties"
import ShowDataLayer from "../Map/ShowDataLayer"
import type {
FeatureSource,
FeatureSourceForLayer,
} from "../../Logic/FeatureSource/FeatureSource"
import SnappingFeatureSource from "../../Logic/FeatureSource/Sources/SnappingFeatureSource"
import FeatureSourceMerger from "../../Logic/FeatureSource/Sources/FeatureSourceMerger"
import LayerConfig from "../../Models/ThemeConfig/LayerConfig"
import { Utils } from "../../Utils"
import { createEventDispatcher } from "svelte"
/**
* An advanced location input, which has support to:
* - Show more layers
* - Snap to layers
*
* This one is mostly used to insert new points, including when importing
*/
export let state: SpecialVisualizationState
/**
* The start coordinate
*/
export let coordinate: { lon: number; lat: number }
export let snapToLayers: string[] | undefined
export let targetLayer: LayerConfig
export let maxSnapDistance: number = undefined
export let snappedTo: UIEventSource<string | undefined>
export let value: UIEventSource<{ lon: number; lat: number }>
if (value.data === undefined) {
value.setData(coordinate)
}
let preciseLocation: UIEventSource<{ lon: number; lat: number }> = new UIEventSource<{
lon: number
lat: number
}>(undefined)
const dispatch = createEventDispatcher<{ click: { lon: number; lat: number } }>()
const xyz = Tiles.embedded_tile(coordinate.lat, coordinate.lon, 16)
const map: UIEventSource<MlMap> = new UIEventSource<MlMap>(undefined)
let initialMapProperties: Partial<MapProperties> = {
zoom: new UIEventSource<number>(19),
maxbounds: new UIEventSource(undefined),
/*If no snapping needed: the value is simply the map location;
* If snapping is needed: the value will be set later on by the snapping feature source
* */
location:
snapToLayers?.length > 0
? new UIEventSource<{ lon: number; lat: number }>(coordinate)
: value,
bounds: new UIEventSource<BBox>(undefined),
allowMoving: new UIEventSource<boolean>(true),
allowZooming: new UIEventSource<boolean>(true),
minzoom: new UIEventSource<number>(18),
rasterLayer: UIEventSource.feedFrom(state.mapProperties.rasterLayer),
}
const featuresForLayer = state.perLayer.get(targetLayer.id)
if (featuresForLayer) {
new ShowDataLayer(map, {
layer: targetLayer,
features: featuresForLayer,
})
}
if (snapToLayers?.length > 0) {
const snapSources: FeatureSource[] = []
for (const layerId of snapToLayers ?? []) {
const layer: FeatureSourceForLayer = state.perLayer.get(layerId)
snapSources.push(layer)
if (layer.features === undefined) {
continue
}
new ShowDataLayer(map, {
layer: layer.layer.layerDef,
zoomToFeatures: false,
features: layer,
})
}
const snappedLocation = new SnappingFeatureSource(
new FeatureSourceMerger(...Utils.NoNull(snapSources)),
// We snap to the (constantly updating) map location
initialMapProperties.location,
{
maxDistance: maxSnapDistance ?? 15,
allowUnsnapped: true,
snappedTo,
snapLocation: value,
}
)
new ShowDataLayer(map, {
layer: targetLayer,
features: snappedLocation,
})
}
</script>
<LocationInput
{map}
on:click={(data) => dispatch("click", data)}
mapProperties={initialMapProperties}
value={preciseLocation}
initialCoordinate={coordinate}
maxDistanceInMeters="50"
/>

View file

@ -0,0 +1,34 @@
<script context="module" lang="ts">
export interface Theme {
id: string
icon: string
title: any
shortDescription: any
definition?: any
mustHaveLanguage?: boolean
hideFromOverview: boolean
keywords?: any[]
}
</script>
<script lang="ts">
import { UIEventSource } from "../../Logic/UIEventSource"
import Svg from "../../Svg"
import ToSvelte from "../Base/ToSvelte.svelte"
import Translations from "../i18n/Translations"
import Tr from "../Base/Tr.svelte"
export let search: UIEventSource<string>
const t = Translations.t.general.morescreen
</script>
<div class="w-full">
<h5>{t.noMatchingThemes.toString()}</h5>
<div class="flex justify-center">
<button on:click={() => search.setData("")}>
<ToSvelte construct={Svg.search_disable_svg().SetClass("w-6 mr-2")} />
<Tr slot="message" t={t.noSearch} />
</button>
</div>
</div>

View file

@ -0,0 +1,21 @@
<script lang="ts">
/**
* A mapcontrol button which allows the user to select a different background.
* Even though the componenet is very small, it gets it's own class as it is often reused
*/
import { Square3Stack3dIcon } from "@babeard/svelte-heroicons/solid"
import type { SpecialVisualizationState } from "../SpecialVisualization"
import Translations from "../i18n/Translations"
import MapControlButton from "../Base/MapControlButton.svelte"
import Tr from "../Base/Tr.svelte"
export let state: SpecialVisualizationState
export let hideTooltip = false
</script>
<MapControlButton on:click={() => state.guistate.backgroundLayerSelectionIsOpened.setData(true)}>
<Square3Stack3dIcon class="h-6 w-6" />
{#if !hideTooltip}
<Tr cls="mx-2" t={Translations.t.general.backgroundSwitch} />
{/if}
</MapControlButton>

View file

@ -0,0 +1,32 @@
<script lang="ts">
import { Store } from "../../Logic/UIEventSource"
import { PencilIcon } from "@babeard/svelte-heroicons/solid"
import Translations from "../i18n/Translations"
import Tr from "../Base/Tr.svelte"
export let mapProperties: { location: Store<{ lon: number; lat: number }>; zoom: Store<number> }
let location = mapProperties.location
let zoom = mapProperties.zoom
export let objectId: undefined | string = undefined
let elementSelect = ""
if (objectId !== undefined) {
const parts = objectId?.split("/")
const tp = parts[0]
if (
parts.length === 2 &&
!isNaN(Number(parts[1])) &&
(tp === "node" || tp === "way" || tp === "relation")
) {
elementSelect = "&" + tp + "=" + parts[1]
}
}
const idLink = `https://www.openstreetmap.org/edit?editor=id${elementSelect}#map=${$zoom ?? 0}/${
$location?.lat ?? 0
}/${$location?.lon ?? 0}`
</script>
<a class="button flex items-center" target="_blank" href={idLink}>
<PencilIcon class="h-12 w-12 p-2 pr-4" />
<Tr t={Translations.t.general.attribution.editId} />
</a>

View file

@ -0,0 +1,59 @@
import Combine from "../Base/Combine"
import { OsmConnection } from "../../Logic/Osm/OsmConnection"
import { Store, UIEventSource } from "../../Logic/UIEventSource"
import { BBox } from "../../Logic/BBox"
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 { Utils } from "../../Utils"
import Constants from "../../Models/Constants"
export class OpenJosm extends Combine {
constructor(osmConnection: OsmConnection, bounds: Store<BBox>, iconStyle?: string) {
const t = Translations.t.general.attribution
const josmState = new UIEventSource<string>(undefined)
// Reset after 15s
josmState.stabilized(15000).addCallbackD((_) => josmState.setData(undefined))
const stateIndication = new VariableUiElement(
josmState.map((state) => {
if (state === undefined) {
return undefined
}
state = state.toUpperCase()
if (state === "OK") {
return t.josmOpened.SetClass("thanks")
}
return t.josmNotOpened.SetClass("alert")
})
)
const toggle = new Toggle(
new SubtleButton(Svg.josm_logo_svg().SetStyle(iconStyle), t.editJosm)
.onClick(() => {
const bbox = bounds.data
if (bbox === undefined) {
return
}
const top = bbox.getNorth()
const bottom = bbox.getSouth()
const right = bbox.getEast()
const left = bbox.getWest()
const josmLink = `http://127.0.0.1:8111/load_and_zoom?left=${left}&right=${right}&top=${top}&bottom=${bottom}`
Utils.download(josmLink)
.then((answer) => josmState.setData(answer.replace(/\n/g, "").trim()))
.catch((_) => josmState.setData("ERROR"))
})
.SetClass("w-full"),
undefined,
osmConnection.userDetails.map(
(ud) => ud.loggedIn && ud.csCount >= Constants.userJourney.historyLinkVisible
)
)
super([stateIndication, toggle])
}
}

View file

@ -0,0 +1,50 @@
<script lang="ts">
/**
* The OverlayToggle shows a single toggle to enable or disable an overlay
*/
import Checkbox from "../Base/Checkbox.svelte"
import { onDestroy } from "svelte"
import { UIEventSource } from "../../Logic/UIEventSource"
import Tr from "../Base/Tr.svelte"
import Translations from "../i18n/Translations"
import { Translation } from "../i18n/Translation"
import type { RasterLayerProperties } from "../../Models/RasterLayerProperties"
export let layerproperties: RasterLayerProperties
export let state: { isDisplayed: UIEventSource<boolean> }
export let zoomlevel: UIEventSource<number>
export let highlightedLayer: UIEventSource<string> | undefined
let isDisplayed: boolean = state.isDisplayed.data
onDestroy(
state.isDisplayed.addCallbackAndRunD((d) => {
isDisplayed = d
return false
})
)
let mainElem: HTMLElement
$: onDestroy(
highlightedLayer.addCallbackAndRun((highlightedLayer) => {
if (highlightedLayer === layerproperties.id) {
mainElem?.classList?.add("glowing-shadow")
} else {
mainElem?.classList?.remove("glowing-shadow")
}
})
)
</script>
{#if layerproperties.name}
<div bind:this={mainElem}>
<label class="flex gap-1">
<Checkbox selected={state.isDisplayed} />
<Tr t={new Translation(layerproperties.name)} />
{#if $zoomlevel < layerproperties.min_zoom}
<span class="alert">
<Tr t={Translations.t.general.layerSelection.zoomInToSeeThisLayer} />
</span>
{/if}
</label>
</div>
{/if}

View file

@ -0,0 +1,127 @@
import { VariableUiElement } from "../Base/VariableUIElement"
import { Store, UIEventSource } from "../../Logic/UIEventSource"
import PlantNet from "../../Logic/Web/PlantNet"
import Loading from "../Base/Loading"
import Wikidata from "../../Logic/Web/Wikidata"
import WikidataPreviewBox from "../Wikipedia/WikidataPreviewBox"
import { Button } from "../Base/Button"
import Combine from "../Base/Combine"
import Title from "../Base/Title"
import Translations from "../i18n/Translations"
import List from "../Base/List"
import Svg from "../../Svg"
export default class PlantNetSpeciesSearch extends VariableUiElement {
/***
* Given images, queries plantnet to search a species matching those images.
* A list of species will be presented to the user, after which they can confirm an item.
* The wikidata-url is returned in the callback when the user selects one
*/
constructor(images: Store<string[]>, onConfirm: (wikidataUrl: string) => Promise<void>) {
const t = Translations.t.plantDetection
super(
images
.bind((images) => {
if (images.length === 0) {
return null
}
return UIEventSource.FromPromiseWithErr(PlantNet.query(images.slice(0, 5)))
})
.map((result) => {
if (images.data.length === 0) {
return new Combine([
t.takeImages,
t.howTo.intro,
new List([t.howTo.li0, t.howTo.li1, t.howTo.li2, t.howTo.li3]),
]).SetClass("flex flex-col")
}
if (result === undefined) {
return new Loading(t.querying.Subs(images.data))
}
if (result["error"] !== undefined) {
return t.error.Subs(<any>result).SetClass("alert")
}
console.log(result)
const success = result["success"]
const selectedSpecies = new UIEventSource<string>(undefined)
const speciesInformation = success.results
.filter((species) => species.score >= 0.005)
.map((species) => {
const wikidata = UIEventSource.FromPromise(
Wikidata.Sparql<{ species }>(
["?species", "?speciesLabel"],
['?species wdt:P846 "' + species.gbif.id + '"']
)
)
const confirmButton = new Button(t.seeInfo, async () => {
await selectedSpecies.setData(wikidata.data[0].species?.value)
}).SetClass("btn")
const match = t.matchPercentage
.Subs({ match: Math.round(species.score * 100) })
.SetClass("font-bold")
const extraItems = new Combine([match, confirmButton]).SetClass(
"flex flex-col"
)
return new WikidataPreviewBox(
wikidata.map((wd) =>
wd == undefined ? undefined : wd[0]?.species?.value
),
{
whileLoading: new Loading(
t.loadingWikidata.Subs({
species: species.species.scientificNameWithoutAuthor,
})
),
extraItems: [new Combine([extraItems])],
imageStyle: "max-width: 8rem; width: unset; height: 8rem",
}
).SetClass("border-2 border-subtle rounded-xl block mb-2")
})
const plantOverview = new Combine([
new Title(t.overviewTitle),
t.overviewIntro,
t.overviewVerify.SetClass("font-bold"),
...speciesInformation,
]).SetClass("flex flex-col")
return new VariableUiElement(
selectedSpecies.map((wikidataSpecies) => {
if (wikidataSpecies === undefined) {
return plantOverview
}
return new Combine([
new Button(
new Combine([
Svg.back_svg().SetClass(
"w-6 mr-1 bg-white rounded-full p-1"
),
t.back,
]).SetClass("flex"),
() => {
selectedSpecies.setData(undefined)
}
).SetClass("btn btn-secondary"),
new Button(
new Combine([
Svg.confirm_svg().SetClass("w-6 mr-1"),
t.confirm,
]).SetClass("flex"),
() => {
onConfirm(wikidataSpecies)
}
).SetClass("btn"),
]).SetClass("flex justify-between")
})
)
})
)
}
}

View file

@ -0,0 +1,25 @@
import Combine from "../Base/Combine"
import Translations from "../i18n/Translations"
import Title from "../Base/Title"
export default class PrivacyPolicy extends Combine {
constructor() {
const t = Translations.t.privacy
super([
new Title(t.title, 2),
t.intro,
new Title(t.trackingTitle),
t.tracking,
new Title(t.geodataTitle),
t.geodata,
new Title(t.editingTitle),
t.editing,
new Title(t.miscCookiesTitle),
t.miscCookies,
new Title(t.whileYoureHere),
t.surveillance,
])
this.SetClass("link-underline")
}
}

View file

@ -0,0 +1,74 @@
<script lang="ts">
import type { Feature } from "geojson"
import { UIEventSource } from "../../Logic/UIEventSource"
import LayerConfig from "../../Models/ThemeConfig/LayerConfig"
import type { SpecialVisualizationState } from "../SpecialVisualization"
import TagRenderingAnswer from "../Popup/TagRendering/TagRenderingAnswer.svelte"
import { onDestroy } from "svelte"
import Translations from "../i18n/Translations"
import Tr from "../Base/Tr.svelte"
import { XCircleIcon } from "@rgossiaux/svelte-heroicons/solid"
export let state: SpecialVisualizationState
export let layer: LayerConfig
export let selectedElement: Feature
export let tags: UIEventSource<Record<string, string>>
let _tags: Record<string, string>
onDestroy(
tags.addCallbackAndRun((tags) => {
_tags = tags
})
)
let _metatags: Record<string, string>
onDestroy(
state.userRelatedState.preferencesAsTags.addCallbackAndRun((tags) => {
_metatags = tags
})
)
</script>
{#if _tags._deleted === "yes"}
<Tr t={Translations.t.delete.isDeleted} />
{:else}
<div
class="low-interaction flex items-center justify-between border-b-2 border-black px-3 drop-shadow-md"
>
<div class="flex flex-col">
<!-- Title element-->
<h3>
<TagRenderingAnswer config={layer.title} {selectedElement} {state} {tags} {layer} />
</h3>
<div
class="no-weblate title-icons links-as-button mr-2 flex flex-row flex-wrap items-center gap-x-0.5 p-1 pt-0.5 sm:pt-1"
>
{#each layer.titleIcons as titleIconConfig}
{#if (titleIconConfig.condition?.matchesProperties(_tags) ?? true) && (titleIconConfig.metacondition?.matchesProperties( { ..._metatags, ..._tags } ) ?? true) && titleIconConfig.IsKnown(_tags)}
<div class="flex h-8 w-8 items-center">
<TagRenderingAnswer
config={titleIconConfig}
{tags}
{selectedElement}
{state}
{layer}
extraClasses="h-full justify-center"
/>
</div>
{/if}
{/each}
</div>
</div>
<XCircleIcon
class="h-8 w-8 cursor-pointer"
on:click={() => state.selectedElement.setData(undefined)}
/>
</div>
{/if}
<style>
:global(.title-icons a) {
display: block !important;
}
</style>

View file

@ -0,0 +1,54 @@
<script lang="ts">
import type { Feature } from "geojson"
import { UIEventSource } from "../../Logic/UIEventSource"
import LayerConfig from "../../Models/ThemeConfig/LayerConfig"
import type { SpecialVisualizationState } from "../SpecialVisualization"
import TagRenderingEditable from "../Popup/TagRendering/TagRenderingEditable.svelte"
import { onDestroy } from "svelte"
import Translations from "../i18n/Translations"
import Tr from "../Base/Tr.svelte"
export let state: SpecialVisualizationState
export let layer: LayerConfig
export let selectedElement: Feature
export let tags: UIEventSource<Record<string, string>>
export let highlightedRendering: UIEventSource<string> = undefined
let _tags: Record<string, string>
onDestroy(
tags.addCallbackAndRun((tags) => {
_tags = tags
})
)
let _metatags: Record<string, string>
onDestroy(
state.userRelatedState.preferencesAsTags.addCallbackAndRun((tags) => {
_metatags = tags
})
)
</script>
{#if _tags._deleted === "yes"}
<Tr t={Translations.t.delete.isDeleted} />
<button class="w-full" on:click={() => state.selectedElement.setData(undefined)}>
<Tr t={Translations.t.general.returnToTheMap} />
</button>
{:else}
<div class="flex flex-col gap-y-2 overflow-y-auto p-1 px-2">
{#each layer.tagRenderings as config (config.id)}
{#if (config.condition === undefined || config.condition.matchesProperties(_tags)) && (config.metacondition === undefined || config.metacondition.matchesProperties( { ..._tags, ..._metatags } ))}
{#if config.IsKnown(_tags)}
<TagRenderingEditable
{tags}
{config}
{state}
{selectedElement}
{layer}
{highlightedRendering}
/>
{/if}
{/if}
{/each}
</div>
{/if}

View file

@ -0,0 +1,256 @@
import { VariableUiElement } from "../Base/VariableUIElement"
import { Translation } from "../i18n/Translation"
import Svg from "../../Svg"
import Combine from "../Base/Combine"
import { Store, UIEventSource } from "../../Logic/UIEventSource"
import { Utils } from "../../Utils"
import Translations from "../i18n/Translations"
import BaseUIElement from "../BaseUIElement"
import LayerConfig from "../../Models/ThemeConfig/LayerConfig"
import { InputElement } from "../Input/InputElement"
import { CheckBox } from "../Input/Checkboxes"
import { SubtleButton } from "../Base/SubtleButton"
import LZString from "lz-string"
import { SpecialVisualizationState } from "../SpecialVisualization"
export class ShareScreen extends Combine {
constructor(state: SpecialVisualizationState) {
const layout = state?.layout
const tr = Translations.t.general.sharescreen
const optionCheckboxes: InputElement<boolean>[] = []
const optionParts: Store<string>[] = []
const includeLocation = new CheckBox(tr.fsIncludeCurrentLocation, true)
optionCheckboxes.push(includeLocation)
const currentLocation = state.mapProperties.location
const zoom = state.mapProperties.zoom
optionParts.push(
includeLocation.GetValue().map(
(includeL) => {
if (currentLocation === undefined) {
return null
}
if (includeL) {
return [
["z", zoom.data],
["lat", currentLocation.data?.lat],
["lon", currentLocation.data?.lon],
]
.filter((p) => p[1] !== undefined)
.map((p) => p[0] + "=" + p[1])
.join("&")
} else {
return null
}
},
[currentLocation, zoom]
)
)
function fLayerToParam(flayer: {
isDisplayed: UIEventSource<boolean>
layerDef: LayerConfig
}) {
if (flayer.isDisplayed.data) {
return null // Being displayed is the default
}
return "layer-" + flayer.layerDef.id + "=" + flayer.isDisplayed.data
}
const currentLayer: Store<
{ id: string; name: string | Record<string, string> } | undefined
> = state.mapProperties.rasterLayer.map((l) => l?.properties)
const currentBackground = new VariableUiElement(
currentLayer.map((layer) => {
return tr.fsIncludeCurrentBackgroundMap.Subs({ name: layer?.name ?? "" })
})
)
const includeCurrentBackground = new CheckBox(currentBackground, true)
optionCheckboxes.push(includeCurrentBackground)
optionParts.push(
includeCurrentBackground.GetValue().map(
(includeBG) => {
if (includeBG) {
return "background=" + currentLayer.data?.id
} else {
return null
}
},
[currentLayer]
)
)
const includeLayerChoices = new CheckBox(tr.fsIncludeCurrentLayers, true)
optionCheckboxes.push(includeLayerChoices)
optionParts.push(
includeLayerChoices.GetValue().map(
(includeLayerSelection) => {
if (includeLayerSelection) {
return Utils.NoNull(
Array.from(state.layerState.filteredLayers.values()).map(fLayerToParam)
).join("&")
} else {
return null
}
},
Array.from(state.layerState.filteredLayers.values()).map(
(flayer) => flayer.isDisplayed
)
)
)
const switches = [
{ urlName: "fs-userbadge", human: tr.fsUserbadge },
{ urlName: "fs-search", human: tr.fsSearch },
{ urlName: "fs-welcome-message", human: tr.fsWelcomeMessage },
{ urlName: "fs-layers", human: tr.fsLayers },
{ urlName: "fs-add-new", human: tr.fsAddNew },
{ urlName: "fs-geolocation", human: tr.fsGeolocation },
]
for (const swtch of switches) {
const checkbox = new CheckBox(Translations.W(swtch.human))
optionCheckboxes.push(checkbox)
optionParts.push(
checkbox.GetValue().map((isEn) => {
if (isEn) {
return null
} else {
return `${swtch.urlName}=false`
}
})
)
}
if (layout.definitionRaw !== undefined) {
optionParts.push(new UIEventSource("userlayout=" + (layout.definedAtUrl ?? layout.id)))
}
const options = new Combine(optionCheckboxes).SetClass("flex flex-col")
const url = (currentLocation ?? new UIEventSource(undefined)).map(() => {
const host = window.location.host
let path = window.location.pathname
path = path.substr(0, path.lastIndexOf("/"))
let id = layout.id.toLowerCase()
if (layout.definitionRaw !== undefined) {
id = "theme.html"
}
let literalText = `https://${host}${path}/${id}`
let hash = ""
if (layout.definedAtUrl === undefined && layout.definitionRaw !== undefined) {
hash = "#" + LZString.compressToBase64(Utils.MinifyJSON(layout.definitionRaw))
}
const parts = Utils.NoEmpty(
Utils.NoNull(optionParts.map((eventSource) => eventSource.data))
)
if (parts.length === 0) {
return literalText + hash
}
return literalText + "?" + parts.join("&") + hash
}, optionParts)
const iframeCode = new VariableUiElement(
url.map((url) => {
return `<span class='literal-code iframe-code-block'>
&lt;iframe src="${url}" allow="geolocation" width="100%" height="100%" style="min-width: 250px; min-height: 250px" title="${
layout.title?.txt ?? "MapComplete"
} with MapComplete"&gt;&lt;/iframe&gt
</span>`
})
)
const linkStatus = new UIEventSource<string | Translation>("")
const link = new VariableUiElement(
url.map(
(url) =>
`<input type="text" value=" ${url}" id="code-link--copyable" style="width:90%">`
)
).onClick(async () => {
const shareData = {
title: Translations.W(layout.title)?.ConstructElement().textContent ?? "",
text: Translations.W(layout.description)?.ConstructElement().textContent ?? "",
url: url.data,
}
function rejected() {
const copyText = document.getElementById("code-link--copyable")
// @ts-ignore
copyText.select()
// @ts-ignore
copyText.setSelectionRange(0, 99999) /*For mobile devices*/
document.execCommand("copy")
const copied = tr.copiedToClipboard.Clone()
copied.SetClass("thanks")
linkStatus.setData(copied)
}
try {
navigator
.share(shareData)
.then(() => {
const thx = tr.thanksForSharing.Clone()
thx.SetClass("thanks")
linkStatus.setData(thx)
}, rejected)
.catch(rejected)
} catch (err) {
rejected()
}
})
let downloadThemeConfig: BaseUIElement = undefined
if (layout.definitionRaw !== undefined) {
const downloadThemeConfigAsJson = new SubtleButton(
Svg.download_svg(),
new Combine([tr.downloadCustomTheme, tr.downloadCustomThemeHelp.SetClass("subtle")])
.onClick(() => {
Utils.offerContentsAsDownloadableFile(
layout.definitionRaw,
layout.id + ".mapcomplete-theme-definition.json",
{
mimetype: "application/json",
}
)
})
.SetClass("flex flex-col")
)
let editThemeConfig: BaseUIElement = undefined
if (layout.definedAtUrl === undefined) {
const patchedDefinition = JSON.parse(layout.definitionRaw)
patchedDefinition["language"] = Object.keys(patchedDefinition.title)
editThemeConfig = new SubtleButton(
Svg.pencil_svg(),
"Edit this theme on the custom theme generator",
{
url: `https://pietervdvn.github.io/mc/legacy/070/customGenerator.html#${btoa(
JSON.stringify(patchedDefinition)
)}`,
}
)
}
downloadThemeConfig = new Combine([
downloadThemeConfigAsJson,
editThemeConfig,
]).SetClass("flex flex-col")
}
super([
tr.intro,
link,
new VariableUiElement(linkStatus),
downloadThemeConfig,
tr.addToHomeScreen,
tr.embedIntro,
options,
iframeCode,
])
this.SetClass("flex flex-col link-underline")
}
}

View file

@ -0,0 +1,21 @@
/**
* Asks to add a feature at the last clicked location, at least if zoom is sufficient
*/
import BaseUIElement from "../BaseUIElement"
import PresetConfig from "../../Models/ThemeConfig/PresetConfig"
import FilteredLayer from "../../Models/FilteredLayer"
/*
* The SimpleAddUI is a single panel, which can have multiple states:
* - A list of presets which can be added by the user
* - A 'confirm-selection' button (or alternatively: please enable the layer)
* - A 'something is wrong - please soom in further'
* - A 'read your unread messages before adding a point'
*/
export interface PresetInfo extends PresetConfig {
name: string | BaseUIElement
icon: () => BaseUIElement
layerToAddTo: FilteredLayer
boundsFactor?: 0.25 | number
}

View file

@ -0,0 +1,41 @@
<script lang="ts">
import ThemeViewState from "../../Models/ThemeViewState"
import Translations from "../i18n/Translations"
import Tr from "../Base/Tr.svelte"
import Loading from "../Base/Loading.svelte"
export let state: ThemeViewState
/**
* Gives the contributor some feedback based on the current state:
* - is data loading?
* - Is all data hidden due to filters?
* - Is no data in view?
*/
let dataIsLoading = state.dataIsLoading
let currentState = state.hasDataInView
currentState.data === ""
const t = Translations.t.centerMessage
</script>
{#if $currentState === "has-visible-features"}
<!-- don't show anything -->
{:else if $currentState === "zoom-to-low"}
<div class="alert w-fit p-4">
<Tr t={t.zoomIn} />
</div>
{:else if $currentState === "all-filtered-away"}
<div class="alert w-fit p-4">
<Tr t={t.allFilteredAway} />
</div>
{:else if $dataIsLoading}
<div class="alert w-fit p-4">
<Loading>
<Tr t={Translations.t.centerMessage.loadingData} />
</Loading>
</div>
{:else if $currentState === "no-data"}
<div class="alert w-fit p-4">
<Tr t={t.noData} />
</div>
{/if}

View file

@ -0,0 +1,55 @@
import { VariableUiElement } from "../Base/VariableUIElement"
import Loading from "../Base/Loading"
import Title from "../Base/Title"
import TagRenderingChart from "./TagRenderingChart"
import Combine from "../Base/Combine"
import Locale from "../i18n/Locale"
import { FeatureSourceForLayer } from "../../Logic/FeatureSource/FeatureSource"
import BaseUIElement from "../BaseUIElement"
export default class StatisticsForLayerPanel extends VariableUiElement {
constructor(elementsInview: FeatureSourceForLayer) {
const layer = elementsInview.layer.layerDef
super(
elementsInview.features.stabilized(1000).map(
(features) => {
if (features === undefined) {
return new Loading("Loading data")
}
if (features.length === 0) {
return "No elements in view"
}
const els: BaseUIElement[] = []
const featuresForLayer = features
if (featuresForLayer.length === 0) {
return
}
els.push(new Title(layer.name.Clone(), 1).SetClass("mt-8"))
const layerStats = []
for (const tagRendering of layer?.tagRenderings ?? []) {
const chart = new TagRenderingChart(featuresForLayer, tagRendering, {
chartclasses: "w-full",
chartstyle: "height: 60rem",
includeTitle: false,
})
const title = new Title(
tagRendering.question?.Clone() ?? tagRendering.id,
4
).SetClass("mt-8")
if (!chart.HasClass("hidden")) {
layerStats.push(
new Combine([title, chart]).SetClass(
"flex flex-col w-full lg:w-1/3"
)
)
}
}
els.push(new Combine(layerStats).SetClass("flex flex-wrap"))
return new Combine(els)
},
[Locale.language]
)
)
}
}

View file

@ -0,0 +1,381 @@
import ChartJs from "../Base/ChartJs"
import TagRenderingConfig from "../../Models/ThemeConfig/TagRenderingConfig"
import { ChartConfiguration } from "chart.js"
import Combine from "../Base/Combine"
import { TagUtils } from "../../Logic/Tags/TagUtils"
import { Utils } from "../../Utils"
import { OsmFeature } from "../../Models/OsmFeature"
export interface TagRenderingChartOptions {
groupToOtherCutoff?: 3 | number
sort?: boolean
}
export class StackedRenderingChart extends ChartJs {
constructor(
tr: TagRenderingConfig,
features: (OsmFeature & { properties: { date: string } })[],
options?: {
period: "day" | "month"
groupToOtherCutoff?: 3 | number
}
) {
const { labels, data } = TagRenderingChart.extractDataAndLabels(tr, features, {
sort: true,
groupToOtherCutoff: options?.groupToOtherCutoff,
})
if (labels === undefined || data === undefined) {
console.error(
"Could not extract data and labels for ",
tr,
" with features",
features,
": no labels or no data"
)
throw "No labels or data given..."
}
// labels: ["cyclofix", "buurtnatuur", ...]; data : [ ["cyclofix-changeset", "cyclofix-changeset", ...], ["buurtnatuur-cs", "buurtnatuur-cs"], ... ]
for (let i = labels.length; i >= 0; i--) {
if (data[i]?.length != 0) {
continue
}
data.splice(i, 1)
labels.splice(i, 1)
}
const datasets: {
label: string /*themename*/
data: number[] /*counts per day*/
backgroundColor: string
}[] = []
const allDays = StackedRenderingChart.getAllDays(features)
let trimmedDays = allDays.map((d) => d.substr(0, 10))
if (options?.period === "month") {
trimmedDays = trimmedDays.map((d) => d.substr(0, 7))
}
trimmedDays = Utils.Dedup(trimmedDays)
for (let i = 0; i < labels.length; i++) {
const label = labels[i]
const changesetsForTheme = data[i]
const perDay: Record<string, OsmFeature[]> = {}
for (const changeset of changesetsForTheme) {
const csDate = new Date(changeset.properties.date)
Utils.SetMidnight(csDate)
let str = csDate.toISOString()
str = str.substr(0, 10)
if (options?.period === "month") {
str = str.substr(0, 7)
}
if (perDay[str] === undefined) {
perDay[str] = [changeset]
} else {
perDay[str].push(changeset)
}
}
const countsPerDay: number[] = []
for (let i = 0; i < trimmedDays.length; i++) {
const day = trimmedDays[i]
countsPerDay[i] = perDay[day]?.length ?? 0
}
let backgroundColor =
TagRenderingChart.borderColors[i % TagRenderingChart.borderColors.length]
if (label === "Unknown") {
backgroundColor = TagRenderingChart.unkownBorderColor
}
if (label === "Other") {
backgroundColor = TagRenderingChart.otherBorderColor
}
datasets.push({
data: countsPerDay,
backgroundColor,
label,
})
}
const perDayData = {
labels: trimmedDays,
datasets,
}
const config = <ChartConfiguration>{
type: "bar",
data: perDayData,
options: {
responsive: true,
legend: {
display: false,
},
scales: {
x: {
stacked: true,
},
y: {
stacked: true,
},
},
},
}
super(config)
}
public static getAllDays(
features: (OsmFeature & { properties: { date: string } })[]
): string[] {
let earliest: Date = undefined
let latest: Date = undefined
let allDates = new Set<string>()
features.forEach((value) => {
const d = new Date(value.properties.date)
Utils.SetMidnight(d)
if (earliest === undefined) {
earliest = d
} else if (d < earliest) {
earliest = d
}
if (latest === undefined) {
latest = d
} else if (d > latest) {
latest = d
}
allDates.add(d.toISOString())
})
while (earliest < latest) {
earliest.setDate(earliest.getDate() + 1)
allDates.add(earliest.toISOString())
}
const days = Array.from(allDates)
days.sort()
return days
}
}
export default class TagRenderingChart extends Combine {
public static readonly unkownColor = "rgba(128, 128, 128, 0.2)"
public static readonly unkownBorderColor = "rgba(128, 128, 128, 0.2)"
public static readonly otherColor = "rgba(128, 128, 128, 0.2)"
public static readonly otherBorderColor = "rgba(128, 128, 255)"
public static readonly notApplicableColor = "rgba(128, 128, 128, 0.2)"
public static readonly notApplicableBorderColor = "rgba(255, 0, 0)"
public static readonly backgroundColors = [
"rgba(255, 99, 132, 0.2)",
"rgba(54, 162, 235, 0.2)",
"rgba(255, 206, 86, 0.2)",
"rgba(75, 192, 192, 0.2)",
"rgba(153, 102, 255, 0.2)",
"rgba(255, 159, 64, 0.2)",
]
public static readonly borderColors = [
"rgba(255, 99, 132, 1)",
"rgba(54, 162, 235, 1)",
"rgba(255, 206, 86, 1)",
"rgba(75, 192, 192, 1)",
"rgba(153, 102, 255, 1)",
"rgba(255, 159, 64, 1)",
]
/**
* Creates a chart about this tagRendering for the given data
*/
constructor(
features: { properties: Record<string, string> }[],
tagRendering: TagRenderingConfig,
options?: TagRenderingChartOptions & {
chartclasses?: string
chartstyle?: string
includeTitle?: boolean
chartType?: "pie" | "bar" | "doughnut"
}
) {
if (tagRendering.mappings?.length === 0 && tagRendering.freeform?.key === undefined) {
super([])
this.SetClass("hidden")
return
}
const { labels, data } = TagRenderingChart.extractDataAndLabels(
tagRendering,
features,
options
)
if (labels === undefined || data === undefined) {
super([])
this.SetClass("hidden")
return
}
const borderColor = [
TagRenderingChart.unkownBorderColor,
TagRenderingChart.otherBorderColor,
TagRenderingChart.notApplicableBorderColor,
]
const backgroundColor = [
TagRenderingChart.unkownColor,
TagRenderingChart.otherColor,
TagRenderingChart.notApplicableColor,
]
while (borderColor.length < data.length) {
borderColor.push(...TagRenderingChart.borderColors)
backgroundColor.push(...TagRenderingChart.backgroundColors)
}
for (let i = data.length; i >= 0; i--) {
if (data[i]?.length === 0) {
labels.splice(i, 1)
data.splice(i, 1)
borderColor.splice(i, 1)
backgroundColor.splice(i, 1)
}
}
let barchartMode = tagRendering.multiAnswer
if (labels.length > 9) {
barchartMode = true
}
const config = <ChartConfiguration>{
type: options.chartType ?? (barchartMode ? "bar" : "doughnut"),
data: {
labels,
datasets: [
{
data: data.map((l) => l.length),
backgroundColor,
borderColor,
borderWidth: 1,
label: undefined,
},
],
},
options: {
plugins: {
legend: {
display: !barchartMode,
},
},
},
}
const chart = new ChartJs(config).SetClass(options?.chartclasses ?? "w-32 h-32")
if (options.chartstyle !== undefined) {
chart.SetStyle(options.chartstyle)
}
super([
options?.includeTitle ? tagRendering.question.Clone() ?? tagRendering.id : undefined,
chart,
])
this.SetClass("block")
}
public static extractDataAndLabels<T extends { properties: Record<string, string> }>(
tagRendering: TagRenderingConfig,
features: T[],
options?: TagRenderingChartOptions
): { labels: string[]; data: T[][] } {
const mappings = tagRendering.mappings ?? []
options = options ?? {}
let unknownCount: T[] = []
const categoryCounts: T[][] = mappings.map((_) => [])
const otherCounts: Record<string, T[]> = {}
let notApplicable: T[] = []
for (const feature of features) {
const props = feature.properties
if (
tagRendering.condition !== undefined &&
!tagRendering.condition.matchesProperties(props)
) {
notApplicable.push(feature)
continue
}
if (!tagRendering.IsKnown(props)) {
unknownCount.push(feature)
continue
}
let foundMatchingMapping = false
if (!tagRendering.multiAnswer) {
for (let i = 0; i < mappings.length; i++) {
const mapping = mappings[i]
if (mapping.if.matchesProperties(props)) {
categoryCounts[i].push(feature)
foundMatchingMapping = true
break
}
}
} else {
for (let i = 0; i < mappings.length; i++) {
const mapping = mappings[i]
if (TagUtils.MatchesMultiAnswer(mapping.if, props)) {
categoryCounts[i].push(feature)
foundMatchingMapping = true
}
}
}
if (!foundMatchingMapping) {
if (
tagRendering.freeform?.key !== undefined &&
props[tagRendering.freeform.key] !== undefined
) {
const otherValue = props[tagRendering.freeform.key]
otherCounts[otherValue] = otherCounts[otherValue] ?? []
otherCounts[otherValue].push(feature)
} else {
unknownCount.push(feature)
}
}
}
if (unknownCount.length + notApplicable.length === features.length) {
console.log("Returning no label nor data: all features are unkown or notApplicable")
return { labels: undefined, data: undefined }
}
let otherGrouped: T[] = []
const otherLabels: string[] = []
const otherData: T[][] = []
const sortedOtherCounts: [string, T[]][] = []
for (const v in otherCounts) {
sortedOtherCounts.push([v, otherCounts[v]])
}
if (options?.sort) {
sortedOtherCounts.sort((a, b) => b[1].length - a[1].length)
}
for (const [v, count] of sortedOtherCounts) {
if (count.length >= (options.groupToOtherCutoff ?? 3)) {
otherLabels.push(v)
otherData.push(otherCounts[v])
} else {
otherGrouped.push(...count)
}
}
const labels = [
"Unknown",
"Other",
"Not applicable",
...(mappings?.map((m) => m.then.txt) ?? []),
...otherLabels,
]
const data: T[][] = [
unknownCount,
otherGrouped,
notApplicable,
...categoryCounts,
...otherData,
]
return { labels, data }
}
}

View file

@ -0,0 +1,90 @@
<script lang="ts">
import { Translation } from "../i18n/Translation"
import * as personal from "../../../assets/themes/personal/personal.json"
import { ImmutableStore, Store, UIEventSource } from "../../Logic/UIEventSource"
import UserDetails, { OsmConnection } from "../../Logic/Osm/OsmConnection"
import Constants from "../../Models/Constants"
import type { LayoutInformation } from "../../Models/ThemeConfig/LayoutConfig"
import Tr from "../Base/Tr.svelte"
import SubtleLink from "../Base/SubtleLink.svelte"
import Translations from "../i18n/Translations"
export let theme: LayoutInformation
export let isCustom: boolean = false
export let userDetails: UIEventSource<UserDetails>
export let state: { layoutToUse?: { id: string }; osmConnection: OsmConnection }
export let selected: boolean = false
$: title = new Translation(
theme.title,
!isCustom && !theme.mustHaveLanguage ? "themes:" + theme.id + ".title" : undefined
)
$: description = new Translation(theme.shortDescription)
// TODO: Improve this function
function createUrl(
layout: { id: string; definition?: string },
isCustom: boolean,
state?: { layoutToUse?: { id } }
): Store<string> {
if (layout === undefined) {
return undefined
}
if (layout.id === undefined) {
console.error("ID is undefined for layout", layout)
return undefined
}
if (layout.id === state?.layoutToUse?.id) {
return undefined
}
let path = window.location.pathname
// Path starts with a '/' and contains everything, e.g. '/dir/dir/page.html'
path = path.substr(0, path.lastIndexOf("/"))
// Path will now contain '/dir/dir', or empty string in case of nothing
if (path === "") {
path = "."
}
let linkPrefix = `${path}/${layout.id.toLowerCase()}.html?`
if (
location.hostname === "localhost" ||
location.hostname === "127.0.0.1" ||
location.port === "1234"
) {
// Redirect to 'theme.html?layout=* instead of 'layout.html'. This is probably a debug run, where the routing does not work
linkPrefix = `${path}/theme.html?layout=${layout.id}&`
}
if (isCustom) {
linkPrefix = `${path}/theme.html?userlayout=${layout.id}&`
}
let hash = ""
if (layout.definition !== undefined) {
hash = "#" + btoa(JSON.stringify(layout.definition))
}
return new ImmutableStore<string>(`${linkPrefix}${hash}`)
}
let href = createUrl(theme, isCustom, state)
</script>
{#if theme.id !== personal.id || $userDetails.csCount > Constants.userJourney.personalLayoutUnlock}
<SubtleLink href={$href} options={{ extraClasses: "w-full" }}>
<img slot="image" src={theme.icon} class="mx-4 block h-11 w-11" alt="" />
<span class="flex flex-col overflow-hidden text-ellipsis">
<Tr t={title} />
<span class="subtle max-h-12 truncate text-ellipsis">
<Tr t={description} />
</span>
{#if selected}
<span class="alert">
<Tr t={Translations.t.general.morescreen.enterToOpen} />
</span>
{/if}
</span>
</SubtleLink>
{/if}

View file

@ -0,0 +1,102 @@
<script lang="ts">
import Translations from "../i18n/Translations"
import Svg from "../../Svg"
import Tr from "../Base/Tr.svelte"
import NextButton from "../Base/NextButton.svelte"
import Geosearch from "./Geosearch.svelte"
import IfNot from "../Base/IfNot.svelte"
import ToSvelte from "../Base/ToSvelte.svelte"
import ThemeViewState from "../../Models/ThemeViewState"
import If from "../Base/If.svelte"
import { UIEventSource } from "../../Logic/UIEventSource"
import { SearchIcon } from "@rgossiaux/svelte-heroicons/solid"
import { twJoin } from "tailwind-merge"
import { Utils } from "../../Utils"
/**
* The theme introduction panel
*/
export let state: ThemeViewState
let layout = state.layout
let selectedElement = state.selectedElement
let selectedLayer = state.selectedLayer
let triggerSearch: UIEventSource<any> = new UIEventSource<any>(undefined)
let searchEnabled = false
function jumpToCurrentLocation() {
const glstate = state.geolocation.geolocationState
if (glstate.currentGPSLocation.data !== undefined) {
const c: GeolocationCoordinates = glstate.currentGPSLocation.data
state.guistate.themeIsOpened.setData(false)
const coor = { lon: c.longitude, lat: c.latitude }
state.mapProperties.location.setData(coor)
}
if (glstate.permission.data !== "granted") {
glstate.requestPermission()
return
}
}
</script>
<div class="flex h-full flex-col justify-between">
<div>
<!-- Intro, description, ... -->
<Tr t={layout.description} />
<Tr t={Translations.t.general.welcomeExplanation.general} />
{#if layout.layers.some((l) => l.presets?.length > 0)}
<If condition={state.featureSwitches.featureSwitchAddNew}>
<Tr t={Translations.t.general.welcomeExplanation.addNew} />
</If>
{/if}
<Tr t={layout.descriptionTail} />
<!-- Buttons: open map, go to location, search -->
<NextButton clss="primary w-full" on:click={() => state.guistate.themeIsOpened.setData(false)}>
<div class="flex w-full justify-center text-2xl">
<Tr t={Translations.t.general.openTheMap} />
</div>
</NextButton>
<div class="flex w-full flex-wrap sm:flex-nowrap">
<IfNot condition={state.geolocation.geolocationState.permission.map((p) => p === "denied")}>
<button class="flex w-full items-center gap-x-2" on:click={jumpToCurrentLocation}>
<ToSvelte construct={Svg.crosshair_svg().SetClass("w-8 h-8")} />
<Tr t={Translations.t.general.openTheMapAtGeolocation} />
</button>
</IfNot>
<div class=".button low-interaction m-1 flex w-full items-center gap-x-2 rounded border p-2">
<div class="w-full">
<Geosearch
bounds={state.mapProperties.bounds}
on:searchCompleted={() => state.guistate.themeIsOpened.setData(false)}
on:searchIsValid={(isValid) => {
searchEnabled = isValid
}}
perLayer={state.perLayer}
{selectedElement}
{selectedLayer}
{triggerSearch}
/>
</div>
<button
class={twJoin("flex items-center justify-between gap-x-2", !searchEnabled && "disabled")}
on:click={() => triggerSearch.ping()}
>
<Tr t={Translations.t.general.search.searchShort} />
<SearchIcon class="h-6 w-6" />
</button>
</div>
</div>
</div>
<div class="links-as-button links-w-full m-2 flex flex-col gap-y-1">
<!-- bottom buttons, a bit hidden away: switch layout -->
<a class="flex" href={Utils.HomepageLink()}>
<img class="h-6 w-6" src="./assets/svg/add.svg" />
<Tr t={Translations.t.general.backToIndex} />
</a>
</div>
</div>

View file

@ -0,0 +1,60 @@
<script lang="ts">
import NoThemeResultButton from "./NoThemeResultButton.svelte"
import { OsmConnection } from "../../Logic/Osm/OsmConnection"
import { UIEventSource } from "../../Logic/UIEventSource"
import ThemeButton from "./ThemeButton.svelte"
import { LayoutInformation } from "../../Models/ThemeConfig/LayoutConfig"
import MoreScreen from "./MoreScreen"
import themeOverview from "../../assets/generated/theme_overview.json"
export let search: UIEventSource<string>
export let themes: LayoutInformation[]
export let state: { osmConnection: OsmConnection }
export let isCustom: boolean = false
export let onMainScreen: boolean = true
export let hideThemes: boolean = true
// Filter theme based on search value
$: filteredThemes = themes.filter((theme) => MoreScreen.MatchesLayout(theme, $search))
// Determine which is the first theme, after the search, using all themes
$: allFilteredThemes = themeOverview.filter((theme) => MoreScreen.MatchesLayout(theme, $search))
$: firstTheme = allFilteredThemes[0]
</script>
<section class="w-full">
<slot name="title" />
{#if onMainScreen}
<div class="gap-4 md:grid md:grid-flow-row md:grid-cols-2 lg:grid-cols-3">
{#each filteredThemes as theme (theme.id)}
{#if theme !== undefined && !(hideThemes && theme?.hideFromOverview)}
<!-- TODO: doesn't work if first theme is hidden -->
{#if theme === firstTheme && !isCustom && $search !== "" && $search !== undefined}
<ThemeButton
{theme}
{isCustom}
userDetails={state.osmConnection.userDetails}
{state}
selected={true}
/>
{:else}
<ThemeButton {theme} {isCustom} userDetails={state.osmConnection.userDetails} {state} />
{/if}
{/if}
{/each}
</div>
{:else}
<div>
{#each filteredThemes as theme (theme.id)}
{#if theme !== undefined && !(hideThemes && theme?.hideFromOverview)}
<ThemeButton {theme} {isCustom} userDetails={state.osmConnection.userDetails} {state} />
{/if}
{/each}
</div>
{/if}
{#if filteredThemes.length === 0}
<NoThemeResultButton {search} />
{/if}
</section>

View file

@ -0,0 +1,37 @@
<script lang="ts">
import { OsmConnection } from "../../Logic/Osm/OsmConnection"
import { Store, Stores, UIEventSource } from "../../Logic/UIEventSource"
import { Utils } from "../../Utils"
import ThemesList from "./ThemesList.svelte"
import Translations from "../i18n/Translations"
import UserRelatedState from "../../Logic/State/UserRelatedState"
export let search: UIEventSource<string>
export let state: UserRelatedState & {
osmConnection: OsmConnection
}
export let onMainScreen: boolean = true
const t = Translations.t.general
const currentIds: Store<string[]> = state.installedUserThemes
const stableIds = Stores.ListStabilized<string>(currentIds)
let customThemes
$: customThemes = Utils.NoNull($stableIds.map((id) => state.GetUnofficialTheme(id)))
$: console.log("Custom themes are", customThemes)
</script>
{#if customThemes.length > 0}
<ThemesList
{search}
{state}
{onMainScreen}
themes={customThemes}
isCustom={true}
hideThemes={false}
>
<svelte:fragment slot="title">
<!-- TODO: Change string to exclude html -->
{@html t.customThemeIntro.toString()}
</svelte:fragment>
</ThemesList>
{/if}

View file

@ -0,0 +1,151 @@
import Toggle from "../Input/Toggle"
import { RadioButton } from "../Input/RadioButton"
import { FixedInputElement } from "../Input/FixedInputElement"
import Combine from "../Base/Combine"
import Translations from "../i18n/Translations"
import { TextField } from "../Input/TextField"
import { Store, UIEventSource } from "../../Logic/UIEventSource"
import Title from "../Base/Title"
import { SubtleButton } from "../Base/SubtleButton"
import Svg from "../../Svg"
import { OsmConnection } from "../../Logic/Osm/OsmConnection"
import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig"
import { Translation } from "../i18n/Translation"
import { LoginToggle } from "../Popup/LoginButton"
export default class UploadTraceToOsmUI extends LoginToggle {
constructor(
trace: (title: string) => string,
state: {
layout: LayoutConfig
osmConnection: OsmConnection
readonly featureSwitchUserbadge: Store<boolean>
},
options?: {
whenUploaded?: () => void | Promise<void>
}
) {
const t = Translations.t.general.uploadGpx
const uploadFinished = new UIEventSource(false)
const traceVisibilities: {
key: "private" | "public"
name: Translation
docs: Translation
}[] = [
{
key: "private",
...t.modes.private,
},
{
key: "public",
...t.modes.public,
},
]
const dropdown = new RadioButton<"private" | "public">(
traceVisibilities.map(
(tv) =>
new FixedInputElement<"private" | "public">(
new Combine([
Translations.W(tv.name).SetClass("font-bold"),
tv.docs,
]).SetClass("flex flex-col"),
tv.key
)
),
{
value: <any>state?.osmConnection?.GetPreference("gps.trace.visibility"),
}
)
const description = new TextField({
placeholder: t.meta.descriptionPlaceHolder,
})
const title = new TextField({
placeholder: t.meta.titlePlaceholder,
})
const clicked = new UIEventSource<boolean>(false)
const confirmPanel = new Combine([
new Title(t.title),
t.intro0,
t.intro1,
t.choosePermission,
dropdown,
new Title(t.meta.title, 4),
t.meta.intro,
title,
t.meta.descriptionIntro,
description,
new Combine([
new SubtleButton(Svg.close_svg(), Translations.t.general.cancel)
.onClick(() => {
clicked.setData(false)
})
.SetClass(""),
new SubtleButton(Svg.upload_svg(), t.confirm).OnClickWithLoading(
t.uploading,
async () => {
const titleStr = UploadTraceToOsmUI.createDefault(
title.GetValue().data,
"Track with mapcomplete"
)
const descriptionStr = UploadTraceToOsmUI.createDefault(
description.GetValue().data,
"Track created with MapComplete with theme " + state?.layout?.id
)
await state?.osmConnection?.uploadGpxTrack(trace(title.GetValue().data), {
visibility: dropdown.GetValue().data,
description: descriptionStr,
filename: titleStr + ".gpx",
labels: ["MapComplete", state?.layout?.id],
})
if (options?.whenUploaded !== undefined) {
await options.whenUploaded()
}
uploadFinished.setData(true)
}
),
]).SetClass("flex flex-wrap flex-wrap-reverse justify-between items-stretch"),
]).SetClass("flex flex-col p-4 rounded border-2 m-2 border-subtle")
super(
new Toggle(
new Toggle(
new Combine([
Svg.confirm_svg().SetClass("w-12 h-12 mr-2"),
t.uploadFinished,
]).SetClass("flex p-2 rounded-xl border-2 subtle-border items-center"),
new Toggle(
confirmPanel,
new SubtleButton(Svg.upload_svg(), t.title).onClick(() =>
clicked.setData(true)
),
clicked
),
uploadFinished
),
new Combine([
Svg.invalid_svg().SetClass("w-8 h-8 m-2"),
t.gpxServiceOffline.SetClass("p-2"),
]).SetClass("flex border alert items-center"),
state.osmConnection.gpxServiceIsOnline.map(
(serviceState) => serviceState === "online"
)
),
undefined,
state
)
}
private static createDefault(s: string, defaultValue: string) {
if (defaultValue.length < 1) {
throw "Default value should have some characters"
}
if (s === undefined || s === null || s === "") {
return defaultValue
}
return s
}
}

View file

@ -0,0 +1,53 @@
<script lang="ts">
import UserDetails, { OsmConnection } from "../../Logic/Osm/OsmConnection"
import { UIEventSource } from "../../Logic/UIEventSource"
import { PencilAltIcon, UserCircleIcon } from "@rgossiaux/svelte-heroicons/solid"
import { onDestroy } from "svelte"
import Showdown from "showdown"
import FromHtml from "../Base/FromHtml.svelte"
import Tr from "../Base/Tr.svelte"
import Translations from "../i18n/Translations.js"
/**
* This panel shows information about the logged-in user, showing account name, profile pick, description and an edit-button
*/
export let osmConnection: OsmConnection
let userdetails: UIEventSource<UserDetails> = osmConnection.userDetails
let description: string
onDestroy(
userdetails.addCallbackAndRunD((userdetails) => {
description = new Showdown.Converter()
.makeHtml(userdetails.description)
?.replace(/&gt;/g, ">")
?.replace(/&lt;/g, "<")
})
)
</script>
<div class="link-underline m-1 flex rounded-md border border-dashed border-gray-600 p-1">
{#if $userdetails.img}
<img src={$userdetails.img} class="m-4 h-12 w-12 rounded-full" />
{:else}
<UserCircleIcon class="h-12 w-12" />
{/if}
<div class="flex flex-col">
<h3>{$userdetails.name}</h3>
{#if description}
<FromHtml src={description} />
<a
href={osmConnection.Backend() + "/profile/edit"}
target="_blank"
class="link-no-underline flex items-center self-end"
>
<PencilAltIcon slot="image" class="h-8 w-8 p-2" />
<Tr slot="message" t={Translations.t.userinfo.editDescription} />
</a>
{:else}
<Tr t={Translations.t.userinfo.noDescription} />
<a href={osmConnection.Backend() + "/profile/edit"} target="_blank" class="flex items-center">
<PencilAltIcon slot="image" class="h-8 w-8 p-2" />
<Tr slot="message" t={Translations.t.userinfo.noDescriptionCallToAction} />
</a>
{/if}
</div>
</div>

View file

@ -0,0 +1,106 @@
<script lang="ts">
/**
* This component shows a map which focuses on a single OSM-Way (linestring) feature.
* Clicking the map will add a new 'scissor' point, projected on the linestring (and possible snapped to an already existing node within the linestring;
* clicking this point again will remove it.
* The bound 'value' will contain the location of these projected points.
* Points are not coalesced with already existing nodes within the way; it is up to the code actually splitting the way to decide to reuse an existing point or not
*
* This component is _not_ responsible for the rest of the flow, e.g. the confirm button
*/
import LayerConfig from "../../Models/ThemeConfig/LayerConfig"
import type { LayerConfigJson } from "../../Models/ThemeConfig/Json/LayerConfigJson"
import split_point from "../../../assets/layers/split_point/split_point.json"
import split_road from "../../../assets/layers/split_road/split_road.json"
import { UIEventSource } from "../../Logic/UIEventSource"
import { Map as MlMap } from "maplibre-gl"
import type { MapProperties } from "../../Models/MapProperties"
import { MapLibreAdaptor } from "../Map/MapLibreAdaptor"
import MaplibreMap from "../Map/MaplibreMap.svelte"
import { OsmWay } from "../../Logic/Osm/OsmObject"
import ShowDataLayer from "../Map/ShowDataLayer"
import StaticFeatureSource from "../../Logic/FeatureSource/Sources/StaticFeatureSource"
import { GeoOperations } from "../../Logic/GeoOperations"
import { BBox } from "../../Logic/BBox"
import type { Feature, LineString, Point } from "geojson"
const splitpoint_style = new LayerConfig(
<LayerConfigJson>split_point,
"(BUILTIN) SplitRoadWizard.ts",
true
) as const
const splitroad_style = new LayerConfig(
<LayerConfigJson>split_road,
"(BUILTIN) SplitRoadWizard.ts",
true
) as const
/**
* The way to focus on
*/
export let osmWay: OsmWay
/**
* How to render this layer.
* A default is given
*/
export let layer: LayerConfig = splitroad_style
/**
* Optional: use these properties to set e.g. background layer
*/
export let mapProperties: undefined | Partial<MapProperties> = undefined
let map: UIEventSource<MlMap> = new UIEventSource<MlMap>(undefined)
let adaptor = new MapLibreAdaptor(map, mapProperties)
const wayGeojson: Feature<LineString> = GeoOperations.forceLineString(osmWay.asGeoJson())
adaptor.location.setData(GeoOperations.centerpointCoordinatesObj(wayGeojson))
adaptor.bounds.setData(BBox.get(wayGeojson).pad(2))
adaptor.maxbounds.setData(BBox.get(wayGeojson).pad(2))
new ShowDataLayer(map, {
features: new StaticFeatureSource([wayGeojson]),
drawMarkers: false,
layer: layer,
})
export let splitPoints: UIEventSource<
Feature<
Point,
{
id: number
index: number
dist: number
location: number
}
>[]
> = new UIEventSource([])
const splitPointsFS = new StaticFeatureSource(splitPoints)
new ShowDataLayer(map, {
layer: splitpoint_style,
features: splitPointsFS,
onClick: (clickedFeature: Feature) => {
console.log("Clicked feature is", clickedFeature, splitPoints.data)
const i = splitPoints.data.findIndex((f) => f === clickedFeature)
if (i < 0) {
return
}
splitPoints.data.splice(i, 1)
splitPoints.ping()
},
})
let id = 0
adaptor.lastClickLocation.addCallbackD(({ lon, lat }) => {
const projected = GeoOperations.nearestPoint(wayGeojson, [lon, lat])
projected.properties["id"] = id
id++
splitPoints.data.push(<any>projected)
splitPoints.ping()
})
</script>
<div class="h-full w-full">
<MaplibreMap {map} />
</div>