Skip to content
Snippets Groups Projects
Commit 4357cc25 authored by Benjamin Bellamy's avatar Benjamin Bellamy :speech_balloon:
Browse files

feat(map): display geolocated episodes on a map page

parent 652fa365
No related branches found
No related tags found
No related merge requests found
Showing
with 243 additions and 21 deletions
...@@ -781,6 +781,12 @@ $routes->group('@(:podcastName)', function ($routes): void { ...@@ -781,6 +781,12 @@ $routes->group('@(:podcastName)', function ($routes): void {
$routes->get('/credits', 'CreditsController', [ $routes->get('/credits', 'CreditsController', [
'as' => 'credits', 'as' => 'credits',
]); ]);
$routes->get('/map', 'MapMarkerController', [
'as' => 'map',
]);
$routes->get('/episodes-markers', 'MapMarkerController::getEpisodesMarkers', [
'as' => 'episodes-markers',
]);
$routes->get('/pages/(:slug)', 'PageController/$1', [ $routes->get('/pages/(:slug)', 'PageController/$1', [
'as' => 'page', 'as' => 'page',
]); ]);
......
...@@ -124,8 +124,8 @@ class PodcastImportController extends BaseController ...@@ -124,8 +124,8 @@ class PodcastImportController extends BaseController
if (property_exists($nsPodcast, 'location') && $nsPodcast->location !== null) { if (property_exists($nsPodcast, 'location') && $nsPodcast->location !== null) {
$location = new Location( $location = new Location(
(string) $nsPodcast->location, (string) $nsPodcast->location,
(string) $nsPodcast->location->attributes()['geo'], $nsPodcast->location->attributes()['geo'] === null ? null : (string) $nsPodcast->location->attributes()['geo'],
(string) $nsPodcast->location->attributes()['osm'], $nsPodcast->location->attributes()['osm'] === null ? null : (string) $nsPodcast->location->attributes()['osm'],
); );
} }
if (property_exists($nsPodcast, 'guid') && $nsPodcast->guid !== null) { if (property_exists($nsPodcast, 'guid') && $nsPodcast->guid !== null) {
...@@ -338,8 +338,8 @@ class PodcastImportController extends BaseController ...@@ -338,8 +338,8 @@ class PodcastImportController extends BaseController
if (property_exists($nsPodcast, 'location') && $nsPodcast->location !== null) { if (property_exists($nsPodcast, 'location') && $nsPodcast->location !== null) {
$location = new Location( $location = new Location(
(string) $nsPodcast->location, (string) $nsPodcast->location,
(string) $nsPodcast->location->attributes()['geo'], $nsPodcast->location->attributes()['geo'] === null ? null : (string) $nsPodcast->location->attributes()['geo'],
(string) $nsPodcast->location->attributes()['osm'], $nsPodcast->location->attributes()['osm'] === null ? null : (string) $nsPodcast->location->attributes()['osm'],
); );
} }
......
<?php
declare(strict_types=1);
/**
* @copyright 2020 Podlibre
* @license https://www.gnu.org/licenses/agpl-3.0.en.html AGPL3
* @link https://castopod.org/
*/
namespace App\Controllers;
use App\Entities\Page;
use App\Models\EpisodeModel;
use CodeIgniter\HTTP\ResponseInterface;
class MapMarkerController extends BaseController
{
public function index(): string
{
$locale = service('request')
->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);
}
}
...@@ -18,6 +18,8 @@ use Config\Services; ...@@ -18,6 +18,8 @@ use Config\Services;
* @property string $name * @property string $name
* @property string|null $geo * @property string|null $geo
* @property string|null $osm * @property string|null $osm
* @property double|null $latitude
* @property double|null $longitude
*/ */
class Location extends Entity class Location extends Entity
{ {
...@@ -34,12 +36,21 @@ class Location extends Entity ...@@ -34,12 +36,21 @@ class Location extends Entity
public function __construct( public function __construct(
protected string $name, protected string $name,
protected ?string $geo = null, 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([ parent::__construct([
'name' => $name, 'name' => $name,
'geo' => $geo, 'geo' => $geo,
'osm' => $osm, 'osm' => $osm,
'latitude' => $latitude,
'longitude' => $longitude,
]); ]);
} }
......
...@@ -25,6 +25,9 @@ if (! function_exists('render_page_links')) { ...@@ -25,6 +25,9 @@ if (! function_exists('render_page_links')) {
$links .= anchor(route_to('credits'), lang('Person.credits'), [ $links .= anchor(route_to('credits'), lang('Person.credits'), [
'class' => 'px-2 underline hover:no-underline', '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) { foreach ($pages as $page) {
$links .= anchor($page->link, $page->title, [ $links .= anchor($page->link, $page->title, [
'class' => 'px-2 underline hover:no-underline', 'class' => 'px-2 underline hover:no-underline',
......
...@@ -26,4 +26,5 @@ return [ ...@@ -26,4 +26,5 @@ return [
'messages' => [ 'messages' => [
'createSuccess' => 'The page “{pageTitle}” was created successfully!', 'createSuccess' => 'The page “{pageTitle}” was created successfully!',
], ],
'map' => 'Map',
]; ];
...@@ -26,4 +26,5 @@ return [ ...@@ -26,4 +26,5 @@ return [
'messages' => [ 'messages' => [
'createSuccess' => 'La page {pageTitle} a été créée avec succès !', 'createSuccess' => 'La page {pageTitle} a été créée avec succès !',
], ],
'map' => 'Cartographie',
]; ];
...@@ -299,6 +299,8 @@ class EpisodeModel extends Model ...@@ -299,6 +299,8 @@ class EpisodeModel extends Model
->deleteMatching("page_podcast#{$episode->podcast_id}*"); ->deleteMatching("page_podcast#{$episode->podcast_id}*");
cache() cache()
->deleteMatching('page_credits_*'); ->deleteMatching('page_credits_*');
cache()
->delete('episodes_markers');
return $data; return $data;
} }
......
app/Resources/images/marker/marker-icon-2x.png

1.94 KiB

app/Resources/images/marker/marker-icon.png

1.17 KiB

app/Resources/images/marker/marker-shadow.png

618 B

import "core-js";
import DrawEpisodesMaps from "./modules/EpisodesMap";
DrawEpisodesMaps();
// Import modules
import am4geodata_worldLow from "@amcharts/amcharts4-geodata/worldLow"; import am4geodata_worldLow from "@amcharts/amcharts4-geodata/worldLow";
import * as am4charts from "@amcharts/amcharts4/charts"; import * as am4charts from "@amcharts/amcharts4/charts";
import * as am4core from "@amcharts/amcharts4/core"; import * as am4core from "@amcharts/amcharts4/core";
......
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:
'&copy; <a href="https://openstreetmap.org/copyright">OpenStreetMap contributors</a>',
}).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(
'<div class="flex min-w-max"><img src="' +
data[i].image_path +
'" alt="' +
data[i].episode_title +
'" class="mr-2 rounded w-16 h-16" /><div class="flex flex-col"><h2 class="lg:text-base text-sm ! font-bold"><a href="' +
data[i].episode_link +
'" class="hover:underline !text-pine-800">' +
data[i].episode_title +
'</a></h2><a href="' +
data[i].podcast_link +
'" class="hover:underline text-xs !text-black !mt-0 !mb-2">' +
data[i].podcast_title +
"</a>" +
'<a href="' +
data[i].location_url +
'" class="inline-flex items-center hover:underline text-xs !text-gray-500" target="_blank" rel="noreferrer noopener"><svg class="mr-1" viewBox="0 0 24 24" fill="currentColor" width="1em" height="1em"><g><path fill="none" d="M0 0h24v24H0z"></path><path d="M18.364 17.364L12 23.728l-6.364-6.364a9 9 0 1 1 12.728 0zM12 13a2 2 0 1 0 0-4 2 2 0 0 0 0 4z"></path></g></svg>' +
data[i].location_name +
"</a></div></div>"
);
markers.push(currentMarker);
cluster.addLayer(currentMarker);
}
episodesMap.addLayer(cluster);
const group = featureGroup(markers);
episodesMap.fitBounds(group.getBounds());
}
};
const DrawEpisodesMaps = (): void => {
const mapDivs: NodeListOf<HTMLDivElement> = 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;
declare module "prosemirror-markdown"; declare module "prosemirror-markdown";
declare module "prosemirror-example-setup"; declare module "prosemirror-example-setup";
declare module "leaflet.markercluster";
import "core-js";
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;
declare const DrawMaps: () => void;
export default DrawMaps;
...@@ -12,25 +12,26 @@ ...@@ -12,25 +12,26 @@
</head> </head>
<body class="flex flex-col min-h-screen mx-auto bg-gray-100"> <body class="flex flex-col min-h-screen mx-auto bg-gray-100">
<header class="bg-white border-b"> <header class="py-8 text-white border-b bg-pine-900">
<div class="container flex items-center justify-between px-2 py-4 mx-auto"> <div class="container flex flex-col px-2 py-4 mx-auto">
<a href="<?= route_to('home') ?>" class="text-2xl"><?= isset($page) <a href="<?= route_to('home') ?>"
class="inline-flex items-center mb-2"><?= icon(
'arrow-left',
'mr-2',
) . lang('Page.back_to_home') ?></a>
<h1 class="text-3xl font-semibold"><?= isset($page)
? $page->title ? $page->title
: 'Castopod' ?></a> : 'Castopod' ?></h1>
</div> </div>
</header> </header>
<main class="container flex-1 px-4 py-10 mx-auto"> <main class="container flex-1 px-4 py-10 mx-auto">
<?= $this->renderSection('content') ?> <?= $this->renderSection('content') ?>
</main> </main>
<footer class="px-2 py-4 bg-white border-t"> <footer class="container flex justify-between px-2 py-4 mx-auto text-sm text-right border-t">
<div class="container flex flex-col items-center justify-between mx-auto text-xs md:flex-row "> <?= render_page_links() ?>
<?= render_page_links('inline-flex mb-4 md:mb-0') ?> <small><?= lang('Common.powered_by', [
<p class="flex flex-col items-center md:items-end"> 'castopod' =>
<?= lang('Common.powered_by', [ '<a class="underline hover:no-underline" href="https://castopod.org/" target="_blank" rel="noreferrer noopener">Castopod</a>',
'castopod' => ]) ?></small>
'<a class="underline hover:no-underline" href="https://castopod.org" target="_blank" rel="noreferrer noopener">Castopod</a>', </footer>
]) ?>
</p>
</div>
</footer>
</body> </body>
<?= helper('page') ?>
<!DOCTYPE html>
<html lang="<?= service('request')->getLocale() ?>" class="h-full">
<head>
<meta charset="UTF-8"/>
<title><?= lang('Page.map') ?></title>
<meta name="description" content="Castopod is an open-source hosting platform made for podcasters who want engage and interact with their audience."/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<link rel="shortcut icon" type="image/png" href="/favicon.ico" />
<?= service('vite')->asset('styles/index.css', 'css') ?>
<?= service('vite')->asset('js/map.ts', 'js') ?>
</head>
<body class="flex flex-col h-full min-h-screen mx-auto bg-gray-100">
<header class="py-8 text-white border-b bg-pine-900">
<div class="container flex flex-col px-2 py-4 mx-auto">
<a href="<?= route_to('home') ?>"
class="inline-flex items-center mb-2"><?= icon(
'arrow-left',
'mr-2',
) . lang('Page.back_to_home') ?></a>
<h1 class="text-3xl font-semibold"><?= lang('Page.map') ?></h1>
</div>
</header>
<main class="flex-1 w-full h-full">
<div id="map" data-episodes-map-data-url="<?= url_to('episodes-markers') ?>" class="w-full h-full"></div>
</main>
<footer class="container flex justify-between px-2 py-4 mx-auto text-sm text-right border-t">
<?= render_page_links() ?>
<small><?= lang('Common.powered_by', [
'castopod' =>
'<a class="underline hover:no-underline" href="https://castopod.org/" target="_blank" rel="noreferrer noopener">Castopod</a>',
]) ?></small>
</footer>
</body>
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment