observatory

This commit is contained in:
alma 2025-05-04 17:07:29 +02:00
parent 506e9f8f05
commit 53371f0220
3 changed files with 142 additions and 139 deletions

View File

@ -0,0 +1,118 @@
"use client";
import { useEffect, useState } from "react";
import L from "leaflet";
import { MapContainer, TileLayer, Marker, Popup, ZoomControl } from "react-leaflet";
// Import leaflet CSS
import "leaflet/dist/leaflet.css";
// Add declaration for Leaflet's Icon.Default
declare module 'leaflet' {
namespace Icon {
interface Default {
_getIconUrl?: string;
}
}
}
interface CountryData {
name: string;
count: number;
position: [number, number]; // latitude, longitude
}
interface MapComponentProps {
countries: CountryData[];
onCountrySelect: (country: string) => void;
selectedCountry: string | null;
}
// Fix Leaflet default icon path issues
function fixLeafletIcons() {
// Delete to prevent duplicate icon definitions
delete L.Icon.Default.prototype._getIconUrl;
// Set paths to the images
L.Icon.Default.mergeOptions({
iconRetinaUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png",
iconUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png",
shadowUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png",
});
}
export function MapComponent({ countries, onCountrySelect, selectedCountry }: MapComponentProps) {
const [isClient, setIsClient] = useState(false);
useEffect(() => {
// Fix Leaflet icons on client side
fixLeafletIcons();
setIsClient(true);
}, []);
// Get custom icon based on count
const getMarkerIcon = (count: number, isSelected: boolean) => {
const size = Math.min(Math.max(20, count * 5), 40);
return L.divIcon({
html: `<div style="
background-color: ${isSelected ? '#3b82f6' : '#ef4444'};
color: white;
border-radius: 50%;
width: ${size}px;
height: ${size}px;
display: flex;
justify-content: center;
align-items: center;
font-weight: bold;
font-size: ${size > 30 ? 14 : 12}px;
box-shadow: 0 0 0 2px white;
">${count}</div>`,
className: '',
iconSize: [size, size],
iconAnchor: [size/2, size/2]
});
};
if (!isClient) {
return (
<div className="w-full h-full bg-gray-100 flex items-center justify-center">
<p className="text-gray-500">Loading map...</p>
</div>
);
}
return (
<MapContainer
center={[20, 0]} // Center of the world
zoom={2}
style={{ height: '100%', width: '100%' }}
zoomControl={false}
key="world-map" // Static key to help with React identity
>
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
<ZoomControl position="bottomright" />
{countries.map((country) => (
<Marker
key={country.name}
position={country.position}
icon={getMarkerIcon(country.count, country.name === selectedCountry)}
eventHandlers={{
click: () => onCountrySelect(country.name)
}}
>
<Popup>
<div className="text-center">
<strong>{country.name}</strong>
<div>{country.count} news articles</div>
</div>
</Popup>
</Marker>
))}
</MapContainer>
);
}

View File

@ -1,6 +1,6 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useEffect, useState } from "react";
import dynamic from "next/dynamic";
interface CountryData {
@ -90,27 +90,17 @@ const COUNTRY_COORDINATES: Record<string, [number, number]> = {
'United Nations': [40.7, -74.0], // UN HQ in New York
};
// Dynamic imports for Leaflet components to avoid SSR issues
// We use "any" type here to avoid TypeScript errors with dynamic imports
const MapContainer: any = dynamic(
() => import('react-leaflet').then(mod => mod.MapContainer),
{ ssr: false }
);
const TileLayer: any = dynamic(
() => import('react-leaflet').then(mod => mod.TileLayer),
{ ssr: false }
);
const Marker: any = dynamic(
() => import('react-leaflet').then(mod => mod.Marker),
{ ssr: false }
);
const Popup: any = dynamic(
() => import('react-leaflet').then(mod => mod.Popup),
{ ssr: false }
);
const ZoomControl: any = dynamic(
() => import('react-leaflet').then(mod => mod.ZoomControl),
{ ssr: false }
// Create a client-only map component to avoid SSR issues
const MapComponent = dynamic(
() => import('./map-component').then((mod) => mod.MapComponent),
{
ssr: false,
loading: () => (
<div className="w-full h-full bg-gray-100 flex items-center justify-center">
<p className="text-gray-500">Loading map...</p>
</div>
),
}
);
export function ObservatoryMap({
@ -118,51 +108,6 @@ export function ObservatoryMap({
onCountrySelect,
selectedCountry
}: ObservatoryMapProps) {
const [isMounted, setIsMounted] = useState(false);
const [mapKey, setMapKey] = useState(Date.now()); // Unique key for map container
// We'll need the Leaflet CSS
useEffect(() => {
// Import Leaflet CSS only on the client
const loadLeafletStyles = async () => {
try {
await import('leaflet/dist/leaflet.css');
} catch (e) {
console.error('Failed to load Leaflet CSS', e);
}
};
loadLeafletStyles();
// Add marker icons to prevent missing icons issue
if (typeof window !== 'undefined') {
// Fix Leaflet's icon paths
delete window._leaflet_L;
try {
const L = require('leaflet');
// Set default icon paths
delete L.Icon.Default.prototype._getIconUrl;
L.Icon.Default.mergeOptions({
iconRetinaUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png',
iconUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png',
shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png',
});
} catch (e) {
console.error('Failed to load Leaflet', e);
}
}
setIsMounted(true);
// Return cleanup function
return () => {
// Generate a new key if the component is unmounted and remounted
setMapKey(Date.now());
};
}, []);
// Prepare countries with coordinates
const countriesWithCoordinates = countries.filter(country => {
return COUNTRY_COORDINATES[country.name] !== undefined;
@ -174,79 +119,13 @@ export function ObservatoryMap({
// Sort countries by count (higher count = shows on top)
const sortedCountries = [...countriesWithCoordinates].sort((a, b) => b.count - a.count);
// Get custom icon based on count
const getMarkerIcon = (count: number, isSelected: boolean) => {
if (typeof window === 'undefined') return null;
try {
const L = require('leaflet');
const size = Math.min(Math.max(20, count * 5), 40);
return L.divIcon({
html: `<div style="
background-color: ${isSelected ? '#3b82f6' : '#ef4444'};
color: white;
border-radius: 50%;
width: ${size}px;
height: ${size}px;
display: flex;
justify-content: center;
align-items: center;
font-weight: bold;
font-size: ${size > 30 ? 14 : 12}px;
box-shadow: 0 0 0 2px white;
">${count}</div>`,
className: '',
iconSize: [size, size],
iconAnchor: [size/2, size/2]
});
} catch (e) {
console.error('Failed to create icon', e);
return null;
}
};
if (!isMounted) {
return (
<div className="w-full h-full bg-gray-100 flex items-center justify-center">
<p className="text-gray-500">Loading map...</p>
</div>
);
}
return (
<div className="w-full h-full">
<MapContainer
key={mapKey} // Add a unique key to ensure the map is only initialized once
center={[20, 0]} // Center of the world
zoom={2}
style={{ height: '100%', width: '100%' }}
zoomControl={false}
>
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
<ZoomControl position="bottomright" />
{sortedCountries.map((country) => (
<Marker
key={country.name}
position={country.position!}
icon={getMarkerIcon(country.count, country.name === selectedCountry)}
eventHandlers={{
click: () => onCountrySelect(country.name)
}}
>
<Popup>
<div className="text-center">
<strong>{country.name}</strong>
<div>{country.count} news articles</div>
</div>
</Popup>
</Marker>
))}
</MapContainer>
<MapComponent
countries={sortedCountries}
onCountrySelect={onCountrySelect}
selectedCountry={selectedCountry}
/>
</div>
);
}

View File

@ -23,6 +23,12 @@ export function ObservatoryView() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [selectedCountry, setSelectedCountry] = useState<string | null>(null);
const [isBrowser, setIsBrowser] = useState(false);
// Check if we're in the browser
useEffect(() => {
setIsBrowser(true);
}, []);
// Fetch news data
const fetchNews = async () => {
@ -186,7 +192,7 @@ export function ObservatoryView() {
</div>
</div>
<div className="flex-grow">
{!loading && (
{!loading && isBrowser && (
<ObservatoryMap
key="observatory-map"
countries={Object.entries(countriesMap).map(([name, items]) => ({