const { useEffect, useState, useRef } = React;
function formatNumber(value) {
return Number(value).toLocaleString('fr-FR');
}
function fetchJson(url) {
return fetch(url).then((res) => {
if (!res.ok) {
throw new Error(`${res.status} ${res.statusText}`);
}
return res.json();
});
}
function useQuartiers() {
const [quartiers, setQuartiers] = useState([]);
const [loadingQuartiers, setLoadingQuartiers] = useState(true);
useEffect(() => {
let active = true;
fetchJson('/api/data')
.then((data) => {
if (!active) return;
const names = [...new Set(data.map((item) => item.quartier).filter(Boolean))].sort();
setQuartiers(names);
})
.catch(() => {
if (active) setQuartiers([]);
})
.finally(() => {
if (active) setLoadingQuartiers(false);
});
return () => {
active = false;
};
}, []);
return { quartiers, loadingQuartiers };
}
// Tile providers available for the map. Keys are stable identifiers used for
// persistence and switching at runtime.
const TILE_PROVIDERS = [
{ key: 'tileProxy', label: 'OSM proxy local', url: '/tiles/{z}/{x}/{y}.png', attribution: '© OpenStreetMap contributors' },
{ key: 'osm', label: 'OpenStreetMap', url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', attribution: '© OpenStreetMap contributors' },
{ key: 'osmfr', label: 'OSM France (osmfr)', url: 'https://tile.openstreetmap.fr/osmfr/{z}/{x}/{y}.png', attribution: '© OpenStreetMap contributors — osmfr' },
{ key: 'stamen', label: 'Stamen Toner', url: 'https://stamen-tiles.a.ssl.fastly.net/toner/{z}/{x}/{y}.png', attribution: 'Map tiles by Stamen' },
{ key: 'carto', label: 'Carto Voyager', url: 'https://cartodb-basemaps-a.global.ssl.fastly.net/rastertiles/voyager/{z}/{x}/{y}.png', attribution: '© Carto' },
{ key: 'embedded', label: 'Embedded (fallback)', url: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAICAYAAADED76LAAAALUlEQVQoU2NkYGD4z0AEYBxVSFGBgYGBgYGBg+M8wMDAwMDAwMDAwMDAwMAAA5YQEuWqz8OAAAAAElFTkSuQmCC', attribution: 'Local fallback' }
];
// Custom simple router based on window location hash
function useHashRouter() {
const normalizeHash = (hash) => {
if (!hash) return '/';
const normalized = hash.startsWith('#') ? hash.slice(1) : hash;
if (normalized === '') return '/';
if (normalized.endsWith('/') && normalized !== '/') {
return normalized.slice(0, -1);
}
return normalized;
};
const [currentHash, setCurrentHash] = useState(normalizeHash(window.location.hash));
useEffect(() => {
const handleHashChange = () => {
setCurrentHash(normalizeHash(window.location.hash));
};
window.addEventListener('hashchange', handleHashChange);
return () => window.removeEventListener('hashchange', handleHashChange);
}, []);
return {
go: (path) => {
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
window.location.hash = normalizedPath;
},
path: currentHash,
};
}
function Sidebar({ router, currentPath }) {
const mapPaths = ['/', '/carte'];
const isActive = (path) => {
if (mapPaths.includes(path)) {
return mapPaths.includes(currentPath);
}
return currentPath === path;
};
return (
);
}
function PageHeader({ title, subtitle }) {
return (
{title}
{subtitle ?
{subtitle}
: null}
);
}
function CartePage() {
const [data, setData] = useState([]);
const [stats, setStats] = useState(null);
const [error, setError] = useState(null);
const [tileProvider, setTileProvider] = useState(localStorage.getItem('tile_provider') || 'tileProxy');
const mapRef = useRef(null);
const leafletMap = useRef(null);
const markers = useRef(null);
useEffect(() => {
async function loadPageData() {
try {
const [dataResult, statsResult] = await Promise.all([fetchJson('/api/data'), fetchJson('/api/stats')]);
// Extract array from { data: [...] } response format
setData(Array.isArray(dataResult) ? dataResult : (dataResult?.data || []));
setStats(statsResult);
if (window.enableLegacyDataAccess) {
window.enableLegacyDataAccess();
}
} catch (err) {
setError(err.message);
}
}
loadPageData();
}, []);
useEffect(() => {
if (!mapRef.current || leafletMap.current) return;
leafletMap.current = L.map(mapRef.current, {
center: [16.0326, -16.4818],
zoom: 13,
zoomControl: true,
});
// Use TILE_PROVIDERS list and expose a runtime switch to change provider.
let providerIndex = 0;
let currentLayer = null;
let providerOk = false;
function applyProviderByKey(key) {
const p = TILE_PROVIDERS.find(t => t.key === key) || TILE_PROVIDERS[0];
if (!leafletMap.current) return;
try { if (currentLayer) leafletMap.current.removeLayer(currentLayer); } catch (e) {}
providerOk = false;
// Use tileSize small for embedded data URI provider
const opts = p.key === 'embedded' ? { tileSize: 8, attribution: p.attribution } : { attribution: p.attribution };
// Add a cache-busting query param for the local proxy provider to avoid stale/blocked tiles
const url = p.key === 'tileProxy' ? `${p.url}?v=${Date.now()}` : p.url;
currentLayer = L.tileLayer(url, opts);
const onTileLoad = () => {
providerOk = true;
currentLayer.off('tileload', onTileLoad);
currentLayer.off('tileerror', onTileError);
};
const onTileError = () => {
setTimeout(() => {
if (!providerOk && p.key !== 'embedded') {
applyProviderByKey('embedded');
}
}, 800);
};
currentLayer.on('tileload', onTileLoad);
currentLayer.on('tileerror', onTileError);
currentLayer.addTo(leafletMap.current);
}
// Expose global switch for other code and for runtime changes
window.setTileProvider = function (key) {
try {
applyProviderByKey(key);
} catch (e) {
applyProviderByKey('embedded');
}
};
// apply initial provider (from persisted selection)
try {
window.setTileProvider(localStorage.getItem('tile_provider') || tileProvider || 'osm');
} catch (e) { window.setTileProvider('embedded'); }
markers.current = L.markerClusterGroup();
leafletMap.current.addLayer(markers.current);
// Force-add proxy TileLayer shortly after initialization to ensure tiles
// are attached to the existing Leaflet instance even if provider logic failed.
setTimeout(() => {
try {
if (leafletMap.current) {
const forced = L.tileLayer('/tiles/{z}/{x}/{y}.png?v=' + Date.now(), {
attribution: '© OpenStreetMap contributors',
maxZoom: 19
}).addTo(leafletMap.current);
try {
// Ask Leaflet to redraw the forced layer and invalidate size
if (typeof forced.redraw === 'function') forced.redraw();
leafletMap.current.invalidateSize(true);
// Update any already-created tile
elements to include a cache-busting param
const now = Date.now();
document.querySelectorAll('.leaflet-tile').forEach((img) => {
try {
if (img && img.src) {
const hasV = /[?&]v=/.test(img.src);
if (!hasV) {
img.src = img.src + (img.src.includes('?') ? '&' : '?') + 'v=' + now;
}
}
} catch (e) {}
});
} catch (e) {}
// If tiles remain blocked, try switching to a different public provider
try {
if (window.setTileProvider) window.setTileProvider('carto');
} catch (e) {}
}
} catch (e) {
console.warn('Force tileLayer add failed', e);
}
}, 500);
return () => {
if (leafletMap.current) {
leafletMap.current.remove();
leafletMap.current = null;
}
};
}, []);
useEffect(() => {
if (!markers.current || !leafletMap.current) return;
if (!Array.isArray(data) || data.length === 0) return;
markers.current.clearLayers();
const markerItems = [];
data.forEach((item) => {
const lat = Number(item.latitude);
const lng = Number(item.longitude);
if (!Number.isFinite(lat) || !Number.isFinite(lng)) return;
const marker = L.marker([lat, lng]);
marker.bindPopup(`
${item.quartier || 'Quartier inconnu'}
Population : ${formatNumber(item.population)}
Cartes vendues : ${formatNumber(item.cartes_vendues)}
Montant : ${formatNumber(item.montant)} FCFA
`);
markers.current.addLayer(marker);
markerItems.push(marker);
});
console.log('CartePage: data items=', data.length, 'markerItems=', markerItems.length);
if (leafletMap.current) {
try {
// ensure map redraw and marker clusters are visible
leafletMap.current.invalidateSize(true);
} catch (e) {}
}
if (markerItems.length > 0) {
// Keep the map centered and zoomed on Saint-Louis. Do not use automatic bounds fitting.
}
}, [data]);
useEffect(() => {
if (mapRef.current && leafletMap.current) {
leafletMap.current.setView([16.0326, -16.4818], 13);
}
}, []);
// When the React state `tileProvider` changes, instruct the map to switch provider.
useEffect(() => {
try {
if (window.setTileProvider) {
window.setTileProvider(tileProvider);
}
localStorage.setItem('tile_provider', tileProvider);
} catch (e) {}
}, [tileProvider]);
return (
{error ? Erreur : {error}
: null}
Statistiques géographiques
{stats ? (
Quartiers analysés
{formatNumber(stats.quartiers_count)}
Population totale
{formatNumber(stats.population_totale)}
Montant total
{formatNumber(stats.ventes_total)} FCFA
Taux de couverture
{stats.couverture_pct ?? 0}%
) : (
Chargement des statistiques...
)}
Filtres associés
);
}
function DashboardPage() {
const [stats, setStats] = useState(null);
const [data, setData] = useState([]);
const [error, setError] = useState(null);
const chartRef = useRef(null);
const chartInstance = useRef(null);
useEffect(() => {
async function loadStats() {
try {
const [statsResult, dataResult] = await Promise.all([fetchJson('/api/stats'), fetchJson('/api/data')]);
setStats(statsResult);
setData(Array.isArray(dataResult) ? dataResult : (dataResult?.data || []));
} catch (err) {
setError(err.message);
}
}
loadStats();
}, []);
useEffect(() => {
if (!chartRef.current || !data.length) return;
const phases = ['Jan', 'Fév', 'Mar', 'Avr', 'Mai', 'Juin'];
const values = [12, 18, 26, 23, 29, data.length];
if (chartInstance.current) {
chartInstance.current.destroy();
}
chartInstance.current = new Chart(chartRef.current, {
type: 'line',
data: {
labels: phases,
datasets: [{
label: 'Evolution des quartiers',
data: values,
borderColor: '#10b981',
backgroundColor: 'rgba(16, 185, 129, 0.15)',
fill: true,
tension: 0.3,
}],
},
options: {
responsive: true,
plugins: {
legend: { display: false },
},
scales: {
x: { grid: { display: false } },
y: { beginAtZero: true },
},
},
});
}, [data]);
return (
{error ? Erreur : {error}
: null}
Population totale
{stats ? formatNumber(stats.population_totale) : '...'}
Quartiers suivis
{stats ? formatNumber(stats.quartiers_count) : '...'}
Total ventes
{stats ? formatNumber(stats.ventes_total) + ' FCFA' : '...'}
Couverture
{stats ? `${stats.couverture_pct ?? 0}%` : '...'}
Statistiques globales
Taux de couverture
{stats ? `${stats.couverture_pct ?? 0}%` : '...'}
Quartiers nouveaux
{stats ? formatNumber(stats.quartiers_count) : '...'}
Valeur moyenne par quartier
{stats ? formatNumber(Math.round(stats.ventes_total / Math.max(stats.quartiers_count, 1))) : '...'}
Tendance
);
}
function VisitesPage() {
const [visites, setVisites] = useState([]);
const [error, setError] = useState(null);
const { quartiers, loadingQuartiers } = useQuartiers();
useEffect(() => {
fetchJson('/api/visites')
.then((res) => {
// Extract array from response if needed
setVisites(Array.isArray(res) ? res : (res?.visites || []));
})
.catch((err) => setError(err.message));
}, []);
return (
{error ? Erreur : {error}
: null}
Liste des visites
{visites.length === 0 ? (
Aucune visite enregistrée.
) : (
visites.map((visite) => (
{visite.quartier || 'Quartier inconnu'}
{visite.date_visite || 'Date indisponible'}
Statut : {visite.statut || 'N/A'}
Notes : {visite.notes || 'Aucune note'}
))
)}
Statistiques visites
Nombre total de visites enregistrées
);
}
function VentesPage() {
const [ventes, setVentes] = useState([]);
const [error, setError] = useState(null);
const { quartiers, loadingQuartiers } = useQuartiers();
useEffect(() => {
fetchJson('/api/ventes')
.then((res) => {
// Extract array from response if needed
setVentes(Array.isArray(res) ? res : (res?.ventes || []));
})
.catch((err) => setError(err.message));
}, []);
const totalMontant = ventes.reduce((sum, vente) => sum + Number(vente.montant || 0), 0);
const totalCartes = ventes.reduce((sum, vente) => sum + Number(vente.cartes || 0), 0);
return (
{error ? Erreur : {error}
: null}
Transactions
{ventes.length}
Total cartes
{formatNumber(totalCartes)}
Montant total
{formatNumber(totalMontant)} FCFA
Historique des ventes
{ventes.length === 0 ? (
Aucune vente enregistrée.
) : (
ventes.map((vente) => (
{vente.quartier || 'Quartier inconnu'}
{vente.cartes || 0} cartes
Montant : {formatNumber(vente.montant || 0)} FCFA
Date : {vente.date || 'N/A'}
))
)}
);
}
function ActivitesPage() {
const [activites, setActivites] = useState([]);
const [error, setError] = useState(null);
const { quartiers, loadingQuartiers } = useQuartiers();
useEffect(() => {
fetchJson('/api/activites')
.then((res) => {
// Extract array from response if needed
setActivites(Array.isArray(res) ? res : (res?.activites || []));
})
.catch((err) => setError(err.message));
}, []);
return (
{error ? Erreur : {error}
: null}
Liste des activités
{activites.length === 0 ? (
Aucune activité enregistrée.
) : (
activites.map((activite) => (
{activite.quartier || 'Quartier inconnu'}
{activite.type_activite || activite.type || 'Type inconnu'}
Participants : {activite.participants || 'N/A'}
Date : {activite.date || 'N/A'}
))
)}
Statistiques activités
Total activités
{activites.length}
Participants totaux
{activites.reduce((sum, item) => sum + Number(item.participants || 0), 0)}
);
}
function App() {
const router = useHashRouter();
const path = router.path.split('?')[0] || '/';
const renderPage = () => {
switch (path) {
case '/':
case '/carte':
return ;
case '/dashboard':
return ;
case '/visites':
return ;
case '/ventes':
return ;
case '/activites':
return ;
default:
return ;
}
};
return (
{renderPage()}
);
}
const root = ReactDOM.createRoot(document.getElementById('react-root'));
root.render();