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 00000000..f61b5ae0 Binary files /dev/null and b/app/Resources/images/marker/marker-icon-2x.png differ diff --git a/app/Resources/images/marker/marker-icon.png b/app/Resources/images/marker/marker-icon.png new file mode 100644 index 00000000..085894d8 Binary files /dev/null and b/app/Resources/images/marker/marker-icon.png differ diff --git a/app/Resources/images/marker/marker-shadow.png b/app/Resources/images/marker/marker-shadow.png new file mode 100644 index 00000000..9fd29795 Binary files /dev/null and b/app/Resources/images/marker/marker-shadow.png differ 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( + '
' +
+          data[i].episode_title +
+          '

' + + data[i].episode_title + + '

' + + data[i].podcast_title + + "" + + '' + + data[i].location_name + + "
" + ); + 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 @@