UX: more work on a search function
This commit is contained in:
parent
3cd04df60b
commit
00ad21d5ef
30 changed files with 636 additions and 138 deletions
4
assets/svg/airport.svg
Normal file
4
assets/svg/airport.svg
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg version="1.1" id="airport" xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 15 15">
|
||||||
|
<path id="path7712-0" style="fill:#000" d="M15,6.8182L15,8.5l-6.5-1
	l-0.3182,4.7727L11,14v1l-3.5-0.6818L4,15v-1l2.8182-1.7273L6.5,7.5L0,8.5V6.8182L6.5,4.5v-3c0,0,0-1.5,1-1.5s1,1.5,1,1.5v2.8182
	L15,6.8182z"/>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 385 B |
2
assets/svg/airport.svg.license
Normal file
2
assets/svg/airport.svg.license
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
SPDX-FileCopyrightText: Maki
|
||||||
|
SPDX-License-Identifier: CC0-1.0
|
|
@ -59,6 +59,16 @@
|
||||||
],
|
],
|
||||||
"sources": []
|
"sources": []
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"path": "airport.svg",
|
||||||
|
"license": "CC0-1.0",
|
||||||
|
"authors": [
|
||||||
|
"Maki"
|
||||||
|
],
|
||||||
|
"sources": [
|
||||||
|
"https://github.com/mapbox/maki/blob/main/icons/airport.svg"
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"path": "back.svg",
|
"path": "back.svg",
|
||||||
"license": "CC0-1.0",
|
"license": "CC0-1.0",
|
||||||
|
@ -1175,6 +1185,16 @@
|
||||||
"https://pngimg.com/image/46283"
|
"https://pngimg.com/image/46283"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"path": "train.svg",
|
||||||
|
"license": "CC0-1.0",
|
||||||
|
"authors": [
|
||||||
|
"Maki"
|
||||||
|
],
|
||||||
|
"sources": [
|
||||||
|
"https://labs.mapbox.com/maki-icons/"
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"path": "translate.svg",
|
"path": "translate.svg",
|
||||||
"license": "CC-BY-SA-3.0",
|
"license": "CC-BY-SA-3.0",
|
||||||
|
|
24
assets/svg/train.svg
Normal file
24
assets/svg/train.svg
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<svg
|
||||||
|
version="1.1"
|
||||||
|
id="svg4619"
|
||||||
|
x="0px"
|
||||||
|
y="0px"
|
||||||
|
width="500"
|
||||||
|
height="500"
|
||||||
|
viewBox="0 0 500 500"
|
||||||
|
xml:space="preserve"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||||
|
xmlns:cc="http://creativecommons.org/ns#"
|
||||||
|
xmlns:dc="http://purl.org/dc/elements/1.1/"><metadata
|
||||||
|
id="metadata8"><rdf:RDF><cc:Work
|
||||||
|
rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type
|
||||||
|
rdf:resource="http://purl.org/dc/dcmitype/StillImage" /></cc:Work></rdf:RDF></metadata><defs
|
||||||
|
id="defs6" />
|
||||||
|
<path
|
||||||
|
id="path14245"
|
||||||
|
d="M 183.33333,0 C 166.66667,0 166.66667,16.666667 166.66667,16.666667 V 50 c 0,9.233332 7.43333,16.666668 16.66666,16.666668 C 192.56667,66.666668 200,59.233332 200,50 V 33.333332 h 33.33333 V 100 H 200 c 0,0 -66.66667,0 -66.66667,66.66667 v 100 c 0,100.00001 100,100.00001 100,100.00001 h 33.33334 c 0,0 100.00001,0 100.00001,-100.00001 v -100 C 366.66668,100 300,100 300,100 H 266.66667 V 33.333332 H 300 V 50 c 0,9.233332 7.43333,16.666668 16.66667,16.666668 9.23333,0 16.66665,-7.433336 16.66665,-16.666668 V 16.666667 C 333.33332,0 316.66667,0 316.66667,0 Z M 250,133.33333 l 68.16333,25.78 15.16999,57.55334 c 4.38669,16.66666 -16.66665,16.66666 -16.66665,16.66666 H 183.33333 c 0,0 -21.05333,0 -16.66666,-16.66666 l 15.16999,-57.55334 z m 0,133.33334 c 9.20333,0 16.66667,7.46333 16.66667,16.66666 C 266.66667,292.53667 259.20333,300 250,300 c -9.20333,0 -16.66667,-7.46333 -16.66667,-16.66667 0,-9.20333 7.46334,-16.66666 16.66667,-16.66666 z M 137.5,400 100,500 h 50 l 12.5,-33.33332 H 337.50002 L 350,500 h 50 L 362.50002,400 H 312.5 L 325,433.33332 H 175 L 187.5,400 Z"
|
||||||
|
style="stroke-width:33.3332; fill: #000" />
|
||||||
|
</svg>
|
After Width: | Height: | Size: 1.8 KiB |
2
assets/svg/train.svg.license
Normal file
2
assets/svg/train.svg.license
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
SPDX-FileCopyrightText: Maki
|
||||||
|
SPDX-License-Identifier: CC0-1.0
|
|
@ -47,6 +47,8 @@
|
||||||
],
|
],
|
||||||
"country_coder_host": "https://raw.githubusercontent.com/pietervdvn/MapComplete-data/main/latlon2country",
|
"country_coder_host": "https://raw.githubusercontent.com/pietervdvn/MapComplete-data/main/latlon2country",
|
||||||
"nominatimEndpoint": "https://geocoding.geofabrik.de/b75350b1cfc34962ac49824fe5b582dc/",
|
"nominatimEndpoint": "https://geocoding.geofabrik.de/b75350b1cfc34962ac49824fe5b582dc/",
|
||||||
|
"#photonEndpoint": "`api/` or `reverse/` will be appended by the code",
|
||||||
|
"photonEndpoint": "https://photon.komoot.io/",
|
||||||
"jsonld-proxy": "https://lod.mapcomplete.org/extractgraph?url={url}",
|
"jsonld-proxy": "https://lod.mapcomplete.org/extractgraph?url={url}",
|
||||||
"protomaps": {
|
"protomaps": {
|
||||||
"api-key": "2af8b969a9e8b692",
|
"api-key": "2af8b969a9e8b692",
|
||||||
|
|
|
@ -1225,14 +1225,14 @@ video {
|
||||||
height: 6rem;
|
height: 6rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.h-screen {
|
|
||||||
height: 100vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
.h-full {
|
.h-full {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.h-screen {
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
.h-fit {
|
.h-fit {
|
||||||
height: -webkit-fit-content;
|
height: -webkit-fit-content;
|
||||||
height: -moz-fit-content;
|
height: -moz-fit-content;
|
||||||
|
@ -1284,10 +1284,6 @@ video {
|
||||||
height: 2.75rem;
|
height: 2.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.h-2\/3 {
|
|
||||||
height: 66.666667%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.h-5 {
|
.h-5 {
|
||||||
height: 1.25rem;
|
height: 1.25rem;
|
||||||
}
|
}
|
||||||
|
@ -2562,6 +2558,11 @@ video {
|
||||||
border-color: rgb(209 213 219 / var(--tw-border-opacity));
|
border-color: rgb(209 213 219 / var(--tw-border-opacity));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.border-red-500 {
|
||||||
|
--tw-border-opacity: 1;
|
||||||
|
border-color: rgb(239 68 68 / var(--tw-border-opacity));
|
||||||
|
}
|
||||||
|
|
||||||
.border-gray-800 {
|
.border-gray-800 {
|
||||||
--tw-border-opacity: 1;
|
--tw-border-opacity: 1;
|
||||||
border-color: rgb(31 41 55 / var(--tw-border-opacity));
|
border-color: rgb(31 41 55 / var(--tw-border-opacity));
|
||||||
|
@ -2657,11 +2658,6 @@ video {
|
||||||
border-color: rgb(34 197 94 / var(--tw-border-opacity));
|
border-color: rgb(34 197 94 / var(--tw-border-opacity));
|
||||||
}
|
}
|
||||||
|
|
||||||
.border-red-500 {
|
|
||||||
--tw-border-opacity: 1;
|
|
||||||
border-color: rgb(239 68 68 / var(--tw-border-opacity));
|
|
||||||
}
|
|
||||||
|
|
||||||
.border-gray-700 {
|
.border-gray-700 {
|
||||||
--tw-border-opacity: 1;
|
--tw-border-opacity: 1;
|
||||||
border-color: rgb(55 65 81 / var(--tw-border-opacity));
|
border-color: rgb(55 65 81 / var(--tw-border-opacity));
|
||||||
|
@ -4636,7 +4632,6 @@ button.unstyled {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
justify-content: start;
|
justify-content: start;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 0;
|
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
|
|
@ -327,8 +327,6 @@ class GenerateLayouts extends Script {
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const apiUrls: string[] = [
|
const apiUrls: string[] = [
|
||||||
...Constants.allServers,
|
...Constants.allServers,
|
||||||
Constants.countryCoderEndpoint,
|
|
||||||
Constants.nominatimEndpoint,
|
|
||||||
"https://www.openstreetmap.org",
|
"https://www.openstreetmap.org",
|
||||||
"https://api.openstreetmap.org",
|
"https://api.openstreetmap.org",
|
||||||
"https://pietervdvn.goatcounter.com",
|
"https://pietervdvn.goatcounter.com",
|
||||||
|
|
|
@ -6,7 +6,7 @@ import { Feature, Polygon } from "geojson"
|
||||||
export class BBox {
|
export class BBox {
|
||||||
static global: BBox = new BBox([
|
static global: BBox = new BBox([
|
||||||
[-180, -90],
|
[-180, -90],
|
||||||
[180, 90],
|
[180, 90]
|
||||||
])
|
])
|
||||||
readonly maxLat: number
|
readonly maxLat: number
|
||||||
readonly maxLon: number
|
readonly maxLon: number
|
||||||
|
@ -53,7 +53,7 @@ export class BBox {
|
||||||
static fromLeafletBounds(bounds) {
|
static fromLeafletBounds(bounds) {
|
||||||
return new BBox([
|
return new BBox([
|
||||||
[bounds.getWest(), bounds.getNorth()],
|
[bounds.getWest(), bounds.getNorth()],
|
||||||
[bounds.getEast(), bounds.getSouth()],
|
[bounds.getEast(), bounds.getSouth()]
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -74,7 +74,7 @@ export class BBox {
|
||||||
// Note: x is longitude
|
// Note: x is longitude
|
||||||
f["bbox"] = new BBox([
|
f["bbox"] = new BBox([
|
||||||
[minX, minY],
|
[minX, minY],
|
||||||
[maxX, maxY],
|
[maxX, maxY]
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
return f["bbox"]
|
return f["bbox"]
|
||||||
|
@ -94,7 +94,7 @@ export class BBox {
|
||||||
}
|
}
|
||||||
return new BBox([
|
return new BBox([
|
||||||
[maxLon, maxLat],
|
[maxLon, maxLat],
|
||||||
[minLon, minLat],
|
[minLon, minLat]
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -121,7 +121,7 @@ export class BBox {
|
||||||
public unionWith(other: BBox) {
|
public unionWith(other: BBox) {
|
||||||
return new BBox([
|
return new BBox([
|
||||||
[Math.max(this.maxLon, other.maxLon), Math.max(this.maxLat, other.maxLat)],
|
[Math.max(this.maxLon, other.maxLon), Math.max(this.maxLat, other.maxLat)],
|
||||||
[Math.min(this.minLon, other.minLon), Math.min(this.minLat, other.minLat)],
|
[Math.min(this.minLon, other.minLon), Math.min(this.minLat, other.minLat)]
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -174,7 +174,7 @@ export class BBox {
|
||||||
|
|
||||||
return new BBox([
|
return new BBox([
|
||||||
[lon - s / 2, lat - s / 2],
|
[lon - s / 2, lat - s / 2],
|
||||||
[lon + s / 2, lat + s / 2],
|
[lon + s / 2, lat + s / 2]
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -231,21 +231,21 @@ export class BBox {
|
||||||
const lonDiff = Math.min(maxIncrease / 2, Math.abs(this.maxLon - this.minLon) * factor)
|
const lonDiff = Math.min(maxIncrease / 2, Math.abs(this.maxLon - this.minLon) * factor)
|
||||||
return new BBox([
|
return new BBox([
|
||||||
[this.minLon - lonDiff, this.minLat - latDiff],
|
[this.minLon - lonDiff, this.minLat - latDiff],
|
||||||
[this.maxLon + lonDiff, this.maxLat + latDiff],
|
[this.maxLon + lonDiff, this.maxLat + latDiff]
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
padAbsolute(degrees: number): BBox {
|
padAbsolute(degrees: number): BBox {
|
||||||
return new BBox([
|
return new BBox([
|
||||||
[this.minLon - degrees, this.minLat - degrees],
|
[this.minLon - degrees, this.minLat - degrees],
|
||||||
[this.maxLon + degrees, this.maxLat + degrees],
|
[this.maxLon + degrees, this.maxLat + degrees]
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
toLngLat(): [[number, number], [number, number]] {
|
toLngLat(): [[number, number], [number, number]] {
|
||||||
return [
|
return [
|
||||||
[this.minLon, this.minLat],
|
[this.minLon, this.minLat],
|
||||||
[this.maxLon, this.maxLat],
|
[this.maxLon, this.maxLat]
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -260,7 +260,7 @@ export class BBox {
|
||||||
return {
|
return {
|
||||||
type: "Feature",
|
type: "Feature",
|
||||||
properties: properties,
|
properties: properties,
|
||||||
geometry: this.asGeometry(),
|
geometry: this.asGeometry()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -273,9 +273,9 @@ export class BBox {
|
||||||
[this.maxLon, this.minLat],
|
[this.maxLon, this.minLat],
|
||||||
[this.maxLon, this.maxLat],
|
[this.maxLon, this.maxLat],
|
||||||
[this.minLon, this.maxLat],
|
[this.minLon, this.maxLat],
|
||||||
[this.minLon, this.minLat],
|
[this.minLon, this.minLat]
|
||||||
],
|
]
|
||||||
],
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -302,7 +302,7 @@ export class BBox {
|
||||||
minLon,
|
minLon,
|
||||||
maxLon,
|
maxLon,
|
||||||
minLat,
|
minLat,
|
||||||
maxLat,
|
maxLat
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -316,4 +316,8 @@ export class BBox {
|
||||||
public overlapsWithFeature(f: Feature) {
|
public overlapsWithFeature(f: Feature) {
|
||||||
return GeoOperations.calculateOverlap(this.asGeoJson({}), [f]).length > 0
|
return GeoOperations.calculateOverlap(this.asGeoJson({}), [f]).length > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
center() {
|
||||||
|
return [(this.minLon + this.maxLon) / 2, (this.minLat + this.maxLat) / 2]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -908,7 +908,7 @@ export class GeoOperations {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GeoOperations.distanceToHuman(52.8) // => "53m"
|
* GeoOperations.distanceToHuman(52.8) // => "50m"
|
||||||
* GeoOperations.distanceToHuman(2800) // => "2.8km"
|
* GeoOperations.distanceToHuman(2800) // => "2.8km"
|
||||||
* GeoOperations.distanceToHuman(12800) // => "13km"
|
* GeoOperations.distanceToHuman(12800) // => "13km"
|
||||||
*
|
*
|
||||||
|
@ -920,11 +920,11 @@ export class GeoOperations {
|
||||||
}
|
}
|
||||||
meters = Math.round(meters)
|
meters = Math.round(meters)
|
||||||
if (meters < 1000) {
|
if (meters < 1000) {
|
||||||
return meters + "m"
|
return Utils.roundHuman(meters) + "m"
|
||||||
}
|
}
|
||||||
|
|
||||||
if (meters >= 10000) {
|
if (meters >= 10000) {
|
||||||
const km = Math.round(meters / 1000)
|
const km = Utils.roundHuman(meters / 1000)
|
||||||
return km + "km"
|
return km + "km"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -9,13 +9,35 @@ export default class CombinedSearcher implements GeocodingProvider {
|
||||||
this._providersWithSuggest = providers.filter(pr => pr.suggest !== undefined)
|
this._providersWithSuggest = providers.filter(pr => pr.suggest !== undefined)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merges the geocode-results from various sources.
|
||||||
|
* If the same osm-id is mentioned multiple times, only the first result will be kept
|
||||||
|
* @param geocoded
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
private merge(geocoded: GeoCodeResult[][]): GeoCodeResult[]{
|
||||||
|
const results : GeoCodeResult[] = []
|
||||||
|
const seenIds = new Set<string>()
|
||||||
|
for (const geocodedElement of geocoded) {
|
||||||
|
for (const entry of geocodedElement) {
|
||||||
|
const id = entry.osm_type+ entry.osm_id
|
||||||
|
if(seenIds.has(id)){
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seenIds.add(id)
|
||||||
|
results.push(entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
async search(query: string, options?: GeocodingOptions): Promise<GeoCodeResult[]> {
|
async search(query: string, options?: GeocodingOptions): Promise<GeoCodeResult[]> {
|
||||||
const results = await Promise.all(this._providers.map(pr => pr.search(query, options)))
|
const results = await Promise.all(this._providers.map(pr => pr.search(query, options)))
|
||||||
return results.flatMap(x => x)
|
return this.merge(results)
|
||||||
}
|
}
|
||||||
|
|
||||||
async suggest(query: string, options?: GeocodingOptions): Promise<GeoCodeResult[]> {
|
async suggest(query: string, options?: GeocodingOptions): Promise<GeoCodeResult[]> {
|
||||||
const results = await Promise.all(this._providersWithSuggest.map(pr => pr.suggest(query, options)))
|
const results = await Promise.all(this._providersWithSuggest.map(pr => pr.suggest(query, options)))
|
||||||
return results.flatMap(x => x)
|
return this.merge(results)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -44,7 +44,8 @@ export default class CoordinateSearch implements GeocodingProvider {
|
||||||
lat: Number(m[1]),
|
lat: Number(m[1]),
|
||||||
lon: Number(m[2]),
|
lon: Number(m[2]),
|
||||||
display_name: "lon: " + m[2] + ", lat: " + m[1],
|
display_name: "lon: " + m[2] + ", lat: " + m[1],
|
||||||
source: "coordinateSearch"
|
source: "coordinateSearch",
|
||||||
|
category: "coordinate"
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@ -54,7 +55,8 @@ export default class CoordinateSearch implements GeocodingProvider {
|
||||||
lat: Number(m[2]),
|
lat: Number(m[2]),
|
||||||
lon: Number(m[1]),
|
lon: Number(m[1]),
|
||||||
display_name: "lon: " + m[1] + ", lat: " + m[2],
|
display_name: "lon: " + m[1] + ", lat: " + m[2],
|
||||||
source: "coordinateSearch"
|
source: "coordinateSearch",
|
||||||
|
category: "coordinate"
|
||||||
})
|
})
|
||||||
|
|
||||||
return matches.concat(matchesLonLat)
|
return matches.concat(matchesLonLat)
|
||||||
|
|
|
@ -1,8 +1,18 @@
|
||||||
import { BBox } from "../BBox"
|
import { BBox } from "../BBox"
|
||||||
import { Feature, FeatureCollection } from "geojson"
|
import { Feature, Geometry } from "geojson"
|
||||||
|
import { DefaultPinIcon } from "../../Models/Constants"
|
||||||
|
|
||||||
|
export type GeocodingCategory = "coordinate" | "city" | "house" | "street" | "locality" | "country" | "train_station" | "county" | "airport"
|
||||||
|
|
||||||
export type GeoCodeResult = {
|
export type GeoCodeResult = {
|
||||||
|
/**
|
||||||
|
* The name of the feature being displayed
|
||||||
|
*/
|
||||||
display_name: string
|
display_name: string
|
||||||
|
/**
|
||||||
|
* Some optional, extra information
|
||||||
|
*/
|
||||||
|
description?: string | Promise<string>,
|
||||||
feature?: Feature,
|
feature?: Feature,
|
||||||
lat: number
|
lat: number
|
||||||
lon: number
|
lon: number
|
||||||
|
@ -12,7 +22,9 @@ export type GeoCodeResult = {
|
||||||
*/
|
*/
|
||||||
boundingbox?: number[]
|
boundingbox?: number[]
|
||||||
osm_type?: "node" | "way" | "relation"
|
osm_type?: "node" | "way" | "relation"
|
||||||
osm_id?: string
|
osm_id?: string,
|
||||||
|
category?: GeocodingCategory,
|
||||||
|
importance?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GeocodingOptions {
|
export interface GeocodingOptions {
|
||||||
|
@ -33,11 +45,52 @@ export default interface GeocodingProvider {
|
||||||
suggest?(query: string, options?: GeocodingOptions): Promise<GeoCodeResult[]>
|
suggest?(query: string, options?: GeocodingOptions): Promise<GeoCodeResult[]>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ReverseGeocodingResult = Feature<Geometry,{
|
||||||
|
osm_id: number,
|
||||||
|
osm_type: "node" | "way" | "relation",
|
||||||
|
country: string,
|
||||||
|
city: string,
|
||||||
|
countrycode: string,
|
||||||
|
type: GeocodingCategory,
|
||||||
|
street: string
|
||||||
|
} >
|
||||||
|
|
||||||
export interface ReverseGeocodingProvider {
|
export interface ReverseGeocodingProvider {
|
||||||
reverseSearch(
|
reverseSearch(
|
||||||
coordinate: { lon: number; lat: number },
|
coordinate: { lon: number; lat: number },
|
||||||
zoom: number,
|
zoom: number,
|
||||||
language?: string
|
language?: string
|
||||||
): Promise<FeatureCollection> ;
|
): Promise<ReverseGeocodingResult[]> ;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class GeocodingUtils {
|
||||||
|
|
||||||
|
public static categoryToZoomLevel: Record<GeocodingCategory, number> = {
|
||||||
|
city: 12,
|
||||||
|
county: 10,
|
||||||
|
coordinate: 16,
|
||||||
|
country: 8,
|
||||||
|
house: 16,
|
||||||
|
locality: 14,
|
||||||
|
street: 15,
|
||||||
|
train_station: 14,
|
||||||
|
airport: 13
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public static categoryToIcon: Record<GeocodingCategory, DefaultPinIcon> = {
|
||||||
|
city: "building_office_2",
|
||||||
|
coordinate: "globe_alt",
|
||||||
|
country: "globe_alt",
|
||||||
|
house: "house",
|
||||||
|
locality: "building_office_2",
|
||||||
|
street: "globe_alt",
|
||||||
|
train_station: "train",
|
||||||
|
county: "building_office_2",
|
||||||
|
airport: "airport"
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -6,9 +6,11 @@ import { GeoOperations } from "../GeoOperations"
|
||||||
|
|
||||||
export default class LocalElementSearch implements GeocodingProvider {
|
export default class LocalElementSearch implements GeocodingProvider {
|
||||||
private readonly _state: ThemeViewState
|
private readonly _state: ThemeViewState
|
||||||
|
private readonly _limit: number
|
||||||
|
|
||||||
constructor(state: ThemeViewState) {
|
constructor(state: ThemeViewState, limit: number) {
|
||||||
this._state = state
|
this._state = state
|
||||||
|
this._limit = limit
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -30,7 +32,8 @@ export default class LocalElementSearch implements GeocodingProvider {
|
||||||
center: [number, number],
|
center: [number, number],
|
||||||
levehnsteinD: number,
|
levehnsteinD: number,
|
||||||
physicalDistance: number,
|
physicalDistance: number,
|
||||||
searchTerms: string[]
|
searchTerms: string[],
|
||||||
|
description: string
|
||||||
}[] = []
|
}[] = []
|
||||||
const properties = this._state.perLayer
|
const properties = this._state.perLayer
|
||||||
query = Utils.simplifyStringForSearch(query)
|
query = Utils.simplifyStringForSearch(query)
|
||||||
|
@ -51,19 +54,29 @@ export default class LocalElementSearch implements GeocodingProvider {
|
||||||
}))
|
}))
|
||||||
const center = GeoOperations.centerpointCoordinates(feature)
|
const center = GeoOperations.centerpointCoordinates(feature)
|
||||||
if (levehnsteinD <= 2) {
|
if (levehnsteinD <= 2) {
|
||||||
|
|
||||||
|
let description = ""
|
||||||
|
function ifDef(prefix: string, key: string){
|
||||||
|
if(feature.properties[key]){
|
||||||
|
description += prefix+ feature.properties[key]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ifDef("", "addr:street")
|
||||||
|
ifDef(" ", "addr:housenumber")
|
||||||
results.push({
|
results.push({
|
||||||
feature,
|
feature,
|
||||||
center,
|
center,
|
||||||
physicalDistance: GeoOperations.distanceBetween(centerPoint, center),
|
physicalDistance: GeoOperations.distanceBetween(centerPoint, center),
|
||||||
levehnsteinD,
|
levehnsteinD,
|
||||||
searchTerms
|
searchTerms,
|
||||||
|
description: description !== "" ? description : undefined
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
results.sort((a, b) => (a.physicalDistance + a.levehnsteinD * 25) - (b.physicalDistance + b.levehnsteinD * 25))
|
results.sort((a, b) => (a.physicalDistance + a.levehnsteinD * 25) - (b.physicalDistance + b.levehnsteinD * 25))
|
||||||
if (options?.limit) {
|
if (this._limit || options?.limit) {
|
||||||
results = results.slice(0, options.limit)
|
results = results.slice(0, Math.min(this._limit ?? options?.limit, options?.limit ?? this._limit))
|
||||||
}
|
}
|
||||||
return results.map(entry => {
|
return results.map(entry => {
|
||||||
const id = entry.feature.properties.id.split("/")
|
const id = entry.feature.properties.id.split("/")
|
||||||
|
@ -74,7 +87,9 @@ export default class LocalElementSearch implements GeocodingProvider {
|
||||||
osm_id: id[1],
|
osm_id: id[1],
|
||||||
display_name: entry.searchTerms[0],
|
display_name: entry.searchTerms[0],
|
||||||
source: "localElementSearch",
|
source: "localElementSearch",
|
||||||
feature: entry.feature
|
feature: entry.feature,
|
||||||
|
importance: 1,
|
||||||
|
description: entry.description
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
137
src/Logic/Geocoding/PhotonSearch.ts
Normal file
137
src/Logic/Geocoding/PhotonSearch.ts
Normal file
|
@ -0,0 +1,137 @@
|
||||||
|
import Constants from "../../Models/Constants"
|
||||||
|
import GeocodingProvider, {
|
||||||
|
GeoCodeResult, GeocodingCategory,
|
||||||
|
GeocodingOptions,
|
||||||
|
ReverseGeocodingProvider,
|
||||||
|
ReverseGeocodingResult
|
||||||
|
} from "./GeocodingProvider"
|
||||||
|
import { Utils } from "../../Utils"
|
||||||
|
import { Feature, FeatureCollection } from "geojson"
|
||||||
|
import Locale from "../../UI/i18n/Locale"
|
||||||
|
import { GeoOperations } from "../GeoOperations"
|
||||||
|
|
||||||
|
export default class PhotonSearch implements GeocodingProvider, ReverseGeocodingProvider {
|
||||||
|
private _endpoint: string
|
||||||
|
private supportedLanguages = ["en", "de", "fr"]
|
||||||
|
private static readonly types = {
|
||||||
|
"R": "relation",
|
||||||
|
"W": "way",
|
||||||
|
"N": "node"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
constructor(endpoint?: string) {
|
||||||
|
this._endpoint = endpoint ?? Constants.photonEndpoint ?? "https://photon.komoot.io/"
|
||||||
|
}
|
||||||
|
|
||||||
|
async reverseSearch(coordinate: {
|
||||||
|
lon: number;
|
||||||
|
lat: number
|
||||||
|
}, zoom: number, language?: string): Promise<ReverseGeocodingResult[]> {
|
||||||
|
const url = `${this._endpoint}/reverse?lon=${coordinate.lon}&lat=${coordinate.lat}&${this.getLanguage(language)}`
|
||||||
|
const result = await Utils.downloadJsonCached<FeatureCollection>(url, 1000 * 60 * 60)
|
||||||
|
for (const f of result.features) {
|
||||||
|
f.properties.osm_type = PhotonSearch.types[f.properties.osm_type]
|
||||||
|
}
|
||||||
|
return <ReverseGeocodingResult[]>result.features
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets a `&lang=en` if the current/requested language is supported
|
||||||
|
* @param language
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
private getLanguage(language?: string): string {
|
||||||
|
|
||||||
|
language ??= Locale.language.data
|
||||||
|
if (this.supportedLanguages.indexOf(language) < 0) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return `&lang=${language}`
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
search(query: string, options?: GeocodingOptions): Promise<GeoCodeResult[]> {
|
||||||
|
return this.suggest(query, options)
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildDescription(entry: Feature) {
|
||||||
|
const p = entry.properties
|
||||||
|
const type = <GeocodingCategory>p.type
|
||||||
|
|
||||||
|
function ifdef(prefix: string, str: string) {
|
||||||
|
if (str) {
|
||||||
|
return prefix + str
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case "house": {
|
||||||
|
|
||||||
|
const addr = ifdef("", p.street) + ifdef(" ", p.housenumber)
|
||||||
|
if(!addr){
|
||||||
|
return p.city
|
||||||
|
}
|
||||||
|
return addr + ifdef(", ", p.city)
|
||||||
|
}
|
||||||
|
case "coordinate":
|
||||||
|
case "street":
|
||||||
|
return p.city ?? p.country
|
||||||
|
case "city":
|
||||||
|
case "locality":
|
||||||
|
if(p.state){
|
||||||
|
return p.state + ifdef(", ", p.country)
|
||||||
|
}
|
||||||
|
return p.country
|
||||||
|
case "country":
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
private getCategory(entry: Feature){
|
||||||
|
const p = entry.properties
|
||||||
|
if(p.osm_value === "train_station" || p.osm_key === "railway"){
|
||||||
|
return "train_station"
|
||||||
|
}
|
||||||
|
if(p.osm_value === "aerodrome" || p.osm_key === "aeroway"){
|
||||||
|
return "airport"
|
||||||
|
}
|
||||||
|
return p.type
|
||||||
|
}
|
||||||
|
|
||||||
|
async suggest?(query: string, options?: GeocodingOptions): Promise<GeoCodeResult[]> {
|
||||||
|
if (query.length < 3) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
const limit = options?.limit ?? 5
|
||||||
|
let bbox = ""
|
||||||
|
if (options?.bbox) {
|
||||||
|
const [lon, lat] = options.bbox.center()
|
||||||
|
bbox = `&lon=${lon}&lat=${lat}`
|
||||||
|
}
|
||||||
|
const url = `${this._endpoint}/api/?q=${encodeURIComponent(query)}&limit=${limit}${this.getLanguage()}${bbox}`
|
||||||
|
const results = await Utils.downloadJsonCached<FeatureCollection>(url, 1000 * 60 * 60)
|
||||||
|
return results.features.map(f => {
|
||||||
|
const [lon, lat] = GeoOperations.centerpointCoordinates(f)
|
||||||
|
let boundingbox: number[] = undefined
|
||||||
|
if (f.properties.extent) {
|
||||||
|
const [lon0, lat0, lon1, lat1] = f.properties.extent
|
||||||
|
boundingbox = [lat0, lat1, lon0, lon1]
|
||||||
|
}
|
||||||
|
return <GeoCodeResult>{
|
||||||
|
feature: f,
|
||||||
|
osm_id: f.properties.osm_id,
|
||||||
|
display_name: f.properties.name,
|
||||||
|
description: this.buildDescription(f),
|
||||||
|
osm_type: PhotonSearch.types[f.properties.osm_type],
|
||||||
|
category: this.getCategory(f),
|
||||||
|
boundingbox,
|
||||||
|
lon, lat
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
49
src/Logic/Geocoding/RecentSearch.ts
Normal file
49
src/Logic/Geocoding/RecentSearch.ts
Normal file
|
@ -0,0 +1,49 @@
|
||||||
|
import { Store, UIEventSource } from "../UIEventSource"
|
||||||
|
import { Feature } from "geojson"
|
||||||
|
import { OsmConnection } from "../Osm/OsmConnection"
|
||||||
|
import { GeoCodeResult } from "./GeocodingProvider"
|
||||||
|
import { GeoOperations } from "../GeoOperations"
|
||||||
|
import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig"
|
||||||
|
|
||||||
|
export class RecentSearch {
|
||||||
|
|
||||||
|
private readonly _recentSearches: UIEventSource<string[]>
|
||||||
|
public readonly recentSearches: Store<string[]>
|
||||||
|
|
||||||
|
private readonly _seenThisSession: UIEventSource<GeoCodeResult[]> = new UIEventSource<GeoCodeResult[]>([])
|
||||||
|
public readonly seenThisSession: Store<GeoCodeResult[]> = this._seenThisSession
|
||||||
|
|
||||||
|
constructor(state: { layout: LayoutConfig, osmConnection: OsmConnection, selectedElement: Store<Feature> }) {
|
||||||
|
const longPref = state.osmConnection.preferencesHandler.GetLongPreference("recent-searches")
|
||||||
|
this._recentSearches = longPref.sync(str => !str ? [] : <string[]>JSON.parse(str), [], strs => JSON.stringify(strs))
|
||||||
|
this.recentSearches = this._recentSearches
|
||||||
|
|
||||||
|
state.selectedElement.addCallbackAndRunD(selected => {
|
||||||
|
const [osm_type, osm_id] = selected.properties.id.split("/")
|
||||||
|
const [lon, lat] = GeoOperations.centerpointCoordinates(selected)
|
||||||
|
const entry = <GeoCodeResult> {
|
||||||
|
feature: selected,
|
||||||
|
osm_id, osm_type,
|
||||||
|
description: "Viewed recently",
|
||||||
|
lon, lat
|
||||||
|
}
|
||||||
|
this.addSelected(entry)
|
||||||
|
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
addSelected(entry: GeoCodeResult) {
|
||||||
|
const arr = [...this.seenThisSession.data.slice(0, 20), entry]
|
||||||
|
|
||||||
|
const seenIds = new Set<string>()
|
||||||
|
for (let i = arr.length - 1; i >= 0; i--) {
|
||||||
|
const id = arr[i].osm_type + arr[i].osm_id
|
||||||
|
if (seenIds.has(id)) {
|
||||||
|
arr.splice(i, 1)
|
||||||
|
} else {
|
||||||
|
seenIds.add(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this._seenThisSession.set(arr)
|
||||||
|
}
|
||||||
|
}
|
|
@ -424,9 +424,11 @@ export default class MetaTagging {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.warn(
|
if (!window.location.pathname.endsWith("theme.html")) {
|
||||||
"Static MetataggingObject for theme is not set; using `new Function` (aka `eval`) to get calculated tags. This might trip up the CSP"
|
console.warn(
|
||||||
)
|
"Static MetataggingObject for theme is not set; using `new Function` (aka `eval`) to get calculated tags. This might trip up the CSP"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const calculatedTags: [string, string, boolean][] = layer?.calculatedTags ?? []
|
const calculatedTags: [string, string, boolean][] = layer?.calculatedTags ?? []
|
||||||
if (calculatedTags === undefined || calculatedTags.length === 0) {
|
if (calculatedTags === undefined || calculatedTags.length === 0) {
|
||||||
|
|
|
@ -617,7 +617,7 @@ export class UIEventSource<T> extends Store<T> implements Writable<T> {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Converts a promise into a UIVentsource, sets the UIEVentSource when the result is calculated.
|
* Converts a promise into a UIventsource, sets the UIeventSource when the result is calculated.
|
||||||
* If the promise fails, the value will stay undefined, but 'onError' will be called
|
* If the promise fails, the value will stay undefined, but 'onError' will be called
|
||||||
*/
|
*/
|
||||||
public static FromPromise<T>(
|
public static FromPromise<T>(
|
||||||
|
|
|
@ -4,6 +4,7 @@ import { Utils } from "../Utils"
|
||||||
import { AuthConfig } from "../Logic/Osm/AuthConfig"
|
import { AuthConfig } from "../Logic/Osm/AuthConfig"
|
||||||
|
|
||||||
export type PriviligedLayerType = (typeof Constants.priviliged_layers)[number]
|
export type PriviligedLayerType = (typeof Constants.priviliged_layers)[number]
|
||||||
|
export type DefaultPinIcon = (typeof Constants._defaultPinIcons)[number]
|
||||||
|
|
||||||
export default class Constants {
|
export default class Constants {
|
||||||
public static vNumber: string = packagefile.version
|
public static vNumber: string = packagefile.version
|
||||||
|
@ -113,14 +114,18 @@ export default class Constants {
|
||||||
public static countryCoderEndpoint: string = Constants.config.country_coder_host
|
public static countryCoderEndpoint: string = Constants.config.country_coder_host
|
||||||
public static osmAuthConfig: AuthConfig = Constants.config.oauth_credentials
|
public static osmAuthConfig: AuthConfig = Constants.config.oauth_credentials
|
||||||
public static nominatimEndpoint: string = Constants.config.nominatimEndpoint
|
public static nominatimEndpoint: string = Constants.config.nominatimEndpoint
|
||||||
|
public static photonEndpoint: string = Constants.config.photonEndpoint
|
||||||
|
|
||||||
public static linkedDataProxy: string = Constants.config["jsonld-proxy"]
|
public static linkedDataProxy: string = Constants.config["jsonld-proxy"]
|
||||||
/**
|
/**
|
||||||
* These are the values that are allowed to use as 'backdrop' icon for a map pin
|
* These are the values that are allowed to use as 'backdrop' icon for a map pin
|
||||||
*/
|
*/
|
||||||
private static readonly _defaultPinIcons = [
|
public static readonly _defaultPinIcons = [
|
||||||
"addSmall",
|
"addSmall",
|
||||||
|
"airport",
|
||||||
"brick_wall_round",
|
"brick_wall_round",
|
||||||
"brick_wall_square",
|
"brick_wall_square",
|
||||||
|
"building_office_2",
|
||||||
"bug",
|
"bug",
|
||||||
"checkmark",
|
"checkmark",
|
||||||
"checkmark",
|
"checkmark",
|
||||||
|
@ -135,12 +140,14 @@ export default class Constants {
|
||||||
"desktop",
|
"desktop",
|
||||||
"direction",
|
"direction",
|
||||||
"gear",
|
"gear",
|
||||||
|
"globe_alt",
|
||||||
"gps_arrow",
|
"gps_arrow",
|
||||||
"heart",
|
"heart",
|
||||||
"heart_outline",
|
"heart_outline",
|
||||||
"help",
|
"help",
|
||||||
"help",
|
"help",
|
||||||
"home",
|
"home",
|
||||||
|
"house",
|
||||||
"invalid",
|
"invalid",
|
||||||
"invalid",
|
"invalid",
|
||||||
"link",
|
"link",
|
||||||
|
@ -160,7 +167,9 @@ export default class Constants {
|
||||||
"square_rounded",
|
"square_rounded",
|
||||||
"teardrop",
|
"teardrop",
|
||||||
"teardrop_with_hole_green",
|
"teardrop_with_hole_green",
|
||||||
|
"train",
|
||||||
"triangle",
|
"triangle",
|
||||||
|
"user_circle",
|
||||||
"wifi",
|
"wifi",
|
||||||
] as const
|
] as const
|
||||||
public static readonly defaultPinIcons: string[] = <any>Constants._defaultPinIcons
|
public static readonly defaultPinIcons: string[] = <any>Constants._defaultPinIcons
|
||||||
|
@ -183,6 +192,7 @@ export default class Constants {
|
||||||
Constants.countryCoderEndpoint,
|
Constants.countryCoderEndpoint,
|
||||||
Constants.osmAuthConfig.url,
|
Constants.osmAuthConfig.url,
|
||||||
Constants.nominatimEndpoint,
|
Constants.nominatimEndpoint,
|
||||||
|
Constants.photonEndpoint,
|
||||||
Constants.linkedDataProxy,
|
Constants.linkedDataProxy,
|
||||||
...Constants.defaultOverpassUrls,
|
...Constants.defaultOverpassUrls,
|
||||||
]
|
]
|
||||||
|
|
|
@ -321,7 +321,13 @@ export interface QuestionableTagRenderingConfigJson extends TagRenderingConfigJs
|
||||||
editButtonAriaLabel?: Translatable
|
editButtonAriaLabel?: Translatable
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* What labels should be applied on this tagRendering?
|
||||||
|
*
|
||||||
* A list of labels. These are strings that are used for various purposes, e.g. to only include a subset of the tagRenderings when reusing a layer
|
* A list of labels. These are strings that are used for various purposes, e.g. to only include a subset of the tagRenderings when reusing a layer
|
||||||
|
*
|
||||||
|
* Special values:
|
||||||
|
* - "hidden": do not show this tagRendering. Useful in it is used by e.g. an accordion
|
||||||
|
* - "description": this label is a description used in the search
|
||||||
*/
|
*/
|
||||||
labels?: string[]
|
labels?: string[]
|
||||||
}
|
}
|
||||||
|
|
|
@ -898,9 +898,7 @@ export default class TagRenderingConfig {
|
||||||
].join("\n")
|
].join("\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
public
|
public usedTags(): TagsFilter[] {
|
||||||
|
|
||||||
usedTags(): TagsFilter[] {
|
|
||||||
const tags: TagsFilter[] = []
|
const tags: TagsFilter[] = []
|
||||||
tags.push(
|
tags.push(
|
||||||
this.metacondition,
|
this.metacondition,
|
||||||
|
|
|
@ -79,6 +79,8 @@ import CombinedSearcher from "../Logic/Geocoding/CombinedSearcher"
|
||||||
import { NominatimGeocoding } from "../Logic/Geocoding/NominatimGeocoding"
|
import { NominatimGeocoding } from "../Logic/Geocoding/NominatimGeocoding"
|
||||||
import CoordinateSearch from "../Logic/Geocoding/CoordinateSearch"
|
import CoordinateSearch from "../Logic/Geocoding/CoordinateSearch"
|
||||||
import LocalElementSearch from "../Logic/Geocoding/LocalElementSearch"
|
import LocalElementSearch from "../Logic/Geocoding/LocalElementSearch"
|
||||||
|
import { RecentSearch } from "../Logic/Geocoding/RecentSearch"
|
||||||
|
import PhotonSearch from "../Logic/Geocoding/PhotonSearch"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
|
@ -160,6 +162,7 @@ export default class ThemeViewState implements SpecialVisualizationState {
|
||||||
|
|
||||||
public readonly nearbyImageSearcher: CombinedFetcher
|
public readonly nearbyImageSearcher: CombinedFetcher
|
||||||
public readonly geosearch: GeocodingProvider
|
public readonly geosearch: GeocodingProvider
|
||||||
|
public readonly recentlySearched: RecentSearch
|
||||||
|
|
||||||
constructor(layout: LayoutConfig, mvtAvailableLayers: Set<string>) {
|
constructor(layout: LayoutConfig, mvtAvailableLayers: Set<string>) {
|
||||||
Utils.initDomPurify()
|
Utils.initDomPurify()
|
||||||
|
@ -387,11 +390,12 @@ export default class ThemeViewState implements SpecialVisualizationState {
|
||||||
this.toCacheSavers = layout.enableCache ? this.initSaveToLocalStorage() : undefined
|
this.toCacheSavers = layout.enableCache ? this.initSaveToLocalStorage() : undefined
|
||||||
|
|
||||||
this.geosearch = new CombinedSearcher(
|
this.geosearch = new CombinedSearcher(
|
||||||
new NominatimGeocoding(),
|
new LocalElementSearch(this, 5),
|
||||||
|
new PhotonSearch(), // new NominatimGeocoding(),
|
||||||
new CoordinateSearch(),
|
new CoordinateSearch(),
|
||||||
new LocalElementSearch(this)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
this.recentlySearched = new RecentSearch(this)
|
||||||
|
|
||||||
this.initActors()
|
this.initActors()
|
||||||
this.drawSpecialLayers()
|
this.drawSpecialLayers()
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { UIEventSource } from "../../Logic/UIEventSource"
|
import { Store, UIEventSource } from "../../Logic/UIEventSource"
|
||||||
import type { Feature } from "geojson"
|
import type { Feature } from "geojson"
|
||||||
import Translations from "../i18n/Translations"
|
import Translations from "../i18n/Translations"
|
||||||
import Loading from "../Base/Loading.svelte"
|
import Loading from "../Base/Loading.svelte"
|
||||||
|
@ -24,9 +24,9 @@
|
||||||
|
|
||||||
export let geolocationState: GeoLocationState | undefined = undefined
|
export let geolocationState: GeoLocationState | undefined = undefined
|
||||||
export let clearAfterView: boolean = true
|
export let clearAfterView: boolean = true
|
||||||
export let searcher : GeocodingProvider = new NominatimGeocoding()
|
export let searcher: GeocodingProvider = new NominatimGeocoding()
|
||||||
export let state : SpecialVisualizationState
|
export let state: SpecialVisualizationState
|
||||||
let searchContents: string = ""
|
let searchContents: UIEventSource<string> = new UIEventSource<string>("")
|
||||||
export let triggerSearch: UIEventSource<any> = new UIEventSource<any>(undefined)
|
export let triggerSearch: UIEventSource<any> = new UIEventSource<any>(undefined)
|
||||||
onDestroy(
|
onDestroy(
|
||||||
triggerSearch.addCallback((_) => {
|
triggerSearch.addCallback((_) => {
|
||||||
|
@ -40,6 +40,8 @@
|
||||||
|
|
||||||
let feedback: string = undefined
|
let feedback: string = undefined
|
||||||
|
|
||||||
|
let isFocused = new UIEventSource(false)
|
||||||
|
|
||||||
function focusOnSearch() {
|
function focusOnSearch() {
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
inputElement?.focus()
|
inputElement?.focus()
|
||||||
|
@ -54,7 +56,7 @@
|
||||||
|
|
||||||
const dispatch = createEventDispatcher<{ searchCompleted; searchIsValid: boolean }>()
|
const dispatch = createEventDispatcher<{ searchCompleted; searchIsValid: boolean }>()
|
||||||
$: {
|
$: {
|
||||||
if (!searchContents?.trim()) {
|
if (!$searchContents?.trim()) {
|
||||||
dispatch("searchIsValid", false)
|
dispatch("searchIsValid", false)
|
||||||
} else {
|
} else {
|
||||||
dispatch("searchIsValid", true)
|
dispatch("searchIsValid", true)
|
||||||
|
@ -67,12 +69,12 @@
|
||||||
isRunning = true
|
isRunning = true
|
||||||
geolocationState?.allowMoving.setData(true)
|
geolocationState?.allowMoving.setData(true)
|
||||||
geolocationState?.requestMoment.setData(undefined) // If the GPS is still searching for a fix, we say that we don't want tozoom to it anymore
|
geolocationState?.requestMoment.setData(undefined) // If the GPS is still searching for a fix, we say that we don't want tozoom to it anymore
|
||||||
searchContents = searchContents?.trim() ?? ""
|
const searchContentsData = $searchContents?.trim() ?? ""
|
||||||
|
|
||||||
if (searchContents === "") {
|
if (searchContentsData === "") {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const result = await searcher.search(searchContents, { bbox: bounds.data, limit: 10 })
|
const result = await searcher.search(searchContentsData, { bbox: bounds.data, limit: 10 })
|
||||||
console.log("Results are", result)
|
console.log("Results are", result)
|
||||||
if (result.length == 0) {
|
if (result.length == 0) {
|
||||||
feedback = Translations.t.general.search.nothing.txt
|
feedback = Translations.t.general.search.nothing.txt
|
||||||
|
@ -84,7 +86,7 @@
|
||||||
bounds.set(
|
bounds.set(
|
||||||
new BBox([
|
new BBox([
|
||||||
[lon0, lat0],
|
[lon0, lat0],
|
||||||
[lon1, lat1],
|
[lon1, lat1]
|
||||||
]).pad(0.01)
|
]).pad(0.01)
|
||||||
)
|
)
|
||||||
if (perLayer !== undefined) {
|
if (perLayer !== undefined) {
|
||||||
|
@ -101,7 +103,7 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (clearAfterView) {
|
if (clearAfterView) {
|
||||||
searchContents = ""
|
searchContents.setData("")
|
||||||
}
|
}
|
||||||
dispatch("searchIsValid", false)
|
dispatch("searchIsValid", false)
|
||||||
dispatch("searchCompleted")
|
dispatch("searchCompleted")
|
||||||
|
@ -114,18 +116,13 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let suggestions: GeoCodeResult[] = []
|
let suggestions: Store<GeoCodeResult[]> = searchContents.stabilized(250).bindD(search =>
|
||||||
|
UIEventSource.FromPromise(searcher.suggest(search), err => console.error(err))
|
||||||
async function updateSuggestions(search){
|
)
|
||||||
|
|
||||||
suggestions = await searcher.suggest(search, {limit: 5})
|
|
||||||
}
|
|
||||||
|
|
||||||
$: updateSuggestions(searchContents)
|
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="normal-background flex justify-between rounded-full pl-2">
|
<div class="normal-background flex justify-between rounded-full pl-2 w-full">
|
||||||
<form class="flex w-full flex-wrap">
|
<form class="flex w-full flex-wrap">
|
||||||
{#if isRunning}
|
{#if isRunning}
|
||||||
<Loading>{Translations.t.general.search.searching}</Loading>
|
<Loading>{Translations.t.general.search.searching}</Loading>
|
||||||
|
@ -138,7 +135,9 @@
|
||||||
feedback = undefined
|
feedback = undefined
|
||||||
return keypr.key === "Enter" ? performSearch() : undefined
|
return keypr.key === "Enter" ? performSearch() : undefined
|
||||||
}}
|
}}
|
||||||
bind:value={searchContents}
|
on:focus={() => {isFocused.setData(true)}}
|
||||||
|
on:blur={() => {isFocused.setData(false)}}
|
||||||
|
bind:value={$searchContents}
|
||||||
use:placeholder={Translations.t.general.search.search}
|
use:placeholder={Translations.t.general.search.search}
|
||||||
use:ariaLabel={Translations.t.general.search.search}
|
use:ariaLabel={Translations.t.general.search.search}
|
||||||
/>
|
/>
|
||||||
|
@ -153,6 +152,9 @@
|
||||||
<SearchIcon aria-hidden="true" class="h-6 w-6 self-end" on:click={performSearch} />
|
<SearchIcon aria-hidden="true" class="h-6 w-6 self-end" on:click={performSearch} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="h-2/3 ">
|
<div class="relative h-0" style="z-index: 10">
|
||||||
<SearchResults {state} results={suggestions}/>
|
|
||||||
|
<div class="absolute right-0" style="width: 25rem; max-width: 98vw">
|
||||||
|
<SearchResults {isFocused} {state} results={$suggestions} searchTerm={searchContents} on:select={() => {searchContents.set("")}}/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,46 +1,84 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { GeoCodeResult } from "../../Logic/Geocoding/GeocodingProvider"
|
import type { GeoCodeResult } from "../../Logic/Geocoding/GeocodingProvider"
|
||||||
|
import { GeocodingUtils } from "../../Logic/Geocoding/GeocodingProvider"
|
||||||
|
|
||||||
import type { SpecialVisualizationState } from "../SpecialVisualization"
|
import type { SpecialVisualizationState } from "../SpecialVisualization"
|
||||||
import LayerConfig from "../../Models/ThemeConfig/LayerConfig"
|
import LayerConfig from "../../Models/ThemeConfig/LayerConfig"
|
||||||
import ToSvelte from "../Base/ToSvelte.svelte"
|
import ToSvelte from "../Base/ToSvelte.svelte"
|
||||||
import { GeoOperations } from "../../Logic/GeoOperations"
|
import { GeoOperations } from "../../Logic/GeoOperations"
|
||||||
import { createEventDispatcher } from "svelte"
|
import { createEventDispatcher } from "svelte"
|
||||||
|
import Icon from "../Map/Icon.svelte"
|
||||||
|
import { BBox } from "../../Logic/BBox"
|
||||||
|
import TagRenderingAnswer from "../Popup/TagRendering/TagRenderingAnswer.svelte"
|
||||||
|
import { UIEventSource } from "../../Logic/UIEventSource"
|
||||||
|
import ArrowUp from "@babeard/svelte-heroicons/mini/ArrowUp"
|
||||||
|
|
||||||
export let entry: GeoCodeResult
|
export let entry: GeoCodeResult
|
||||||
export let state: SpecialVisualizationState
|
export let state: SpecialVisualizationState
|
||||||
let layer: LayerConfig
|
let layer: LayerConfig
|
||||||
if (entry.feature) {
|
let tags : UIEventSource<Record<string, string>>
|
||||||
|
if (entry.feature?.properties?.id) {
|
||||||
layer = state.layout.getMatchingLayer(entry.feature.properties)
|
layer = state.layout.getMatchingLayer(entry.feature.properties)
|
||||||
|
tags = state.featureProperties.getStore(entry.feature.properties.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
let dispatch = createEventDispatcher<{select}>()
|
let dispatch = createEventDispatcher<{ select }>()
|
||||||
let distance = state.mapProperties.location.mapD(l => GeoOperations.distanceBetween([l.lon, l.lat], [entry.lon, entry.lat]))
|
let distance = state.mapProperties.location.mapD(l => GeoOperations.distanceBetween([l.lon, l.lat], [entry.lon, entry.lat]))
|
||||||
|
let bearing = state.mapProperties.location.mapD(l => GeoOperations.bearing([l.lon, l.lat], [entry.lon, entry.lat]))
|
||||||
|
let mapRotation = state.mapProperties.rotation
|
||||||
|
let inView = state.mapProperties.bounds.mapD(bounds => bounds.contains([entry.lon, entry.lat]))
|
||||||
|
|
||||||
function select() {
|
function select() {
|
||||||
state.mapProperties.flyTo(entry.lon, entry.lat, 17)
|
console.log("Selected search entry", entry)
|
||||||
|
if (entry.boundingbox) {
|
||||||
|
const [lat0, lat1, lon0, lon1] = entry.boundingbox
|
||||||
|
state.mapProperties.bounds.set(
|
||||||
|
new BBox([
|
||||||
|
[lon0, lat0],
|
||||||
|
[lon1, lat1]
|
||||||
|
]).pad(0.01)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
state.mapProperties.flyTo(entry.lon, entry.lat, GeocodingUtils.categoryToZoomLevel[entry.category] ?? 17)
|
||||||
|
}
|
||||||
if (entry.feature) {
|
if (entry.feature) {
|
||||||
state.selectedElement.set(entry.feature)
|
state.selectedElement.set(entry.feature)
|
||||||
}
|
}
|
||||||
|
state.recentlySearched.addSelected(entry)
|
||||||
dispatch("select")
|
dispatch("select")
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
<button class="unstyled w-full link-no-underline"
|
<button class="unstyled w-full link-no-underline" on:click={() => select() }>
|
||||||
on:click={() => select()}>
|
<div class="p-2 flex items-center w-full gap-y-2 w-full">
|
||||||
<div class="p-2 flex items-center w-full gap-y-2 ">
|
|
||||||
|
|
||||||
{#if layer}
|
{#if layer}
|
||||||
<ToSvelte construct={() => layer.defaultIcon(entry.feature.properties).SetClass("w-6 h-6")} />
|
<ToSvelte construct={() => layer.defaultIcon(entry.feature.properties).SetClass("w-6 h-6")} />
|
||||||
|
{:else if entry.category}
|
||||||
|
<Icon icon={GeocodingUtils.categoryToIcon[entry.category]} clss="w-6 h-6 shrink-0" color="#aaa" />
|
||||||
{/if}
|
{/if}
|
||||||
<div class="flex flex-col items-start pl-2">
|
<div class="flex flex-col items-start pl-2 w-full">
|
||||||
<div class="flex">
|
<div class="flex flex-wrap gap-x-2 justify-between w-full">
|
||||||
|
<b class="nowrap">
|
||||||
{entry.display_name ?? entry.osm_id}
|
{#if layer && $tags?.id}
|
||||||
</div>
|
<TagRenderingAnswer config={layer.title} selectedElement={entry.feature} {state} {tags} {layer} />
|
||||||
<div class="subtle">
|
{:else}
|
||||||
{#if $distance}
|
{entry.display_name ?? entry.osm_id}
|
||||||
{GeoOperations.distanceToHuman($distance)}
|
{/if}
|
||||||
{/if}
|
</b>
|
||||||
|
<div class="flex gap-x-1 items-center">
|
||||||
|
{#if $bearing && !$inView}
|
||||||
|
<ArrowUp class="w-4 h-4 shrink-0" style={`transform: rotate(${$bearing - $mapRotation}deg)`} />
|
||||||
|
{/if}
|
||||||
|
{#if $distance}
|
||||||
|
{GeoOperations.distanceToHuman($distance)}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{#if entry.description}
|
||||||
|
<div class="subtle flex justify-between w-full">
|
||||||
|
{entry.description}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
|
@ -3,25 +3,79 @@
|
||||||
import SearchResult from "./SearchResult.svelte"
|
import SearchResult from "./SearchResult.svelte"
|
||||||
import type { SpecialVisualizationState } from "../SpecialVisualization"
|
import type { SpecialVisualizationState } from "../SpecialVisualization"
|
||||||
import { XMarkIcon } from "@babeard/svelte-heroicons/solid"
|
import { XMarkIcon } from "@babeard/svelte-heroicons/solid"
|
||||||
|
import { Store } from "../../Logic/UIEventSource"
|
||||||
|
import Loading from "../Base/Loading.svelte"
|
||||||
|
|
||||||
export let state: SpecialVisualizationState
|
export let state: SpecialVisualizationState
|
||||||
export let results: GeoCodeResult[]
|
export let results: GeoCodeResult[]
|
||||||
|
export let searchTerm: Store<string>
|
||||||
|
export let isFocused: Store<boolean>
|
||||||
|
|
||||||
function close(){
|
let recentlySeen: Store<GeoCodeResult[]> = state.recentlySearched.seenThisSession
|
||||||
results = []
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if results.length > 0}
|
<div class="w-full collapsable" style="height: 50rem;" class:collapsed={!$isFocused}>
|
||||||
<div class="relative w-full">
|
{#if $searchTerm.length > 0 && results === undefined}
|
||||||
|
<div class="searchbox normal-background items-center">
|
||||||
|
<Loading />
|
||||||
|
</div>
|
||||||
|
{:else if results?.length > 0}
|
||||||
|
<div class="relative w-full h-full">
|
||||||
|
<div class="absolute top-0 right-0 searchbox normal-background"
|
||||||
|
style="width: 25rem">
|
||||||
|
<div style="max-height: calc(50vh - 1rem - 2px);" class="overflow-y-auto">
|
||||||
|
|
||||||
<div class="absolute top-0 left-0 flex flex-col gap-y-2 normal-background p-2 rounded-xl border border-black w-full">
|
{#each results as entry (entry)}
|
||||||
{#each results as entry (entry)}
|
<SearchResult on:select {entry} {state} />
|
||||||
<SearchResult on:select={() => close()} {entry} {state} />
|
{/each}
|
||||||
{/each}
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<div class="absolute top-2 right-2 cursor-pointer" on:click={() => close()}>
|
||||||
|
<XMarkIcon class="w-4 h-4 hover:bg-stone-200 rounded-full" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="absolute top-2 right-2" on:click={() => close()}>
|
{:else }
|
||||||
<XMarkIcon class="w-4 h-4 hover:bg-stone-200 rounded-full" />
|
|
||||||
</div>
|
<div class="searchbox normal-background ">
|
||||||
</div>
|
{#if $searchTerm.length > 0}
|
||||||
{/if}
|
<!-- TODO add translation -->
|
||||||
|
<b class="flex justify-center p-4">No results found for {$searchTerm}</b>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if $recentlySeen?.length > 0}
|
||||||
|
<!-- TODO add translation -->
|
||||||
|
<h4>Recent searches</h4>
|
||||||
|
{#each $recentlySeen as entry}
|
||||||
|
<SearchResult {entry} {state} on:select />
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.searchbox {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
row-gap: 0.5rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border: 1px solid black;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collapsable {
|
||||||
|
max-height: 50vh;
|
||||||
|
transition: max-height 350ms ease-in-out;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collapsed {
|
||||||
|
padding-top: 0 !important;
|
||||||
|
padding-bottom: 0 !important;
|
||||||
|
max-height: 0 !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
|
@ -23,7 +23,7 @@
|
||||||
import Brick_wall_round from "../../assets/svg/Brick_wall_round.svelte"
|
import Brick_wall_round from "../../assets/svg/Brick_wall_round.svelte"
|
||||||
import Gps_arrow from "../../assets/svg/Gps_arrow.svelte"
|
import Gps_arrow from "../../assets/svg/Gps_arrow.svelte"
|
||||||
import { HeartIcon, PencilIcon, WifiIcon } from "@babeard/svelte-heroicons/solid"
|
import { HeartIcon, PencilIcon, WifiIcon } from "@babeard/svelte-heroicons/solid"
|
||||||
import { HeartIcon as HeartOutlineIcon } from "@babeard/svelte-heroicons/outline"
|
import { HeartIcon as HeartOutlineIcon, HomeIcon } from "@babeard/svelte-heroicons/outline"
|
||||||
import Confirm from "../../assets/svg/Confirm.svelte"
|
import Confirm from "../../assets/svg/Confirm.svelte"
|
||||||
import Not_found from "../../assets/svg/Not_found.svelte"
|
import Not_found from "../../assets/svg/Not_found.svelte"
|
||||||
import { twMerge } from "tailwind-merge"
|
import { twMerge } from "tailwind-merge"
|
||||||
|
@ -31,7 +31,7 @@
|
||||||
import Mastodon from "../../assets/svg/Mastodon.svelte"
|
import Mastodon from "../../assets/svg/Mastodon.svelte"
|
||||||
import Party from "../../assets/svg/Party.svelte"
|
import Party from "../../assets/svg/Party.svelte"
|
||||||
import AddSmall from "../../assets/svg/AddSmall.svelte"
|
import AddSmall from "../../assets/svg/AddSmall.svelte"
|
||||||
import { LinkIcon } from "@babeard/svelte-heroicons/mini"
|
import { GlobeAltIcon, LinkIcon } from "@babeard/svelte-heroicons/mini"
|
||||||
import Square_rounded from "../../assets/svg/Square_rounded.svelte"
|
import Square_rounded from "../../assets/svg/Square_rounded.svelte"
|
||||||
import Bug from "../../assets/svg/Bug.svelte"
|
import Bug from "../../assets/svg/Bug.svelte"
|
||||||
import Cross_bottom_right from "../../assets/svg/Cross_bottom_right.svelte"
|
import Cross_bottom_right from "../../assets/svg/Cross_bottom_right.svelte"
|
||||||
|
@ -39,6 +39,9 @@
|
||||||
import Gear from "../../assets/svg/Gear.svelte"
|
import Gear from "../../assets/svg/Gear.svelte"
|
||||||
import { DesktopComputerIcon, UserCircleIcon } from "@rgossiaux/svelte-heroicons/solid"
|
import { DesktopComputerIcon, UserCircleIcon } from "@rgossiaux/svelte-heroicons/solid"
|
||||||
import Relocation from "../../assets/svg/Relocation.svelte"
|
import Relocation from "../../assets/svg/Relocation.svelte"
|
||||||
|
import BuildingOffice2 from "@babeard/svelte-heroicons/outline/BuildingOffice2"
|
||||||
|
import Train from "../../assets/svg/Train.svelte"
|
||||||
|
import Airport from "../../assets/svg/Airport.svelte"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders a single icon.
|
* Renders a single icon.
|
||||||
|
@ -146,10 +149,21 @@
|
||||||
<PencilIcon class={clss} {color} />
|
<PencilIcon class={clss} {color} />
|
||||||
{:else if icon === "user_circle"}
|
{:else if icon === "user_circle"}
|
||||||
<UserCircleIcon class={clss} {color} />
|
<UserCircleIcon class={clss} {color} />
|
||||||
|
{:else if icon==="globe_alt"}
|
||||||
|
<GlobeAltIcon class={clss} {color} />
|
||||||
|
{:else if icon === "building_office_2"}
|
||||||
|
<BuildingOffice2 class={clss} {color} />
|
||||||
|
{:else if icon === "house"}
|
||||||
|
<HomeIcon class={clss} {color} />
|
||||||
|
{:else if icon === "train"}
|
||||||
|
<Train {color} class={clss}/>
|
||||||
|
{:else if icon === "airport"}
|
||||||
|
<Airport {color} class={clss}/>
|
||||||
{:else if Utils.isEmoji(icon)}
|
{:else if Utils.isEmoji(icon)}
|
||||||
<span style={`font-size: ${emojiHeight}px; line-height: ${emojiHeight}px`}>
|
<span style={`font-size: ${emojiHeight}px; line-height: ${emojiHeight}px`}>
|
||||||
{icon}
|
{icon}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{:else}
|
{:else}
|
||||||
<img class={clss ?? "h-full w-full"} src={icon} aria-hidden="true" alt="" />
|
<img class={clss ?? "h-full w-full"} src={icon} aria-hidden="true" alt="" />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
|
@ -29,6 +29,7 @@ import LayoutSource from "../Logic/FeatureSource/Sources/LayoutSource"
|
||||||
import { Map as MlMap } from "maplibre-gl"
|
import { Map as MlMap } from "maplibre-gl"
|
||||||
import ShowDataLayer from "./Map/ShowDataLayer"
|
import ShowDataLayer from "./Map/ShowDataLayer"
|
||||||
import { CombinedFetcher } from "../Logic/Web/NearbyImagesSearch"
|
import { CombinedFetcher } from "../Logic/Web/NearbyImagesSearch"
|
||||||
|
import { RecentSearch } from "../Logic/Geocoding/RecentSearch"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The state needed to render a special Visualisation.
|
* The state needed to render a special Visualisation.
|
||||||
|
@ -95,6 +96,8 @@ export interface SpecialVisualizationState {
|
||||||
readonly previewedImage: UIEventSource<ProvidedImage>
|
readonly previewedImage: UIEventSource<ProvidedImage>
|
||||||
readonly nearbyImageSearcher: CombinedFetcher
|
readonly nearbyImageSearcher: CombinedFetcher
|
||||||
readonly geolocation: GeoLocationHandler
|
readonly geolocation: GeoLocationHandler
|
||||||
|
readonly recentlySearched: RecentSearch
|
||||||
|
|
||||||
|
|
||||||
showCurrentLocationOn(map: Store<MlMap>): ShowDataLayer
|
showCurrentLocationOn(map: Store<MlMap>): ShowDataLayer
|
||||||
reportError(message: string): Promise<void>
|
reportError(message: string): Promise<void>
|
||||||
|
|
88
src/Utils.ts
88
src/Utils.ts
|
@ -114,7 +114,7 @@ In the case that MapComplete is pointed to the testing grounds, the edit will be
|
||||||
"version",
|
"version",
|
||||||
"wayHandling",
|
"wayHandling",
|
||||||
"widenFactor",
|
"widenFactor",
|
||||||
"width",
|
"width"
|
||||||
]
|
]
|
||||||
private static extraKeys = [
|
private static extraKeys = [
|
||||||
"nl",
|
"nl",
|
||||||
|
@ -133,7 +133,7 @@ In the case that MapComplete is pointed to the testing grounds, the edit will be
|
||||||
"yes",
|
"yes",
|
||||||
"no",
|
"no",
|
||||||
"true",
|
"true",
|
||||||
"false",
|
"false"
|
||||||
]
|
]
|
||||||
private static injectedDownloads = {}
|
private static injectedDownloads = {}
|
||||||
private static _download_cache = new Map<
|
private static _download_cache = new Map<
|
||||||
|
@ -150,7 +150,7 @@ In the case that MapComplete is pointed to the testing grounds, the edit will be
|
||||||
if (Utils.runningFromConsole) {
|
if (Utils.runningFromConsole) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
DOMPurify.addHook("afterSanitizeAttributes", function (node) {
|
DOMPurify.addHook("afterSanitizeAttributes", function(node) {
|
||||||
// set all elements owning target to target=_blank + add noopener noreferrer
|
// set all elements owning target to target=_blank + add noopener noreferrer
|
||||||
const target = node.getAttribute("target")
|
const target = node.getAttribute("target")
|
||||||
if (target) {
|
if (target) {
|
||||||
|
@ -163,7 +163,7 @@ In the case that MapComplete is pointed to the testing grounds, the edit will be
|
||||||
public static purify(src: string): string {
|
public static purify(src: string): string {
|
||||||
return DOMPurify.sanitize(src, {
|
return DOMPurify.sanitize(src, {
|
||||||
USE_PROFILES: { html: true },
|
USE_PROFILES: { html: true },
|
||||||
ADD_ATTR: ["target"], // Don't remove target='_blank'. Note that Utils.initDomPurify does add a hook which automatically adds 'rel=noopener'
|
ADD_ATTR: ["target"] // Don't remove target='_blank'. Note that Utils.initDomPurify does add a hook which automatically adds 'rel=noopener'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -193,7 +193,7 @@ In the case that MapComplete is pointed to the testing grounds, the edit will be
|
||||||
parsed[spec.name] = arg
|
parsed[spec.name] = arg
|
||||||
}
|
}
|
||||||
|
|
||||||
return <T> parsed
|
return <T>parsed
|
||||||
}
|
}
|
||||||
|
|
||||||
static EncodeXmlValue(str) {
|
static EncodeXmlValue(str) {
|
||||||
|
@ -344,7 +344,7 @@ In the case that MapComplete is pointed to the testing grounds, the edit will be
|
||||||
console.error("Error while calculating a lazy property", e)
|
console.error("Error while calculating a lazy property", e)
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -368,7 +368,7 @@ In the case that MapComplete is pointed to the testing grounds, the edit will be
|
||||||
whenDone()
|
whenDone()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
},
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -651,7 +651,7 @@ In the case that MapComplete is pointed to the testing grounds, the edit will be
|
||||||
if (!Array.isArray(targetV)) {
|
if (!Array.isArray(targetV)) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"Cannot concatenate: value to add is not an array: " +
|
"Cannot concatenate: value to add is not an array: " +
|
||||||
JSON.stringify(targetV)
|
JSON.stringify(targetV)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (Array.isArray(sourceV)) {
|
if (Array.isArray(sourceV)) {
|
||||||
|
@ -659,9 +659,9 @@ In the case that MapComplete is pointed to the testing grounds, the edit will be
|
||||||
} else {
|
} else {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"Could not merge concatenate " +
|
"Could not merge concatenate " +
|
||||||
JSON.stringify(sourceV) +
|
JSON.stringify(sourceV) +
|
||||||
" and " +
|
" and " +
|
||||||
JSON.stringify(targetV)
|
JSON.stringify(targetV)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
@ -922,7 +922,7 @@ In the case that MapComplete is pointed to the testing grounds, the edit will be
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
const i = part.charCodeAt(0)
|
const i = part.charCodeAt(0)
|
||||||
result += '"' + keys[i] + '":' + part.substring(1)
|
result += "\"" + keys[i] + "\":" + part.substring(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
@ -1000,7 +1000,7 @@ In the case that MapComplete is pointed to the testing grounds, the edit will be
|
||||||
resolve({
|
resolve({
|
||||||
error: "other error: " + xhr.statusText + ", " + xhr.responseText,
|
error: "other error: " + xhr.statusText + ", " + xhr.responseText,
|
||||||
url,
|
url,
|
||||||
statuscode: xhr.status,
|
statuscode: xhr.status
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1014,12 +1014,12 @@ In the case that MapComplete is pointed to the testing grounds, the edit will be
|
||||||
xhr.onerror = (ev: ProgressEvent<EventTarget>) =>
|
xhr.onerror = (ev: ProgressEvent<EventTarget>) =>
|
||||||
reject(
|
reject(
|
||||||
"Could not get " +
|
"Could not get " +
|
||||||
url +
|
url +
|
||||||
", xhr status code is " +
|
", xhr status code is " +
|
||||||
xhr.status +
|
xhr.status +
|
||||||
" (" +
|
" (" +
|
||||||
xhr.statusText +
|
xhr.statusText +
|
||||||
")"
|
")"
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
@ -1077,12 +1077,13 @@ In the case that MapComplete is pointed to the testing grounds, the edit will be
|
||||||
}
|
}
|
||||||
const promise =
|
const promise =
|
||||||
/*NO AWAIT as we work with the promise directly */ Utils.downloadJsonAdvanced<T>(
|
/*NO AWAIT as we work with the promise directly */ Utils.downloadJsonAdvanced<T>(
|
||||||
url,
|
url,
|
||||||
headers
|
headers
|
||||||
)
|
)
|
||||||
Utils._download_cache.set(url, { promise, timestamp: new Date().getTime() })
|
Utils._download_cache.set(url, { promise, timestamp: new Date().getTime() })
|
||||||
return await promise
|
return await promise
|
||||||
}
|
}
|
||||||
|
|
||||||
public static async downloadJson<T = object | []>(
|
public static async downloadJson<T = object | []>(
|
||||||
url: string,
|
url: string,
|
||||||
headers?: Record<string, string>
|
headers?: Record<string, string>
|
||||||
|
@ -1271,7 +1272,7 @@ In the case that MapComplete is pointed to the testing grounds, the edit will be
|
||||||
): T[] {
|
): T[] {
|
||||||
const withDistance: [T, number][] = ts.map((t) => [
|
const withDistance: [T, number][] = ts.map((t) => [
|
||||||
t,
|
t,
|
||||||
Utils.levenshteinDistance(getName(t), reference),
|
Utils.levenshteinDistance(getName(t), reference)
|
||||||
])
|
])
|
||||||
withDistance.sort(([_, a], [__, b]) => a - b)
|
withDistance.sort(([_, a], [__, b]) => a - b)
|
||||||
return withDistance.map((n) => n[0])
|
return withDistance.map((n) => n[0])
|
||||||
|
@ -1393,7 +1394,7 @@ In the case that MapComplete is pointed to the testing grounds, the edit will be
|
||||||
return {
|
return {
|
||||||
r: Utils.percentageToNumber(match[1]),
|
r: Utils.percentageToNumber(match[1]),
|
||||||
g: Utils.percentageToNumber(match[2]),
|
g: Utils.percentageToNumber(match[2]),
|
||||||
b: Utils.percentageToNumber(match[3]),
|
b: Utils.percentageToNumber(match[3])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1404,14 +1405,14 @@ In the case that MapComplete is pointed to the testing grounds, the edit will be
|
||||||
return {
|
return {
|
||||||
r: parseInt(hex.substr(1, 1), 16),
|
r: parseInt(hex.substr(1, 1), 16),
|
||||||
g: parseInt(hex.substr(2, 1), 16),
|
g: parseInt(hex.substr(2, 1), 16),
|
||||||
b: parseInt(hex.substr(3, 1), 16),
|
b: parseInt(hex.substr(3, 1), 16)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
r: parseInt(hex.substr(1, 2), 16),
|
r: parseInt(hex.substr(1, 2), 16),
|
||||||
g: parseInt(hex.substr(3, 2), 16),
|
g: parseInt(hex.substr(3, 2), 16),
|
||||||
b: parseInt(hex.substr(5, 2), 16),
|
b: parseInt(hex.substr(5, 2), 16)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1586,7 +1587,7 @@ In the case that MapComplete is pointed to the testing grounds, the edit will be
|
||||||
line: Number(line),
|
line: Number(line),
|
||||||
column: Number(column),
|
column: Number(column),
|
||||||
markdownLocation,
|
markdownLocation,
|
||||||
filename: path.substring(path.lastIndexOf("/") + 1),
|
filename: path.substring(path.lastIndexOf("/") + 1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1611,8 +1612,8 @@ In the case that MapComplete is pointed to the testing grounds, the edit will be
|
||||||
* Utils.simplifyStringForSearch("abc def; ghi 564") // => "abcdefghi564"
|
* Utils.simplifyStringForSearch("abc def; ghi 564") // => "abcdefghi564"
|
||||||
* Utils.simplifyStringForSearch("âbc déf; ghi 564") // => "abcdefghi564"
|
* Utils.simplifyStringForSearch("âbc déf; ghi 564") // => "abcdefghi564"
|
||||||
*/
|
*/
|
||||||
public static simplifyStringForSearch(str: string): string{
|
public static simplifyStringForSearch(str: string): string {
|
||||||
return Utils.RemoveDiacritics(str) .toLowerCase().replace(/[^a-z0-9]/g, "")
|
return Utils.RemoveDiacritics(str).toLowerCase().replace(/[^a-z0-9]/g, "")
|
||||||
}
|
}
|
||||||
|
|
||||||
public static randomString(length: number): string {
|
public static randomString(length: number): string {
|
||||||
|
@ -1723,6 +1724,7 @@ In the case that MapComplete is pointed to the testing grounds, the edit will be
|
||||||
}
|
}
|
||||||
|
|
||||||
private static readonly _metrixPrefixes = ["", "k", "M", "G", "T", "P", "E"]
|
private static readonly _metrixPrefixes = ["", "k", "M", "G", "T", "P", "E"]
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Converts a big number (e.g. 1000000) into a rounded postfixed verion (e.g. 1M)
|
* Converts a big number (e.g. 1000000) into a rounded postfixed verion (e.g. 1M)
|
||||||
*
|
*
|
||||||
|
@ -1737,6 +1739,34 @@ In the case that MapComplete is pointed to the testing grounds, the edit will be
|
||||||
return n + Utils._metrixPrefixes[index]
|
return n + Utils._metrixPrefixes[index]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rounds to a human-number
|
||||||
|
* @param number
|
||||||
|
*
|
||||||
|
* Utils.roundHuman(7) // => 7
|
||||||
|
* Utils.roundHuman(147) // => 150
|
||||||
|
* Utils.roundHuman(386) // => 375
|
||||||
|
* Utils.roundHuman(521) // => 500
|
||||||
|
*/
|
||||||
|
public static roundHuman(number: number) {
|
||||||
|
if (number <= 25) {
|
||||||
|
return number
|
||||||
|
}
|
||||||
|
if (number < 100) {
|
||||||
|
return 5 * Math.round(number / 5)
|
||||||
|
}
|
||||||
|
if (number < 250) {
|
||||||
|
return 10 * Math.round(number / 10)
|
||||||
|
|
||||||
|
}
|
||||||
|
if (number < 500) {
|
||||||
|
return 25 * Math.round(number / 25)
|
||||||
|
|
||||||
|
}
|
||||||
|
return 50 * Math.round(number / 50)
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
static NoNullInplace(layers: any[]): void {
|
static NoNullInplace(layers: any[]): void {
|
||||||
for (let i = layers.length - 1; i >= 0; i--) {
|
for (let i = layers.length - 1; i >= 0; i--) {
|
||||||
if (layers[i] === null || layers[i] === undefined) {
|
if (layers[i] === null || layers[i] === undefined) {
|
||||||
|
|
4
src/assets/svg/Airport.svelte
Normal file
4
src/assets/svg/Airport.svelte
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
<script>
|
||||||
|
export let color = "#000000"
|
||||||
|
</script>
|
||||||
|
<svg {...$$restProps} on:click on:mouseover on:mouseenter on:mouseleave on:keydown on:focus version="1.1" id="airport" xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 15 15"> <path id="path7712-0" style="fill:{color}" d="M15,6.8182L15,8.5l-6.5-1
	l-0.3182,4.7727L11,14v1l-3.5-0.6818L4,15v-1l2.8182-1.7273L6.5,7.5L0,8.5V6.8182L6.5,4.5v-3c0,0,0-1.5,1-1.5s1,1.5,1,1.5v2.8182
	L15,6.8182z"/> </svg>
|
4
src/assets/svg/Train.svelte
Normal file
4
src/assets/svg/Train.svelte
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
<script>
|
||||||
|
export let color = "#000000"
|
||||||
|
</script>
|
||||||
|
<svg {...$$restProps} on:click on:mouseover on:mouseenter on:mouseleave on:keydown on:focus version="1.1" id="svg4619" x="0px" y="0px" width="500" height="500" viewBox="0 0 500 500" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:svg="http://www.w3.org/2000/svg" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/"><metadata id="metadata8"><rdf:RDF><cc:Work rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage" /></cc:Work></rdf:RDF></metadata><defs id="defs6" /> <path id="path14245" d="M 183.33333,0 C 166.66667,0 166.66667,16.666667 166.66667,16.666667 V 50 c 0,9.233332 7.43333,16.666668 16.66666,16.666668 C 192.56667,66.666668 200,59.233332 200,50 V 33.333332 h 33.33333 V 100 H 200 c 0,0 -66.66667,0 -66.66667,66.66667 v 100 c 0,100.00001 100,100.00001 100,100.00001 h 33.33334 c 0,0 100.00001,0 100.00001,-100.00001 v -100 C 366.66668,100 300,100 300,100 H 266.66667 V 33.333332 H 300 V 50 c 0,9.233332 7.43333,16.666668 16.66667,16.666668 9.23333,0 16.66665,-7.433336 16.66665,-16.666668 V 16.666667 C 333.33332,0 316.66667,0 316.66667,0 Z M 250,133.33333 l 68.16333,25.78 15.16999,57.55334 c 4.38669,16.66666 -16.66665,16.66666 -16.66665,16.66666 H 183.33333 c 0,0 -21.05333,0 -16.66666,-16.66666 l 15.16999,-57.55334 z m 0,133.33334 c 9.20333,0 16.66667,7.46333 16.66667,16.66666 C 266.66667,292.53667 259.20333,300 250,300 c -9.20333,0 -16.66667,-7.46333 -16.66667,-16.66667 0,-9.20333 7.46334,-16.66666 16.66667,-16.66666 z M 137.5,400 100,500 h 50 l 12.5,-33.33332 H 337.50002 L 350,500 h 50 L 362.50002,400 H 312.5 L 325,433.33332 H 175 L 187.5,400 Z" style="stroke-width:33.3332; fill: {color}" /> </svg>
|
Loading…
Reference in a new issue