Created
January 9, 2025 19:58
-
-
Save sebilasse/ca76c60955e5414cff2c253f1cd89af4 to your computer and use it in GitHub Desktop.
OSM Place to ActivityPub (WIP)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import { AsLocation, AsLinkObject, AsObjectNormalized } from "@AS"; | |
| import { OsmRes, NominatimRes, NominatimLookup } from '../interfaces.d.ts'; | |
| import { ASUnits } from "../context/index.ts"; | |
| import { getSitelinkUrl } from "../wikidata.ts"; | |
| export interface LocationFeatureSpecification { | |
| /* amenity */ | |
| propertyID?: string; | |
| /* restaurant */ | |
| value?: any; /* string | boolean | number | https://schema.org/StructuredValue; */ | |
| /* osm:979019920 */ | |
| valueReference?: any; | |
| maxValue?: number; | |
| minValue?: number; | |
| measurementTechnique?: string; | |
| unitCode?: string; | |
| unitText?: string; | |
| } | |
| /* | |
| TODO schema | |
| Accommodation: CampingPitch Room Suite | |
| Residence: ApartmentComplex GatedResidenceCommunity | |
| CivicStructure: -> amenity | |
| */ | |
| const icons: any = { | |
| amenity: { | |
| restaurant: 'b/bb/Restaurant-14', | |
| food_court: 'b/bb/Restaurant-14', | |
| cafe: 'd/da/Cafe-16', | |
| fast_food: '1/1f/Fast-food-16', | |
| bar: '9/94/Bar-16', | |
| pub: '5/5d/Pub-16', | |
| ice_cream: '0/0f/Ice-cream-14', | |
| biergarten: 'e/e1/Biergarten-16', | |
| community_centre: '0/0b/Community_centre-14', | |
| library: 'c/c5/Library.14', | |
| theatre: 'e/eb/Theatre-16', | |
| cinema: '3/31/Cinema-16', | |
| nightclub: 'e/ee/Nightclub-16', | |
| arts_centre: 'b/bf/Arts_centre', | |
| internet_cafe: '8/89/Internet_cafe-14', | |
| casino: '8/83/Casino-14', | |
| public_bookcase: 'b/b2/Public_bookcase-14', | |
| public_bath: '0/01/Public_bath', | |
| toilets: 'f/fa/Toilets-16', | |
| recycling: '1/16/Recycling-16', | |
| waste_basket: '6/6f/Waste-basket-12', | |
| waste_disposal: 'e/e6/Waste_disposal-14', | |
| bench: '0/0c/Bench-16', | |
| shelter: 'f/f8/Shelter-14', | |
| drinking_water: '0/08/Drinking-water-16', | |
| fountain: 'a/a1/Fountain-14', | |
| bbq: '5/50/Bbq-14', | |
| shower: '5/5a/Shower-14', | |
| bank: '3/3b/Bank-16', | |
| atm: 'f/f9/Atm-14', | |
| bureau_de_change: 'e/ed/Bureau_de_change-14', | |
| pharmacy: '1/1e/Pharmacy-14', | |
| hospital: '3/33/Hospital-14', | |
| clinic: '7/71/Doctors-14', | |
| doctors: '7/71/Doctors-14', | |
| dentist: '8/86/Dentist-14', | |
| veterinary: 'f/fc/Veterinary-14', | |
| post_box: 'd/d4/Post_box-12', | |
| post_office: 'e/e1/Post_office-14', | |
| telephone: 'f/fa/Telephone.16', | |
| parking: '7/7b/Parking-16', | |
| fuel: '7/77/Fuel-16', | |
| bicycle_parking: '7/7f/Parking-bicycle-16', | |
| bus_station: '5/5a/Amenity_bus_station', | |
| bicycle_rental: 'd/d5/Rental-bicycle-16', | |
| taxi: '9/94/Taxi.16', | |
| charging_station: 'a/af/Charging_station.16', | |
| car_rental: '1/11/Rental-car-16', | |
| ferry_terminal: '2/24/Ferry-icon', | |
| motorcycle_parking: '3/31/Parking-motorcycle-16', | |
| bicycle_repair_station: '0/01/Bicycle_repair_station-14', | |
| boat_rental: 'b/b1/Boat_rental-14', | |
| police: '5/59/Police-16', | |
| townhall: 'a/a3/Town-hall-16', | |
| fire_station: 'b/b7/Fire-station-16', | |
| social_facility: '0/0e/Social_facility-14', | |
| courthouse: 'd/db/Courthouse-16', | |
| prison: 'd/d0/Prison-16', | |
| marketplace: '1/1c/Marketplace-14', | |
| car_wash: '6/65/Car_wash-14', | |
| vehicle_inspection: '5/56/Vehicle_inspection-14', | |
| driving_school: 'c/c4/Shop-other-16', | |
| nursing_home: 'a/ad/Social_amenity_darken_80-16', | |
| childcare: 'a/ad/Social_amenity_darken_80-16', | |
| hunting_stand: 'a/a6/Hunting-stand-16' | |
| }, | |
| leisure: { | |
| outdoor_seating: 'a/ac/Outdoor_seating-14', | |
| amusement_arcade: '4/44/Amusement_arcade-14', | |
| playground: '3/31/Playground-16', | |
| fitness_centre: 'b/bd/Fitness', | |
| fitness_station: 'b/bd/Fitness', | |
| golf_course: 'd/d2/Golf-icon', | |
| sauna: '3/3a/Sauna-14', | |
| miniature_golf: '4/44/Miniature_golf', | |
| beach_resort: 'c/cd/Beach_resort-14', | |
| fishing: 'b/ba/Fishing-14', | |
| bowling_alley: '0/05/Bowling_alley-14', | |
| dog_park: 'd/da/Dog_park', | |
| picnic_table: '7/7d/Table-16', | |
| firepit: 'd/df/Firepit', | |
| bird_hide: '9/92/Bird_hide-14', | |
| slipway: '8/88/Transport_slipway' | |
| }, | |
| tourism: { | |
| artwork: '1/12/Artwork-14', | |
| museum: 'a/a9/Museum-16', | |
| gallery: '7/7e/Gallery-14', | |
| picnic_site: 'f/fc/Picnic_site', | |
| camp_site: 'e/e4/Camping.16', | |
| caravan_site: 'a/a1/Caravan-16', | |
| viewpoint: 'c/c2/Viewpoint-16', | |
| hotel: 'c/ca/Hotel-16', | |
| guest_house: 'd/dc/Tourism_guest_house', | |
| hostel: '4/4f/Hostel-16', | |
| chalet: 'e/e9/Chalet', | |
| motel: '1/10/Motel-16', | |
| apartment: '0/0d/Apartment', | |
| alpine_hut: 'f/f1/Alpinehut', | |
| wilderness_hut: '8/8e/Wilderness_hut' | |
| }, | |
| historic: { | |
| memorial: '6/6e/Memorial-16', | |
| archaeological_site: '7/7d/Archaeological-site-16', | |
| wayside_shrine: '1/17/Carto_shrine', | |
| monument: '9/94/Monument-16', | |
| castle: '5/51/Castle-14', | |
| fort: '0/0d/Historic-fort', | |
| city_gate: '4/47/City-gate-14', | |
| wayside_cross: '2/26/Christian.9' | |
| }, | |
| man_made: { | |
| obelisk: '8/82/Obelisk-14', | |
| storage_tank: '1/15/Storage_tank-14', | |
| silo: '1/15/Storage_tank-14', | |
| tower: '0/0d/Tower_freestanding', | |
| cross: '2/26/Christian.9', | |
| water_tower: '1/13/Water-tower-16', | |
| mast: '4/4b/Mast_general', | |
| chimney: '8/8a/Chimney-14', | |
| lighthouse: 'c/c2/Lighthouse-16', | |
| crane: 'e/e0/Crane-14', | |
| windmill: '0/0b/Windmill-16', | |
| communications_tower: '0/0c/Communication_tower-14' | |
| }, | |
| shop: { | |
| massage: '2/29/Massage-14', | |
| convenience: '9/96/Convenience-14', | |
| supermarket: '7/76/Supermarket-14', | |
| clothes: 'd/de/Clothes-16', | |
| fashion: 'd/de/Clothes-16', | |
| hairdresser: '6/6b/Hairdresser-16', | |
| bakery: 'f/fe/Bakery-16', | |
| car_repair: '2/26/Car_repair-14', | |
| doityourself: 'c/c3/Doityourself-16', | |
| hardware: 'c/c3/Doityourself-16', | |
| car: 'b/b2/Purple-car', | |
| kiosk: 'b/bf/Newsagent-14', | |
| newsagent: 'b/bf/Newsagent-14', | |
| beauty: '0/06/Beauty-14', | |
| butcher: 'b/b8/Butcher', | |
| alcohol: 'e/eb/Alcohol-16', | |
| wine: 'e/eb/Alcohol-16', | |
| furniture: 'a/a0/Furniture-16', | |
| florist: '6/69/Florist-16', | |
| mobile_phone: '1/19/Mobile-phone-16', | |
| electronics: '2/27/Electronics-16', | |
| shoes: '3/3b/Shoes-16', | |
| car_parts: '7/78/Car_parts-14', | |
| greengrocer: 'd/d8/Greengrocer-14', | |
| farm: 'd/d8/Greengrocer-14', | |
| laundry: '3/34/Laundry-14', | |
| dry_cleaning: '3/34/Laundry-14', | |
| optician: '6/60/Optician-16', | |
| jewelry: '8/8d/Jewellery-16', | |
| jewellery: '8/8d/Jewellery-16', | |
| books: '1/18/Books-16', | |
| gift: '1/11/Gift-16', | |
| department_store: '7/79/Department_store-16', | |
| bicycle: '1/1b/Bicycle-16', | |
| confectionery: 'c/cc/Confectionery-14', | |
| chocolate: 'c/cc/Confectionery-14', | |
| pastry: 'c/cc/Confectionery-14', | |
| variety_store: '2/24/Variety_store-14', | |
| travel_agency: 'b/b1/Travel_agency-14', | |
| sports: 'd/df/Sports-14', | |
| chemist: '3/36/Chemist-14', | |
| computer: 'b/bb/Computer-14', | |
| stationery: '5/58/Stationery-14', | |
| pet: '5/5d/Pet-16', | |
| beverages: '9/98/Beverages-14', | |
| cosmetics: 'e/e9/Perfumery-14', | |
| perfumery: 'e/e9/Perfumery-14', | |
| tyres: '5/53/Tyres', | |
| motorcycle: '5/5d/Shop_motorcycle', | |
| garden_centre: '4/48/Garden_centre-14', | |
| copyshop: '2/2c/Copyshop-14', | |
| toys: '6/62/Toys-14', | |
| deli: '3/3b/Deli-14', | |
| tobacco: 'b/b0/Tobacco-14', | |
| seafood: 'd/d9/Seafood-14', | |
| interior_decoration: 'f/f4/Interior_decoration-14', | |
| ticket: '7/79/Ticket-14', | |
| photo: '4/4a/Photo-14', | |
| trade: 'f/f2/Trade-14', | |
| wholesale: 'f/f2/Trade-14', | |
| outdoor: '7/76/Outdoor-14', | |
| houseware: '8/84/Houseware-14', | |
| art: 'f/fb/Art-14', | |
| paint: '3/31/Paint-14', | |
| fabric: '4/45/Fabric-14', | |
| bookmaker: '8/81/Bookmaker-14', | |
| second_hand: 'a/a3/Second_hand-14', | |
| charity: '8/85/Charity-14', | |
| bed: '9/91/Bed-14', | |
| medical_supply: 'f/fa/Medical_supply', | |
| hifi: '0/0c/Hifi-14', | |
| music: '1/13/Shop_music', | |
| coffee: 'd/d5/Coffee-14', | |
| musical_instrument: 'd/d0/Musical_instrument-14', | |
| tea: '3/34/Tea-14', | |
| video: '2/2d/Video-14', | |
| bag: '4/41/Bag-14', | |
| carpet: '5/5f/Carpet-14', | |
| video_games: 'a/ab/Video_games-14', | |
| dairy: '0/0e/Dairy', | |
| '*': 'c/c4/Shop-other-16' | |
| }, | |
| golf: { pin: 'c/ca/Leisure-golf-pin' }, | |
| emergency: { phone: '1/1c/Emergency-phone.16' }, | |
| highway: { | |
| bus_stop: '5/52/Bus-stop-12', | |
| elevator: '6/6d/Elevator-12', | |
| traffic_signals: '8/84/Traffic_light-16', | |
| mini_roundabout: '9/9b/Highway_mini_roundabout' | |
| }, | |
| railway: { | |
| station: '1/11/Tram-16', | |
| halt: '1/11/Tram-16', | |
| tram_stop: '1/11/Tram-16', | |
| subway_entrance: '3/3c/Subway-entrance-12', | |
| level_crossing: 'f/f7/Level_crossing', | |
| crossing: 'f/f7/Level_crossing' | |
| }, | |
| aeroway: { helipad: 'e/ed/Helipad.16', aerodrome: 'b/bc/Aerodrome' }, | |
| oneway: { yes: 'e/e6/Oneway' }, | |
| barrier: { | |
| gate: '9/97/Barrier_gate', | |
| bollard: '8/8f/Barrier', | |
| block: '8/8f/Barrier', | |
| turnstile: '8/8f/Barrier', | |
| log: '8/8f/Barrier', | |
| lift_gate: '8/8f/Liftgate-7', | |
| swing_gate: '8/8f/Liftgate-7', | |
| cycle_barrier: '0/09/Cycle_barrier-14', | |
| stile: '7/7c/Barrier_stile-14', | |
| toll_booth: 'd/d7/Toll_booth', | |
| cattle_grid: '4/4c/Barrier_cattle_grid-14', | |
| kissing_gate: '2/2f/Kissing_gate-14', | |
| 'full-height_turnstile': 'b/bb/Full-height_turnstile-14', | |
| motorcycle_barrier: 'b/b5/Motorcycle_barrier-14' | |
| }, | |
| ford: { yes: '5/50/Ford.16', stepping_stones: '5/50/Ford.16' }, | |
| vending: { | |
| 'public_transport_tickets': [ | |
| '2/28/Public_transport_tickets-14', | |
| 'A machine vending bus, tram, train... tickets' | |
| ], | |
| 'parking_tickets': [ | |
| 'b/be/Parking_tickets-14', 'A machine selling tickets for parking' | |
| ], | |
| 'excrement_bags': [ '0/08/Excrement_bags-14', 'Excrement bag dispenser' ] | |
| }, | |
| waterway: { | |
| dam: 'c/cb/Dam_node', | |
| weir: '0/00/Weir_node', | |
| lock_gate: '9/97/Lock_gate_node', | |
| waterfall: '7/72/Waterfall-14' | |
| }, | |
| 'Node with highway': { | |
| 'turning_circle at way with highwaytrack': '2/2f/Turning_circle_on_highway_track-16' | |
| }, | |
| natural: { | |
| tree: '6/65/Tree-16', | |
| peak: '6/67/Peak-8', | |
| spring: '0/0e/Spring-14', | |
| cave_entrance: 'b/b1/Cave.14', | |
| saddle: 'a/a3/Saddle-8', | |
| volcano: 'e/e3/Volcano-8' | |
| }, | |
| military: { bunker: '3/36/Bunker-osmcarto' }, | |
| advertising: { column: '2/20/Column-14' }, | |
| power: { tower: 'e/e3/Power_tower', pole: 'd/d8/Power_pole' }, | |
| entrance: { | |
| yes: '9/92/Rect', | |
| main: '0/00/Entrance_main', | |
| service: '2/27/Entrance' | |
| }, | |
| wheelchair: { | |
| yes: '5/5f/Wheelchair_sign_yes', | |
| limited: '0/0c/Wheelchair_sign_limited', | |
| no: ' 4/46/Wheelchair_sign_no', | |
| designated: '8/8a/Wheelchair_sign_only' | |
| }, | |
| place: { city: '9/97/Place-6' }, | |
| capital: '0/0d/Place-capital-8', | |
| office: '9/92/Office-16' | |
| }; | |
| interface WheelchairAndAmenities { wheelchair: [string,string][]; amenity: [string,string][]; } | |
| export function osmWheelchairAndAmenities( | |
| locationFeatures: LocationFeatureSpecification[] | LocationFeatureSpecification, | |
| hasLocalIcon = true, | |
| isAdministrativeForCountry: false | string = false | |
| ): WheelchairAndAmenities { | |
| if (typeof locationFeatures !== 'object') { return {wheelchair: [], amenity: []} } | |
| const _a = (!Array.isArray(locationFeatures) ? [locationFeatures] : locationFeatures); | |
| const sl = (s: string) => s.trim().toLowerCase(); | |
| const o = _a.reduce((_o: any, l) => { | |
| if ( | |
| l.hasOwnProperty('propertyID') && l.hasOwnProperty('value') && | |
| typeof l.propertyID === 'string' && typeof l.value === 'string' | |
| ) { | |
| const k = sl(l.propertyID.replace('osm:','')); | |
| const v = sl(l.value); | |
| if (!_o.hasOwnProperty(k)) { _o[k] = {}; } | |
| _o[k][v] = 1; | |
| } | |
| return _o | |
| }, {}); | |
| // console.log(o) | |
| const a: [string, string][] = []; | |
| if (isAdministrativeForCountry || o?.boundary === 'administrative') { | |
| a.push(['AdministrativeArea', '0/00/AdministrativeArea']); | |
| } else if (!!o.amenity) { | |
| if (typeof o.amenity === 'string') { o.amenity = {[o.amenity]: o.amenity} } | |
| if (!!o.amenity.parking && !!o.parking && (!!o.parking.lane || !!o.parking.street_side)) { | |
| a.push(['parkingSubtle', '6/64/Parking-subtle']); | |
| } | |
| if (!!o.amenity.parking_entrance && !!o.parking) { | |
| if (!!o.parking.underground) { | |
| a.push(['parkingU', 'b/b1/Parking_entrance-14']); | |
| } | |
| if (!!o.parking['multi-storey']) { | |
| a.push(['parkingMulti', 'b/b1/Parking_entrance-14']); | |
| } | |
| } | |
| if (!!o.amenity.place_of_worship) { | |
| const religions: [string, string][] = [ | |
| ['christian', '3/39/Christian-16'], ['jewish', 'c/cb/Jewish-16'], | |
| ['muslim', '5/5d/Muslim-16'], ['taoist', '7/7e/Taoist-16'], | |
| ['hindu', '2/2f/Hinduist-16'], ['buddhist', 'a/ae/Buddhist-16'], | |
| ['shinto', '8/8a/Shintoist-16'], ['sikh', '7/75/Sikhist-16'], | |
| ['religion_und', '0/04/Place-of-worship-16'] | |
| ]; | |
| if (!!o.religion) { | |
| religions.forEach((rA) => { | |
| if (!!o.religion[rA[0]]) { a.push(rA); } | |
| }); | |
| } | |
| } | |
| /* | |
| for (let aKey in icons.amenity) { | |
| if (o.amenity.) | |
| }*/ | |
| } | |
| if (!!o.artwork_type) { | |
| if (!!o.artwork_type.statue) { a.push(['statue', '6/68/Statue-14']) } | |
| if (!!o.artwork_type.bust) { a.push(['bust', '9/9f/Bust-14']) } | |
| } | |
| if (!!o.tourism && !!o.tourism.information && !!o.information) { | |
| if (!!o.information.guidepost) { a.push(['guidepost', 'd/dc/Guidepost-14']) } | |
| if (!!o.information.board) { a.push(['infoBoard', '7/77/Board-14']) } | |
| if (!!o.information.map) { a.push(['infoMap', 'c/ca/Map-14']) } | |
| if (!!o.information.tactile_map) { a.push(['infoMap3d', 'c/ca/Map-14']) } | |
| if (!!o.information.office) { a.push(['infoOffice', '7/78/Office-14']) } | |
| if (!!o.information.terminal) { a.push(['infoTerminal', '9/9c/Terminal-14']) } | |
| if (!!o.information.audioguide) { a.push(['audioguide', '6/6a/Audioguide-14']) } | |
| } | |
| if (!!o.office && !!o.office.diplomatic && !!o.diplomatic) { | |
| if (!!o.diplomatic.embassy) { a.push(['embassy', 'f/f5/Diplomatic']) } | |
| if (!!o.diplomatic.consulate) { a.push(['consulate', '4/4f/Office-diplomatic-consulate']) } | |
| } | |
| if (!!o.historic && !!o.historic.memorial && !!o.memorial) { | |
| if (!!o.memorial.plaque || !!o.memorial.blue_plaque) { a.push(['plaque', 'b/b2/Plaque']) } | |
| if (!!o.memorial.statue) { a.push(['statue', '6/68/Statue-14']) } | |
| if (!!o.memorial.stone) { a.push(['stone', '8/87/Stone-14']) } | |
| if (!!o.memorial.bust) { a.push(['bust', '9/9f/Bust-14']) } | |
| } | |
| if (!!o.historic && !!o.historic.castle) { | |
| if (!!o.castle_type && !!o.castle_type.palace) { a.push(['palace', '3/33/Palace-14']) } | |
| if (!!o.castle_type && !!o.castle_type.stately) { a.push(['stately', '3/33/Palace-14']) } | |
| if (!!o.castle_type && !!o.castle_type.manor) { a.push(['manor', '4/41/Manor-14']) } | |
| if (!!o.castle_type && !o.castle_type.palace && !o.castle_type.stately) { | |
| a.push([Object.keys(o.castle_type)[0]||'castle', '3/31/Fortress-14']) | |
| } | |
| if (!o.castle_type) { | |
| a.push(['castle', '3/31/Fortress-14']) | |
| } | |
| } | |
| if (!!o.leisure) { | |
| if (!!o.leisure.swimming_area || (!!o.leisure.sports_centre && !!o.sport && !!o.sport.swimming)) { | |
| a.push(['swimmingArea', 'c/cb/Swimming-16']) | |
| } | |
| if (!!o.leisure.water_park) { | |
| a.push(['waterPark', 'c/cb/Swimming-16']) | |
| } | |
| } | |
| if (!!o.entrance && !!o.access && !!o.access.no) { | |
| a.push(['noEntrance', '0/0d/Rectdiag']) | |
| } | |
| const withLocalIcon = (_a: [string, string]) => { | |
| if (Array.isArray(_a) && _a.length > 1 && typeof _a[1] === 'string') { | |
| _a[1] = `/static/theme/svg/osm/${_a[1].split('/').reverse()[0]}.svg`; | |
| } else { | |
| console.log(o,_a) | |
| } | |
| return _a | |
| } | |
| for (let k in o) { | |
| if (k === 'wheelchair' || k === 'hasDriveThroughService' || !o.hasOwnProperty(k)) { continue } | |
| if (a.length > 1) { break } | |
| if (icons.hasOwnProperty(k)) { | |
| for (let v in o[k]) { | |
| if (a.length > 1) { break } | |
| if (icons[k].hasOwnProperty(v)) { | |
| typeof icons[k][v] === 'string' && a.push([`${k}_${v}`, icons[k][v]]); | |
| } | |
| } | |
| !a.length && typeof icons[k] === 'string' && a.push([k, icons[k]]); | |
| } | |
| } | |
| if (!!o.hasDriveThroughService && !!o.hasDriveThroughService.yes) { | |
| a.push(['hasDriveThroughService_yes','c/c4/Car-14']) | |
| } | |
| let w: [string, string][] = []; | |
| if (!!o.wheelchair) { | |
| for (let wk in o.wheelchair) { | |
| o.wheelchair.hasOwnProperty(wk) && w.push([`wheelchair_${wk}`, icons.wheelchair[wk]]); | |
| } | |
| } else { | |
| w.push(['wheelchair_und', '9/93/Wheelchair_sign_unknown']); | |
| } | |
| return {wheelchair: (hasLocalIcon ? w.map(withLocalIcon) : w), amenity: (hasLocalIcon ? a.map(withLocalIcon) : a)} | |
| } | |
| /* TODO multi-level keys ... | |
| 'man_made=tower + tower:type=communication': [ '2/27/Tower_cantilever_communication', 'Communication towers' ], | |
| 'power=generator + generator:source=wind ( + generator:method=wind_turbine)': [ 'b/b2/Generator_wind-14', 'Wind turbine' ], | |
| 'man_made=tower + tower:type=observation / man_made=tower + tower:type=watchtower': [ 'b/b9/Tower_observation', 'Observation tower / Watch tower' ], | |
| 'man_made=tower + tower:type=bell_tower': [ '1/1a/Tower_bell_tower', 'Bell tower' ], | |
| 'man_made=tower + tower:type=lighting': [ '3/3d/Tower_lighting', 'Towers for lighting' ], | |
| 'man_made=tower + tower:type=communication + tower:construction=lattice': [ | |
| '9/9d/Tower_lattice_communication', | |
| 'Lattice communication towers' | |
| ], | |
| 'man_made=mast + tower:type=lighting': [ 'e/e9/Mast_lighting', 'Poles for lighting' ], | |
| 'man_made=mast + tower:type=communication': [ '2/25/Mast_communications', 'Mast with transmitters' ], | |
| 'man_made=tower + tower:type=defensive': [ '0/0f/Tower_defensive', 'Fortified defensive tower' ], | |
| 'man_made=tower + tower:type=cooling': [ 'b/be/Tower_cooling', 'Cooling tower' ], | |
| 'man_made=tower + tower:construction=lattice': [ | |
| 'e/e9/Tower_lattice', | |
| 'The tower is constructed from steel lattice' | |
| ], | |
| 'man_made=tower + tower:type=lighting + tower:construction=lattice': [ | |
| 'c/c4/Tower_lattice_lighting', | |
| 'Tower is constructed from steel lattice for lighting' | |
| ], | |
| 'man_made=tower + tower:construction=dish': [ 'c/c3/Tower_dish', "The 'communication tower' is a dish" ], | |
| 'man_made=tower + tower:construction=dome': [ 'c/c0/Tower_dome', "The 'communication tower' is a dome" ], | |
| 'man_made=telescope + telescope:type=radio': [ '5/59/Telescope_dish-14', 'Radio telescope' ], | |
| 'man_made=telescope + telescope:type=optical': [ 'e/e0/Telescope_dome-14', 'Optical telescope' ], | |
| */ | |
| /* TODO check existing for _osmToWd | |
| TYPE | |
| // done | |
| [ | |
| "building", "amenity", "water", "highway", "railway", "waterway", "cycleway", "aerialway", "aeroway", "geological" | |
| // Structure | |
| "craft", "leisure", "shop", "tourism", "sport", "healthcare", "military", "memorial", "public_transport", | |
| "diplomatic", "government", "industrial", "emergency" | |
| ] | |
| // TODO : place, landuse, natural, man_made | |
| // TODO : historic | |
| // healthcare:speciality, club, cuisine, museum, vending, pedagogy, playground, fountain, | |
| // surveillance, wheelchair, ramp:wheelchair, dog, smoking, opening_hours, fee | |
| TODO split semicolon and combis | |
| */ | |
| function isOSM(x: OsmRes | NominatimRes): x is OsmRes { | |
| return !!(x?.elements && !(x as any)?.osm_type); | |
| } | |
| export function osmToAs(o: OsmRes | NominatimRes, osmId: string = '', isAdministrativeForCountry: false | string = false) { | |
| const [domain, baseUrl] = ['openstreetmap.org', 'https://www.openstreetmap.org']; | |
| const features: LocationFeatureSpecification[] = []; | |
| // https://www.openstreetmap.org/way/30475956.json | |
| // https://api.openstreetmap.org/api/0.6/way/30475956/full.json | |
| // https://nominatim.openstreetmap.org/lookup?format=json&extratags=1&namedetails=1&osm_ids=W30475956 | |
| let [id, ref] = [osmId, osmId]; | |
| if (!id || typeof id !== 'string') { | |
| if (isOSM(o)) { | |
| const elements = o.elements.reverse(); | |
| let el: any; | |
| for (el of elements) { | |
| if (el.type && el.id) { | |
| id = `${el.type}/${el.id}`; | |
| break; | |
| } | |
| } | |
| } else { | |
| id = `${o.osm_type}/${o.osm_id}`; | |
| } | |
| } | |
| if (!id || typeof id !== 'string') { return { type: ["Place"] } } | |
| const lId = id.toLowerCase(); | |
| // console.log('b',lId); | |
| if (lId.indexOf(domain) < 0 && (lId.startsWith('way')||lId.startsWith('node')||lId.startsWith('relation'))) { | |
| id = `${baseUrl}/${lId}`; | |
| } | |
| if (ref && ref.indexOf(`api.${domain}`) < 0) { | |
| ref = id.replace(`www.${domain}`, `api.${domain}/api/0.6`); | |
| } | |
| const type = ['Place', 'redaktor:Factual','redaktor:Topic']; | |
| const asRes: AsObjectNormalized = { type, id, name: [] }; | |
| const q = isOSM(o) | |
| ? o.elements.filter((el) => {return `${el.id}` === osmId.split('/')[1]})[0] | |
| : o; | |
| let tags: any = {}; | |
| if (isOSM(o) && q?.tags) { | |
| tags = q.tags; | |
| } else { | |
| const nq = (q as NominatimRes); | |
| if (nq?.extratags) { | |
| tags = nq.extratags; | |
| if (nq.class && nq.type) tags[nq.class] = nq.type; | |
| if (nq.category && nq.type) tags[(nq as any /* OSM JSONv2 */).category] = nq.type; | |
| if (nq.name) tags.name = nq.name; | |
| } | |
| } | |
| // console.log( findType({type, ...tags}) ) | |
| let [geoQ, accuracy] = [((typeof q.lat === 'number' && typeof q.lon === 'number') || tags?.ele) && q, 100]; | |
| // console.log(q, geoQ) | |
| if (!geoQ) { | |
| if (isOSM(o)) { | |
| const geos = o.elements.filter((el) => (el.lat && typeof el.lat === 'string' && el.lon && typeof el.lon === 'string')); | |
| geoQ = geos.length && geos[0]; | |
| accuracy = 90; | |
| } else { | |
| geoQ = o; | |
| } | |
| } | |
| const asLocation: AsLocation = {accuracy}; | |
| if (geoQ && geoQ.lat && geoQ.lon) { | |
| asLocation.latitude = geoQ.lat; | |
| asLocation.longitude = geoQ.lon; | |
| if (geoQ?.tags?.ele || geoQ?.ele || geoQ['ele:wgs84']) { | |
| asLocation.altitude = geoQ?.tags?.ele | |
| ? parseFloat(geoQ.tags.ele) | |
| : (geoQ?.ele ? parseFloat(geoQ.ele) : geoQ['ele:wgs84']); | |
| } | |
| } else if (isOSM(o)) { | |
| const [lat, lon] = o.elements.map((o) => o.lat && o.lon ? [o.lat, o.lon] : 0).filter((a) => !!a) | |
| .reduce((r: number[][], pair) => { | |
| r[0].push(pair[0]); | |
| r[1].push(pair[1]); | |
| return r | |
| }, [[], []]) | |
| .map((a) => a.reduce( ( p, c ) => p + c, 0 ) / a.length); | |
| asLocation.latitude = lat; | |
| asLocation.longitude = lon; | |
| } | |
| asRes.location = asLocation; | |
| const hasTag = (k: string) => | |
| tags[k] && typeof tags[k] === 'string' && tags[k] !== 'no' | |
| && tags[k] !== 'proposed' && tags[k] !== 'maybe'; | |
| const getLink = (href: string, name?: string, hreflang?: string,) => { | |
| const l: AsLinkObject = { type: "Link", href, mediaType: "text/html" }; | |
| if (typeof hreflang === 'string' && hreflang) l.hreflang = hreflang; | |
| if (typeof name === 'string' && name) l.name = name; | |
| return l; | |
| } | |
| if (isOSM(o)) { | |
| let [addrQ, addrAccuracy] = [tags && Object.keys(tags||{}).filter((k) => k.startsWith('addr:')).length ? tags : null, 99]; | |
| if (!addrQ) { | |
| const addrs = o.elements.filter((el) => Object.keys(el.tags||{}).filter((k) => k.startsWith('addr:')).length); | |
| if (addrs.length) { | |
| addrQ = addrs[0].tags && Object.keys(addrs[0].tags||{}).filter((k) => k.startsWith('addr:')) ? addrs[0].tags : null; | |
| addrAccuracy = 90; | |
| } | |
| } | |
| if (addrQ) { | |
| asRes.address = [{accuracy: addrAccuracy}]; | |
| for (let k in (addrQ as any)) { | |
| if (k.startsWith('addr:')) { | |
| asRes.address[0][k.replace('addr:','')] = addrQ[k]; | |
| } | |
| } | |
| } | |
| const contactQ = Object.keys(tags||{}).filter((k) => k.startsWith('contact:')) ? tags : null; | |
| if (contactQ) { | |
| if (!asRes.address || !asRes.address.length) { asRes.address = [{accuracy: addrAccuracy}]; } | |
| for (let k in contactQ) { | |
| if (k.startsWith('contact:')) { | |
| if (k === 'website') { | |
| if (!asRes.url) { asRes.url = [] } | |
| asRes.url.push({...getLink(contactQ[k], 'website'), rel: 'me'}); | |
| } else if (k === 'email') { | |
| if (!asRes.url) { asRes.url = [] } | |
| asRes.url.push({...getLink(`mailto:${contactQ[k]}`, 'email'), rel: 'me'}); | |
| asRes.address[0][k.replace('contact:','')] = contactQ[k]; | |
| } else { | |
| asRes.address[0][k.replace('contact:','')] = contactQ[k]; | |
| } | |
| } | |
| } | |
| } | |
| } else if (o?.address) { | |
| asRes.address = [{accuracy: 100, ...o.address}]; | |
| } | |
| if (hasTag('website') && typeof tags.website === 'string') { | |
| if (!asRes.url) { asRes.url = []; } | |
| asRes.url.push({...getLink(tags.website, 'website'), rel: 'me'}) | |
| } | |
| if (hasTag('opendata_portal') && typeof tags.opendata_portal === 'string') { | |
| if (!asRes.url) { asRes.url = []; } | |
| asRes.url.push({...getLink(tags.opendata_portal, 'Open Data Portal'), rel: 'me'}) | |
| } | |
| if (hasTag('wikidata') && tags.wikidata.startsWith('Q')) { | |
| asRes.wd = tags.wikidata; | |
| asRes.describes = [ `wd:${tags.wikidata}` ]; | |
| } | |
| // operator:wikidata=* network:wikidata=* brand:wikidata=* | |
| if (hasTag('wikipedia') && tags.wikipedia.indexOf(':') > 0) { | |
| try { | |
| const [lang, title] = tags.wikipedia.split(':'); | |
| const href = getSitelinkUrl(lang, title); | |
| if (href) { | |
| if (!asRes.url) { asRes.url = []; } | |
| asRes.url.push(getLink(href, title, lang)); | |
| } | |
| } catch (e) { | |
| // console.log(e); | |
| } | |
| } | |
| if (hasTag('official_name')) { | |
| if (!asRes.name) { asRes.name = []; } | |
| asRes.name = (tags.name.split(',')||[] as string[]) | |
| } | |
| if (hasTag('name')) { | |
| if (!asRes.name) { asRes.name = []; } | |
| asRes.name = asRes.name.concat(tags.name.split(',')||[] as string[]) | |
| } | |
| if ( (q as NominatimRes)?.display_name) { | |
| if (!asRes.name) { asRes.name = []; } | |
| asRes.name.push(q['display_name']) | |
| } | |
| asRes.name = Array.from(new Set(asRes.name)); | |
| for (let k in tags) { | |
| const f: LocationFeatureSpecification = {}; | |
| if (k === 'name') { continue } | |
| if (k === 'loc_name' && hasTag('loc_name')) { | |
| if (!asRes.tag || !Array.isArray(asRes.tag) || !asRes.tag.filter((t) => t.name === tags.loc_name).length) { | |
| if (!asRes.tag) { asRes.tag = [] } | |
| if (!Array.isArray(asRes.tag)) { asRes.tag = [asRes.tag] } | |
| // TODO : {href: '/tagged/cats'} | |
| asRes.tag.push({type: ['Hashtag', 'wdt:P1449'], name: tags.loc_name}); | |
| } | |
| } else if (k === 'old_name' && hasTag('old_name')) { | |
| // TODO | |
| } else if (k.startsWith('name:')) { | |
| if (!asRes.nameMap) { asRes.nameMap = ({} as any); } | |
| (asRes.nameMap as any)[k.replace('name:','') as any] = tags[k]; | |
| } else if (k.startsWith('nickname:') || k.startsWith('loc_name:')) { | |
| if (!asRes.alternativeNameMap) { asRes.alternativeNameMap = {}; } | |
| asRes.alternativeNameMap[k.replace('nickname:','').replace('loc_name:','') as any] = tags[k]; | |
| } else if (k === 'nickname' || k === 'loc_name') { | |
| if (!asRes.alternativeNameMap) { asRes.alternativeNameMap = {}; } | |
| asRes.alternativeNameMap.und = tags[k]; | |
| } else if (k.startsWith('admin_title:')) { | |
| if (!asRes.summaryMap) { asRes.summaryMap = [{}]; } | |
| asRes.summaryMap[0][k.replace('admin_title:','') as any] = tags[k]; | |
| } else if (k === 'admin_title') { | |
| if (!asRes.summaryMap) { asRes.summaryMap = [{}]; } | |
| asRes.summaryMap[0].und = tags[k]; | |
| } else if (k === 'height') { | |
| // "height":"110 m", | |
| const fH = parseFloat(k.replace(' m','')); | |
| const spaced = k.trim().split(' '); | |
| if (fH && !isNaN(fH)) { | |
| asRes.height = fH; | |
| } else if (spaced.length === 2) { | |
| const [fValue, fUnit] = spaced; | |
| const [height, units] = [parseFloat(fValue), fUnit.trim().toLowerCase()]; | |
| if (height && !isNaN(height) && units in ASUnits) { | |
| asRes.height = height; | |
| asRes.units = units; | |
| } | |
| } | |
| } else { | |
| f.propertyID = k; f.value = tags[k]; f.valueReference = ref; | |
| features.push(f); | |
| } | |
| } | |
| /** TODO | |
| * nominatim: | |
| * place_rank 30 (JSONv2), importance 0.00000999999999995449 | |
| */ | |
| /** | |
| * The value provides detail for the key-specified feature. | |
| * Commonly, values are free form text (e.g., name="Jeff Memorial Highway"), | |
| * one of a set of distinct values (an enumeration; e.g., highway=motorway), | |
| * multiple values from an enumeration (separated by a semicolon), | |
| * or a number (integer or decimal), such as a distance. | |
| * The value is obligatory for the tag, even if the key is self-explanatory (e.g. motorcycle:rental=yes). | |
| */ | |
| // TODO type | |
| // only main for | |
| // tags[k] === 'yes' || tags[k] === '*' || tags[k] === 'other' || tags[k] === 'mixed_use' | |
| // TODO + wikidata + amenity = ldType | |
| const feature = osmWheelchairAndAmenities(features, true, isAdministrativeForCountry); | |
| let isAdministrativeArea = !!isAdministrativeForCountry; | |
| if (feature.amenity.length) { | |
| if (!asRes.icon) { asRes.icon = []; } | |
| feature.amenity.forEach((a) => { | |
| if (a[0] === 'AdministrativeArea') { | |
| asRes.type.push('schema:AdministrativeArea'); | |
| isAdministrativeArea = true; | |
| } | |
| (asRes.icon as any).push({ | |
| type: 'Image', | |
| name: a[0], | |
| url: { | |
| type: 'Link', | |
| mediaType: 'image/svg+xml', | |
| width: 64, | |
| height: 64, | |
| href: a[1] | |
| } | |
| }) | |
| }) | |
| } | |
| if (!isAdministrativeArea) { | |
| // TODO feature.wheelchair | |
| } else { | |
| if (hasTag('admin_level')) { | |
| const l = parseInt(tags.admin_level, 10) || 0; | |
| asRes.type.push(`osm:admin_level#${l}`); | |
| if (tags.admin_level === 2) { | |
| asRes.type.push('schema:Country'); | |
| } else if (tags.admin_level === 4) { | |
| asRes.type.push('redaktor:ADM1'); | |
| asRes.type.push('schema:State'); | |
| } else if (tags.admin_level === 5) { | |
| asRes.type.push('redaktor:ADM2'); | |
| } else if (tags.admin_level === 6) { | |
| asRes.type.push('redaktor:ADM3'); | |
| } | |
| } else if (hasTag('boundary') && tags.boundary === 'administrative') { | |
| asRes.type.push('schema:AdministrativeArea'); | |
| } | |
| } | |
| for (const cat in feature) { | |
| if (cat === 'wheelchair') { continue } | |
| // asRes.type.push(`osm:${cat}`); | |
| for (const a of feature[cat]) { | |
| if (a[0] === 'AdministrativeArea') { continue } | |
| if (a[0]) { asRes.type.push(`osm:${a[0]}`); } | |
| } | |
| } | |
| if (hasTag('short_name') && typeof tags.short_name === 'string') { | |
| asRes.tag = [{ | |
| type: ['Hashtag'], | |
| name: (tags.short_name.toString().toLowerCase() | |
| .replace(/\s+/g, '-').replace(/[^\w\-]+/g, '').replace(/\-\-+/g, '-').replace(/^-+/, '').replace(/-+$/, '')) | |
| }]; | |
| } | |
| if (hasTag('license_plate_code') && typeof tags.license_plate_code === 'string') { | |
| asRes.licencePlate = tags.license_plate_code; | |
| } | |
| const typesMap: {[p: string]: string[]} = { | |
| country: ['wd:Q6256','schema:Country'], state: ['wd:Q107390','schema:State', 'redaktor:ADM1'], region: ['wd:Q82794'], province: ['wd:Q34876'], | |
| district: ['wd:Q149621'], county: ['wd:Q28575'], municipality: ['wd:Q15284', 'schema:City', 'redaktor:ADM4'], | |
| city: ['wd:Q515','schema:City'], borough: ['wd:Q5195043', 'redaktor:ADM5'], suburb: ['wd:Q188509', 'redaktor:ADM5'], quarter: ['wd:Q2983893'], | |
| neighbourhood: ['wd:Q123705','schema:SchoolDistrict'], city_block: ['wd:Q1348006'], plot: ['wd:Q683595'], town: ['wd:Q3957'], | |
| village: ['wd:Q532'], hamlet: ['wd:Q5084'], isolated_dwelling: ['wd:Q699405'], farm: ['wd:Q131596'], allotments: ['wd:Q4404694'], | |
| continent: ['wd:Q5107'], archipelago: ['wd:Q33837'], island: ['wd:Q23442'], islet: ['wd:Q207524'], square: ['wd:Q174782'], | |
| locality: ['wd:Q3257686'], polder: ['wd:Q106259'], sea: ['wd:Q165'], ocean: ['wd:Q9430'] | |
| } | |
| if (hasTag('place') && typeof tags.place === 'string') { | |
| tags.place.split(',').forEach((p) => { | |
| if (typesMap[p]) { | |
| asRes.type = asRes.type.concat(typesMap[p]); | |
| } | |
| }); | |
| } else if (hasTag('border_type') && typeof tags.border_type === 'string') { | |
| tags.border_type.split(',').forEach((p) => { | |
| if (typesMap[p]) { | |
| asRes.type = asRes.type.concat(typesMap[p]); | |
| } | |
| }); | |
| } | |
| ['abandoned', 'destroyed', 'demolished', 'disused', 'removed'].forEach((h) => { | |
| if (hasTag(h)) { | |
| asRes.type.push('redaktor:Historic'); | |
| asRes.type.push(`osm:${h}`); | |
| } | |
| }); | |
| asRes.type = Array.from(new Set(asRes.type)); | |
| if (hasTag('note') && typeof tags.note === 'string') { | |
| asRes.content = tags.note; | |
| } | |
| if (hasTag('population')) { | |
| const p = typeof tags.population === 'number' | |
| ? tags.population | |
| : parseInt(tags.population, 10); | |
| if (p) { | |
| // TODO | |
| //"population": "71", "population:date": "1987-05-25" source:population | |
| asRes.population = p; | |
| } | |
| } | |
| for (const k in tags) { | |
| if (hasTag(k) && k.startsWith('ref:')) { | |
| asRes[`osm:${k}`] = tags[k]; | |
| } | |
| if (hasTag(k) && (k.startsWith('ISO3166') || k.startsWith('TMC'))) { | |
| asRes[k.replace('-','_')] = tags[k]; | |
| } | |
| if (!isAdministrativeForCountry || typeof isAdministrativeForCountry !== 'string' || k.indexOf(':') < 1) { continue } | |
| const [country, _k] = k.split(':'); | |
| if (isAdministrativeForCountry && country === isAdministrativeForCountry.toLowerCase()) { | |
| asRes[`osm:${k}`] = tags[k]; | |
| } | |
| } | |
| return asRes | |
| } | |
| function area(coordinates: number[][]){ | |
| let s = 0.0; | |
| const ring = coordinates.length && coordinates[0].length && typeof coordinates[0][0] === 'number' ? coordinates : coordinates[0]; | |
| if (!Array.isArray(ring) || !ring) { return s; } | |
| for(let i = 0; i < (ring.length-1); i++){ | |
| s += (ring[i][0] * ring[i+1][1] - ring[i+1][0] * ring[i][1]); | |
| } | |
| return 0.5 * s; | |
| } | |
| function centroid(coordinates: number[][]) { | |
| let c = [0,0]; | |
| const ring = coordinates.length && coordinates[0].length && typeof coordinates[0][0] === 'number' ? coordinates : coordinates[0]; | |
| for(let i = 0; i < (ring.length-1); i++){ | |
| c[0] += (ring[i][0] + ring[i+1][0]) * (ring[i][0]*ring[i+1][1] - ring[i+1][0]*ring[i][1]); | |
| c[1] += (ring[i][1] + ring[i+1][1]) * (ring[i][0]*ring[i+1][1] - ring[i+1][0]*ring[i][1]); | |
| } | |
| const a = area(coordinates); | |
| c[0] /= a * 6; | |
| c[1] /= a * 6; | |
| return c; | |
| } | |
| export function center(geojson) { | |
| let isMulti = false; | |
| let coordinates = [0,0]; | |
| if (geojson?.type === 'Feature' && geojson?.geometry) { | |
| if (geojson.geometry?.type === 'MultiPolygon') { isMulti = true; } | |
| coordinates = geojson.geometry?.coordinates; | |
| } else if (geojson?.coordinates) { | |
| coordinates = geojson.coordinates; | |
| } else if (Array.isArray(geojson) && geojson.length && Array.isArray(geojson[0])) { | |
| coordinates = geojson; | |
| } else { | |
| return coordinates; | |
| } | |
| const coord: any = coordinates; | |
| return !isMulti ? centroid(coord) : coord.map(centroid) | |
| .reduce((r: any, pair) => { | |
| r[0].push(pair[0]); | |
| r[1].push(pair[1]); | |
| return r | |
| }, [[], []]) | |
| .map((a) => a.reduce( ( p, c ) => p + c, 0 ) / a.length); | |
| } | |
| /* | |
| "tags": { | |
| // currency; currency:EUR=yes / currency:USD=no / currency:JPY=yes+currency:others=no | |
| // language; language:de=main / language:en=yes | |
| // postal code for ways; incomplete boundary=postal_code | |
| ... | |
| admin_title independent town | |
| admin_title:de Kreisfreie Stadt | |
| ... | |
| "geographical_region": "Berliner Urstromtal", | |
| "coat_of_arms": "File:Wappen der Hamburgischen Bürgerschaft.svg", | |
| "flag": "File:Flag of Hamburg.svg", | |
| "logo": "File:Hamburg-logo.svg", | |
| "source": "http://wiki.openstreetmap.org/wiki/Import/Catalogue/Kreisgrenzen_Deutschland_2005", | |
| "official_status": "Land", | |
| "official_status:ar": "ولاية", | |
| "official_status:de": "Land", | |
| "official_status:en": "State", | |
| "official_status:ru": "земля" | |
| } | |
| Key:tourism:level | |
| highway motorway aerodrome aerialway railway etc. | |
| */ | |
| /* | |
| "loc_name":"Elphi", | |
| "name":"Elbphilharmonie", | |
| "name:en":"Elbe Philharmonic Hall", | |
| "name:nl":"Elbphilharmonie", | |
| "old_name":"Kaispeicher A", */ | |
| /* | |
| "building":"public", | |
| "building:levels":"26", | |
| "amenity":"theatre", | |
| "theatre:genre":"philharmonic", | |
| "theatre:type":"concert_hall", | |
| "tourism":"attraction", | |
| "wheelchair":"limited", | |
| "toilets:wheelchair":"yes", | |
| "start_date":"2016-10-31", | |
| "wikidata":"Q673223", | |
| "wikimedia_commons":"Category:Elbphilharmonie", | |
| "wikipedia":"de:Elbphilharmonie" | |
| "architect":"Werner Kallmorgen;Herzog & de Meuron", | |
| "construction_year":"2003-2016", | |
| "image":"https://photos.app.goo.gl/rWdeHuVtpviFyEgh6", | |
| "operator":"Stadt Hamburg", | |
| */ | |
| /* { | |
| wheelchair: [ | |
| [ | |
| "wheelchair_yes", | |
| "/static/theme/svg/osm/Wheelchair_sign_yes.svg" | |
| ] | |
| ], | |
| amenities: [ [ "amenity_theatre", "/static/theme/svg/osm/Theatre-16.svg" ] ] | |
| } */ |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment