Add QR-code to all popups, add direction indicator to popup and visual feedback, make reviews accessible to screenreaders (both to read them and to make them)

This commit is contained in:
Pieter Vander Vennet 2023-12-24 05:01:10 +01:00
parent 5567869bb4
commit bfd818cb38
33 changed files with 415 additions and 98 deletions

View file

@ -40,6 +40,30 @@
"centroid"
],
"anchor": "center"
},
{
"marker": [
{
"color": "--catch-detail-color",
"icon": "direction"
}
],
"iconSize": {
"render": "0,0",
"mappings": [
{
"if": "alpha~*",
"then": "40,40"
}
]
},
"pitchAlignment": "map",
"rotation": "{alpha}deg",
"location": [
"point",
"centroid"
],
"anchor": "center"
}
],
"lineRendering": []

View file

@ -302,6 +302,14 @@
"condition": "_favourite=yes",
"icon": "circle:white;heart:red",
"metacondition": "__showTimeSensitiveIcons!=no"
},
{
"id": "direction",
"labels": [
"defaults",
"in_favourite"
],
"render": "{direction_indicator()}"
}
]
}

View file

@ -366,7 +366,7 @@
},
"freeform": {
"key": "min_age",
"type": "pnat"
"type": "nat"
},
"id": "playground-min_age"
},

View file

@ -399,12 +399,34 @@
"useSearchForMore": "Use the search function to search within {total} more values…",
"visualFeedback": {
"closestFeaturesAre": "{n} features within viewport.",
"directionsAbsolute": {
"E": "east",
"N": "north",
"NE": "northeast",
"NW": "northwest",
"S": "south",
"SE": "southeast",
"SW": "southwest",
"W": "west"
},
"directionsRelative": {
"behind": "on your back",
"left": "left",
"right": "right",
"sharp_left": "sharply left",
"sharp_right": "sharply right",
"slight_left": "slightly left",
"slight_right": "slightly right",
"straight": "straight ahead"
},
"east": "Moving east",
"fromGps": "{distance} {direction} of your location",
"fromMapCenter": "{distance} {direction} of the map center",
"in": "Zooming in to level {z}",
"islocked": "View locked to your GPS-location, moving disabled. Press the geolocation button to unlock.",
"locked": "View is now locked to your GPS-location, moving disabled.",
"navigation": "Use arrow keys to move the map, press space to select the closest feature. Press a number to select locations further away.",
"noCloseFeatures": "No features in view",
"noCloseFeatures": "No features in view.",
"north": "Moving north",
"oneFeatureInView": "One feature within viewport.",
"out": "Zooming out to level {z}",
@ -636,12 +658,15 @@
"reviews": {
"affiliated_reviewer_warning": "(Affiliated review)",
"attribution": "Reviews are powered by <a href='https://mangrove.reviews/' target='_blank'>Mangrove Reviews</a> and are available under <a href='https://mangrove.reviews/terms#8-licensing-of-content' target='_blank'>CC-BY 4.0</a>.",
"averageRating": "Average rating of {n} stars",
"i_am_affiliated": "I am affiliated with this object",
"i_am_affiliated_explanation": "Check if you are an owner, creator, employee, …",
"name_required": "A name is required in order to display and create reviews",
"no_reviews_yet": "There are no reviews yet. Be the first to write one and help open data and the business!",
"question": "How would you rate {title()}?",
"question_opinion": "How was your experience?",
"rate": "Rate {n} stars",
"rated": "Rated {n} stars",
"reviewing_as": "Reviewing as {nickname}",
"reviewing_as_anonymous": "Reviewing as anonymous",
"save": "Save",

View file

@ -1,6 +1,6 @@
{
"name": "mapcomplete",
"version": "0.36.7",
"version": "0.36.8",
"repository": "https://github.com/pietervdvn/MapComplete",
"description": "A small website to edit OSM easily",
"bugs": "https://github.com/pietervdvn/MapComplete/issues",

View file

@ -1347,6 +1347,10 @@ video {
appearance: none;
}
.grid-cols-3 {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.grid-cols-2 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
@ -1450,14 +1454,26 @@ video {
row-gap: 0.5rem;
}
.gap-x-1 {
-webkit-column-gap: 0.25rem;
column-gap: 0.25rem;
}
.gap-x-2 {
-webkit-column-gap: 0.5rem;
column-gap: 0.5rem;
}
.gap-x-1 {
-webkit-column-gap: 0.25rem;
column-gap: 0.25rem;
.space-x-0\.5 > :not([hidden]) ~ :not([hidden]) {
--tw-space-x-reverse: 0;
margin-right: calc(0.125rem * var(--tw-space-x-reverse));
margin-left: calc(0.125rem * calc(1 - var(--tw-space-x-reverse)));
}
.space-x-0 > :not([hidden]) ~ :not([hidden]) {
--tw-space-x-reverse: 0;
margin-right: calc(0px * var(--tw-space-x-reverse));
margin-left: calc(0px * calc(1 - var(--tw-space-x-reverse)));
}
.space-x-1 > :not([hidden]) ~ :not([hidden]) {
@ -1466,6 +1482,18 @@ video {
margin-left: calc(0.25rem * calc(1 - var(--tw-space-x-reverse)));
}
.space-y-0\.5 > :not([hidden]) ~ :not([hidden]) {
--tw-space-y-reverse: 0;
margin-top: calc(0.125rem * calc(1 - var(--tw-space-y-reverse)));
margin-bottom: calc(0.125rem * var(--tw-space-y-reverse));
}
.space-y-0 > :not([hidden]) ~ :not([hidden]) {
--tw-space-y-reverse: 0;
margin-top: calc(0px * calc(1 - var(--tw-space-y-reverse)));
margin-bottom: calc(0px * var(--tw-space-y-reverse));
}
.space-x-2 > :not([hidden]) ~ :not([hidden]) {
--tw-space-x-reverse: 0;
margin-right: calc(0.5rem * var(--tw-space-x-reverse));
@ -1554,6 +1582,10 @@ video {
text-overflow: clip;
}
.break-words {
overflow-wrap: break-word;
}
.break-all {
word-break: break-all;
}
@ -1695,11 +1727,6 @@ video {
border-color: rgb(219 234 254 / var(--tw-border-opacity));
}
.border-red-500 {
--tw-border-opacity: 1;
border-color: rgb(239 68 68 / var(--tw-border-opacity));
}
.border-gray-300 {
--tw-border-opacity: 1;
border-color: rgb(209 213 219 / var(--tw-border-opacity));

View file

@ -10,6 +10,7 @@ import { GeoOperations } from "../GeoOperations"
import { OsmTags } from "../../Models/OsmFeature"
import StaticFeatureSource from "../FeatureSource/Sources/StaticFeatureSource"
import { MapProperties } from "../../Models/MapProperties"
import { Orientation } from "../../Sensors/Orientation"
/**
* The geolocation-handler takes a map-location and a geolocation state.
@ -128,10 +129,10 @@ export default class GeoLocationHandler {
}
// We check that the GPS location is not out of bounds
const bounds = this.mapProperties.maxbounds.data
const bounds: BBox = this.mapProperties.maxbounds.data
if (bounds !== undefined) {
// B is an array with our lock-location
const inRange = new BBox(bounds).contains([newLocation.longitude, newLocation.latitude])
const inRange = bounds.contains([newLocation.longitude, newLocation.latitude])
if (!inRange) {
return
}
@ -167,6 +168,9 @@ export default class GeoLocationHandler {
altitude: location.altitude,
altitudeAccuracy: location.altitudeAccuracy,
heading: location.heading,
alpha: Orientation.singleton.gotMeasurement.data
? "" + Orientation.singleton.alpha.data
: undefined,
}
i++

View file

@ -55,8 +55,9 @@ export default class FeatureReviews {
private static readonly _featureReviewsCache: Record<string, FeatureReviews> = {}
public readonly subjectUri: Store<string>
public readonly average: Store<number | null>
private readonly _reviews: UIEventSource<(Review & { madeByLoggedInUser: Store<boolean> })[]> =
new UIEventSource([])
private readonly _reviews: UIEventSource<
(Review & { kid: string; signature: string; madeByLoggedInUser: Store<boolean> })[]
> = new UIEventSource([])
public readonly reviews: Store<(Review & { madeByLoggedInUser: Store<boolean> })[]> =
this._reviews
private readonly _lat: number
@ -176,11 +177,15 @@ export default class FeatureReviews {
...review,
}
const keypair: CryptoKeyPair = this._identity.keypair.data
console.log(r)
const jwt = await MangroveReviews.signReview(keypair, r)
console.log("Signed:", jwt)
const kid = await MangroveReviews.publicToPem(keypair.publicKey)
await MangroveReviews.submitReview(jwt)
this._reviews.data.push({ ...r, madeByLoggedInUser: new ImmutableStore(true) })
this._reviews.data.push({
...r,
kid,
signature: jwt,
madeByLoggedInUser: new ImmutableStore(true),
})
this._reviews.ping()
}
@ -189,7 +194,7 @@ export default class FeatureReviews {
* @param reviews
* @private
*/
private addReviews(reviews: { payload: Review; kid: string }[]) {
private addReviews(reviews: { payload: Review; kid: string; signature: string }[]) {
const self = this
const alreadyKnown = new Set(self._reviews.data.map((r) => r.rating + " " + r.opinion))
@ -199,7 +204,6 @@ export default class FeatureReviews {
try {
const url = new URL(review.sub)
console.log("URL is", url)
if (url.protocol === "geo:") {
const coordinate = <[number, number]>(
url.pathname.split(",").map((n) => Number(n))
@ -222,6 +226,8 @@ export default class FeatureReviews {
}
self._reviews.data.push({
...review,
kid: reviewData.kid,
signature: reviewData.signature,
madeByLoggedInUser: this._identity.key_id.map((user_key_id) => {
return reviewData.kid === user_key_id
}),

View file

@ -99,6 +99,7 @@ export default class Constants {
* In seconds
*/
static zoomToLocationTimeout = 15
public static readonly viewportCenterCloseToGpsCutoff: number = 20
private static readonly config = (() => {
const defaultConfig = packagefile.config
return { ...defaultConfig, ...extraconfig }

View file

@ -576,6 +576,7 @@ export class AddEditingElements extends DesugaringStep<LayerConfigJson> {
"last_edit",
"favourite_state",
"all_tags",
"qr_code",
]
private readonly _desugaring: DesugaringContext
@ -657,6 +658,13 @@ export class AddEditingElements extends DesugaringStep<LayerConfigJson> {
})
}
if (!usedSpecialFunctions.has("qr_code")) {
json.tagRenderings.push({
id: "qr_code",
render: { "*": "{qr_code()}" },
})
}
if (!usedSpecialFunctions.has("all_tags")) {
const trc: QuestionableTagRenderingConfigJson = {
id: "all-tags",

View file

@ -631,6 +631,10 @@ export default class TagRenderingConfig {
* , "testcase")
* config.constructChangeSpecification(undefined, undefined, [false, true, false], {amenity: "public_bookcase"}) // => new And([new Tag("books","adults")])
*
* const config = new TagRenderingConfig({"id":"capacity", "render": "Fits {capcity} books",freeform: {"key":"capacity",type:"pnat"} })
* config.constructChangeSpecification("", undefined, undefined, {}) // => undefined
* config.constructChangeSpecification("5", undefined, undefined, {}).optimize() // => new Tag("capacity", "5")
*
* @param freeformValue The freeform value which will be applied as 'freeform.key'. Ignored if 'freeform.key' is not set
*
* @param singleSelectedMapping (Only used if multiAnswer == false): the single mapping to apply. Use (mappings.length) for the freeform

View file

@ -49,7 +49,7 @@ export class Orientation {
if (rotateAlpha) {
this._animateFakeMeasurements = true
Stores.Chronic(25).addCallback((date) => {
this.alpha.setData((date.getTime() / 100) % 360)
this.alpha.setData((date.getTime() / 50) % 360)
if (!this._animateFakeMeasurements) {
return true
}

View file

@ -8,20 +8,25 @@
import { GeoOperations } from "../../Logic/GeoOperations"
import { Store } from "../../Logic/UIEventSource"
import type { Feature } from "geojson"
import ThemeViewState from "../../Models/ThemeViewState"
import Compass_arrow from "../../assets/svg/Compass_arrow.svelte"
import { twMerge } from "tailwind-merge"
import { Orientation } from "../../Sensors/Orientation"
import Translations from "../i18n/Translations"
import Constants from "../../Models/Constants"
import Locale from "../i18n/Locale"
import { ariaLabelStore } from "../../Utils/ariaLabel"
import type { SpecialVisualizationState } from "../SpecialVisualization"
export let state: ThemeViewState
export let state: SpecialVisualizationState
export let feature: Feature
export let size = "w-8 h-8"
let fcenter = GeoOperations.centerpointCoordinates(feature)
// Bearing and distance relative to the map center
let bearingAndDist: Store<{ bearing: number; dist: number }> = state.mapProperties.location.map(
(l) => {
let mapCenter = [l.lon, l.lat]
let bearing = Math.round(GeoOperations.bearing(fcenter, mapCenter))
let bearing = Math.round(GeoOperations.bearing(mapCenter, fcenter))
let dist = Math.round(GeoOperations.distanceBetween(fcenter, mapCenter))
return { bearing, dist }
},
@ -29,19 +34,87 @@
let bearingFromGps = state.geolocation.geolocationState.currentGPSLocation.mapD(coordinate => {
return GeoOperations.bearing([coordinate.longitude, coordinate.latitude], fcenter)
})
let compass = Orientation.singleton.alpha.map(compass => compass ?? 0)
export let size = "w-8 h-8"
let compass = Orientation.singleton.alpha
let relativeDirections = Translations.t.general.visualFeedback.directionsRelative
let absoluteDirections = Translations.t.general.visualFeedback.directionsAbsolute
let closeToCurrentLocation = state.geolocation.geolocationState.currentGPSLocation.map(gps => {
if (!gps) {
return false
}
let l = state.mapProperties.location.data
let mapCenter = [l.lon, l.lat]
const dist = GeoOperations.distanceBetween([gps.longitude, gps.latitude], mapCenter)
return dist < Constants.viewportCenterCloseToGpsCutoff
},
[state.mapProperties.location],
)
let labelFromCenter: Store<string> = bearingAndDist.mapD(({ bearing, dist }) => {
const distHuman = GeoOperations.distanceToHuman(dist)
const lang = Locale.language.data
const t = absoluteDirections[GeoOperations.bearingToHuman(bearing)]
const mainTr = Translations.t.general.visualFeedback.fromMapCenter.Subs({
distance: distHuman,
direction: t.textFor(lang),
})
return mainTr.textFor(lang)
}, [compass, Locale.language])
// Bearing and distance relative to the map center
let bearingAndDistGps: Store<{
bearing: number;
dist: number
} | undefined> = state.geolocation.geolocationState.currentGPSLocation.mapD(
({ longitude, latitude }) => {
let gps = [longitude, latitude]
let bearing = Math.round(GeoOperations.bearing(gps, fcenter))
let dist = Math.round(GeoOperations.distanceBetween(fcenter, gps))
return { bearing, dist }
},
)
let labelFromGps: Store<string | undefined> = bearingAndDistGps.mapD(({ bearing, dist }) => {
const distHuman = GeoOperations.distanceToHuman(dist)
const lang = Locale.language.data
let bearingHuman: string
if (compass.data !== undefined) {
console.log("compass:", compass.data)
const bearingRelative = bearing - compass.data
const t = relativeDirections[GeoOperations.bearingToHumanRelative(bearingRelative)]
bearingHuman = t.textFor(lang)
} else {
bearingHuman = absoluteDirections[GeoOperations.bearingToHuman(bearing)].textFor(lang)
}
const mainTr = Translations.t.general.visualFeedback.fromGps.Subs({
distance: distHuman,
direction: bearingHuman,
})
return mainTr.textFor(lang)
}, [compass, Locale.language])
let label = labelFromCenter.map(labelFromCenter => {
if (labelFromGps.data !== undefined) {
if(closeToCurrentLocation.data){
return labelFromGps.data
}
return labelFromCenter + ", " + labelFromGps.data
}
return labelFromCenter
}, [labelFromGps])
function focusMap(){
state.mapProperties.location.setData({ lon: fcenter[0], lat: fcenter[1] })
}
</script>
<div class={twMerge("relative", size)}>
<div class={twMerge("absolute top-0 left-0 flex items-center justify-center text-sm",size)}>
{GeoOperations.distanceToHuman($bearingAndDist.dist)}
<button class={twMerge("relative rounded-full soft", size)} use:ariaLabelStore={label} on:click={() => focusMap()}>
<div class={twMerge("absolute top-0 left-0 flex items-center justify-center text-sm break-words",size)}>
{GeoOperations.distanceToHuman($bearingAndDistGps.dist)}
</div>
{#if $bearingFromGps !== undefined}
<div class={twMerge("absolute top-0 left-0 rounded-full border border-gray-500", size)}>
<div class={twMerge("absolute top-0 left-0 rounded-full", size)}>
<Compass_arrow class={size}
style={`transform: rotate( calc( 45deg + ${$bearingFromGps - $compass}deg) );`} />
style={`transform: rotate( calc( 45deg + ${$bearingFromGps - ($compass ?? 0)}deg) );`} />
</div>
{/if}
</div>
<span>{$bearingAndDist.bearing}° {GeoOperations.bearingToHuman($bearingAndDist.bearing)} {GeoOperations.bearingToHumanRelative($bearingAndDist.bearing - $compass)}</span>
</button>

View file

@ -11,11 +11,12 @@
export let cls: string = ""
// Text for the current language
let txt: Store<string | undefined> = t?.current
$: {txt = t?.current}
</script>
{#if $txt}
<span class={cls}>
<FromHtml src={$txt} />
<WeblateLink context={t.context} />
<WeblateLink context={t?.context} />
</span>
{/if}

View file

@ -4,12 +4,16 @@
import ThemeViewState from "../../Models/ThemeViewState"
import Tr from "../Base/Tr.svelte"
import Translations from "../i18n/Translations"
import { Orientation } from "../../Sensors/Orientation"
import { Translation } from "../i18n/Translation"
import Constants from "../../Models/Constants"
/**
* Indicates how far away the viewport center is from the current user location
*/
export let state: ThemeViewState
const t = Translations.t.general.visualFeedback
const relativeDir = t.directionsRelative
let map = state.mapProperties
let currentLocation = state.geolocation.geolocationState.currentGPSLocation
@ -23,14 +27,29 @@
const distanceInMeters = Math.round(GeoOperations.distanceBetween(gps, mapCenter))
const distance = GeoOperations.distanceToHuman(distanceInMeters)
const bearing = Math.round(GeoOperations.bearing(gps, mapCenter))
return { distance, bearing, distanceInMeters }
const bearingDirection = GeoOperations.bearingToHuman(bearing)
return { distance, bearing, distanceInMeters, bearingDirection }
}, [currentLocation])
let hasCompass = Orientation.singleton.gotMeasurement
let compass = Orientation.singleton.alpha
let relativeBearing: Store<{distance: string, bearing: Translation}> =
compass.mapD(compass => {
const bearing: Translation = relativeDir[GeoOperations.bearingToHumanRelative(distanceToCurrentLocation.data.bearing - compass)]
return {bearing, distance: distanceToCurrentLocation.data.distance}
}, [distanceToCurrentLocation])
let viewportCenterDetails = Translations.DynamicSubstitute(t.viewportCenterDetails, relativeBearing)
let viewportCenterDetailsAbsolute = Translations.DynamicSubstitute(t.viewportCenterDetails, distanceToCurrentLocation.map(({distance, bearing}) => {
return {distance, bearing: t.directionsAbsolute[GeoOperations.bearingToHuman(bearing)]}
}))
</script>
{#if $currentLocation !== undefined}
{#if $distanceToCurrentLocation.distanceInMeters < 20}
{#if $distanceToCurrentLocation.distanceInMeters < Constants.viewportCenterCloseToGpsCutoff}
<Tr t={t.viewportCenterCloseToGps} />
{:else if $hasCompass}
{$viewportCenterDetails}
{:else}
<Tr t={t.viewportCenterDetails.Subs($distanceToCurrentLocation)} />
{$viewportCenterDetailsAbsolute}
{/if}
{/if}

View file

@ -55,11 +55,10 @@
{#if currentLocation}
<div
role="alert"
aria-live="assertive"
class="normal-background border-interactive rounded-full px-2 flex flex-col items-center"
>
{currentLocation}
{currentLocation}.
<MapCenterDetails {state}/>
</div>
{/if}

View file

@ -4,6 +4,7 @@
import LayerConfig from "../../Models/ThemeConfig/LayerConfig"
import TagRenderingAnswer from "../Popup/TagRendering/TagRenderingAnswer.svelte"
import DirectionIndicator from "../Base/DirectionIndicator.svelte"
import ThemeViewState from "../../Models/ThemeViewState"
export let state: SpecialVisualizationState
export let feature: Feature
@ -14,11 +15,14 @@
</script>
<a class="small flex space-x-1 cursor-pointer w-fit" href={`#${feature.properties.id}`}>
<span class="inline-flex gap-x-1">
<a class="small flex space-x-0.5 cursor-pointer w-fit items-center" href={`#${feature.properties.id}`}>
{#if i !== undefined}
<span class="font-bold">{i + 1} &nbsp; </span>
{/if}
<TagRenderingAnswer config={layer.title} extraClasses="inline-flex w-fit" {layer} selectedElement={feature} {state}
{tags} />
<DirectionIndicator {feature} {state} />
</a>
<DirectionIndicator {feature} {state} />
</span>

View file

@ -32,7 +32,7 @@
lastAction.stabilized(750).addCallbackAndRunD((_) => lastAction.setData(undefined))
</script>
<div aria-live="assertive" class="p-1 interactive" role="alert">
<div aria-live="assertive" class="p-1 bg-white m-1 rounded">
{#if $lastAction?.key === "out"}
<Tr t={t.out.Subs({z: map.zoom.data - 1})} />
{:else if $lastAction?.key === "in"}
@ -46,13 +46,11 @@
<div class="pointer-events-auto">
<Tr t={$translationWithLength} />
<MapCenterDetails {state} />
<ol>
<div class="grid grid-cols-3 space-x-1 space-y-0.5">
{#each $centerFeatures.slice(0, 9) as feat, i (feat.properties.id)}
<li>
<Summary {state} feature={feat} {i} />
</li>
<Summary {state} feature={feat} {i} />
{/each}
</ol>
</div>
</div>
{/if}
</div>

View file

@ -2,9 +2,9 @@
import type { SpecialVisualizationState } from "../SpecialVisualization"
import TagRenderingAnswer from "../Popup/TagRendering/TagRenderingAnswer.svelte"
import type { Feature } from "geojson"
import { ImmutableStore, UIEventSource } from "../../Logic/UIEventSource"
import { UIEventSource } from "../../Logic/UIEventSource"
import { GeoOperations } from "../../Logic/GeoOperations"
import Center from "../../assets/svg/Center.svelte"
import DirectionIndicator from "../Base/DirectionIndicator.svelte"
export let feature: Feature
let properties: Record<string, string> = feature.properties
@ -30,11 +30,6 @@
center()
}
const coord = GeoOperations.centerpointCoordinates(feature)
const distance = state.mapProperties.location.stabilized(500).mapD(({ lon, lat }) => {
let meters = Math.round(GeoOperations.distanceBetween(coord, [lon, lat]))
return GeoOperations.distanceToHuman(meters)
})
const titleIconBlacklist = ["osmlink", "sharelink", "favourite_title_icon"]
</script>
@ -57,7 +52,7 @@
class="title-icons links-as-button flex flex-wrap items-center gap-x-0.5 self-end justify-self-end p-1 pt-0.5 sm:pt-1"
>
{#each favConfig.titleIcons as titleIconConfig}
{#if titleIconBlacklist.indexOf(titleIconConfig.id) < 0 && (titleIconConfig.condition?.matchesProperties(properties) ?? true) && (titleIconConfig.metacondition?.matchesProperties( { ...properties, ...state.userRelatedState.preferencesAsTags.data } ) ?? true) && titleIconConfig.IsKnown(properties)}
{#if titleIconBlacklist.indexOf(titleIconConfig.id) < 0 && (titleIconConfig.condition?.matchesProperties(properties) ?? true) && (titleIconConfig.metacondition?.matchesProperties({ ...properties, ...state.userRelatedState.preferencesAsTags.data }) ?? true) && titleIconConfig.IsKnown(properties)}
<div class={titleIconConfig.renderIconClass ?? "flex h-8 w-8 items-center"}>
<TagRenderingAnswer
config={titleIconConfig}
@ -71,12 +66,7 @@
{/if}
{/each}
<button class="p-1" on:click={() => center()}>
<Center class="h-6 w-6" />
</button>
<div class="w-14">
{$distance}
</div>
<DirectionIndicator {state} {feature} />
</div>
</div>
{/if}

View file

@ -48,7 +48,7 @@
<div class="flex flex-col" on:keypress={(e) => console.log("Got keypress", e)}>
<Tr t={Translations.t.favouritePoi.intro.Subs({ length: $favourites?.length ?? 0 })} />
<Tr t={Translations.t.favouritePoi.privacy} />
<Tr t={Translations.t.favouritePoi.priintroPrivacyvacy} />
{#each $favourites as feature (feature.properties.id)}
<FavouriteSummary {feature} {state} />

View file

@ -57,7 +57,11 @@
validator = Validators.get(type ?? "string")
_placeholder = placeholder ?? validator?.getPlaceholder() ?? type
feedback?.setData(validator?.getFeedback(_value.data, getCountry))
if(_value.data?.length > 0){
feedback?.setData(validator?.getFeedback(_value.data, getCountry))
}else{
feedback?.setData(undefined)
}
initValueAndDenom()
}
@ -65,9 +69,14 @@
function setValues() {
// Update the value stores
const v = _value.data
if (!validator?.isValid(v, getCountry) || v === "") {
if(v === ""){
value.setData(undefined)
feedback.setData(undefined)
return
}
if (!validator?.isValid(v, getCountry)) {
feedback?.setData(validator?.getFeedback(v, getCountry))
value.setData("")
value.setData(undefined)
return
}

View file

@ -16,15 +16,22 @@ export default class NatValidator extends IntValidator {
return str.indexOf(".") < 0 && !isNaN(Number(str)) && Number(str) >= 0
}
/**
*
* const validator = new NatValidator()
* validator.getFeedback(-4).textFor("en") // => "This number should be positive"
*/
getFeedback(s: string): Translation {
console.log("Getting feedback for", s)
const n = Number(s)
if (!isNaN(n) && n < 0) {
return Translations.t.validation.nat.mustBePositive
}
const spr = super.getFeedback(s)
if (spr !== undefined) {
return spr
}
const n = Number(s)
if (n < 0) {
return Translations.t.validation.nat.mustBePositive
}
return undefined
}
}

View file

@ -3,16 +3,20 @@
import { ImmutableStore, Store } from "../../Logic/UIEventSource"
import DynamicIcon from "./DynamicIcon.svelte"
import TagRenderingConfig from "../../Models/ThemeConfig/TagRenderingConfig"
import { Orientation } from "../../Sensors/Orientation"
/**
* Renders a 'marker', which consists of multiple 'icons'
*/
export let marker: IconConfig[] = config?.marker
export let marker: IconConfig[]
export let tags: Store<Record<string, string>>
export let rotation: TagRenderingConfig = undefined
let _rotation = rotation
let _rotation: Store<string> = rotation
? tags.map((tags) => rotation.GetRenderValue(tags).Subs(tags).txt)
: new ImmutableStore(0)
: new ImmutableStore("0deg")
if(rotation?.render?.txt === "{alpha}deg"){
_rotation = Orientation.singleton.alpha.map(alpha => alpha ? (alpha)+"deg" : "0deg ")
}
</script>
{#if marker && marker}

View file

@ -27,6 +27,7 @@
import Confirm from "../../assets/svg/Confirm.svelte"
import Not_found from "../../assets/svg/Not_found.svelte"
import { twMerge } from "tailwind-merge"
import Direction_gradient from "../../assets/svg/Direction_gradient.svelte"
/**
* Renders a single icon.
@ -100,6 +101,8 @@
<HeartOutlineIcon class={clss} />
{:else if icon === "confirm"}
<Confirm class={clss} {color} />
{:else if icon === "direction"}
<Direction_gradient class={clss} {color} />
{:else if icon === "not_found"}
<Not_found class={twMerge(clss, "no-image-background")} {color} />
{:else}

View file

@ -171,6 +171,7 @@ class PointRenderingLayer {
store
.map((tags) => this._config.rotationAlignment.GetRenderValue(tags).Subs(tags).txt)
.addCallbackAndRun((pitchAligment) => marker.setRotationAlignment(<any>pitchAligment))
if (feature.geometry.type === "Point") {
// When the tags get 'pinged', check that the location didn't change
store.addCallbackAndRunD(() => {

View file

@ -4,13 +4,11 @@
import StarsBar from "./StarsBar.svelte"
import Translations from "../i18n/Translations"
import Tr from "../Base/Tr.svelte"
import { ariaLabel } from "../../Utils/ariaLabel"
export let review: Review & { madeByLoggedInUser: Store<boolean> }
export let review: Review & { kid: string,signature: string, madeByLoggedInUser: Store<boolean> }
let name = review.metadata.nickname
name ??= (review.metadata.given_name ?? "") + " " + (review.metadata.family_name ?? "").trim()
if (name.length === 0) {
name = "Anonymous"
}
name ??= ((review.metadata.given_name ?? "") + " " + (review.metadata.family_name ?? "")).trim()
let d = new Date()
d.setTime(review.iat * 1000)
let date = d.toDateString()
@ -19,18 +17,32 @@
<div class={"low-interaction rounded-lg p-1 px-2 " + ($byLoggedInUser ? "border-interactive" : "")}>
<div class="flex items-center justify-between">
<StarsBar score={review.rating} />
<div tabindex="0" use:ariaLabel={Translations.t.reviews.rated.Subs({n: ""+(Math.round(review.rating / 10)/2)})}>
<StarsBar readonly={true} score={review.rating} />
</div>
<div class="flex flex-wrap space-x-2">
<div class="font-bold">
{name}
</div>
<a href={`https://mangrove.reviews/list?kid=${encodeURIComponent(review.kid)}`} rel="noopener"
target="_blank">
{#if !name}
<i>Anonymous</i>
{:else}
<span class="font-bold">
{name}
</span>
{/if}
</a>
<span class="subtle">
{date}
</span>
</div>
</div>
{#if review.opinion}
{review.opinion}
<div class="link-no-underline">
<a target="_blank" rel="noopener nofollow"
href={`https://mangrove.reviews/list?signature=${encodeURIComponent(review.signature)}`}>
{review.opinion}
</a>
</div>
{/if}
{#if review.metadata.is_affiliated}
<Tr t={Translations.t.reviews.affiliated_reviewer_warning} />

View file

@ -1,31 +1,32 @@
<script lang="ts">
import ToSvelte from "../Base/ToSvelte.svelte"
import Svg from "../../Svg"
import { createEventDispatcher } from "svelte"
import Star from "../../assets/svg/Star.svelte"
import Star_half from "../../assets/svg/Star_half.svelte"
import Star_outline from "../../assets/svg/Star_outline.svelte"
import { ariaLabel, ariaLabelStore } from "../../Utils/ariaLabel"
import Translations from "../i18n/Translations"
export let score: number
export let cutoff: number
export let starSize = "w-h h-4"
export let i: number
export let readonly = false
let dispatch = createEventDispatcher<{ hover: { score: number }, click: { score: number } }>()
let dispatch = createEventDispatcher<{ hover: { score: number }, click: { score: number } }>()
let container: HTMLElement
function getScore(e: MouseEvent): number {
if (e.clientX === 0 && e.clientY === 0) {
// Keyboard triggered 'click' -> return max value
return cutoff
}
const x = e.clientX - e.target.getBoundingClientRect().x
const w = container.getClientRects()[0]?.width
return x / w < 0.5 ? cutoff - 10 : cutoff
}
</script>
<div
bind:this={container}
on:click={(e) => dispatch("click", { score: getScore(e) })}
on:mousemove={(e) => dispatch("hover", { score: getScore(e) })}
>
{#if readonly}
{#if score >= cutoff}
<Star class={starSize} />
{:else if score + 10 >= cutoff}
@ -33,4 +34,22 @@
{:else}
<Star_outline class={starSize} />
{/if}
</div>
{:else}
<button
use:ariaLabel={Translations.t.reviews.rate.Subs({n: i+1})}
class="small soft rounded-full no-image-background"
style="padding: 0; border: none;"
bind:this={container}
on:click={(e) => dispatch("click", { score: getScore(e) })}
on:mousemove={(e) => dispatch("hover", { score: getScore(e) })}
>
{#if score >= cutoff}
<Star class={starSize} />
{:else if score + 10 >= cutoff}
<Star_half class={starSize} />
{:else}
<Star_outline class={starSize} />
{/if}
</button>
{/if}

View file

@ -1,5 +1,4 @@
<script lang="ts">
import { createEventDispatcher } from "svelte"
import StarElement from "./StarElement.svelte"
/**
@ -9,12 +8,13 @@
let cutoffs = [20, 40, 60, 80, 100]
export let starSize = "w-h h-4"
export let readonly = false
</script>
{#if score !== undefined}
<div class="flex" on:mouseout>
{#each cutoffs as cutoff, i}
<StarElement {score} {i} {cutoff} {starSize} on:hover on:click />
<StarElement {readonly} {score} {i} {cutoff} {starSize} on:hover on:click />
{/each}
</div>
{/if}

View file

@ -1,10 +1,16 @@
<script lang="ts">
import { Store } from "../../Logic/UIEventSource"
import StarsBar from "./StarsBar.svelte"
import { ariaLabel } from "../../Utils/ariaLabel"
import Translations from "../i18n/Translations"
export let score: Store<number>
let scoreRounded = score.mapD(count => Math.round(count / 10) / 2)
</script>
{#if $score !== undefined && $score !== null}
<StarsBar score={$score} />
<div tabindex="0"
use:ariaLabel={Translations.t.reviews.averageRating.Subs({n: $scoreRounded})}>
<StarsBar readonly={true} score={$score} />
</div>
{/if}

View file

@ -19,6 +19,7 @@ import { ImageUploadManager } from "../Logic/ImageProviders/ImageUploadManager"
import { OsmTags } from "../Models/OsmFeature"
import FavouritesFeatureSource from "../Logic/FeatureSource/Sources/FavouritesFeatureSource"
import { ProvidedImage } from "../Logic/ImageProviders/ImageProvider"
import GeoLocationHandler from "../Logic/Actors/GeoLocationHandler"
/**
* The state needed to render a special Visualisation.
@ -87,6 +88,7 @@ export interface SpecialVisualizationState {
readonly imageUploadManager: ImageUploadManager
readonly previewedImage: UIEventSource<ProvidedImage>
readonly geolocation: GeoLocationHandler
}
export interface SpecialVisualization {

View file

@ -85,6 +85,9 @@ import { Unit } from "../Models/Unit"
import Link from "./Base/Link.svelte"
import OrientationDebugPanel from "./Debug/OrientationDebugPanel.svelte"
import MaprouletteSetStatus from "./MapRoulette/MaprouletteSetStatus.svelte"
import DirectionIndicator from "./Base/DirectionIndicator.svelte"
import Img from "./Base/Img"
import Qr from "../Utils/Qr"
class NearbyImageVis implements SpecialVisualization {
// Class must be in SpecialVisualisations due to weird cyclical import that breaks the tests
@ -1539,6 +1542,43 @@ export default class SpecialVisualizations {
})
},
},
{
funcName: "direction_indicator",
args: [],
needsUrls: [],
docs: "Gives a distance indicator and a compass pointing towards the location from your GPS-location. If clicked, centers the map on the object",
constr(
state: SpecialVisualizationState,
tagSource: UIEventSource<Record<string, string>>,
argument: string[],
feature: Feature,
layer: LayerConfig
): BaseUIElement {
return new SvelteUIElement(DirectionIndicator, { state, feature })
},
},
{
funcName: "qr_code",
args: [],
needsUrls: [],
docs: "Generates a QR-code to share the selected object",
constr(
state: SpecialVisualizationState,
tagSource: UIEventSource<Record<string, string>>,
argument: string[],
feature: Feature,
layer: LayerConfig
): BaseUIElement {
const url =
window.location.protocol +
"//" +
window.location.host +
window.location.pathname +
"#" +
feature.properties.id
return new Img(new Qr(url).toImageElement(75)).SetStyle("width: 75px")
},
},
]
specialVisualizations.push(new AutoApplyButton(specialVisualizations))

View file

@ -4,6 +4,9 @@ import BaseUIElement from "../BaseUIElement"
import CompiledTranslations from "../../assets/generated/CompiledTranslations"
import LanguageUtils from "../../Utils/LanguageUtils"
import { ClickableToggle } from "../Input/Toggle"
import { Store } from "../../Logic/UIEventSource"
import Locale from "./Locale"
import { Utils } from "../../Utils"
export default class Translations {
static readonly t: Readonly<typeof CompiledTranslations.t> = CompiledTranslations.t
@ -130,6 +133,29 @@ export default class Translations {
}
}
public static DynamicSubstitute<T extends Record<string, string | Translation>>(
translation: TypedTranslation<T>,
t: Store<T>
): Store<string> {
return Locale.language.map(
(lang) => {
const tags: Record<string, string> = {}
for (const k in t.data) {
let v = t.data[k]
if (!v) {
continue
}
if (v["textFor"] !== undefined) {
v = v["textFor"](lang)
}
tags[k] = <string>v
}
return Utils.SubstituteKeys(translation.textFor(lang), t.data)
},
[t]
)
}
static isProbablyATranslation(transl: any) {
if (!transl || typeof transl !== "object") {
return false

View file

@ -4,23 +4,20 @@ import Qrcode from "qrcode-generator"
* Creates a QR-code as Blob
*/
export default class Qr {
private _textToShow: string
private readonly _textToShow: string
constructor(textToShow: string) {
this._textToShow = textToShow
}
public toImageElement(totalSize: number): string {
console.log("Creating a QR code for", this._textToShow)
const typeNumber = 0
const errorCorrectionLevel = "L"
const qr = Qrcode(typeNumber, errorCorrectionLevel)
qr.addData(this._textToShow)
qr.make()
const moduleCount = qr.getModuleCount()
const img = document.createElement("img")
const cellSize = Math.round(totalSize / moduleCount)
console.log("Cellsize", cellSize)
return qr.createDataURL(cellSize)
}
}