forked from MapComplete/MapComplete
		
	Add summary layer
This commit is contained in:
		
							parent
							
								
									5b318236bf
								
							
						
					
					
						commit
						74fb4bd5d1
					
				
					 19 changed files with 533 additions and 88 deletions
				
			
		|  | @ -36,6 +36,17 @@ Storing properties to table '"public"."osm2pgsql_properties" takes about 25 minu | |||
| Belgium (~555mb) takes 15m | ||||
| World (80GB) should take 15m*160 = 2400m = 40hr | ||||
| 
 | ||||
| 73G Jan 23 00:22 planet-240115.osm.pbf: 2024-02-10 16:45:11  osm2pgsql took 871615s (242h 6m 55s; 10 days) overall on lain.local with RAID5 on 4 HDD disks, database is over 1Terrabyte (!) | ||||
| 
 | ||||
| Server specs | ||||
| 
 | ||||
| Lenovo thinkserver RD350, Intel Xeon E5-2600, 2Rx4 PC3  | ||||
|     11 watt powered off, 73 watt idle, ~100 watt when importing | ||||
| 
 | ||||
| HP ProLiant DL360 G7 (1U): 2Rx4 DDR3-memory (PC3) | ||||
|     Intel Xeon X56** | ||||
| 
 | ||||
| 
 | ||||
| 
 | ||||
| ## Deploying a tile server | ||||
| 
 | ||||
|  |  | |||
|  | @ -4,6 +4,7 @@ | |||
|     "en": "Ice cream parlors", | ||||
|     "de": "Eisdielen" | ||||
|   }, | ||||
|   "minzoom": 14, | ||||
|   "description": { | ||||
|     "en": "A place where ice cream is sold over the counter", | ||||
|     "de": "Ein Ort, an dem Eiscreme an der Theke verkauft wird" | ||||
|  |  | |||
|  | @ -1,7 +1,7 @@ | |||
| { | ||||
|   "id": "last_click", | ||||
|   "name": null, | ||||
|   "description": "This layer defines how to render the 'last click'-location. By default, it will show a marker with the possibility to add a new point (if there are some presets) and/or to add a new note (if the 'note' layer attribute is set). If none are possible, this layer won't show up", | ||||
|   "description": "This 'layer' is not really a layer, but contains part of the code how the popup to 'add a new marker' is displayed", | ||||
|   "source": "special", | ||||
|   "isShown": { | ||||
|     "or": [ | ||||
|  |  | |||
							
								
								
									
										25
									
								
								assets/layers/summary/summary.json
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										25
									
								
								assets/layers/summary/summary.json
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,25 @@ | |||
| { | ||||
|   "id": "summary", | ||||
|   "description": "Special layer which shows `count`", | ||||
|   "source": "special", | ||||
|   "name": "CLusters", | ||||
|   "title": { | ||||
|     "render": {"en": "Summary"} | ||||
|   }, | ||||
|   "tagRenderings": [ | ||||
|     "all_tags" | ||||
|   ], | ||||
|   "pointRendering": [ | ||||
|     { | ||||
|       "location": [ | ||||
|         "point", | ||||
|         "centroid" | ||||
|       ], | ||||
|       "iconSize": "25,25", | ||||
|       "label": { | ||||
|         "render": "{total}" | ||||
|       }, | ||||
|       "labelCssClasses": "bg-white w-6 h-6 text-lg rounded-full" | ||||
|     } | ||||
|   ] | ||||
| } | ||||
|  | @ -26,7 +26,7 @@ | |||
|   "source": { | ||||
|     "osmTags": "amenity=toilets" | ||||
|   }, | ||||
|   "minzoom": 9, | ||||
|   "minzoom": 10, | ||||
|   "title": { | ||||
|     "render": { | ||||
|       "en": "Toilet", | ||||
|  | @ -548,6 +548,115 @@ | |||
|         } | ||||
|       ] | ||||
|     }, | ||||
|     { | ||||
|       "condition": "toilets:position!=urinal", | ||||
|       "id": "gender_segregated", | ||||
|       "question": { | ||||
|         "en": "Are these toilets gender-segregated?", | ||||
|         "nl": "Zijn deze toiletten gescheiden op basis van geslacht?" | ||||
|       }, | ||||
|       "questionHint": { | ||||
|         "en": "Are there separate stalls or separate areas for men and women and are they signposted as such?", | ||||
|         "nl": "Is er een aparte ruimte voor mannen en vrouwen en zijn deze ruimtes ook expliciet aangegeven?" | ||||
|       }, | ||||
|       "mappings": [ | ||||
|         { | ||||
|           "if": "gender_segregated=yes", | ||||
|           "then": { | ||||
|             "en": "There is a separate, signposted area for men and women", | ||||
|             "nl": "Er zijn aparte ruimtes of toiletten voor mannen en vrouwen" | ||||
|           } | ||||
|         }, | ||||
|         { | ||||
|           "if": "gender_segregated=no", | ||||
|           "then": { | ||||
|             "en": "There is no separate, signposted area for men and women", | ||||
|             "nl": "Mannen en vrouwen gebruiken dezelfde ruimtes en toiletten" | ||||
|           } | ||||
|         } | ||||
|       ] | ||||
|     }, | ||||
|     { | ||||
|       "id": "menstrual_products", | ||||
|       "question": { | ||||
|         "en": "Are free, menstrual products distributed here?", | ||||
|         "nl": "Zijn er gratis menstruatieproducten beschikbaar?" | ||||
|       }, | ||||
|       "questionHint": { | ||||
|         "en": "This is only about menstrual products that are free of charge. If e.g. a vending machine is available which charges for menstrual products, ignore it for this question.", | ||||
|         "nl": "Dit gaat enkel over menstruatieproducten die gratis geschikbaar zijn. Indien er bv. een verkoopautomaat met menstruatieproducten is, negeer deze dan" | ||||
|       }, | ||||
|       "mappings": [ | ||||
|         { | ||||
|           "if": "toilets:menstrual_products=yes", | ||||
|           "then": { | ||||
|             "en": "Free menstrual products are available to all visitors of these toilets", | ||||
|             "nl": "Er zijn gratis menstruatieprocten beschikbaar voor alle bezoekers van deze toiletten" | ||||
|           } | ||||
|         }, | ||||
|         { | ||||
|           "if": "toilets:menstrual_products=limited", | ||||
|           "then": { | ||||
|             "en": "Free menstrual products are available to some visitors of these toilets", | ||||
|             "nl": "De gratis menstruatieproducten zijn enkel beschikbaar in een deel van de toiletten" | ||||
|           }, | ||||
|           "hideInAnswer": "gender_segregated=yes" | ||||
|         }, | ||||
|         { | ||||
|           "if": "toilets:menstrual_products=no", | ||||
|           "alsoShowIf": "toilets:menstrual_products=", | ||||
|           "then": { | ||||
|             "en": "No free menstrual products are available here", | ||||
|             "nl": "Er zijn geen gratis menstruatieproducten beschikbaar" | ||||
|           } | ||||
|         } | ||||
|       ] | ||||
|     }, | ||||
|     { | ||||
|       "id": "menstrual_products_location", | ||||
|       "question": { | ||||
|         "en": "Where are the free menstrual products located?", | ||||
|         "nl": "Waar bevinden de gratis menstruatieproducten zich?" | ||||
|       }, | ||||
|       "condition": { | ||||
|         "or": [ | ||||
|           "toilets:menstrual_products=limited", | ||||
|           "toilets:menstrual_products:location~*" | ||||
|         ] | ||||
|       }, | ||||
|       "render": { | ||||
|         "en": "The menstrual products are located in {toilets:menstrual_products:location}", | ||||
|         "nl": "De menstruatieproducten bevinden zich in {toilets:menstrual_products:location}" | ||||
|       }, | ||||
|       "freeform": { | ||||
|         "key": "toilets:menstrual_products:location", | ||||
|         "inline": true | ||||
|       }, | ||||
|       "mappings": [ | ||||
|         { | ||||
|           "then": { | ||||
|             "en": "The free, menstrual products are located in the toilet for women", | ||||
|             "nl": "De gratis menstruatieproducten bevinden zich in het vrouwentoilet" | ||||
|           }, | ||||
|           "if": "toilets:menstrual_products:location=female_toilet", | ||||
|           "alsoShowIf": "toilets:menstrual_products:location=" | ||||
|         }, | ||||
|         { | ||||
|           "then": { | ||||
|             "en": "The free, menstrual products are located in the toilet for men", | ||||
|             "nl": "De gratis menstruatieproducten bevinden zich in het mannentoilet" | ||||
|           }, | ||||
|           "if": "toilets:menstrual_products:location=male_toilet" | ||||
|         }, | ||||
|         { | ||||
|           "if": "toilets:menstrual_products:location=wheelchair_toilet", | ||||
|           "then": { | ||||
|             "en": "The free, menstrual products are located in the toilet for wheelchair users", | ||||
|             "nl": "De gratis menstruatieproducten bevinden zich in het rolstoeltoegankelijke toilet" | ||||
|           } | ||||
|         } | ||||
|       ] | ||||
|     }, | ||||
|     { | ||||
|       "id": "toilets-changing-table", | ||||
|       "labels": [ | ||||
|  | @ -576,7 +685,8 @@ | |||
|             "ca": "Hi ha un canviador per a nadons", | ||||
|             "cs": "Přebalovací pult je k dispozici" | ||||
|           }, | ||||
|           "if": "changing_table=yes" | ||||
|           "if": "changing_table=yes", | ||||
|           "icon": "./assets/layers/toilet/baby.svg" | ||||
|         }, | ||||
|         { | ||||
|           "if": "changing_table=no", | ||||
|  | @ -610,10 +720,10 @@ | |||
|         "cs": "Kde je umístěn přebalovací pult?" | ||||
|       }, | ||||
|       "render": { | ||||
|         "en": "The changing table is located at {changing_table:location}", | ||||
|         "de": "Die Wickeltabelle befindet sich in {changing_table:location}", | ||||
|         "en": "A changing table is located at {changing_table:location}", | ||||
|         "de": "Ein Wickeltisch befindet sich in {changing_table:location}", | ||||
|         "fr": "Emplacement de la table à langer : {changing_table:location}", | ||||
|         "nl": "De luiertafel bevindt zich in {changing_table:location}", | ||||
|         "nl": "Er bevindt zich een luiertafel in {changing_table:location}", | ||||
|         "it": "Il fasciatoio si trova presso {changing_table:location}", | ||||
|         "es": "El cambiador está en {changing_table:location}", | ||||
|         "da": "Puslebordet er placeret på {changing_table:location}", | ||||
|  | @ -632,10 +742,10 @@ | |||
|       "mappings": [ | ||||
|         { | ||||
|           "then": { | ||||
|             "en": "The changing table is in the toilet for women. ", | ||||
|             "de": "Der Wickeltisch befindet sich in der Damentoilette. ", | ||||
|             "en": "A changing table is in the toilet for women", | ||||
|             "de": "Ein Wickeltisch ist in der Damentoilette vorhanden", | ||||
|             "fr": "La table à langer est dans les toilettes pour femmes. ", | ||||
|             "nl": "De luiertafel bevindt zich in de vrouwentoiletten ", | ||||
|             "nl": "Er bevindt zich een luiertafel in de vrouwentoiletten ", | ||||
|             "it": "Il fasciatoio è nei servizi igienici femminili. ", | ||||
|             "da": "Puslebordet er på toilettet til kvinder. ", | ||||
|             "ca": "El canviador està al lavabo per a dones. ", | ||||
|  | @ -645,10 +755,10 @@ | |||
|         }, | ||||
|         { | ||||
|           "then": { | ||||
|             "en": "The changing table is in the toilet for men. ", | ||||
|             "de": "Der Wickeltisch befindet sich in der Herrentoilette. ", | ||||
|             "en": "A changing table is in the toilet for men", | ||||
|             "de": "Ein Wickeltisch ist in der Herrentoilette vorhanden", | ||||
|             "fr": "La table à langer est dans les toilettes pour hommes. ", | ||||
|             "nl": "De luiertafel bevindt zich in de herentoiletten ", | ||||
|             "nl": "Er bevindt zich een luiertafel in de herentoiletten ", | ||||
|             "it": "Il fasciatoio è nei servizi igienici maschili. ", | ||||
|             "ca": "El canviador està al lavabo per a homes. ", | ||||
|             "cs": "Přebalovací pult je na pánské toaletě. " | ||||
|  | @ -658,10 +768,10 @@ | |||
|         { | ||||
|           "if": "changing_table:location=wheelchair_toilet", | ||||
|           "then": { | ||||
|             "en": "The changing table is in the toilet for wheelchair users. ", | ||||
|             "de": "Der Wickeltisch befindet sich in der Toilette für Rollstuhlfahrer. ", | ||||
|             "en": "A changing table is in the toilet for wheelchair users", | ||||
|             "de": "Ein Wickeltisch ist in der barrierefreien Toilette vorhanden", | ||||
|             "fr": "La table à langer est dans les toilettes pour personnes à mobilité réduite. ", | ||||
|             "nl": "De luiertafel bevindt zich in de rolstoeltoegankelijke toilet ", | ||||
|             "nl": "Er bevindt zich een luiertafel in de rolstoeltoegankelijke toilet ", | ||||
|             "it": "Il fasciatoio è nei servizi igienici per persone in sedia a rotelle. ", | ||||
|             "da": "Puslebordet er på toilettet for kørestolsbrugere. ", | ||||
|             "ca": "El canviador està al lavabo per a usuaris de cadira de rodes. ", | ||||
|  | @ -671,10 +781,10 @@ | |||
|         { | ||||
|           "if": "changing_table:location=dedicated_room", | ||||
|           "then": { | ||||
|             "en": "The changing table is in a dedicated room. ", | ||||
|             "de": "Der Wickeltisch befindet sich in einem eigenen Raum. ", | ||||
|             "en": "A changing table is in a dedicated room", | ||||
|             "de": "Ein Wickeltisch befindet sich in einem eigenen Raum", | ||||
|             "fr": "La table à langer est dans un espace dédié. ", | ||||
|             "nl": "De luiertafel bevindt zich in een daartoe voorziene kamer ", | ||||
|             "nl": "Er bevindt zich een luiertafel in een daartoe voorziene kamer ", | ||||
|             "it": "Il fasciatoio è in una stanza dedicata. ", | ||||
|             "es": "El cambiador está en una habitación dedicada ", | ||||
|             "da": "Vuggestuen står i et særligt rum. ", | ||||
|  | @ -683,6 +793,7 @@ | |||
|           } | ||||
|         } | ||||
|       ], | ||||
|       "multiAnswer": true, | ||||
|       "id": "toilet-changing_table:location" | ||||
|     }, | ||||
|     { | ||||
|  |  | |||
|  | @ -54,5 +54,7 @@ | |||
|     "pharmacy", | ||||
|     "ice_cream" | ||||
|   ], | ||||
|   "widenFactor": 3 | ||||
|   "overideAll": { | ||||
|     "minzoom": 16 | ||||
|   } | ||||
| } | ||||
|  |  | |||
|  | @ -1,44 +1,240 @@ | |||
| import { BBox } from "../../src/Logic/BBox" | ||||
| import { Client } from "pg" | ||||
| import http from "node:http" | ||||
| import { Tiles } from "../../src/Models/TileRange" | ||||
| 
 | ||||
| /** | ||||
|  * Just the OSM2PGSL default database | ||||
|  */ | ||||
| interface PoiDatabaseMeta { | ||||
|     attributes | ||||
|     current_timestamp | ||||
|     db_format | ||||
|     flat_node_file | ||||
|     import_timestamp | ||||
|     output | ||||
|     prefix | ||||
|     replication_base_url | ||||
|     replication_sequence_number | ||||
|     replication_timestamp | ||||
|     style | ||||
|     updatable | ||||
|     version | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * Connects with a Postgis database, gives back how much items there are within the given BBOX | ||||
|  */ | ||||
| export default class TilecountServer { | ||||
| class OsmPoiDatabase { | ||||
|     private static readonly prefixes: ReadonlyArray<string> = ["pois", "lines", "polygons"] | ||||
|     private readonly _client: Client | ||||
|     private isConnected = false | ||||
|     private supportedLayers: string[] = undefined | ||||
|     private metaCache: PoiDatabaseMeta = undefined | ||||
|     private metaCacheDate: Date = undefined | ||||
| 
 | ||||
|     constructor(connectionString: string) { | ||||
|         this._client = new Client(connectionString) | ||||
|     } | ||||
| 
 | ||||
|     async getCount(layer: string, bbox: BBox = undefined): Promise<number> { | ||||
|     async getCount( | ||||
|         layer: string, | ||||
|         bbox: [[number, number], [number, number]] = undefined | ||||
|     ): Promise<number> { | ||||
|         if (!this.isConnected) { | ||||
|             await this._client.connect() | ||||
|             this.isConnected = true | ||||
|         } | ||||
| 
 | ||||
|         let query = "SELECT COUNT(*) FROM " + layer | ||||
|         let total = 0 | ||||
| 
 | ||||
|         if(bbox){ | ||||
|             query += ` WHERE ST_MakeEnvelope (${bbox.minLon}, ${bbox.minLat}, ${bbox.maxLon}, ${bbox.maxLat}, 4326) ~ geom` | ||||
|         for (const prefix of OsmPoiDatabase.prefixes) { | ||||
|             let query = "SELECT COUNT(*) FROM " + prefix + "_" + layer | ||||
| 
 | ||||
|             if (bbox) { | ||||
|                 query += ` WHERE ST_MakeEnvelope (${bbox[0][0]}, ${bbox[0][1]}, ${bbox[1][0]}, ${bbox[1][1]}, 4326) ~ geom` | ||||
|             } | ||||
|             console.log("Query:", query) | ||||
|             const result = await this._client.query(query) | ||||
|             total += Number(result.rows[0].count) | ||||
|         } | ||||
| console.log(query) | ||||
|         const result = await this._client.query(query) | ||||
|         return result.rows[0].count | ||||
|         return total | ||||
|     } | ||||
| 
 | ||||
|     disconnect() { | ||||
|         this._client.end() | ||||
|     } | ||||
| 
 | ||||
|     async getLayers(): Promise<string[]> { | ||||
|         if (this.supportedLayers !== undefined) { | ||||
|             return this.supportedLayers | ||||
|         } | ||||
|         const result = await this._client.query( | ||||
|             "SELECT table_name \n" + | ||||
|                 "FROM information_schema.tables \n" + | ||||
|                 "WHERE table_schema = 'public' AND table_name LIKE 'lines_%';" | ||||
|         ) | ||||
|         const layers = result.rows.map((r) => r.table_name.substring("lines_".length)) | ||||
|         this.supportedLayers = layers | ||||
|         return layers | ||||
|     } | ||||
| 
 | ||||
|     async getMeta(): Promise<PoiDatabaseMeta> { | ||||
|         const now = new Date() | ||||
|         if (this.metaCache !== undefined) { | ||||
|             const diffSec = (this.metaCacheDate.getTime() - now.getTime()) / 1000 | ||||
|             if (diffSec < 120) { | ||||
|                 return this.metaCache | ||||
|             } | ||||
|         } | ||||
|         const result = await this._client.query("SELECT * FROM public.osm2pgsql_properties") | ||||
|         const meta = {} | ||||
|         for (const { property, value } of result.rows) { | ||||
|             meta[property] = value | ||||
|         } | ||||
|         this.metaCacheDate = now | ||||
|         this.metaCache = <any>meta | ||||
|         return this.metaCache | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| const tcs = new TilecountServer("postgresql://user:none@localhost:5444/osm-poi") | ||||
| console.log(">>>", await tcs.getCount("drinking_water", new BBox([ | ||||
|     [1.5052013991654007, | ||||
|         42.57480750272123, | ||||
|     ], [ | ||||
|         1.6663677350703097, | ||||
|         42.499856652770745, | ||||
|     ]]))) | ||||
| tcs.disconnect() | ||||
| class Server { | ||||
|     constructor( | ||||
|         port: number, | ||||
|         handle: { | ||||
|             mustMatch: string | RegExp | ||||
|             mimetype: string | ||||
|             handle: (path: string) => Promise<string> | ||||
|         }[] | ||||
|     ) { | ||||
|         handle.push({ | ||||
|             mustMatch: "", | ||||
|             mimetype: "text/html", | ||||
|             handle: async () => { | ||||
|                 return `<html><body>Supported endpoints are <ul>${handle | ||||
|                     .filter((h) => h.mustMatch !== "") | ||||
|                     .map((h) => { | ||||
|                         let l = h.mustMatch | ||||
|                         if (typeof h.mustMatch === "string") { | ||||
|                             l = `<a href='${l}'>${l}</a>` | ||||
|                         } | ||||
|                         return "<li>" + l + "</li>" | ||||
|                     }) | ||||
|                     .join("")}</ul></body></html>` | ||||
|             }, | ||||
|         }) | ||||
|         http.createServer(async (req: http.IncomingMessage, res) => { | ||||
|             try { | ||||
|                 console.log( | ||||
|                     req.method + " " + req.url, | ||||
|                     "from:", | ||||
|                     req.headers.origin, | ||||
|                     new Date().toISOString() | ||||
|                 ) | ||||
| 
 | ||||
|                 const url = new URL(`http://127.0.0.1/` + req.url) | ||||
|                 let path = url.pathname | ||||
|                 while (path.startsWith("/")) { | ||||
|                     path = path.substring(1) | ||||
|                 } | ||||
|                 const handler = handle.find((h) => { | ||||
|                     if (typeof h.mustMatch === "string") { | ||||
|                         return h.mustMatch === path | ||||
|                     } | ||||
|                     if (path.match(h.mustMatch)) { | ||||
|                         return true | ||||
|                     } | ||||
|                 }) | ||||
| 
 | ||||
|                 if (handler === undefined || handler === null) { | ||||
|                     res.writeHead(404, { "Content-Type": "text/html" }) | ||||
|                     res.write("<html><body><p>Not found...</p></body></html>") | ||||
|                     res.end() | ||||
|                     return | ||||
|                 } | ||||
| 
 | ||||
|                 res.setHeader( | ||||
|                     "Access-Control-Allow-Headers", | ||||
|                     "Origin, X-Requested-With, Content-Type, Accept" | ||||
|                 ) | ||||
|                 res.setHeader("Access-Control-Allow-Origin", req.headers.origin ?? "*") | ||||
|                 if (req.method === "OPTIONS") { | ||||
|                     res.setHeader( | ||||
|                         "Access-Control-Allow-Methods", | ||||
|                         "POST, GET, OPTIONS, DELETE, UPDATE" | ||||
|                     ) | ||||
|                     res.writeHead(204, { "Content-Type": handler.mimetype }) | ||||
|                     res.end() | ||||
|                     return | ||||
|                 } | ||||
|                 if (req.method === "POST" || req.method === "UPDATE") { | ||||
|                     return | ||||
|                 } | ||||
| 
 | ||||
|                 if (req.method === "DELETE") { | ||||
|                     return | ||||
|                 } | ||||
| 
 | ||||
|                 try { | ||||
|                     const result = await handler.handle(path) | ||||
|                     res.writeHead(200, { "Content-Type": handler.mimetype }) | ||||
|                     res.write(result) | ||||
|                     res.end() | ||||
|                 } catch (e) { | ||||
|                     console.error("Could not handle request:", e) | ||||
|                     res.writeHead(500) | ||||
|                     res.write(e) | ||||
|                     res.end() | ||||
|                 } | ||||
|             } catch (e) { | ||||
|                 console.error("FATAL:", e) | ||||
|                 res.end() | ||||
|             } | ||||
|         }).listen(port) | ||||
|         console.log( | ||||
|             "Server is running on port " + port, | ||||
|             ". Supported endpoints are: " + handle.map((h) => h.mustMatch).join(", ") | ||||
|         ) | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| const connectionString = "postgresql://user:password@localhost:5444/osm-poi" | ||||
| const tcs = new OsmPoiDatabase(connectionString) | ||||
| const server = new Server(2345, [ | ||||
|     { | ||||
|         mustMatch: "status.json", | ||||
|         mimetype: "application/json", | ||||
|         handle: async (path: string) => { | ||||
|             const layers = await tcs.getLayers() | ||||
|             const meta = await tcs.getMeta() | ||||
|             return JSON.stringify({ meta, layers }) | ||||
|         }, | ||||
|     }, | ||||
|     { | ||||
|         mustMatch: /[a-zA-Z0-9+]+\/[0-9]+\/[0-9]+\/[0-9]+\.json/, | ||||
|         mimetype: "application/json", // "application/vnd.geo+json",
 | ||||
|         async handle(path) { | ||||
|             console.log("Path is:", path, path.split(".")[0]) | ||||
|             const [layers, z, x, y] = path.split(".")[0].split("/") | ||||
| 
 | ||||
|             let sum = 0 | ||||
|             let properties: Record<string, number> = {} | ||||
|             for (const layer of layers.split("+")) { | ||||
|                 const count = await tcs.getCount( | ||||
|                     layer, | ||||
|                     Tiles.tile_bounds_lon_lat(Number(z), Number(x), Number(y)) | ||||
|                 ) | ||||
|                 properties[layer] = count | ||||
|                 sum += count | ||||
|             } | ||||
| 
 | ||||
|             return JSON.stringify({ ...properties, total: sum }) | ||||
|         }, | ||||
|     }, | ||||
| ]) | ||||
| console.log( | ||||
|     ">>>", | ||||
|     await tcs.getCount("drinking_water", [ | ||||
|         [3.194358020772171, 51.228073636083394], | ||||
|         [3.2839964396059145, 51.172701162680994], | ||||
|     ]) | ||||
| ) | ||||
|  |  | |||
|  | @ -11,16 +11,15 @@ import { OsmTags } from "../../../Models/OsmFeature" | |||
|  * Highly specialized feature source. | ||||
|  * Based on a lon/lat UIEVentSource, will generate the corresponding feature with the correct properties | ||||
|  */ | ||||
| export class LastClickFeatureSource implements WritableFeatureSource { | ||||
|     public readonly features: UIEventSource<Feature[]> = new UIEventSource<Feature[]>([]) | ||||
|     public readonly hasNoteLayer: boolean | ||||
| export class LastClickFeatureSource { | ||||
|     public readonly renderings: string[] | ||||
|     public readonly hasPresets: boolean | ||||
|     private i: number = 0 | ||||
|     private readonly hasPresets: boolean | ||||
|     private readonly hasNoteLayer: boolean | ||||
| 
 | ||||
|     constructor(location: Store<{ lon: number; lat: number }>, layout: LayoutConfig) { | ||||
|         this.hasNoteLayer = layout.layers.some((l) => l.id === "note") | ||||
|         this.hasPresets = layout.layers.some((l) => l.presets?.length > 0) | ||||
|     constructor(layout: LayoutConfig) { | ||||
|         this.hasNoteLayer = layout.hasNoteLayer() | ||||
|         this.hasPresets = layout.hasPresets() | ||||
|         const allPresets: BaseUIElement[] = [] | ||||
|         for (const layer of layout.layers) | ||||
|             for (let i = 0; i < (layer.presets ?? []).length; i++) { | ||||
|  | @ -43,16 +42,11 @@ export class LastClickFeatureSource implements WritableFeatureSource { | |||
|                 Utils.runningFromConsole ? "" : uiElem.ConstructElement().innerHTML | ||||
|             ) | ||||
|         ) | ||||
| 
 | ||||
|         location.addCallbackAndRunD(({ lon, lat }) => { | ||||
|             this.features.setData([this.createFeature(lon, lat)]) | ||||
|         }) | ||||
|     } | ||||
| 
 | ||||
|     public createFeature(lon: number, lat: number): Feature<Point, OsmTags> { | ||||
|         const properties: OsmTags = { | ||||
|             lastclick: "yes", | ||||
|             id: "last_click_" + this.i, | ||||
|             id: "new_point_dialog", | ||||
|             has_note_layer: this.hasNoteLayer ? "yes" : "no", | ||||
|             has_presets: this.hasPresets ? "yes" : "no", | ||||
|             renderings: this.renderings.join(""), | ||||
|  |  | |||
|  | @ -0,0 +1,85 @@ | |||
| import DynamicTileSource from "./DynamicTileSource" | ||||
| import { Store, UIEventSource } from "../../UIEventSource" | ||||
| import { BBox } from "../../BBox" | ||||
| import StaticFeatureSource from "../Sources/StaticFeatureSource" | ||||
| import { Feature, Point } from "geojson" | ||||
| import { Utils } from "../../../Utils" | ||||
| import { Tiles } from "../../../Models/TileRange" | ||||
| 
 | ||||
| /** | ||||
|  * Provides features summarizing the total amount of features at a given location | ||||
|  */ | ||||
| export class SummaryTileSource extends DynamicTileSource { | ||||
|     private static readonly empty = [] | ||||
|     constructor( | ||||
|         cacheserver: string, | ||||
|         layers: string[], | ||||
|         zoomRounded: Store<number>, | ||||
|         mapProperties: { | ||||
|             bounds: Store<BBox> | ||||
|             zoom: Store<number> | ||||
|         }, | ||||
|         options?: { | ||||
|             isActive?: Store<boolean> | ||||
|         } | ||||
|     ) { | ||||
|         const layersSummed = layers.join("+") | ||||
|         super( | ||||
|             zoomRounded, | ||||
|             0, // minzoom
 | ||||
|             (tileIndex) => { | ||||
|                 const [z, x, y] = Tiles.tile_from_index(tileIndex) | ||||
|                 const coordinates = Tiles.centerPointOf(z, x, y) | ||||
| 
 | ||||
|                 const count = UIEventSource.FromPromiseWithErr( | ||||
|                     Utils.downloadJson(`${cacheserver}/${layersSummed}/${z}/${x}/${y}.json`) | ||||
|                 ) | ||||
|                 const features: Store<Feature<Point>[]> = count.mapD((count) => { | ||||
|                     if (count["error"] !== undefined) { | ||||
|                         console.error( | ||||
|                             "Could not download count for tile", | ||||
|                             z, | ||||
|                             x, | ||||
|                             y, | ||||
|                             "due to", | ||||
|                             count["error"] | ||||
|                         ) | ||||
|                         return SummaryTileSource.empty | ||||
|                     } | ||||
|                     const counts = count["success"] | ||||
|                     if (counts === undefined || counts["total"] === 0) { | ||||
|                         return SummaryTileSource.empty | ||||
|                     } | ||||
|                     return [ | ||||
|                         { | ||||
|                             type: "Feature", | ||||
|                             properties: { | ||||
|                                 id: "summary_" + tileIndex, | ||||
|                                 summary: "yes", | ||||
|                                 ...counts, | ||||
|                                 layers: layersSummed, | ||||
|                             }, | ||||
|                             geometry: { | ||||
|                                 type: "Point", | ||||
|                                 coordinates, | ||||
|                             }, | ||||
|                         }, | ||||
|                     ] | ||||
|                 }) | ||||
|                 return new StaticFeatureSource( | ||||
|                     features.map( | ||||
|                         (f) => { | ||||
|                             if (z !== zoomRounded.data) { | ||||
|                                 return SummaryTileSource.empty | ||||
|                             } | ||||
|                             return f | ||||
|                         }, | ||||
|                         [zoomRounded] | ||||
|                     ) | ||||
|                 ) | ||||
|             }, | ||||
|             mapProperties, | ||||
|             options | ||||
|         ) | ||||
|     } | ||||
| } | ||||
|  | @ -638,8 +638,9 @@ export class UIEventSource<T> extends Store<T> implements Writable<T> { | |||
|         promise: Promise<T> | ||||
|     ): UIEventSource<{ success: T } | { error: any } | undefined> { | ||||
|         const src = new UIEventSource<{ success: T } | { error: any }>(undefined) | ||||
|         promise?.then((d) => src.setData({ success: d })) | ||||
|         promise?.catch((err) => src.setData({ error: err })) | ||||
|         promise | ||||
|             ?.then((d) => src.setData({ success: d })) | ||||
|             ?.catch((err) => src.setData({ error: err })) | ||||
|         return src | ||||
|     } | ||||
| 
 | ||||
|  |  | |||
|  | @ -24,6 +24,7 @@ export default class Constants { | |||
|         "range", | ||||
|         "last_click", | ||||
|         "favourite", | ||||
|         "summary", | ||||
|     ] as const | ||||
|     /** | ||||
|      * Special layers which are not included in a theme by default | ||||
|  | @ -36,7 +37,7 @@ export default class Constants { | |||
|         "import_candidate", | ||||
|         "usersettings", | ||||
|         "icons", | ||||
|         "filters" | ||||
|         "filters", | ||||
|     ] as const | ||||
|     /** | ||||
|      * Layer IDs of layers which have special properties through built-in hooks | ||||
|  | @ -151,7 +152,6 @@ export default class Constants { | |||
|         "mastodon", | ||||
|         "party", | ||||
|         "addSmall", | ||||
| 
 | ||||
|     ] as const | ||||
|     public static readonly defaultPinIcons: string[] = <any>Constants._defaultPinIcons | ||||
|     /** | ||||
|  |  | |||
|  | @ -45,7 +45,6 @@ export default class LayerConfig extends WithContextLoader { | |||
|     public readonly isShown: TagsFilter | ||||
|     public minzoom: number | ||||
|     public minzoomVisible: number | ||||
|     public readonly maxzoom: number | ||||
|     public readonly title?: TagRenderingConfig | ||||
|     public readonly titleIcons: TagRenderingConfig[] | ||||
|     public readonly mapRendering: PointRenderingConfig[] | ||||
|  | @ -464,9 +463,7 @@ export default class LayerConfig extends WithContextLoader { | |||
|                     return [ | ||||
|                         new Combine([ | ||||
|                             new Link( | ||||
|                                 Utils.runningFromConsole | ||||
|                                     ? "<img src='https://mapcomplete.org/assets/svg/statistics.svg' height='18px'>" | ||||
|                                     : Svg.statistics_svg().SetClass("w-4 h-4 mr-2"), | ||||
|                                 "<img src='https://mapcomplete.org/assets/svg/statistics.svg' height='18px'>", | ||||
|                                 "https://taginfo.openstreetmap.org/keys/" + values.key + "#values", | ||||
|                                 true | ||||
|                             ), | ||||
|  |  | |||
|  | @ -245,6 +245,14 @@ export default class LayoutConfig implements LayoutInformation { | |||
|         return this.layers.some((l) => l.isLeftRightSensitive()) | ||||
|     } | ||||
| 
 | ||||
|     public hasNoteLayer() { | ||||
|         return this.layers.some((l) => l.id === "note") | ||||
|     } | ||||
| 
 | ||||
|     public hasPresets() { | ||||
|         return this.layers.some((l) => l.presets?.length > 0) | ||||
|     } | ||||
| 
 | ||||
|     public missingTranslations(extraInspection: any): { | ||||
|         untranslated: Map<string, string[]> | ||||
|         total: number | ||||
|  |  | |||
|  | @ -79,7 +79,6 @@ export default class PointRenderingConfig extends WithContextLoader { | |||
|             } | ||||
|         }) | ||||
| 
 | ||||
| 
 | ||||
|         this.marker = (json.marker ?? []).map((m) => new IconConfig(<any>m)) | ||||
|         if (json.css !== undefined) { | ||||
|             this.cssDef = this.tr("css", undefined) | ||||
|  | @ -307,7 +306,7 @@ export default class PointRenderingConfig extends WithContextLoader { | |||
|                 const label = self.label | ||||
|                     ?.GetRenderValue(tags) | ||||
|                     ?.Subs(tags) | ||||
|                     ?.SetClass("block center absolute text-center marker-label") | ||||
|                     ?.SetClass("flex items-center justify-center absolute marker-label") | ||||
|                     ?.SetClass(cssClassesLabel) | ||||
|                 if (cssLabel) { | ||||
|                     label.SetStyle(cssLabel) | ||||
|  |  | |||
|  | @ -62,7 +62,9 @@ import FavouritesFeatureSource from "../Logic/FeatureSource/Sources/FavouritesFe | |||
| import { ProvidedImage } from "../Logic/ImageProviders/ImageProvider" | ||||
| import { GeolocationControlState } from "../UI/BigComponents/GeolocationControl" | ||||
| import Zoomcontrol from "../UI/Zoomcontrol" | ||||
| 
 | ||||
| import { SummaryTileSource } from "../Logic/FeatureSource/TiledFeatureSource/SummaryTileSource" | ||||
| import summaryLayer from "../assets/generated/layers/summary.json" | ||||
| import { LayerConfigJson } from "./ThemeConfig/Json/LayerConfigJson" | ||||
| /** | ||||
|  * | ||||
|  * The themeviewState contains all the state needed for the themeViewGUI. | ||||
|  | @ -140,7 +142,6 @@ export default class ThemeViewState implements SpecialVisualizationState { | |||
|      * Triggered by navigating the map with arrows or by pressing 'space' or 'enter' | ||||
|      */ | ||||
|     public readonly visualFeedback: UIEventSource<boolean> = new UIEventSource<boolean>(false) | ||||
|     private readonly newPointDialog: FilteredLayer | ||||
| 
 | ||||
|     constructor(layout: LayoutConfig) { | ||||
|         Utils.initDomPurify() | ||||
|  | @ -309,7 +310,6 @@ export default class ThemeViewState implements SpecialVisualizationState { | |||
|                 fs.layer.layerDef.maxAgeOfCache | ||||
|             ) | ||||
|         }) | ||||
|         this.newPointDialog = this.layerState.filteredLayers.get("last_click") | ||||
| 
 | ||||
|         this.floors = this.featuresInView.features.stabilized(500).map((features) => { | ||||
|             if (!features) { | ||||
|  | @ -343,10 +343,7 @@ export default class ThemeViewState implements SpecialVisualizationState { | |||
|             return sorted | ||||
|         }) | ||||
| 
 | ||||
|         this.lastClickObject = new LastClickFeatureSource( | ||||
|             this.mapProperties.lastClickLocation, | ||||
|             this.layout | ||||
|         ) | ||||
|         this.lastClickObject = new LastClickFeatureSource(this.layout) | ||||
| 
 | ||||
|         this.osmObjectDownloader = new OsmObjectDownloader( | ||||
|             this.osmConnection.Backend(), | ||||
|  | @ -446,7 +443,6 @@ export default class ThemeViewState implements SpecialVisualizationState { | |||
|         const feature = this.lastClickObject.createFeature(lon, lat) | ||||
|         this.featureProperties.trackFeature(feature) | ||||
|         this.selectedElement.setData(feature) | ||||
|         this.selectedLayer.setData(this.newPointDialog.layerDef) | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|  | @ -472,16 +468,6 @@ export default class ThemeViewState implements SpecialVisualizationState { | |||
| 
 | ||||
|         this.userRelatedState.markLayoutAsVisited(this.layout) | ||||
| 
 | ||||
|         this.selectedElement.addCallbackAndRunD((feature) => { | ||||
|             // As soon as we have a selected element, we clear the selected element
 | ||||
|             // This is to work around maplibre, which'll _first_ register the click on the map and only _then_ on the feature
 | ||||
|             // The only exception is if the last element is the 'add_new'-button, as we don't want it to disappear
 | ||||
|             if (feature.properties.id === "last_click") { | ||||
|                 return | ||||
|             } | ||||
|             this.lastClickObject.features.setData([]) | ||||
|         }) | ||||
| 
 | ||||
|         this.selectedElement.addCallback((selected) => { | ||||
|             if (selected === undefined) { | ||||
|                 Zoomcontrol.resetzoom() | ||||
|  | @ -656,6 +642,19 @@ export default class ThemeViewState implements SpecialVisualizationState { | |||
|         }) | ||||
|     } | ||||
| 
 | ||||
|     private setupSummaryLayer() { | ||||
|         const layers = this.layout.layers.filter( | ||||
|             (l) => | ||||
|                 Constants.priviliged_layers.indexOf(<any>l.id) < 0 && | ||||
|                 l.source.geojsonSource === undefined | ||||
|         ) | ||||
|         return new SummaryTileSource( | ||||
|             "http://127.0.0.1:2345", | ||||
|             layers.map((l) => l.id), | ||||
|             this.mapProperties.zoom.map((z) => Math.max(Math.ceil(z) + 1, 0)), | ||||
|             this.mapProperties | ||||
|         ) | ||||
|     } | ||||
|     /** | ||||
|      * Add the special layers to the map | ||||
|      */ | ||||
|  | @ -683,6 +682,7 @@ export default class ThemeViewState implements SpecialVisualizationState { | |||
|             ), | ||||
|             current_view: this.currentView, | ||||
|             favourite: this.favourites, | ||||
|             summary: this.setupSummaryLayer(), | ||||
|         } | ||||
| 
 | ||||
|         this.closestFeatures.registerSource(specialLayers.favourite, "favourite") | ||||
|  | @ -720,15 +720,16 @@ export default class ThemeViewState implements SpecialVisualizationState { | |||
|             rangeIsDisplayed?.syncWith(this.featureSwitches.featureSwitchIsTesting, true) | ||||
|         } | ||||
| 
 | ||||
|         // enumarate all 'normal' layers and match them with the appropriate 'special' layer - if applicable
 | ||||
|         // enumerate all 'normal' layers and match them with the appropriate 'special' layer - if applicable
 | ||||
|         this.layerState.filteredLayers.forEach((flayer) => { | ||||
|             const id = flayer.layerDef.id | ||||
|             const features: FeatureSource = specialLayers[id] | ||||
|             if (features === undefined) { | ||||
|                 return | ||||
|             } | ||||
|             if (id === "favourite") { | ||||
|                 console.log("Matching special layer", id, flayer) | ||||
|             if (id === "summary") { | ||||
|                 console.log("Skipping summary!") | ||||
|                 return | ||||
|             } | ||||
| 
 | ||||
|             this.featureProperties.trackFeatureSource(features) | ||||
|  | @ -741,6 +742,20 @@ export default class ThemeViewState implements SpecialVisualizationState { | |||
|                 selectedLayer: this.selectedLayer, | ||||
|             }) | ||||
|         }) | ||||
| 
 | ||||
|         const maxzoom = Math.min( | ||||
|             ...this.layout.layers | ||||
|                 .filter((l) => Constants.priviliged_layers.indexOf(<any>l.id) < 0) | ||||
|                 .map((l) => l.minzoom) | ||||
|         ) | ||||
|         console.log("Maxzoom is", maxzoom) | ||||
|         new ShowDataLayer(this.map, { | ||||
|             features: specialLayers.summary, | ||||
|             layer: new LayerConfig(<LayerConfigJson>summaryLayer, "summaryLayer"), | ||||
|             doShowLayer: this.mapProperties.zoom.map((z) => z < maxzoom), | ||||
|             selectedLayer: this.selectedLayer, | ||||
|             selectedElement: this.selectedElement, | ||||
|         }) | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|  | @ -761,8 +776,6 @@ export default class ThemeViewState implements SpecialVisualizationState { | |||
| 
 | ||||
|         this.selectedElement.addCallback((selected) => { | ||||
|             if (selected === undefined) { | ||||
|                 // We did _unselect_ an item - we always remove the lastclick-object
 | ||||
|                 this.lastClickObject.features.setData([]) | ||||
|                 this.selectedLayer.setData(undefined) | ||||
|                 this.focusOnMap() | ||||
|             } | ||||
|  |  | |||
|  | @ -35,7 +35,6 @@ | |||
|   async function clicked() { | ||||
|     isExporting = true | ||||
|     const gpsLayer = state.layerState.filteredLayers.get(<PriviligedLayerType>"gps_location") | ||||
|     state.lastClickObject.features.setData([]) | ||||
|     state.userRelatedState.preferencesAsTags.data["__showTimeSensitiveIcons"] = "no" | ||||
|     state.userRelatedState.preferencesAsTags.ping() | ||||
|     const gpsIsDisplayed = gpsLayer.isDisplayed.data | ||||
|  |  | |||
|  | @ -89,7 +89,6 @@ | |||
|     state.selectedElement.setData(undefined) | ||||
|     // When aborted, we force the contributors to place the pin _again_ | ||||
|     // This is because there might be a nearby object that was disabled; this forces them to re-evaluate the map | ||||
|     state.lastClickObject.features.setData([]) | ||||
|     preciseInputIsTapped = false | ||||
|   } | ||||
| 
 | ||||
|  |  | |||
|  | @ -84,7 +84,6 @@ export interface SpecialVisualizationState { | |||
|         readonly preferencesAsTags: UIEventSource<Record<string, string>> | ||||
|         readonly language: UIEventSource<string> | ||||
|     } | ||||
|     readonly lastClickObject: WritableFeatureSource | ||||
| 
 | ||||
|     readonly availableLayers: Store<RasterLayerPolygon[]> | ||||
| 
 | ||||
|  |  | |||
|  | @ -96,6 +96,11 @@ | |||
|       if (element.properties.id.startsWith("current_view")) { | ||||
|         return currentViewLayer | ||||
|       } | ||||
|       console.log(">>> selected:", element) | ||||
|       if(element.properties.id === "new_point_dialog"){ | ||||
|         console.log(">>> searching last_click layer", layout) | ||||
|         return layout.layers.find(l => l.id === "last_click") | ||||
|       } | ||||
|       if(element.properties.id === "location_track"){ | ||||
|         return layout.layers.find(l => l.id === "gps_track") | ||||
|       } | ||||
|  | @ -259,7 +264,7 @@ | |||
|   <div class="flex w-full items-end justify-between px-4"> | ||||
|     <div class="flex flex-col"> | ||||
|       <If condition={featureSwitches.featureSwitchEnableLogin}> | ||||
|         {#if state.lastClickObject.hasPresets || state.lastClickObject.hasNoteLayer} | ||||
|         {#if state.layout.hasPresets() || state.layout.hasNoteLayer()} | ||||
|           <button | ||||
|             class="pointer-events-auto w-fit" | ||||
|             on:click={() => { | ||||
|  | @ -267,7 +272,7 @@ | |||
|             }} | ||||
|             on:keydown={forwardEventToMap} | ||||
|           > | ||||
|             {#if state.lastClickObject.hasPresets} | ||||
|             {#if state.layout.hasPresets()} | ||||
|               <Tr t={Translations.t.general.add.title} /> | ||||
|             {:else} | ||||
|               <Tr t={Translations.t.notes.addAComment} /> | ||||
|  |  | |||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue