From 4357cc25ccc585ce398035c1c25d566b6a9df775 Mon Sep 17 00:00:00 2001 From: Benjamin Bellamy Date: Fri, 17 Sep 2021 15:50:55 +0000 Subject: [PATCH] feat(map): display geolocated episodes on a map page --- app/Config/Routes.php | 6 + .../Admin/PodcastImportController.php | 8 +- app/Controllers/MapMarkerController.php | 59 + app/Entities/Location.php | 13 +- app/Helpers/page_helper.php | 3 + app/Language/en/Page.php | 1 + app/Language/fr/Page.php | 1 + app/Models/EpisodeModel.php | 2 + .../images/marker/marker-icon-2x.png | Bin 0 -> 1983 bytes app/Resources/images/marker/marker-icon.png | Bin 0 -> 1200 bytes app/Resources/images/marker/marker-shadow.png | Bin 0 -> 618 bytes app/Resources/js/map.ts | 4 + app/Resources/js/modules/Charts.ts | 1 - app/Resources/js/modules/EpisodesMap.ts | 90 + app/Resources/js/typings.d.ts | 1 + app/Resources/types/js/map.d.ts | 1 + .../types/js/modules/EpisodesMap.d.ts | 5 + app/Resources/types/js/modules/Map.d.ts | 2 + app/Views/_layout.php | 31 +- app/Views/map.php | 36 + app/Views/podcast/_partials/sidebar.php | 2 +- package-lock.json | 11866 +++++++++------- package.json | 3 + vite.config.ts | 1 + 24 files changed, 6836 insertions(+), 5300 deletions(-) create mode 100644 app/Controllers/MapMarkerController.php create mode 100644 app/Resources/images/marker/marker-icon-2x.png create mode 100644 app/Resources/images/marker/marker-icon.png create mode 100644 app/Resources/images/marker/marker-shadow.png create mode 100644 app/Resources/js/map.ts create mode 100644 app/Resources/js/modules/EpisodesMap.ts create mode 100644 app/Resources/types/js/map.d.ts create mode 100644 app/Resources/types/js/modules/EpisodesMap.d.ts create mode 100644 app/Resources/types/js/modules/Map.d.ts create mode 100644 app/Views/map.php diff --git a/app/Config/Routes.php b/app/Config/Routes.php index c4423b48..eb02b2c8 100644 --- a/app/Config/Routes.php +++ b/app/Config/Routes.php @@ -781,6 +781,12 @@ $routes->group('@(:podcastName)', function ($routes): void { $routes->get('/credits', 'CreditsController', [ 'as' => 'credits', ]); +$routes->get('/map', 'MapMarkerController', [ + 'as' => 'map', +]); +$routes->get('/episodes-markers', 'MapMarkerController::getEpisodesMarkers', [ + 'as' => 'episodes-markers', +]); $routes->get('/pages/(:slug)', 'PageController/$1', [ 'as' => 'page', ]); diff --git a/app/Controllers/Admin/PodcastImportController.php b/app/Controllers/Admin/PodcastImportController.php index 1d35ffba..410f040e 100644 --- a/app/Controllers/Admin/PodcastImportController.php +++ b/app/Controllers/Admin/PodcastImportController.php @@ -124,8 +124,8 @@ class PodcastImportController extends BaseController if (property_exists($nsPodcast, 'location') && $nsPodcast->location !== null) { $location = new Location( (string) $nsPodcast->location, - (string) $nsPodcast->location->attributes()['geo'], - (string) $nsPodcast->location->attributes()['osm'], + $nsPodcast->location->attributes()['geo'] === null ? null : (string) $nsPodcast->location->attributes()['geo'], + $nsPodcast->location->attributes()['osm'] === null ? null : (string) $nsPodcast->location->attributes()['osm'], ); } if (property_exists($nsPodcast, 'guid') && $nsPodcast->guid !== null) { @@ -338,8 +338,8 @@ class PodcastImportController extends BaseController if (property_exists($nsPodcast, 'location') && $nsPodcast->location !== null) { $location = new Location( (string) $nsPodcast->location, - (string) $nsPodcast->location->attributes()['geo'], - (string) $nsPodcast->location->attributes()['osm'], + $nsPodcast->location->attributes()['geo'] === null ? null : (string) $nsPodcast->location->attributes()['geo'], + $nsPodcast->location->attributes()['osm'] === null ? null : (string) $nsPodcast->location->attributes()['osm'], ); } diff --git a/app/Controllers/MapMarkerController.php b/app/Controllers/MapMarkerController.php new file mode 100644 index 00000000..4d862757 --- /dev/null +++ b/app/Controllers/MapMarkerController.php @@ -0,0 +1,59 @@ +getLocale(); + $cacheName = "page_map_{$locale}"; + if (! ($found = cache($cacheName))) { + $found = view('map', [], [ + 'cache' => DECADE, + 'cache_name' => $cacheName, + ]); + } + return $found; + } + + public function getEpisodesMarkers(): ResponseInterface + { + $cacheName = 'episodes_markers'; + if (! ($found = cache($cacheName))) { + $episodes = (new EpisodeModel())->where('location_geo is not', null) + ->findAll(); + $found = []; + foreach ($episodes as $episode) { + $found[] = [ + 'latitude' => $episode->location->latitude, + 'longitude' => $episode->location->longitude, + 'location_name' => $episode->location->name, + 'location_url' => $episode->location->url, + 'episode_link' => $episode->link, + 'podcast_link' => $episode->podcast->link, + 'image_path' => $episode->image->thumbnail_url, + 'podcast_title' => $episode->podcast->title, + 'episode_title' => $episode->title, + ]; + } + // The page cache is set to a decade so it is deleted manually upon episode update + cache() + ->save($cacheName, $found, DECADE); + } + return $this->response->setJSON($found); + } +} diff --git a/app/Entities/Location.php b/app/Entities/Location.php index 19fe3aa1..470eec88 100644 --- a/app/Entities/Location.php +++ b/app/Entities/Location.php @@ -18,6 +18,8 @@ use Config\Services; * @property string $name * @property string|null $geo * @property string|null $osm + * @property double|null $latitude + * @property double|null $longitude */ class Location extends Entity { @@ -34,12 +36,21 @@ class Location extends Entity public function __construct( protected string $name, protected ?string $geo = null, - protected ?string $osm = null + protected ?string $osm = null, ) { + $latitude = null; + $longitude = null; + if ($geo !== null) { + $geoArray = explode(',', substr($geo, 4)); + $latitude = floatval($geoArray[0]); + $longitude = floatval($geoArray[1]); + } parent::__construct([ 'name' => $name, 'geo' => $geo, 'osm' => $osm, + 'latitude' => $latitude, + 'longitude' => $longitude, ]); } diff --git a/app/Helpers/page_helper.php b/app/Helpers/page_helper.php index 7177f7cc..2c9519a0 100644 --- a/app/Helpers/page_helper.php +++ b/app/Helpers/page_helper.php @@ -25,6 +25,9 @@ if (! function_exists('render_page_links')) { $links .= anchor(route_to('credits'), lang('Person.credits'), [ 'class' => 'px-2 underline hover:no-underline', ]); + $links .= anchor(route_to('map'), lang('Page.map'), [ + 'class' => 'px-2 underline hover:no-underline', + ]); foreach ($pages as $page) { $links .= anchor($page->link, $page->title, [ 'class' => 'px-2 underline hover:no-underline', diff --git a/app/Language/en/Page.php b/app/Language/en/Page.php index 7981e9d4..86eb345d 100644 --- a/app/Language/en/Page.php +++ b/app/Language/en/Page.php @@ -26,4 +26,5 @@ return [ 'messages' => [ 'createSuccess' => 'The page “{pageTitle}” was created successfully!', ], + 'map' => 'Map', ]; diff --git a/app/Language/fr/Page.php b/app/Language/fr/Page.php index fb71fa56..8bb2ac2d 100644 --- a/app/Language/fr/Page.php +++ b/app/Language/fr/Page.php @@ -26,4 +26,5 @@ return [ 'messages' => [ 'createSuccess' => 'La page {pageTitle} a été créée avec succès !', ], + 'map' => 'Cartographie', ]; diff --git a/app/Models/EpisodeModel.php b/app/Models/EpisodeModel.php index a9cbb148..11b885b3 100644 --- a/app/Models/EpisodeModel.php +++ b/app/Models/EpisodeModel.php @@ -299,6 +299,8 @@ class EpisodeModel extends Model ->deleteMatching("page_podcast#{$episode->podcast_id}*"); cache() ->deleteMatching('page_credits_*'); + cache() + ->delete('episodes_markers'); return $data; } diff --git a/app/Resources/images/marker/marker-icon-2x.png b/app/Resources/images/marker/marker-icon-2x.png new file mode 100644 index 0000000000000000000000000000000000000000..f61b5ae0251ebcb75eb6bda3bde481e5e07b7049 GIT binary patch literal 1983 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 Ef<_J(9smFU literal 0 HcmV?d00001 diff --git a/app/Resources/js/map.ts b/app/Resources/js/map.ts new file mode 100644 index 00000000..66afdef9 --- /dev/null +++ b/app/Resources/js/map.ts @@ -0,0 +1,4 @@ +import "core-js"; +import DrawEpisodesMaps from "./modules/EpisodesMap"; + +DrawEpisodesMaps(); diff --git a/app/Resources/js/modules/Charts.ts b/app/Resources/js/modules/Charts.ts index cf37414e..886fa620 100644 --- a/app/Resources/js/modules/Charts.ts +++ b/app/Resources/js/modules/Charts.ts @@ -1,4 +1,3 @@ -// Import modules import am4geodata_worldLow from "@amcharts/amcharts4-geodata/worldLow"; import * as am4charts from "@amcharts/amcharts4/charts"; import * as am4core from "@amcharts/amcharts4/core"; diff --git a/app/Resources/js/modules/EpisodesMap.ts b/app/Resources/js/modules/EpisodesMap.ts new file mode 100644 index 00000000..41b794c5 --- /dev/null +++ b/app/Resources/js/modules/EpisodesMap.ts @@ -0,0 +1,90 @@ +import { + control, + featureGroup, + icon, + map, + Marker, + marker, + tileLayer, +} from "leaflet"; +import { MarkerClusterGroup } from "leaflet.markercluster"; +import "leaflet.markercluster/dist/MarkerCluster.css"; +import "leaflet.markercluster/dist/MarkerCluster.Default.css"; +import "leaflet/dist/leaflet.css"; +import markerIconRetina from "../../images/marker/marker-icon-2x.png"; +import markerIcon from "../../images/marker/marker-icon.png"; +import markerShadow from "../../images/marker/marker-shadow.png"; + +Marker.prototype.options.icon = icon({ + iconRetinaUrl: markerIconRetina, + iconUrl: markerIcon, + shadowUrl: markerShadow, + iconSize: [25, 41], + iconAnchor: [12, 41], + popupAnchor: [1, -34], + tooltipAnchor: [16, -28], + shadowSize: [41, 41], +}); + +const drawEpisodesMap = async (mapDivId: string, dataUrl: string) => { + const episodesMap = map(mapDivId).setView([48.858, 2.294], 13); + + tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", { + maxZoom: 19, + attribution: + '© OpenStreetMap contributors', + }).addTo(episodesMap); + control.scale({ imperial: true, metric: true }).addTo(episodesMap); + + const data = await fetch(dataUrl).then((response) => response.json()); + + if (data.length > 0) { + const markers = []; + const cluster = new MarkerClusterGroup({ showCoverageOnHover: false }); + for (let i = 0; i < data.length; i++) { + const currentMarker = marker([ + data[i].latitude, + data[i].longitude, + ]).bindPopup( + '" + ); + markers.push(currentMarker); + cluster.addLayer(currentMarker); + } + episodesMap.addLayer(cluster); + const group = featureGroup(markers); + episodesMap.fitBounds(group.getBounds()); + } +}; + +const DrawEpisodesMaps = (): void => { + const mapDivs: NodeListOf = document.querySelectorAll( + "div[data-episodes-map-data-url]" + ); + for (let i = 0; i < mapDivs.length; i++) { + const mapDiv: HTMLDivElement = mapDivs[i]; + + if (mapDiv.dataset.episodesMapDataUrl) { + drawEpisodesMap(mapDiv.id, mapDiv.dataset.episodesMapDataUrl); + } + } +}; + +export default DrawEpisodesMaps; diff --git a/app/Resources/js/typings.d.ts b/app/Resources/js/typings.d.ts index fac47a92..fe9d4f51 100644 --- a/app/Resources/js/typings.d.ts +++ b/app/Resources/js/typings.d.ts @@ -1,2 +1,3 @@ declare module "prosemirror-markdown"; declare module "prosemirror-example-setup"; +declare module "leaflet.markercluster"; diff --git a/app/Resources/types/js/map.d.ts b/app/Resources/types/js/map.d.ts new file mode 100644 index 00000000..c3fee8a1 --- /dev/null +++ b/app/Resources/types/js/map.d.ts @@ -0,0 +1 @@ +import "core-js"; diff --git a/app/Resources/types/js/modules/EpisodesMap.d.ts b/app/Resources/types/js/modules/EpisodesMap.d.ts new file mode 100644 index 00000000..67f900a5 --- /dev/null +++ b/app/Resources/types/js/modules/EpisodesMap.d.ts @@ -0,0 +1,5 @@ +import "leaflet.markercluster/dist/MarkerCluster.css"; +import "leaflet.markercluster/dist/MarkerCluster.Default.css"; +import "leaflet/dist/leaflet.css"; +declare const DrawEpisodesMaps: () => void; +export default DrawEpisodesMaps; diff --git a/app/Resources/types/js/modules/Map.d.ts b/app/Resources/types/js/modules/Map.d.ts new file mode 100644 index 00000000..4fe78599 --- /dev/null +++ b/app/Resources/types/js/modules/Map.d.ts @@ -0,0 +1,2 @@ +declare const DrawMaps: () => void; +export default DrawMaps; diff --git a/app/Views/_layout.php b/app/Views/_layout.php index 990fcab6..bf9bcded 100644 --- a/app/Views/_layout.php +++ b/app/Views/_layout.php @@ -12,25 +12,26 @@ -
-
- +
+ +

title - : 'Castopod' ?> + : 'Castopod' ?>

renderSection('content') ?>
- + diff --git a/app/Views/map.php b/app/Views/map.php new file mode 100644 index 00000000..4daeb9f7 --- /dev/null +++ b/app/Views/map.php @@ -0,0 +1,36 @@ + + + + + + + <?= lang('Page.map') ?> + + + + asset('styles/index.css', 'css') ?> + asset('js/map.ts', 'js') ?> + + + +
+
+ +

+
+
+
+
+
+ + diff --git a/app/Views/podcast/_partials/sidebar.php b/app/Views/podcast/_partials/sidebar.php index 9041ee28..6d703437 100644 --- a/app/Views/podcast/_partials/sidebar.php +++ b/app/Views/podcast/_partials/sidebar.php @@ -72,7 +72,7 @@