diff --git a/cache.example/chaostreff-info.json b/cache.example/chaostreff-info.json index 9783c01..eb4b700 100644 --- a/cache.example/chaostreff-info.json +++ b/cache.example/chaostreff-info.json @@ -1 +1 @@ -{"Regensburg": {"location": [12.11902809759291, 49.009833549999996], "web": "https://binary-kitchen.de", "name": "Binary Kitchen"}, "Erfurt": {"location": [11.039392, 50.9828132], "web": "https://bytespeicher.org/", "name": "Bytespeicher"}, "Amsterdam": {"location": [4.870297612903226, 52.36339370322581], "web": "https://chaos.amsterdam/", "name": "Chaos Amsterdam"}, "Luxemburg": {"location": [6.1226317, 49.6207341], "web": "https://c3l.lu/", "name": "Chaos Computer Club L\u00ebtzebuerg"}, "Kiel": {"location": [10.135555, 54.3227085], "web": "https://toppoint.de/", "name": "Chaostreff in Kiel"}, "Alzey": {"location": [8.328899881948397, 49.702704049999994], "web": "http://chaostreff-alzey.de/", "name": "Chaostreff Alzey"}, "Augsburg": {"location": [10.886817639154547, 48.35789355], "web": "http://c3a.de", "name": "Chaostreff Augsburg"}, "Backnang": {"location": [9.4295622, 48.9487308], "web": "https://hacknang.de/", "name": "Digitales Backnang"}, "Bayreuth": {"location": [11.574946989405685, 49.9477097], "web": "https://imaginaerraum.de/", "name": "Hackerspace Bayreuth"}, "Bern": {"location": [7.4484931, 46.9564373], "web": "https://www.chaostreffbern.ch", "name": "Chaostreff Bern"}, "Bielefeld": {"location": [8.533127303708476, 52.038277], "web": "https://hackerspace-bielefeld.de/", "name": "Chaos Computer Club Bielefeld"}, "Budapest": {"location": [19.059225604898185, 47.489209200000005], "web": "https://hsbp.org/", "name": "Chaostreff Budapest"}, "Chemnitz": {"location": [12.9298883, 50.8166968], "web": "https://chaoschemnitz.de/", "name": "Chaostreff Chemnitz"}, "Coburg": {"location": [10.9660664, 50.2633598], "web": "https://hackzogtum-coburg.de/", "name": "Hackzogtum Coburg"}, "Rapperswil-Jona": {"location": [8.83370045, 47.2252042], "web": "https://www.coredump.ch/", "name": "Chaostreff Coredump (Rapperswil)"}, "Cottbus": {"location": [14.3221859, 51.768973], "web": "http://chaos-cb.de", "name": "Chaostreff Cottbus"}, "Flensburg": {"location": [9.423505460496958, 54.80446775], "web": "https://chaostreff-flensburg.de/", "name": "Chaostreff Flensburg e.V."}, "Gie\u00dfen": {"location": [8.6790748, 50.5820278], "web": "https://giessen.ccc.de", "name": "Chaostreff Gie\u00dfen"}, "Graz": {"location": [15.450608284226139, 47.0655229], "web": "https://wp.realraum.at/", "name": "Chaostreff Graz"}, "Halle (Saale)": {"location": [11.992221563581825, 51.47991935], "web": "https://eigenbaukombinat.de/", "name": "Chaostreff Halle"}, "Heidelberg": {"location": [8.6660724, 49.4183048], "web": "https://www.noname-ev.de/", "name": "Chaostreff Heidelberg"}, "Hildesheim": {"location": [9.9467753, 52.1685013], "web": "https://freieslabor.org", "name": "Chaostreff Hildesheim"}, "Hilpoltstein": {"location": [11.19428251600182, 49.1892185], "web": "https://chaos-hip.de/", "name": "Chaostreff Hilpoltstein"}, "Ingolstadt": {"location": [11.381725, 48.7710702], "web": "https://www.bytewerk.org/", "name": "bytewerk"}, "Innsbruck": {"location": [11.3962816, 47.257827], "web": "https://it-syndikat.org/", "name": "Chaostreff Innsbruck"}, "Iserlohn": {"location": [7.6979603, 51.3743032], "web": "https://www.chaos-consulting.de/", "name": "Chaos Consulting"}, "Itzehoe": {"location": [9.50196591631791, 53.9379972], "web": "https://www.cciz.de/", "name": "Computerclub Itzehoe"}, "Jena": {"location": [11.5826767, 50.929203], "web": "https://kraut.space/", "name": "Chaostreff Jena"}, "Klaus": {"location": [9.6460406, 47.304616], "web": "https://open-lab.at/index.php/chaostreff", "name": "Chaostreff Klaus (Vorarlberg)"}, "Ludwigsburg": {"location": [9.184755297842258, 48.8958537], "web": "https://complb.de/", "name": "Chaostreff Ludwigsburg"}, "Marburg": {"location": [8.7783888, 50.8161331], "web": "https://hsmr.cc/", "name": "Hackspace Marburg"}, "M\u00fcnster": {"location": [7.6386827, 51.9446182], "web": "http://www.warpzone.ms/", "name": "Chaostref M\u00fcnster"}, "N\u00fcrnberg": {"location": [11.081815196597816, 49.44836835], "web": "https://chaostreff-nuernberg.de/", "name": "Chaostreff N\u00fcrnberg"}, "Osnabr\u00fcck": {"location": [8.047635, 52.2719595], "web": "https://chaostreff-osnabrueck.de/", "name": "Chaostreff Osnabr\u00fcck"}, "Potsdam": {"location": [13.078557, 52.3894652], "web": "http://www.ccc-p.org/", "name": "Chaostreff Potsdam"}, "Recklinghausen": {"location": [7.1691246, 51.62435], "web": "https://www.c3re.de/", "name": "Chaostreff Recklinghausen"}, "Rothenburg ob der Tauber": {"location": [10.1779991, 49.3783145], "web": "https://fablab-rothenburg.de/", "name": "FabLab Region Rothenburg ob der Tauber"}, "Rotterdam": {"location": [4.433639012034096, 51.9099513], "web": "https://www.pixelbar.nl/", "name": "Chaostreff Rotterdam"}, "Schwerin": {"location": [11.4182505, 53.6010948], "web": "https://hacklabor.de/", "name": "Chaostreff Schwerin"}, "Villingen-Schwenningen": {"location": [8.455688210119636, 48.06480785], "web": "https://vspace.one/", "name": "Chaostreff Villingen-Schwenningen"}, "Winterthur": {"location": [8.7291498, 47.4991723], "web": "http://chaostreff.dingens.org/", "name": "Chaostreff Winterthur"}, "Wuppertal": {"location": [7.144908700674055, 51.26671315], "web": "https://chaostal.de/", "name": "Chaostal (Chaostreff Wuppertal)"}, "L\u00fcbeck": {"location": [10.6713232, 53.8687751], "web": "https://chaotikum.org/", "name": "Chaotikum"}, "Bonn": {"location": [7.0987248, 50.7387823], "web": "https://datenburg.org/", "name": "Chaostreff Bonn"}, "Saarbr\u00fccken": {"location": [7.0357391, 49.279199], "web": "https://hacksaar.de/", "name": "Chaostreff Saarbr\u00fccken"}, "Aalen": {"location": [10.0931765, 48.8362705], "web": "https://aalen.space/", "name": "Chaostreff Aalen"}, "Trier": {"location": [6.6338265, 49.7529551], "web": "https://maschinendeck.org/", "name": "Maschinendeck"}, "Aschaffenburg": {"location": [9.1358843, 49.9878536], "web": "https://schaffenburg.org/", "name": "Schaffenburg"}, "Offenburg": {"location": [7.9458244, 48.4762239], "web": "https://section77.de/"}, "L\u00f6rrach": {"location": [7.6650755, 47.6155335], "web": "https://technik.cafe/", "name": "technik.cafe"}} \ No newline at end of file +[{"name": "Binary Kitchen", "city": "Regensburg", "web": "https://binary-kitchen.de", "display_name": "Binary Kitchen", "location": [12.11902809759291, 49.009833549999996]}, {"name": "Bytespeicher", "city": "Erfurt", "web": "https://bytespeicher.org/", "display_name": "Bytespeicher", "location": [11.039392, 50.9828132]}, {"name": "Chaos Amsterdam", "city": "Amsterdam", "web": "https://chaos.amsterdam/", "display_name": "Chaos Amsterdam", "location": [4.870297612903226, 52.36339370322581]}, {"name": "Chaos Computer Club L\u00ebtzebuerg", "city": "Luxemburg", "web": "https://c3l.lu/", "display_name": "Chaos Computer Club L\u00ebtzebuerg", "location": [6.1226317, 49.6207341]}, {"name": "ChaosKiel", "city": "Kiel", "web": "https://toppoint.de/", "display_name": "Chaostreff in Kiel", "location": [10.135555, 54.3227085]}, {"name": "Chaostreff Alzey", "city": "Alzey", "web": "http://chaostreff-alzey.de/", "display_name": "Chaostreff Alzey", "location": [8.328899881948397, 49.702704049999994]}, {"name": "Chaostreff Augsburg", "city": "Augsburg", "web": "http://c3a.de", "display_name": "Chaostreff Augsburg", "location": [10.886817639154547, 48.35789355]}, {"name": "Chaostreff Backnang", "city": "Backnang", "web": "https://hacknang.de/", "display_name": "Digitales Backnang", "location": [9.4295622, 48.9487308]}, {"name": "Chaostreff Bayreuth", "city": "Bayreuth", "web": "https://imaginaerraum.de/", "display_name": "Hackerspace Bayreuth", "location": [11.574946989405685, 49.9477097]}, {"name": "Chaostreff Bern", "city": "Bern", "web": "https://www.chaostreffbern.ch", "display_name": "Chaostreff Bern", "location": [7.4484931, 46.9564373]}, {"name": "Chaostreff Bielefeld", "city": "Bielefeld", "web": "https://hackerspace-bielefeld.de/", "display_name": "Chaos Computer Club Bielefeld", "location": [8.533127303708476, 52.038277]}, {"name": "Chaostreff Budapest", "city": "Budapest", "web": "https://hsbp.org/", "display_name": "Chaostreff Budapest", "location": [19.059225604898185, 47.489209200000005]}, {"name": "Chaostreff Chemnitz", "city": "Chemnitz", "web": "https://chaoschemnitz.de/", "display_name": "Chaostreff Chemnitz", "location": [12.9298883, 50.8166968]}, {"name": "Chaostreff Coburg", "city": "Coburg", "web": "https://hackzogtum-coburg.de/", "display_name": "Hackzogtum Coburg", "location": [10.9660664, 50.2633598]}, {"name": "Chaostreff Coredump (Rapperswil-Jona)", "city": "Rapperswil-Jona", "web": "https://www.coredump.ch/", "display_name": "Chaostreff Coredump (Rapperswil)", "location": [8.83370045, 47.2252042]}, {"name": "Chaostreff Cottbus", "city": "Cottbus", "web": "http://chaos-cb.de", "display_name": "Chaostreff Cottbus", "location": [14.3221859, 51.768973]}, {"name": "Chaostreff Flensburg", "city": "Flensburg", "web": "https://chaostreff-flensburg.de/", "display_name": "Chaostreff Flensburg e.V.", "location": [9.423505460496958, 54.80446775]}, {"name": "Chaostreff Gie\u00dfen", "city": "Gie\u00dfen", "web": "https://giessen.ccc.de", "display_name": "Chaostreff Gie\u00dfen", "location": [8.6790748, 50.5820278]}, {"name": "Chaostreff Graz", "city": "Graz", "web": "https://wp.realraum.at/", "display_name": "Chaostreff Graz", "location": [15.450608284226139, 47.0655229]}, {"name": "Chaostreff Halle (Saale)", "city": "Halle (Saale)", "web": "https://eigenbaukombinat.de/", "display_name": "Chaostreff Halle", "location": [11.992221563581825, 51.47991935]}, {"name": "Chaostreff Heidelberg", "city": "Heidelberg", "web": "https://www.noname-ev.de/", "display_name": "Chaostreff Heidelberg", "location": [8.6660724, 49.4183048]}, {"name": "Chaostreff Hildesheim", "city": "Hildesheim", "web": "https://freieslabor.org", "display_name": "Chaostreff Hildesheim", "location": [9.9467753, 52.1685013]}, {"name": "Chaostreff Hilpoltstein", "city": "Hilpoltstein", "web": "https://chaos-hip.de/", "display_name": "Chaostreff Hilpoltstein", "location": [11.19428251600182, 49.1892185]}, {"name": "Chaostreff Ingolstadt", "city": "Ingolstadt", "web": "https://www.bytewerk.org/", "display_name": "bytewerk", "location": [11.381725, 48.7710702]}, {"name": "Chaostreff Innsbruck", "city": "Innsbruck", "web": "https://it-syndikat.org/", "display_name": "Chaostreff Innsbruck", "location": [11.3962816, 47.257827]}, {"name": "Chaostreff Iserlohn", "city": "Iserlohn", "web": "https://www.chaos-consulting.de/", "display_name": "Chaos Consulting", "location": [7.6979603, 51.3743032]}, {"name": "Chaostreff Itzehoe", "city": "Itzehoe", "web": "https://www.cciz.de/", "display_name": "Computerclub Itzehoe", "location": [9.50196591631791, 53.9379972]}, {"name": "Chaostreff Jena", "city": "Jena", "web": "https://kraut.space/", "display_name": "Chaostreff Jena", "location": [11.5826767, 50.929203]}, {"name": "Chaostreff Klaus (Vorarlberg)", "city": "Klaus", "web": "https://open-lab.at/index.php/chaostreff", "display_name": "Chaostreff Klaus (Vorarlberg)", "location": [9.6460406, 47.304616]}, {"name": "Chaostreff Ludwigsburg", "city": "Ludwigsburg", "web": "https://complb.de/", "display_name": "Chaostreff Ludwigsburg", "location": [9.184755297842258, 48.8958537]}, {"name": "Chaostreff Marburg", "city": "Marburg", "web": "https://hsmr.cc/", "display_name": "Hackspace Marburg", "location": [8.7783888, 50.8161331]}, {"name": "Chaostreff M\u00fcnster", "city": "M\u00fcnster", "web": "http://www.warpzone.ms/", "display_name": "Chaostreff M\u00fcnster", "location": [7.6386827, 51.9446182]}, {"name": "Chaostreff N\u00fcrnberg", "city": "N\u00fcrnberg", "web": "https://chaostreff-nuernberg.de/", "display_name": "Chaostreff N\u00fcrnberg", "location": [11.081815196597816, 49.44836835]}, {"name": "Chaostreff Osnabr\u00fcck", "city": "Osnabr\u00fcck", "web": "https://chaostreff-osnabrueck.de/", "display_name": "Chaostreff Osnabr\u00fcck", "location": [8.047635, 52.2719595]}, {"name": "Chaostreff Potsdam", "city": "Potsdam", "web": "http://www.ccc-p.org/", "display_name": "Chaostreff Potsdam", "location": [13.078557, 52.3894652]}, {"name": "Chaostreff Recklinghausen", "city": "Recklinghausen", "web": "https://www.c3re.de/", "display_name": "Chaostreff Recklinghausen", "location": [7.1691246, 51.62435]}, {"name": "Chaostreff Rothenburg ob der Tauber", "city": "Rothenburg ob der Tauber", "web": "https://fablab-rothenburg.de/", "display_name": "FabLab Region Rothenburg ob der Tauber", "location": [10.1779991, 49.3783145]}, {"name": "Chaostreff Rotterdam", "city": "Rotterdam", "web": "https://www.pixelbar.nl/", "display_name": "Chaostreff Rotterdam", "location": [4.433639012034096, 51.9099513]}, {"name": "Chaostreff Schwerin", "city": "Schwerin", "web": "https://hacklabor.de/", "display_name": "Chaostreff Schwerin", "location": [11.4182505, 53.6010948]}, {"name": "Chaostreff Villingen-Schwenningen", "city": "Villingen-Schwenningen", "web": "https://vspace.one/", "display_name": "Chaostreff Villingen-Schwenningen", "location": [8.455688210119636, 48.06480785]}, {"name": "Chaostreff Winterthur", "city": "Winterthur", "web": "http://chaostreff.dingens.org/", "display_name": "Chaostreff Winterthur", "location": [8.7291498, 47.4991723]}, {"name": "Chaostreff Wuppertal", "city": "Wuppertal", "web": "https://chaostal.de/", "display_name": "Chaostal (Chaostreff Wuppertal)", "location": [7.144908700674055, 51.26671315]}, {"name": "Chaotikum", "city": "L\u00fcbeck", "web": "https://chaotikum.org/", "display_name": "Chaotikum", "location": [10.6713232, 53.8687751]}, {"name": "Datenburg", "city": "Bonn", "web": "https://datenburg.org/", "display_name": "Chaostreff Bonn", "location": [7.0987248, 50.7387823]}, {"name": "Hacksaar", "city": "Saarbr\u00fccken", "web": "https://hacksaar.de/", "display_name": "Chaostreff Saarbr\u00fccken", "location": [7.0357391, 49.279199]}, {"name": "Hackwerk Aalen", "city": "Aalen", "web": "https://aalen.space/", "display_name": "Chaostreff Aalen", "location": [10.0931765, 48.8362705]}, {"name": "Maschinendeck", "city": "Trier", "web": "https://maschinendeck.org/", "display_name": "Maschinendeck", "location": [6.6338265, 49.7529551]}, {"name": "Schaffenburg", "city": "Aschaffenburg", "web": "https://schaffenburg.org/", "display_name": "Schaffenburg", "location": [9.1358843, 49.9878536]}, {"name": "Section77", "city": "Offenburg", "web": "https://section77.de/", "display_name": "Section77", "location": [7.9458244, 48.4762239]}, {"name": "Technik.cafe", "city": "L\u00f6rrach", "web": "https://technik.cafe/", "display_name": "technik.cafe", "location": [7.6650755, 47.6155335]}, {"name": "UN-Hack-Bar", "city": "Unna", "web": "https://www.un-hack-bar.de/", "display_name": "UN-Hack-Bar", "location": [7.6917073, 51.5357811]}, {"name": "Westwoodlabs", "city": "Ransbach-Baumbach", "web": "https://westwoodlabs.de/", "display_name": "Chaostreff Westerwald", "location": [7.7252417, 50.4629465]}, {"name": "Dezentrale", "city": "Leipzig", "web": "https://dezentrale.space", "display_name": "dezentrale e.V.", "location": [12.3383176, 51.3365064]}, {"name": "FNordeingang", "city": "Neuss", "web": "https://fnordeingang.de/", "display_name": "fNordeingang", "location": [6.692534622093034, 51.186381499999996]}, {"name": "Gehacktes", "city": "Hoher Fl\u00e4ming", "web": "https://ge.hackt.es/", "display_name": "gehacktes - Chaostreff Hoher Fl\u00e4ming", "location": [12.40713030377876, 52.11842805]}, {"name": "HacKNology", "city": "Konstanz", "web": "https://www.hacknology.de/", "display_name": "hacKNology.de", "location": [9.1659379, 47.6594199]}, {"name": "Haxko", "city": "Andernach", "web": "https://haxko.space", "display_name": "Hacker- & Makerspace Koblenz", "location": [7.4044718, 50.4345198]}, {"name": "Z-Labor", "city": "Zwickau", "web": "http://z-labor.space/", "display_name": "Chaostreff z-Labor", "location": [12.480294009950217, 50.722194]}] \ No newline at end of file diff --git a/cache.example/erfa-info.json b/cache.example/erfa-info.json index b0c44a0..cd20dc5 100644 --- a/cache.example/erfa-info.json +++ b/cache.example/erfa-info.json @@ -1 +1 @@ -{"Erlangen": {"location": [11.0028028, 49.6000372], "web": "https://erlangen.ccc.de/", "name": "Bits'n'Bugs"}, "Paderborn": {"location": [8.7479251, 51.7171873], "web": "https://c3pb.de/", "name": "Chaos Computer Club Paderborn"}, "Hamburg": {"location": [9.9443486, 53.5583644], "web": "https://hamburg.ccc.de/", "name": "Chaos Computer Club Hamburg"}, "Aachen": {"location": [6.091774700480718, 50.7715109], "web": "https://aachen.ccc.de/", "name": "Chaos Computer Club Aachen"}, "Basel": {"location": [7.6342977, 47.5323068], "web": "https://www.ccc-basel.ch/", "name": "Chaos Computer Club Basel"}, "Berlin": {"location": [13.38283, 52.5217046], "web": "https://berlin.ccc.de/", "name": "Chaos Computer Club Berlin"}, "Bremen": {"location": [8.7879452, 53.0864586], "web": "https://ccchb.de/", "name": "Chaos Computer Club Bremen"}, "K\u00f6ln": {"location": [6.912896, 50.9505492], "web": "https://koeln.ccc.de/", "name": "Chaos Computer Club Cologne"}, "Darmstadt": {"location": [8.6511275, 49.8708409], "web": "https://www.chaos-darmstadt.de/", "name": "Chaos Computer Club Darmstadt"}, "Dresden": {"location": [13.7288095, 51.0811269], "web": "https://c3d2.de/", "name": "Chaos Computer Club Dresden"}, "D\u00fcsseldorf": {"location": [6.7996122, 51.2125738], "web": "https://chaosdorf.de/", "name": "Chaos Computer Club D\u00fcsseldorf"}, "Frankfurt am Main": {"location": [8.6362035, 50.1241797], "web": "https://ccc-ffm.de/", "name": "Chaos Computer Club Frankfurt"}, "Freiburg": {"location": [7.8405746483681735, 47.99297755], "web": "https://cccfr.de/", "name": "Chaos Computer Club Freiburg"}, "G\u00f6ttingen": {"location": [9.9446185, 51.5453725], "web": "https://cccgoe.de/", "name": "Chaos Computer Club G\u00f6ttingen"}, "Hannover": {"location": [9.717936908887404, 52.38812135], "web": "https://hannover.ccc.de/", "name": "Chaos Computer Club Hannover"}, "Karlsruhe": {"location": [8.4073787, 49.0066019], "web": "https://entropia.de/", "name": "Chaos Computer Club Karlsruhe"}, "Mannheim": {"location": [8.4886818, 49.4636517], "web": "https://www.ccc-mannheim.de/", "name": "Chaos Computer Club Mannheim"}, "M\u00fcnchen": {"location": [11.5607949, 48.153629], "web": "https://www.muc.ccc.de/", "name": "Chaos Computer Club M\u00fcnchen"}, "Salzburg": {"location": [13.0561199, 47.7939873], "web": "https://cccsbg.at", "name": "Chaos Computer Club Salzburg"}, "Stuttgart": {"location": [9.1800132, 48.7784485], "web": "https://cccs.de/", "name": "Chaos Computer Club Stuttgart"}, "Ulm": {"location": [9.9910884, 48.4005863], "web": "https://ulm.ccc.de/", "name": "Chaos Computer Club Ulm"}, "Wien": {"location": [16.35611792351422, 48.209451099999995], "web": "https://c3w.at/", "name": "Chaos Computer Club Wien"}, "Wiesbaden": {"location": [8.2295012, 50.0831784], "web": "https://cccwi.de/", "name": "Chaos Computer Club Wiesbaden"}, "Z\u00fcrich": {"location": [8.5203159, 47.3869751], "web": "https://www.ccczh.ch/", "name": "Chaos Computer Club Z\u00fcrich"}, "Siegen": {"location": [8.0044503, 50.8689203], "web": "https://chaos-siegen.de/", "name": "Chaos Computer Club Siegen"}, "Kaiserslautern": {"location": [7.762277299724986, 49.44049735], "web": "http://www.chaos-inkl.de", "name": "Chaos inKL."}, "Essen": {"location": [7.024639594585695, 51.43860565], "web": "https://chaospott.de/"}, "Dortmund": {"location": [7.464966783180763, 51.52768425], "web": "https://www.chaostreff-dortmund.de/", "name": "Chaostreff Dortmund"}, "L\u00fcbeck": {"location": [10.6713232, 53.8687751], "web": "https://chaotikum.org/", "name": "Chaotikum"}, "Fulda": {"location": [9.6775152, 50.5588931], "web": "https://maglab.space/", "name": "Magrathea Laboratoratories"}, "W\u00fcrzburg": {"location": [9.923678752181619, 49.80223915], "web": "https://nerd2nerd.org/", "name": "Nerd2Nerd"}, "Bamberg": {"location": [10.89268892402827, 49.90189135], "web": "https://www.hackerspace-bamberg.de/", "name": "backspace"}, "Kassel": {"location": [9.484939, 51.3183203], "web": "https://flipdot.org/", "name": "flipdot"}} \ No newline at end of file +[{"name": "Bits'n'Bugs", "city": "Erlangen", "web": "https://erlangen.ccc.de/", "display_name": "Bits'n'Bugs", "location": [11.0028028, 49.6000372]}, {"name": "C3PB", "city": "Paderborn", "web": "https://c3pb.de/", "display_name": "Chaos Computer Club Paderborn", "location": [8.7479251, 51.7171873]}, {"name": "CCC Hansestadt Hamburg", "city": "Hamburg", "web": "https://hamburg.ccc.de/", "display_name": "Chaos Computer Club Hamburg", "location": [9.9443486, 53.5583644]}, {"name": "Chaos Computer Club Aachen", "city": "Aachen", "web": "https://aachen.ccc.de/", "display_name": "Chaos Computer Club Aachen", "location": [6.091774700480718, 50.7715109]}, {"name": "Chaos Computer Club Basel", "city": "Basel", "web": "https://www.ccc-basel.ch/", "display_name": "Chaos Computer Club Basel", "location": [7.6342977, 47.5323068]}, {"name": "Chaos Computer Club Berlin", "city": "Berlin", "web": "https://berlin.ccc.de/", "display_name": "Chaos Computer Club Berlin", "location": [13.38283, 52.5217046]}, {"name": "Chaos Computer Club Bremen", "city": "Bremen", "web": "https://ccchb.de/", "display_name": "Chaos Computer Club Bremen", "location": [8.7879452, 53.0864586]}, {"name": "Chaos Computer Club Cologne", "city": "K\u00f6ln", "web": "https://koeln.ccc.de/", "display_name": "Chaos Computer Club Cologne", "location": [6.912896, 50.9505492]}, {"name": "Chaos Computer Club Darmstadt", "city": "Darmstadt", "web": "https://www.chaos-darmstadt.de/", "display_name": "Chaos Computer Club Darmstadt", "location": [8.6511275, 49.8708409]}, {"name": "Chaos Computer Club Dresden", "city": "Dresden", "web": "https://c3d2.de/", "display_name": "Chaos Computer Club Dresden", "location": [13.7288095, 51.0811269]}, {"name": "Chaos Computer Club D\u00fcsseldorf", "city": "D\u00fcsseldorf", "web": "https://chaosdorf.de/", "display_name": "Chaos Computer Club D\u00fcsseldorf", "location": [6.7996122, 51.2125738]}, {"name": "Chaos Computer Club Frankfurt", "city": "Frankfurt am Main", "web": "https://ccc-ffm.de/", "display_name": "Chaos Computer Club Frankfurt", "location": [8.6362035, 50.1241797]}, {"name": "Chaos Computer Club Freiburg", "city": "Freiburg", "web": "https://cccfr.de/", "display_name": "Chaos Computer Club Freiburg", "location": [7.8405746483681735, 47.99297755]}, {"name": "Chaos Computer Club G\u00f6ttingen", "city": "G\u00f6ttingen", "web": "https://cccgoe.de/", "display_name": "Chaos Computer Club G\u00f6ttingen", "location": [9.9446185, 51.5453725]}, {"name": "Chaos Computer Club Hannover", "city": "Hannover", "web": "https://hannover.ccc.de/", "display_name": "Chaos Computer Club Hannover", "location": [9.717936908887404, 52.38812135]}, {"name": "Chaos Computer Club Karlsruhe", "city": "Karlsruhe", "web": "https://entropia.de/", "display_name": "Chaos Computer Club Karlsruhe", "location": [8.4073787, 49.0066019]}, {"name": "Chaos Computer Club Mannheim", "city": "Mannheim", "web": "https://www.ccc-mannheim.de/", "display_name": "Chaos Computer Club Mannheim", "location": [8.4886818, 49.4636517]}, {"name": "Chaos Computer Club M\u00fcnchen", "city": "M\u00fcnchen", "web": "https://www.muc.ccc.de/", "display_name": "Chaos Computer Club M\u00fcnchen", "location": [11.5607949, 48.153629]}, {"name": "Chaos Computer Club Salzburg", "city": "Salzburg", "web": "https://cccsbg.at", "display_name": "Chaos Computer Club Salzburg", "location": [13.0561199, 47.7939873]}, {"name": "Chaos Computer Club Stuttgart", "city": "Stuttgart", "web": "https://cccs.de/", "display_name": "Chaos Computer Club Stuttgart", "location": [9.1800132, 48.7784485]}, {"name": "Chaos Computer Club Ulm", "city": "Ulm", "web": "https://ulm.ccc.de/", "display_name": "Chaos Computer Club Ulm", "location": [9.9910884, 48.4005863]}, {"name": "Chaos Computer Club Wien", "city": "Wien", "web": "https://c3w.at/", "display_name": "Chaos Computer Club Wien", "location": [16.35611792351422, 48.209451099999995]}, {"name": "Chaos Computer Club Wiesbaden", "city": "Wiesbaden", "web": "https://cccwi.de/", "display_name": "Chaos Computer Club Wiesbaden", "location": [8.2295012, 50.0831784]}, {"name": "Chaos Computer Club Z\u00fcrich", "city": "Z\u00fcrich", "web": "https://www.ccczh.ch/", "display_name": "Chaos Computer Club Z\u00fcrich", "location": [8.5203159, 47.3869751]}, {"name": "Chaos Siegen", "city": "Siegen", "web": "https://chaos-siegen.de/", "display_name": "Chaos Computer Club Siegen", "location": [8.0044503, 50.8689203]}, {"name": "Chaos inKL.", "city": "Kaiserslautern", "web": "http://www.chaos-inkl.de", "display_name": "Chaos inKL.", "location": [7.762277299724986, 49.44049735]}, {"name": "Chaospott", "city": "Essen", "web": "https://chaospott.de/", "display_name": "Chaospott", "location": [7.024639594585695, 51.43860565]}, {"name": "Chaostreff Dortmund", "city": "Dortmund", "web": "https://www.chaostreff-dortmund.de/", "display_name": "Chaostreff Dortmund", "location": [7.464966783180763, 51.52768425]}, {"name": "Chaotikum", "city": "L\u00fcbeck", "web": "https://chaotikum.org/", "display_name": "Chaotikum", "location": [10.6713232, 53.8687751]}, {"name": "Magrathea Laboratories e.V.", "city": "Fulda", "web": "https://maglab.space/", "display_name": "Magrathea Laboratoratories", "location": [9.6775152, 50.5588931]}, {"name": "Nerd2nerd", "city": "W\u00fcrzburg", "web": "https://nerd2nerd.org/", "display_name": "Nerd2Nerd", "location": [9.923678752181619, 49.80223915]}, {"name": "Backspace", "city": "Bamberg", "web": "https://www.hackerspace-bamberg.de/", "display_name": "backspace", "location": [10.89268892402827, 49.90189135]}, {"name": "Flipdot", "city": "Kassel", "web": "https://flipdot.org/", "display_name": "flipdot", "location": [9.484939, 51.3183203]}] \ No newline at end of file diff --git a/generate_map.py b/generate_map.py index c640918..1d6018f 100755 --- a/generate_map.py +++ b/generate_map.py @@ -2,6 +2,7 @@ # # https://git.kabelsalat.ch/s3lph/erfamap +import abc import os import urllib.request import urllib.parse @@ -54,74 +55,40 @@ class OutputPaths: return os.path.relpath(path, start=self.path) -ERFA_URL = 'https://doku.ccc.de/index.php?title=Spezial:Semantische_Suche&x=-5B-5BKategorie%3AErfa-2DKreise-5D-5D-20-5B-5BChaostreff-2DActive%3A%3Awahr-5D-5D%2F-3FChaostreff-2DCity%2F-3FChaostreff-2DPhysical-2DAddress%2F-3FChaostreff-2DPhysical-2DHousenumber%2F-3FChaostreff-2DPhysical-2DPostcode%2F-3FChaostreff-2DPhysical-2DCity%2F-3FChaostreff-2DCountry%2F-3FPublic-2DWeb%2F-3FChaostreff-2DLongname%2F-3FChaostreff-2DNickname%2F-3FChaostreff-2DRealname&format=json&limit=200&link=all&headers=show&searchlabel=JSON&class=sortable+wikitable+smwtable&sort=&order=asc&offset=0&mainlabel=&prettyprint=true&unescape=true' +class CoordinateTransform: -CHAOSTREFF_URL = 'https://doku.ccc.de/index.php?title=Spezial:Semantische_Suche&x=-5B-5BKategorie%3AChaostreffs-5D-5D-20-5B-5BChaostreff-2DActive%3A%3Awahr-5D-5D%2F-3FChaostreff-2DCity%2F-3FChaostreff-2DPhysical-2DAddress%2F-3FChaostreff-2DPhysical-2DHousenumber%2F-3FChaostreff-2DPhysical-2DPostcode%2F-3FChaostreff-2DPhysical-2DCity%2F-3FChaostreff-2DCountry%2F-3FPublic-2DWeb%2F-3FChaostreff-2DLongname%2F-3FChaostreff-2DNickname%2F-3FChaostreff-2DRealname&format=json&limit=200&link=all&headers=show&searchlabel=JSON&class=sortable+wikitable+smwtable&sort=&order=asc&offset=0&mainlabel=&prettyprint=true&unescape=true' + def __init__(self, projection: str): + self._proj = pyproj.Transformer.from_crs('epsg:4326', projection) + self.setup() + + def setup(self, scalex=1.0, scaley=1.0, bbox=[(-180, -90), (180, 90)]): + self._scalex = scalex + self._scaley = scaley + west = min(bbox[0][0], bbox[1][0]) + north = max(bbox[0][1], bbox[1][1]) + east = max(bbox[0][0], bbox[1][0]) + south = min(bbox[0][1], bbox[1][1]) + self._ox, self._oy = self._proj.transform(west, north) + return self(east, south) + + def __call__(self, lon, lat): + xt, yt = self._proj.transform(lon, lat) + return ( + (xt - self._ox) * self._scalex, + (self._oy - yt) * self._scaley + ) -def sparql_query(query): - headers = { - 'Content-Type': 'application/x-www-form-urlencoded', - 'User-Agent': USER_AGENT, - 'Accept': 'application/sparql-results+json', - } - body = urllib.parse.urlencode({'query': query}).encode() - req = urllib.request.Request('https://query.wikidata.org/sparql', headers=headers, data=body) - with urllib.request.urlopen(req) as resp: - resultset = json.load(resp) - results = {} - for r in resultset.get('results', {}).get('bindings'): - basename = None - url = None - for k, v in r.items(): - if k == 'item': - basename = os.path.basename(v['value']) - elif k == 'map': - url = v['value'] - results[basename] = url - return results +class Drawable(abc.ABC): + + def render(self, svg, texts): + pass -def fetch_geoshapes(target, shape_urls): - os.makedirs(target, exist_ok=True) - candidates = {} - keep = {} - for item, url in tqdm.tqdm(shape_urls.items()): - try: - with urllib.request.urlopen(url) as resp: - shape = json.load(resp) - if not shape.get('license', 'proprietary').startswith('CC0-'): - # Only include public domain data - continue - candidates.setdefault(item, []).append(shape) - except urllib.error.HTTPError as e: - print(e) - for item, ican in candidates.items(): - # Prefer zoom level 4 - keep[item] = min(ican, key=lambda x: abs(4-x.get('zoom', 1000))) - for item, shape in keep.items(): - with open(os.path.join(target, item + '.json'), 'w') as f: - json.dump(shape, f) +class Country(Drawable): - -def fetch_wikidata_states(target): - shape_urls = sparql_query(''' - -PREFIX wd: -PREFIX wdt: - -SELECT DISTINCT ?item ?map WHERE { - # ?item is instance of federal state of germany and has geoshape ?map - ?item wdt:P31 wd:Q1221156; - wdt:P3896 ?map -} -''') - print('Retrieving state border shapes') - fetch_geoshapes(target, shape_urls) - - -def fetch_wikidata_countries(target): - shape_urls = sparql_query(''' + SVG_CLASS = 'country' + SPARQL_QUERY = ''' PREFIX wd: PREFIX wdt: @@ -142,134 +109,313 @@ SELECT DISTINCT ?item ?map WHERE { FILTER (?stateclass = wd:Q6256 || ?stateclass = wd:Q3624078). FILTER (?euroclass = wd:Q46 || ?euroclass = wd:Q8932). } -''') - print('Retrieving country border shapes') - fetch_geoshapes(target, shape_urls) +''' + def __init__(self, ns, name, polygons): + self.name = name + self.polygons = [[ns.projection(lon, lat) for lon, lat in p] for p in polygons] -def filter_boundingbox(source, target, bbox): - files = os.listdir(source) - os.makedirs(target, exist_ok=True) - print('Filtering countries outside the bounding box') - for f in tqdm.tqdm(files): - if not f.endswith('.json') or 'Q183.json' in f: - continue - path = os.path.join(source, f) - with open(path, 'r') as sf: - shapedata = sf.read() - shape = json.loads(shapedata) - keep = False - geo = shape['data']['features'][0]['geometry'] - if geo['type'] == 'Polygon': - geo['coordinates'] = [geo['coordinates']] - for poly in geo['coordinates']: - for point in poly[0]: - if point[0] >= bbox[0][0] and point[1] >= bbox[0][1] \ - and point[0] <= bbox[1][0] and point[1] <= bbox[1][1]: - keep = True + def __len__(self): + return sum([len(p) for p in self.polygons]) + + def render(self, svg, texts): + for polygon in self.polygons: + points = ' '.join([f'{x},{y}' for x, y in polygon]) + poly = etree.Element('polygon', points=points) + poly.set('class', self.__class__.SVG_CLASS) + poly.set('data-country', self.name) + svg.append(poly) + + @classmethod + def sparql_query(cls): + headers = { + 'Content-Type': 'application/x-www-form-urlencoded', + 'User-Agent': USER_AGENT, + 'Accept': 'application/sparql-results+json', + } + body = urllib.parse.urlencode({'query': cls.SPARQL_QUERY}).encode() + req = urllib.request.Request('https://query.wikidata.org/sparql', headers=headers, data=body) + with urllib.request.urlopen(req) as resp: + resultset = json.load(resp) + results = {} + for r in resultset.get('results', {}).get('bindings'): + basename = None + url = None + for k, v in r.items(): + if k == 'item': + basename = os.path.basename(v['value']) + elif k == 'map': + url = v['value'] + results[basename] = url + return results + + @classmethod + def fetch(cls, target): + os.makedirs(target, exist_ok=True) + shape_urls = cls.sparql_query() + candidates = {} + keep = {} + for item, url in tqdm.tqdm(shape_urls.items()): + try: + with urllib.request.urlopen(url) as resp: + shape = json.load(resp) + if not shape.get('license', 'proprietary').startswith('CC0-'): + # Only include public domain data + continue + candidates.setdefault(item, []).append(shape) + except urllib.error.HTTPError as e: + print(e) + for item, ican in candidates.items(): + # Prefer zoom level 4 + keep[item] = min(ican, key=lambda x: abs(4-x.get('zoom', 1000))) + for item, shape in keep.items(): + with open(os.path.join(target, item + '.json'), 'w') as f: + json.dump(shape, f) + + @classmethod + def from_cache(cls, ns, source): + countries = [] + files = os.listdir(source) + for f in files: + if not f.endswith('.json'): + continue + path = os.path.join(source, f) + with open(path, 'r') as sf: + shapedata = sf.read() + shape = json.loads(shapedata) + name = shape['description']['en'] + geo = shape['data']['features'][0]['geometry'] + if geo['type'] == 'Polygon': + geo['coordinates'] = [geo['coordinates']] + polygons = [] + for poly in geo['coordinates']: + polygons.append(poly[0]) + countries.append(cls(ns, name, polygons)) + return countries + + @classmethod + def filter_boundingbox(cls, ns, source, target, bbox): + files = os.listdir(source) + os.makedirs(target, exist_ok=True) + for f in tqdm.tqdm(files): + if not f.endswith('.json') or 'Q183.json' in f: + continue + path = os.path.join(source, f) + with open(path, 'r') as sf: + shapedata = sf.read() + shape = json.loads(shapedata) + keep = False + geo = shape['data']['features'][0]['geometry'] + if geo['type'] == 'Polygon': + geo['coordinates'] = [geo['coordinates']] + for poly in geo['coordinates']: + for point in poly[0]: + if point[0] >= bbox[0][0] and point[1] >= bbox[0][1] \ + and point[0] <= bbox[1][0] and point[1] <= bbox[1][1]: + keep = True + break + if keep: break if keep: - break - if keep: - with open(os.path.join(target, f), 'w') as sf: - sf.write(shapedata) + with open(os.path.join(target, f), 'w') as sf: + sf.write(shapedata) -def address_lookup(name, erfa): - locator = Nominatim(user_agent=USER_AGENT) - number = erfa['Chaostreff-Physical-Housenumber'] - street = erfa['Chaostreff-Physical-Address'] - zipcode = erfa['Chaostreff-Physical-Postcode'] - acity = erfa['Chaostreff-Physical-City'] - city = erfa['Chaostreff-City'][0] - country = erfa['Chaostreff-Country'][0] - # Try the most accurate address first, try increasingly inaccurate addresses on failure. - formats = [ - # Muttenz, Schweiz - f'{city}, {country}' - ] - if zipcode and acity: - # 4132 Muttenz, Schweiz - formats.insert(0, f'{zipcode[0]} {acity[0]}, {country}') - if zipcode and acity and number and street: - # Birsfelderstrasse 6, 4132 Muttenz, Schweiz - formats.insert(0, f'{street[0]} {number[0]}, {zipcode[0]} {acity[0]}, {country}') +class FederalState(Country): - for fmt in formats: - response = locator.geocode(fmt) - if response is not None: - return response.longitude, response.latitude + SVG_CLASS = 'state' + SPARQL_QUERY = ''' - print(f'No location found for {name}, tried the following address formats:') - for fmt in formats: - print(f' {fmt}') - return None +PREFIX wd: +PREFIX wdt: + +SELECT DISTINCT ?item ?map WHERE { + # ?item is instance of federal state of germany and has geoshape ?map + ?item wdt:P31 wd:Q1221156; + wdt:P3896 ?map +} +''' -def fetch_erfas(target, url): - userpw = os.getenv('DOKU_CCC_DE_BASICAUTH') - if userpw is None: - print('Please set environment variable DOKU_CCC_DE_BASICAUTH=username:password') - exit(1) - auth = base64.b64encode(userpw.encode()).decode() - erfas = {} - req = urllib.request.Request(url, headers={'Authorization': f'Basic {auth}'}) - with urllib.request.urlopen(req) as resp: - erfadata = json.loads(resp.read().decode()) - print('Looking up addresses') - for name, erfa in tqdm.tqdm(erfadata['results'].items()): - location = address_lookup(name, erfa['printouts']) - if location is None: - print(f'WARNING: No location for {name}') - city = erfa['printouts']['Chaostreff-City'][0] - erfas[city] = {'location': location} - if len(erfa['printouts']['Public-Web']) > 0: - erfas[city]['web'] = erfa['printouts']['Public-Web'][0] - if len(erfa['printouts']['Chaostreff-Longname']) > 0: - erfas[city]['name'] = erfa['printouts']['Chaostreff-Longname'][0] - elif len(erfa['printouts']['Chaostreff-Nickname']) > 0: - erfas[city]['name'] = erfa['printouts']['Chaostreff-Nickname'][0] - elif len(erfa['printouts']['Chaostreff-Realname']) > 0: - erfas[city]['name'] = erfa['printouts']['Chaostreff-Realname'][0] +class Erfa(Drawable): + + SMW_URL = 'https://doku.ccc.de/index.php?title=Spezial:Semantische_Suche&x=-5B-5BKategorie%3A{category}-5D-5D-20-5B-5BChaostreff-2DActive%3A%3Awahr-5D-5D%2F-3FChaostreff-2DCity%2F-3FChaostreff-2DPhysical-2DAddress%2F-3FChaostreff-2DPhysical-2DHousenumber%2F-3FChaostreff-2DPhysical-2DPostcode%2F-3FChaostreff-2DPhysical-2DCity%2F-3FChaostreff-2DCountry%2F-3FPublic-2DWeb%2F-3FChaostreff-2DLongname%2F-3FChaostreff-2DNickname%2F-3FChaostreff-2DRealname&format=json&limit={limit}&link=all&headers=show&searchlabel=JSON&class=sortable+wikitable+smwtable&sort=&order=asc&offset={offset}&mainlabel=&prettyprint=true&unescape=true' + + SMW_REQUEST_LIMIT = 50 + + SMW_CATEGORY = 'Erfa-2DKreise' + + SVG_CLASS = 'erfa' + SVG_DATA = 'data-erfa' + SVG_LABEL = True + SVG_LABELCLASS = 'erfalabel' + + def __init__(self, ns, name, city, lon, lat, display_name=None, web=None, radius=15): + self.name = name + self.city = city + self.lon = lon + self.lat = lat + self.x, self.y = ns.projection(lon, lat) + self.display_name = display_name if display_name is not None else name + self.web = web + self.radius = radius + + def __eq__(self, o): + if not isinstance(o, Erfa): + return False + return self.name == o.name + + def render(self, svg, texts): + cls = self.__class__ + if self.web is not None: + group = etree.Element('a', href=self.web, target='_blank') else: - erfas[city]['name'] = name - - - with open(target, 'w') as f: - json.dump(erfas, f) + group = etree.Element('g') + group.set(cls.SVG_DATA, self.city) + circle = etree.Element('circle', cx=str(self.x), cy=str(self.y), r=str(self.radius)) + circle.set('class', cls.SVG_CLASS) + circle.set(cls.SVG_DATA, self.city) + title = etree.Element('title') + title.text = self.display_name + circle.append(title) + group.append(circle) + if self.city in texts: + box = texts[self.city] + text = etree.Element('text', x=str(box.left), y=str(box.top + box.meta['baseline'])) + text.set('class', cls.SVG_LABELCLASS) + text.set(cls.SVG_DATA, self.city) + text.text = box.meta['text'] + group.append(text) + svg.append(group) + @classmethod + def address_lookup(cls, attr): + locator = Nominatim(user_agent=USER_AGENT) + number = attr['Chaostreff-Physical-Housenumber'] + street = attr['Chaostreff-Physical-Address'] + zipcode = attr['Chaostreff-Physical-Postcode'] + acity = attr['Chaostreff-Physical-City'] + city = attr['Chaostreff-City'][0] + country = attr['Chaostreff-Country'][0] -def compute_bbox(ns): - if ns.bbox is not None: - return [ - (min(ns.bbox[0], ns.bbox[2]), min(ns.bbox[1], ns.bbox[3])), - (max(ns.bbox[0], ns.bbox[2]), max(ns.bbox[1], ns.bbox[3])) + # Try the most accurate address first, try increasingly inaccurate addresses on failure. + formats = [ + # Muttenz, Schweiz + f'{city}, {country}' ] + if zipcode and acity: + # 4132 Muttenz, Schweiz + formats.insert(0, f'{zipcode[0]} {acity[0]}, {country}') + if zipcode and acity and number and street: + # Birsfelderstrasse 6, 4132 Muttenz, Schweiz + formats.insert(0, f'{street[0]} {number[0]}, {zipcode[0]} {acity[0]}, {country}') - print('Computing map bounding box') - bounds = [] - for path in tqdm.tqdm([ns.cache_directory.erfa_info, ns.cache_directory.chaostreff_info]): - with open(path, 'r') as f: - erfadata = json.load(f) - for data in erfadata.values(): - if 'location' not in data: - continue - lon, lat = data['location'] - if len(bounds) == 0: - bounds.append(lon) - bounds.append(lat) - bounds.append(lon) - bounds.append(lat) - else: - bounds[0] = min(bounds[0], lon) - bounds[1] = min(bounds[1], lat) - bounds[2] = max(bounds[2], lon) - bounds[3] = max(bounds[3], lat) - return [ - (bounds[0] - ns.bbox_margin, bounds[1] - ns.bbox_margin), - (bounds[2] + ns.bbox_margin, bounds[3] + ns.bbox_margin) - ] + for fmt in formats: + response = locator.geocode(fmt) + if response is not None: + return response.longitude, response.latitude + + print(f'No location found for {name}, tried the following address formats:') + for fmt in formats: + print(f' {fmt}') + return None + + @classmethod + def from_api(cls, name, attr, radius, ns): + city = attr['Chaostreff-City'][0] + location = cls.address_lookup(attr) + if location is None: + raise ValueError(f'No location for {name}') + lon, lat = location + # There are up to 4 different names: 3 SMW attrs and the page name + if len(attr['Chaostreff-Longname']) > 0: + display_name = attr['Chaostreff-Longname'][0] + elif len(attr['Chaostreff-Nickname']) > 0: + display_name = attr['Chaostreff-Nickname'][0] + elif len(attr['Chaostreff-Realname']) > 0: + display_name = attr['Chaostreff-Realname'][0] + else: + display_name = name + if len(attr['Public-Web']) > 0: + web = attr['Public-Web'][0] + else: + web = None + return cls(ns, name, city, lon, lat, display_name, web, radius) + + def to_dict(self): + return { + 'name': self.name, + 'city': self.city, + 'web': self.web, + 'display_name': self.display_name, + 'location': [self.lon, self.lat] + } + + @classmethod + def from_dict(cls, ns, dct, radius): + name = dct['name'] + city = dct['city'] + lon, lat = dct['location'] + display_name = dct.get('display_name', name) + web = dct.get('web') + radius = radius + return Erfa(ns, name, city, lon, lat, display_name, web, radius) + + @classmethod + def fetch(cls, ns, target, radius): + userpw = os.getenv('DOKU_CCC_DE_BASICAUTH') + if userpw is None: + print('Please set environment variable DOKU_CCC_DE_BASICAUTH=username:password') + exit(1) + auth = base64.b64encode(userpw.encode()).decode() + erfas = [] + + offset = 0 + while True: + url = cls.SMW_URL.format( + category=cls.SMW_CATEGORY, + limit=cls.SMW_REQUEST_LIMIT, + offset=offset + ) + req = urllib.request.Request(url, headers={'Authorization': f'Basic {auth}'}) + with urllib.request.urlopen(req) as resp: + body = resp.read().decode() + if len(body) == 0: + # All pages read, new response is empty + break + erfadata = json.loads(body) + if erfadata['rows'] == 0: + break + offset += erfadata['rows'] + for name, attr in erfadata['results'].items(): + try: + erfa = cls.from_api(ns, name, attr['printouts'], radius) + erfas.append(erfa) + except BaseException as e: + print(e) + continue + + # Save to cache + with open(target, 'w') as f: + json.dump([e.to_dict() for e in erfas], f) + return erfas + + @classmethod + def from_cache(cls, ns, source, radius): + with open(source, 'r') as f: + data = json.load(f) + return [Erfa.from_dict(ns, d, radius) for d in data] + + +class Chaostreff(Erfa): + + SMW_CATEGORY = 'Chaostreffs' + + SVG_CLASS = 'chaostreff' + SVG_DATA = 'data-chaostreff' + SVG_DOTSIZE_ATTR = 'dotsize_treff' + SVG_LABEL = False class BoundingBox: @@ -395,7 +541,7 @@ class BoundingBox: # Basically the weights correspond to geometrical distances, # except for an actual collision, which gets a huge extra weight. for o in other: - if o.meta['city'] == self.meta['city']: + if o.meta['erfa'] == self.meta['erfa']: continue if o in self: if o.finished: @@ -406,17 +552,19 @@ class BoundingBox: self._optimal = False else: maxs.append(max(pdist*2 - swm.chebyshev_distance(o), 0)) - for city, location in erfas.items(): - if city == self.meta['city']: + for erfa in erfas: + if erfa == self.meta['erfa']: continue + location = (erfa.x, erfa.y) if location in swe: w += 1000 self._optimal = False else: - maxs.append(max(pdist*2 - swe.chebyshev_distance(o), 0)) - for city, location in chaostreffs.items(): - if city == self.meta['city']: + maxs.append(max(pdist*2 - swe.chebyshev_distance(location), 0)) + for treff in chaostreffs: + if treff == self.meta['erfa']: continue + location = (treff.x, treff.y) if location in swc: w += 1000 self._optimal = False @@ -439,7 +587,35 @@ class BoundingBox: return f'(({int(self.left)}, {int(self.top)}, {int(self.right)}, {int(self.bottom)}), weight={self.weight})' -def optimize_text_layout(ns, erfas, chaostreffs, size, svg): +def compute_bbox(ns): + if ns.bbox is not None: + return [ + (min(ns.bbox[0], ns.bbox[2]), min(ns.bbox[1], ns.bbox[3])), + (max(ns.bbox[0], ns.bbox[2]), max(ns.bbox[1], ns.bbox[3])) + ] + + print('Computing map bounding box') + bounds = [] + for path in tqdm.tqdm([ns.cache_directory.erfa_info, ns.cache_directory.chaostreff_info]): + erfas = Erfa.from_cache(ns, path, radius=0) + for e in erfas: + if len(bounds) == 0: + bounds.append(e.lon) + bounds.append(e.lat) + bounds.append(e.lon) + bounds.append(e.lat) + else: + bounds[0] = min(bounds[0], e.lon) + bounds[1] = min(bounds[1], e.lat) + bounds[2] = max(bounds[2], e.lon) + bounds[3] = max(bounds[3], e.lat) + return [ + (bounds[0] - ns.bbox_margin, bounds[1] - ns.bbox_margin), + (bounds[2] + ns.bbox_margin, bounds[3] + ns.bbox_margin) + ] + + +def optimize_text_layout(ns, erfas, chaostreffs, width, svg): # Load the font and measure its various different heights font = ImageFont.truetype(ns.font, ns.font_size) @@ -450,15 +626,15 @@ def optimize_text_layout(ns, erfas, chaostreffs, size, svg): # Generate a discrete set of text placement candidates around each erfa dot candidates = {} - for city, location in erfas.items(): - text = city - erfax, erfay = location + for erfa in erfas: + city = erfa.city + text = erfa.city for rfrom, to in ns.rename: if rfrom == city: text = to break - meta = {'city': city, 'text': text, 'baseline': capheight} + meta = {'erfa': erfa, 'text': text, 'baseline': capheight} textbox = pil.textbbox((0, 0), text, font=font, anchor='ls') # left, baseline at 0,0 mw, mh = textbox[2] - textbox[0], textbox[3] - textbox[1] candidates[city] = [] @@ -473,20 +649,20 @@ def optimize_text_layout(ns, erfas, chaostreffs, size, svg): if i == 0: bw -= 0.003 bwl, bwr = bw, bw - if erfax > 0.8 * size[0]: + if erfa.x > 0.8 * width: bwr = bw + 0.001 else: bwl = bw + 0.001 - candidates[city].append(BoundingBox(erfax - dist - mw, erfay + voffset + ns.dotsize_erfa*i*2, width=mw, height=mh, meta=meta, base_weight=bwl)) - candidates[city].append(BoundingBox(erfax + dist, erfay + voffset + ns.dotsize_erfa*i*2, width=mw, height=mh, meta=meta, base_weight=bwr)) + candidates[city].append(BoundingBox(erfa.x - dist - mw, erfa.y + voffset + ns.dotsize_erfa*i*2, width=mw, height=mh, meta=meta, base_weight=bwl)) + candidates[city].append(BoundingBox(erfa.x + dist, erfa.y + voffset + ns.dotsize_erfa*i*2, width=mw, height=mh, meta=meta, base_weight=bwr)) # Generate 3 candidates each above and beneath the dot, aligned left, centered and right candidates[city].extend([ - BoundingBox(erfax - mw/2, erfay - dist - mh, width=mw, height=mh, meta=meta, base_weight=bw + 0.001), - BoundingBox(erfax - mw/2, erfay + dist, width=mw, height=mh, meta=meta, base_weight=bw + 0.001), - BoundingBox(erfax - ns.dotsize_erfa, erfay - dist - mh, width=mw, height=mh, meta=meta, base_weight=bw + 0.002), - BoundingBox(erfax - ns.dotsize_erfa, erfay + dist, width=mw, height=mh, meta=meta, base_weight=bw + 0.003), - BoundingBox(erfax + ns.dotsize_erfa - mw, erfay - dist - mh, width=mw, height=mh, meta=meta, base_weight=bw + 0.002), - BoundingBox(erfax + ns.dotsize_erfa - mw, erfay + dist, width=mw, height=mh, meta=meta, base_weight=bw + 0.003), + BoundingBox(erfa.x - mw/2, erfa.y - dist - mh, width=mw, height=mh, meta=meta, base_weight=bw + 0.001), + BoundingBox(erfa.x - mw/2, erfa.y + dist, width=mw, height=mh, meta=meta, base_weight=bw + 0.001), + BoundingBox(erfa.x - ns.dotsize_erfa, erfa.y - dist - mh, width=mw, height=mh, meta=meta, base_weight=bw + 0.002), + BoundingBox(erfa.x - ns.dotsize_erfa, erfa.y + dist, width=mw, height=mh, meta=meta, base_weight=bw + 0.003), + BoundingBox(erfa.x + ns.dotsize_erfa - mw, erfa.y - dist - mh, width=mw, height=mh, meta=meta, base_weight=bw + 0.002), + BoundingBox(erfa.x + ns.dotsize_erfa - mw, erfa.y + dist, width=mw, height=mh, meta=meta, base_weight=bw + 0.003), ]) # If debugging is enabled, render one rectangle around each label's bounding box, and one rectangle around each label's median box @@ -500,7 +676,7 @@ def optimize_text_layout(ns, erfas, chaostreffs, size, svg): svg.append(dr) - unfinished = {c for c in erfas.keys()} + unfinished = {e.city for e in erfas} finished = {} # Greedily choose a candidate for each label @@ -527,7 +703,7 @@ def optimize_text_layout(ns, erfas, chaostreffs, size, svg): # If no candidate with at least one optimal solution is left, go by global minimum minbox = min(unfinished_boxes, key=lambda box: box.weight) - mincity = minbox.meta['city'] + mincity = minbox.meta['erfa'].city finished[mincity] = minbox minbox.finished = True unfinished.discard(mincity) @@ -553,9 +729,7 @@ def optimize_text_layout(ns, erfas, chaostreffs, size, svg): return finished -def create_imagemap(ns, size, parent, - erfas, erfa_urls, erfa_names, texts, - chaostreffs, chaostreff_urls, chaostreff_names): +def create_imagemap(ns, size, parent, erfas, chaostreffs, texts): s = ns.png_scale img = etree.Element('img', src=ns.output_directory.rel(ns.output_directory.png_path), @@ -563,34 +737,24 @@ def create_imagemap(ns, size, parent, width=str(size[0]*s), height=str(size[1]*s)) imgmap = etree.Element('map', name='erfamap') - for city, location in erfas.items(): - if city not in erfa_urls: + for erfa in erfas: + if erfa.web is None: continue - box = texts[city] area = etree.Element('area', shape='circle', - coords=f'{location[0]*s},{location[1]*s},{ns.dotsize_erfa*s}', - href=erfa_urls[city]) - area2 = etree.Element('area', - shape='rect', - coords=f'{box.left*s},{box.top*s},{box.right*s},{box.bottom*s}', - href=erfa_urls[city]) - if city in erfa_names: - area.set('title', erfa_names[city]) - area2.set('title', erfa_names[city]) + coords=f'{erfa.x*s},{erfa.y*s},{erfa.radius*s}', + href=erfa.web, + title=erfa.display_name) imgmap.append(area) - imgmap.append(area2) - for city, location in chaostreffs.items(): - if city not in chaostreff_urls: - continue - area = etree.Element('area', - shape='circle', - coords=f'{location[0]*s},{location[1]*s},{ns.dotsize_treff*s}', - href=chaostreff_urls[city]) - if city in chaostreff_names: - area.set('title', chaostreff_names[city]) - imgmap.append(area) + if erfa.city in texts: + box = texts[erfa.city] + area2 = etree.Element('area', + shape='rect', + coords=f'{box.left*s},{box.top*s},{box.right*s},{box.bottom*s}', + href=erfa.web, + title=erfa.display_name) + imgmap.append(area2) parent.append(img) parent.append(imgmap) @@ -600,110 +764,26 @@ def create_svg(ns, bbox): print('Creating SVG image') # Convert from WGS84 lon, lat to chosen projection - transformer = pyproj.Transformer.from_crs('epsg:4326', ns.projection) - scalex = ns.scale_x - scaley = ns.scale_y - blt = transformer.transform(*bbox[0]) - trt = transformer.transform(*bbox[1]) - trans_bounding_box = [ - (scalex*blt[0], scaley*trt[1]), - (scalex*trt[0], scaley*blt[1]) - ] - origin = trans_bounding_box[0] - svg_box = (trans_bounding_box[1][0] - origin[0], origin[1] - trans_bounding_box[1][1]) - - # Load state border lines from cached JSON files - shapes_states = [] - files = os.listdir(ns.cache_directory.shapes_states) - for f in files: - if not f.endswith('.json'): - continue - path = os.path.join(ns.cache_directory.shapes_states, f) - with open(path, 'r') as sf: - shapedata = sf.read() - shape = json.loads(shapedata) - name = shape['description']['en'] - geo = shape['data']['features'][0]['geometry'] - if geo['type'] == 'Polygon': - geo['coordinates'] = [geo['coordinates']] - for poly in geo['coordinates']: - ts = [] - for x, y in poly[0]: - xt, yt = transformer.transform(x, y) - ts.append((xt*scalex - origin[0], origin[1] - yt*scaley)) - shapes_states.append((name, ts)) - - # Load country border lines from cached JSON files - shapes_countries = [] - files = os.listdir(ns.cache_directory.shapes_filtered) - for f in files: - if not f.endswith('.json'): - continue - path = os.path.join(ns.cache_directory.shapes_filtered, f) - with open(path, 'r') as sf: - shapedata = sf.read() - shape = json.loads(shapedata) - name = shape['description']['en'] - geo = shape['data']['features'][0]['geometry'] - if geo['type'] == 'Polygon': - geo['coordinates'] = [geo['coordinates']] - for poly in geo['coordinates']: - ts = [] - for x, y in poly[0]: - xt, yt = transformer.transform(x, y) - ts.append((xt*scalex - origin[0], origin[1] - yt*scaley)) - shapes_countries.append((name, ts)) - - # Load Erfa infos from cached JSON files - erfas = {} - erfa_urls = {} - erfa_names = {} - with open(ns.cache_directory.erfa_info, 'r') as f: - ctdata = json.load(f) - for city, data in ctdata.items(): - location = data.get('location') - if location is None: - continue - xt, yt = transformer.transform(*location) - erfas[city] = (xt*scalex - origin[0], origin[1] - yt*scaley) - web = data.get('web') - if web is not None: - erfa_urls[city] = web - name = data.get('name') - if name is not None: - erfa_names[city] = name - - # Load Chaostreff infos from cached JSON files - chaostreffs = {} - chaostreff_urls = {} - chaostreff_names = {} - with open(ns.cache_directory.chaostreff_info, 'r') as f: - ctdata = json.load(f) - for city, data in ctdata.items(): - location = data.get('location') - if location is None: - continue - if city in erfas: - # There is an edge case where when a space changed states between Erfa and Chaostreff, the - # Semantic MediaWiki engine returns this space as both an Erfa and a Chaostreff, resulting - # in glitches in the rendering. As a workaround, here we simply assume that it's an Erfa. - continue - xt, yt = transformer.transform(*location) - chaostreffs[city] = (xt*scalex - origin[0], origin[1] - yt*scaley) - web = data.get('web') - if web is not None: - chaostreff_urls[city] = web - name = data.get('name') - if name is not None: - chaostreff_names[city] = name - + svg_box = ns.projection.setup(ns.scale_x, ns.scale_y, bbox=bbox) rectbox = [0, 0, svg_box[0], svg_box[1]] - for name, shape in shapes_states + shapes_countries: - for lon, lat in shape: - rectbox[0] = min(lon, rectbox[0]) - rectbox[1] = min(lat, rectbox[1]) - rectbox[2] = max(lon, rectbox[2]) - rectbox[3] = max(lat, rectbox[3]) + + # Load everything from cached JSON files + countries = Country.from_cache(ns, ns.cache_directory.shapes_filtered) + states = FederalState.from_cache(ns, ns.cache_directory.shapes_states) + erfas = Erfa.from_cache(ns, ns.cache_directory.erfa_info, ns.dotsize_erfa) + chaostreffs = Chaostreff.from_cache(ns, ns.cache_directory.chaostreff_info, ns.dotsize_treff) + # There is an edge case where when a space changed states between Erfa and Chaostreff, the + # Semantic MediaWiki engine returns this space as both an Erfa and a Chaostreff, resulting + # in glitches in the rendering. As a workaround, here we simply assume that it's an Erfa. + chaostreffs = [c for c in chaostreffs if c not in erfas] + + for c in states + countries: + for poly in c.polygons: + for x, y in poly: + rectbox[0] = min(x, rectbox[0]) + rectbox[1] = min(y, rectbox[1]) + rectbox[2] = max(x, rectbox[2]) + rectbox[3] = max(y, rectbox[3]) print('Copying stylesheet and font') dst = os.path.join(ns.output_directory.path, ns.stylesheet) @@ -731,65 +811,19 @@ def create_svg(ns, bbox): bg.set('class', 'background') svg.append(bg) - # Render country borders - for name, shape in shapes_countries: - points = ' '.join([f'{lon},{lat}' for lon, lat in shape]) - poly = etree.Element('polygon', points=points) - poly.set('class', 'country') - poly.set('data-country', name) - svg.append(poly) - - # Render state borders - # Render shortest shapes last s.t. Berlin, Hamburg and Bremen are rendered on top of their surrounding states - for name, shape in sorted(shapes_states, key=lambda x: -sum(len(s) for s in x[1])): - points = ' '.join([f'{lon},{lat}' for lon, lat in shape]) - poly = etree.Element('polygon', points=points) - poly.set('class', 'state') - poly.set('data-state', name) - svg.append(poly) - # This can take some time, especially if lots of candidates are generated print('Layouting labels') - texts = optimize_text_layout(ns, erfas, chaostreffs, (int(svg_box[0]), int(svg_box[1])), svg) + texts = optimize_text_layout(ns, erfas, chaostreffs, width=svg_box[0], svg=svg) + + # Render shortest shapes last s.t. Berlin, Hamburg and Bremen are rendered on top of their surrounding states + states = sorted(states, key=lambda x: -len(x)) + # Render country and state borders + for c in countries + states: + c.render(svg, texts) # Render Erfa dots and their labels - for city, location in erfas.items(): - box = texts[city] - if city in erfa_urls: - group = etree.Element('a', href=erfa_urls[city], target='_blank') - else: - group = etree.Element('g') - group.set('data-erfa', city) - circle = etree.Element('circle', cx=str(location[0]), cy=str(location[1]), r=str(ns.dotsize_erfa)) - circle.set('class', 'erfa') - circle.set('data-erfa', city) - if city in erfa_names: - title = etree.Element('title') - title.text = erfa_names[city] - circle.append(title) - group.append(circle) - text = etree.Element('text', x=str(box.left), y=str(box.top + box.meta['baseline'])) - text.set('class', 'erfalabel') - text.set('data-erfa', city) - text.text = box.meta['text'] - group.append(text) - svg.append(group) - - # Render Chaostreff dots - for city, location in chaostreffs.items(): - circle = etree.Element('circle', cx=str(location[0]), cy=str(location[1]), r=str(ns.dotsize_treff)) - circle.set('class', 'chaostreff') - circle.set('data-chaostreff', city) - if city in chaostreff_names: - title = etree.Element('title') - title.text = chaostreff_names[city] - circle.append(title) - if city in chaostreff_urls: - a = etree.Element('a', href=chaostreff_urls[city], target='_blank') - a.append(circle) - svg.append(a) - else: - svg.append(circle) + for erfa in erfas + chaostreffs: + erfa.render(svg, texts) # Generate SVG, PNG and HTML output files @@ -812,9 +846,7 @@ def create_svg(ns, bbox): obj = etree.Element('object', data=ns.output_directory.rel(ns.output_directory.svg_path), width=str(svg_box[0]), height=str(svg_box[1])) - create_imagemap(ns, svg_box, obj, - erfas, erfa_urls, erfa_names, texts, - chaostreffs, chaostreff_urls, chaostreff_names) + create_imagemap(ns, svg_box, obj, erfas, chaostreffs, texts) body.append(obj) html.append(body) with open(ns.output_directory.html_path, 'wb') as f: @@ -830,9 +862,7 @@ def create_svg(ns, bbox): body = etree.Element('body') html.append(body) - create_imagemap(ns, svg_box, body, - erfas, erfa_urls, erfa_names, texts, - chaostreffs, chaostreff_urls, chaostreff_names) + create_imagemap(ns, svg_box, body, erfas, chaostreffs, texts) with open(ns.output_directory.imagemap_path, 'wb') as f: f.write(b'\n') @@ -856,7 +886,7 @@ def main(): ap.add_argument('--dotsize-erfa', type=float, default=13, help='Radius of Erfa dots') ap.add_argument('--dotsize-treff', type=float, default=8, help='Radius of Chaostreff dots') ap.add_argument('--rename', type=str, action='append', nargs=2, metavar=('FROM', 'TO'), default=[], help='Rename a city with an overly long name (e.g. "Rothenburg ob der Tauber" to "Rothenburg")') - ap.add_argument('--projection', type=str, default='epsg:4258', help='Map projection to convert the WGS84 coordinates to') + ap.add_argument('--projection', type=CoordinateTransform, default='epsg:4258', help='Map projection to convert the WGS84 coordinates to') ap.add_argument('--scale-x', type=float, default=130, help='X axis scale to apply after projecting') ap.add_argument('--scale-y', type=float, default=200, help='Y axis scale to apply after projecting') ap.add_argument('--png-scale', type=float, default=1.0, help='Scale of the PNG image') @@ -867,26 +897,31 @@ def main(): if ns.update_borders or not os.path.isdir(ns.cache_directory.shapes_countries): if os.path.isdir(ns.cache_directory.shapes_countries): shutil.rmtree(ns.cache_directory.shapes_countries) - fetch_wikidata_countries(target=ns.cache_directory.shapes_countries) + print('Retrieving country border shapes') + Country.fetch(target=ns.cache_directory.shapes_countries) if ns.update_borders or not os.path.isdir(ns.cache_directory.shapes_states): if os.path.isdir(ns.cache_directory.shapes_states): shutil.rmtree(ns.cache_directory.shapes_states) - fetch_wikidata_states(target=ns.cache_directory.shapes_states) + print('Retrieving state border shapes') + FederalState.fetch(target=ns.cache_directory.shapes_states) if ns.update_erfalist or not os.path.isfile(ns.cache_directory.erfa_info): if os.path.exists(ns.cache_directory.erfa_info): os.unlink(ns.cache_directory.erfa_info) - fetch_erfas(target=ns.cache_directory.erfa_info, url=ERFA_URL) + print('Retrieving Erfas information') + Erfa.fetch(ns, target=ns.cache_directory.erfa_info, radius=ns.dotsize_erfa) if ns.update_erfalist or not os.path.isfile(ns.cache_directory.chaostreff_info): if os.path.exists(ns.cache_directory.chaostreff_info): os.unlink(ns.cache_directory.chaostreff_info) - fetch_erfas(target=ns.cache_directory.chaostreff_info, url=CHAOSTREFF_URL) + print('Retrieving Chaostreffs information') + Chaostreff.fetch(target=ns.cache_directory.chaostreff_info, radius=ns.dotsize_treff) bbox = compute_bbox(ns) - filter_boundingbox(ns.cache_directory.shapes_countries, ns.cache_directory.shapes_filtered, bbox) + print('Filtering countries outside the bounding box') + Country.filter_boundingbox(ns, ns.cache_directory.shapes_countries, ns.cache_directory.shapes_filtered, bbox) create_svg(ns, bbox) diff --git a/map.readme.png b/map.readme.png index af18c1c..945aec4 100644 Binary files a/map.readme.png and b/map.readme.png differ