forked from MapComplete/MapComplete
		
	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:
		
							parent
							
								
									5567869bb4
								
							
						
					
					
						commit
						bfd818cb38
					
				
					 33 changed files with 415 additions and 98 deletions
				
			
		|  | @ -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": [] | ||||
|  |  | |||
|  | @ -302,6 +302,14 @@ | |||
|       "condition": "_favourite=yes", | ||||
|       "icon": "circle:white;heart:red", | ||||
|       "metacondition": "__showTimeSensitiveIcons!=no" | ||||
|     }, | ||||
|     { | ||||
|       "id": "direction", | ||||
|       "labels": [ | ||||
|         "defaults", | ||||
|         "in_favourite" | ||||
|       ], | ||||
|       "render": "{direction_indicator()}" | ||||
|     } | ||||
|   ] | ||||
| } | ||||
|  |  | |||
|  | @ -366,7 +366,7 @@ | |||
|       }, | ||||
|       "freeform": { | ||||
|         "key": "min_age", | ||||
|         "type": "pnat" | ||||
|         "type": "nat" | ||||
|       }, | ||||
|       "id": "playground-min_age" | ||||
|     }, | ||||
|  |  | |||
|  | @ -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", | ||||
|  |  | |||
|  | @ -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", | ||||
|  |  | |||
|  | @ -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)); | ||||
|  |  | |||
|  | @ -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++ | ||||
| 
 | ||||
|  |  | |||
|  | @ -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 | ||||
|                 }), | ||||
|  |  | |||
|  | @ -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 } | ||||
|  |  | |||
|  | @ -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", | ||||
|  |  | |||
|  | @ -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 | ||||
|  |  | |||
|  | @ -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 | ||||
|                 } | ||||
|  |  | |||
|  | @ -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> | ||||
|  |  | |||
|  | @ -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} | ||||
|  |  | |||
|  | @ -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} | ||||
|  |  | |||
|  | @ -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} | ||||
|  |  | |||
|  | @ -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}   </span> | ||||
|   {/if} | ||||
|   <TagRenderingAnswer config={layer.title} extraClasses="inline-flex w-fit" {layer} selectedElement={feature} {state} | ||||
|                       {tags} /> | ||||
|   <DirectionIndicator {feature} {state} /> | ||||
| </a> | ||||
|   <DirectionIndicator {feature} {state} /> | ||||
| </span> | ||||
|  |  | |||
|  | @ -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> | ||||
|         {/each} | ||||
|       </ol> | ||||
|       </div> | ||||
|     </div> | ||||
|   {/if} | ||||
| </div> | ||||
|  |  | |||
|  | @ -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> | ||||
| 
 | ||||
|  | @ -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} | ||||
|  |  | |||
|  | @ -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} /> | ||||
|  |  | |||
|  | @ -57,7 +57,11 @@ | |||
|     validator = Validators.get(type ?? "string") | ||||
| 
 | ||||
|     _placeholder = placeholder ?? validator?.getPlaceholder() ?? type | ||||
|     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 | ||||
|     } | ||||
| 
 | ||||
|  |  | |||
|  | @ -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 | ||||
|     } | ||||
| } | ||||
|  |  | |||
|  | @ -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} | ||||
|  |  | |||
|  | @ -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} | ||||
|  |  | |||
|  | @ -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(() => { | ||||
|  |  | |||
|  | @ -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 class="flex flex-wrap space-x-2"> | ||||
|       <div class="font-bold"> | ||||
|         {name} | ||||
|     <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"> | ||||
|       <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} | ||||
|     <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} /> | ||||
|  |  | |||
|  | @ -1,27 +1,45 @@ | |||
| <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 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 | ||||
| {#if readonly} | ||||
|   {#if score >= cutoff} | ||||
|     <Star class={starSize} /> | ||||
|   {:else if score + 10 >= cutoff} | ||||
|     <Star_half class={starSize} /> | ||||
|   {:else} | ||||
|     <Star_outline class={starSize} /> | ||||
|   {/if} | ||||
| 
 | ||||
| {: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) })} | ||||
|  | @ -33,4 +51,5 @@ | |||
|     {:else} | ||||
|       <Star_outline class={starSize} /> | ||||
|     {/if} | ||||
| </div> | ||||
|   </button> | ||||
| {/if} | ||||
|  |  | |||
|  | @ -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} | ||||
|  |  | |||
|  | @ -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} | ||||
|  |  | |||
|  | @ -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 { | ||||
|  |  | |||
|  | @ -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)) | ||||
|  |  | |||
|  | @ -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 | ||||
|  |  | |||
|  | @ -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) | ||||
|     } | ||||
| } | ||||
|  |  | |||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue