diff --git a/migrations/create_infrastructure_reseau.sql b/migrations/create_infrastructure_reseau.sql
new file mode 100644
index 0000000..262dcdb
--- /dev/null
+++ b/migrations/create_infrastructure_reseau.sql
@@ -0,0 +1,54 @@
+-- ============================================================================
+-- Migration : Infrastructure du réseau ferroviaire (Ferrovia Panel)
+-- ============================================================================
+
+-- 1. Table principale pour l'infrastructure
+CREATE TABLE IF NOT EXISTS "public"."infrastructure_reseau" (
+ "id" uuid DEFAULT gen_random_uuid() NOT NULL PRIMARY KEY,
+ "version" varchar(20) DEFAULT '1.0' NOT NULL,
+ "nom_reseau" varchar(255) DEFAULT 'Réseau Ferroviaire Principal',
+ "region" varchar(100),
+ "rails" jsonb DEFAULT '[]'::jsonb NOT NULL,
+ "signaux" jsonb DEFAULT '[]'::jsonb NOT NULL,
+ "pancartes" jsonb DEFAULT '[]'::jsonb NOT NULL,
+ "config" jsonb DEFAULT '{}'::jsonb NOT NULL,
+ "created_at" timestamptz NOT NULL DEFAULT now(),
+ "updated_at" timestamptz NOT NULL DEFAULT now()
+);
+
+-- Index pour recherche rapide
+CREATE INDEX IF NOT EXISTS "idx_infra_reseau_region" ON "public"."infrastructure_reseau" ("region");
+
+-- 2. Ajout des colonnes latitude & longitude dans la table gares si nécessaire
+DO $$
+BEGIN
+ IF NOT EXISTS (
+ SELECT 1 FROM information_schema.columns
+ WHERE table_schema = 'public' AND table_name = 'gares' AND column_name = 'latitude'
+ ) THEN
+ ALTER TABLE "public"."gares" ADD COLUMN "latitude" double precision;
+ END IF;
+
+ IF NOT EXISTS (
+ SELECT 1 FROM information_schema.columns
+ WHERE table_schema = 'public' AND table_name = 'gares' AND column_name = 'longitude'
+ ) THEN
+ ALTER TABLE "public"."gares" ADD COLUMN "longitude" double precision;
+ END IF;
+END $$;
+
+-- 3. RLS (Row-Level Security)
+ALTER TABLE "public"."infrastructure_reseau" ENABLE ROW LEVEL SECURITY;
+
+CREATE POLICY "Lecture ouverte infrastructure_reseau"
+ ON "public"."infrastructure_reseau"
+ FOR SELECT
+ TO authenticated, anon
+ USING (true);
+
+CREATE POLICY "Écriture ouverte infrastructure_reseau"
+ ON "public"."infrastructure_reseau"
+ FOR ALL
+ TO authenticated, anon
+ USING (true)
+ WITH CHECK (true);
diff --git a/package-lock.json b/package-lock.json
index dc63870..ea2d62d 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -18,6 +18,7 @@
"bcryptjs": "^3.0.3",
"jspdf": "^2.5.2",
"jszip": "^3.10.2",
+ "leaflet": "^1.9.4",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-scripts": "5.0.1",
@@ -12725,6 +12726,12 @@
"shell-quote": "^1.8.3"
}
},
+ "node_modules/leaflet": {
+ "version": "1.9.4",
+ "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
+ "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
+ "license": "BSD-2-Clause"
+ },
"node_modules/leven": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz",
diff --git a/package.json b/package.json
index bd60113..72d8bc8 100644
--- a/package.json
+++ b/package.json
@@ -13,6 +13,7 @@
"bcryptjs": "^3.0.3",
"jspdf": "^2.5.2",
"jszip": "^3.10.2",
+ "leaflet": "^1.9.4",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-scripts": "5.0.1",
@@ -22,6 +23,7 @@
"xlsx": "^0.18.5"
},
"scripts": {
+ "prebuild": "node -e \"const fs=require('fs'); if(fs.existsSync('node_modules/wcs-core/dist/wcs')){ fs.cpSync('node_modules/wcs-core/dist/wcs','public/wcs',{recursive:true}); }\"",
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
diff --git a/public/index.html b/public/index.html
index 7805429..df4f1a3 100644
--- a/public/index.html
+++ b/public/index.html
@@ -37,9 +37,32 @@
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
+
+
+
+
+
+
+
+
+
+
+
+
Admin - Gestion des régions
-
+
You need to enable JavaScript to run this app.
+
+
+
+
+ ${escapeHtml(aiguillage.nom || 'Aig')} (${position === 'directe' ? 'Dir' : 'Dév'})
+
+
+ `;
+
+ return L.divIcon({
+ className: 'ferrovia-div-icon',
+ html: html,
+ iconSize: [24, 24],
+ iconAnchor: [12, 12],
+ popupAnchor: [0, -14]
+ });
+}
+
+/**
+ * Icône pour une TJD (Traversée Jonction Double)
+ */
+export function createTjdIcon(tjd, isSelected = false) {
+ const orientation = tjd.orientation || 0;
+ const position = tjd.position_active || 'directe_1'; // 'directe_1' | 'directe_2' | 'jonction_a' | 'jonction_b'
+
+ const html = `
+
+
+
+ ${escapeHtml(tjd.nom || 'TJD')}
+
+
+ `;
+
+ return L.divIcon({
+ className: 'ferrovia-div-icon',
+ html: html,
+ iconSize: [26, 26],
+ iconAnchor: [13, 13],
+ popupAnchor: [0, -14]
+ });
+}
+
+/**
+ * Icône pour un Train en circulation (Mode Régulation)
+ */
+export function createTrainRegulationIcon(train, retardMinutes = 0, isSelected = false) {
+ const isDelayed = retardMinutes > 0;
+ const isCancelled = train.statut === 'supprime' || retardMinutes >= 999;
+
+ let badgeColor = '#10B981'; // À l'heure
+ if (isCancelled) badgeColor = '#EF4444'; // Supprimé
+ else if (retardMinutes >= 15) badgeColor = '#DC2626'; // Fort retard
+ else if (retardMinutes > 0) badgeColor = '#F59E0B'; // Retard léger
+
+ const retardText = isCancelled
+ ? 'SUPPR'
+ : isDelayed
+ ? `+${retardMinutes}'`
+ : 'À l\'heure';
+
+ const html = `
+
+
+ 🚆
+ ${escapeHtml(train.numero_train || 'Train')}
+ ${retardText}
+
+
+ `;
+
+ return L.divIcon({
+ className: 'ferrovia-div-icon',
+ html: html,
+ iconSize: [110, 30],
+ iconAnchor: [55, 15],
+ popupAnchor: [0, -16]
+ });
+}
+
+/**
+ * Icône pour une Perturbation sur la carte
+ */
+export function createPerturbationMarkerIcon(perturbation, isSelected = false) {
+ const html = `
+
+ `;
+
+ return L.divIcon({
+ className: 'ferrovia-div-icon',
+ html: html,
+ iconSize: [30, 30],
+ iconAnchor: [15, 15],
+ popupAnchor: [0, -18]
+ });
+}
+
+/**
+ * Handle de point pour l'édition de tracé
+ */
+export function createWaypointIcon(index, isHovered = false) {
+ const html = `
+
+ ${index + 1}
+
+ `;
+ return L.divIcon({
+ className: 'ferrovia-waypoint-icon',
+ html: html,
+ iconSize: [14, 14],
+ iconAnchor: [7, 7]
+ });
+}
+
+/**
+ * Icône d'aimantage / raccordement magnétique à un rail existant
+ */
+export function createRailAttachmentIcon(target) {
+ const isEndpoint = target.isEndpoint;
+ const color = isEndpoint ? '#10b981' : '#0284c7';
+ const ringColor = isEndpoint ? 'rgba(16, 185, 129, 0.4)' : 'rgba(2, 132, 199, 0.4)';
+ const label = target.railNom ? `${target.railNom} (${target.positionLabel})` : target.positionLabel;
+
+ const html = `
+
+
+
+
+ 🔗
+ Raccord : ${escapeHtml(label)}
+
+
+ `;
+
+ return L.divIcon({
+ className: 'ferrovia-div-icon',
+ html: html,
+ iconSize: [24, 24],
+ iconAnchor: [12, 12]
+ });
+}
+
+/**
+ * Icône interactive pour un sommet de tracé en mode modification des points
+ */
+export function createVertexEditIcon(index, isStart = false, isEnd = false, isSelected = false) {
+ let bgColor = '#0088ce';
+ let label = String(index + 1);
+
+ if (isStart) {
+ bgColor = '#10b981';
+ label = '1';
+ } else if (isEnd) {
+ bgColor = '#ef4444';
+ label = 'F';
+ }
+
+ const border = isSelected ? '3px solid #fbbf24' : '2px solid #ffffff';
+ const shadow = isSelected ? '0 0 12px rgba(251, 191, 36, 0.8), 0 3px 8px rgba(0,0,0,0.5)' : '0 2px 6px rgba(0,0,0,0.35)';
+ const scale = isSelected ? 'transform: scale(1.25);' : '';
+
+ const html = `
+
+ ${label}
+
+ `;
+
+ return L.divIcon({
+ className: 'ferrovia-vertex-icon',
+ html: html,
+ iconSize: [22, 22],
+ iconAnchor: [11, 11]
+ });
+}
+
+/**
+ * Poignée intermédiaire (+) pour insérer un nouveau point entre deux sommets
+ */
+export function createMidpointAddIcon() {
+ const html = `
+
+ +
+
+ `;
+
+ return L.divIcon({
+ className: 'ferrovia-midpoint-icon',
+ html: html,
+ iconSize: [16, 16],
+ iconAnchor: [8, 8]
+ });
+}
+
diff --git a/src/pages/infrastructure/InfrastructureWidget.css b/src/pages/infrastructure/InfrastructureWidget.css
new file mode 100644
index 0000000..7edcf84
--- /dev/null
+++ b/src/pages/infrastructure/InfrastructureWidget.css
@@ -0,0 +1,301 @@
+/* ==========================================================================
+ InfrastructureWidget.css - Vue Widget Diffusable en Plein Écran
+ ========================================================================== */
+
+.infra-widget-container {
+ position: relative;
+ width: 100vw;
+ height: 100vh;
+ margin: 0;
+ padding: 0;
+ overflow: hidden;
+ background-color: #0f172a;
+ font-family: 'Avenir', 'Helvetica Neue', Arial, sans-serif;
+ color: #f8fafc;
+}
+
+.infra-widget-map {
+ width: 100%;
+ height: 100%;
+ z-index: 1;
+}
+
+/* Floating Top HUD */
+.infra-widget-header {
+ position: absolute;
+ top: 14px;
+ left: 14px;
+ right: 14px;
+ z-index: 1000;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ pointer-events: none;
+}
+
+.infra-widget-branding {
+ pointer-events: auto;
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ background: rgba(15, 23, 42, 0.88);
+ backdrop-filter: blur(10px);
+ -webkit-backdrop-filter: blur(10px);
+ padding: 6px 14px;
+ border-radius: 24px;
+ border: 1px solid rgba(255, 255, 255, 0.12);
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
+}
+
+.infra-widget-logo-badge {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ font-weight: 800;
+ font-size: 0.92rem;
+ letter-spacing: -0.3px;
+ color: #38bdf8;
+}
+
+.live-pulse-dot {
+ width: 8px;
+ height: 8px;
+ background-color: #22c55e;
+ border-radius: 50%;
+ box-shadow: 0 0 0 0 rgba(34, 197, 94, 0.7);
+ animation: pulse-green 1.8s infinite;
+}
+
+@keyframes pulse-green {
+ 0% {
+ transform: scale(0.95);
+ box-shadow: 0 0 0 0 rgba(34, 197, 94, 0.7);
+ }
+ 70% {
+ transform: scale(1);
+ box-shadow: 0 0 0 6px rgba(34, 197, 94, 0);
+ }
+ 100% {
+ transform: scale(0.95);
+ box-shadow: 0 0 0 0 rgba(34, 197, 94, 0);
+ }
+}
+
+.infra-widget-clock {
+ font-size: 0.78rem;
+ font-family: monospace;
+ font-weight: 700;
+ color: #94a3b8;
+ border-left: 1px solid rgba(255, 255, 255, 0.15);
+ padding-left: 10px;
+}
+
+.infra-widget-stats {
+ font-size: 0.76rem;
+ color: #cbd5e1;
+ border-left: 1px solid rgba(255, 255, 255, 0.15);
+ padding-left: 10px;
+}
+
+/* Floating Controls (Right) */
+.infra-widget-controls {
+ pointer-events: auto;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.infra-widget-btn {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ height: 34px;
+ padding: 0 12px;
+ background: rgba(15, 23, 42, 0.88);
+ backdrop-filter: blur(10px);
+ -webkit-backdrop-filter: blur(10px);
+ color: #f1f5f9;
+ border: 1px solid rgba(255, 255, 255, 0.15);
+ border-radius: 20px;
+ font-size: 0.8rem;
+ font-weight: 600;
+ cursor: pointer;
+ transition: all 0.2s ease;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
+}
+
+.infra-widget-btn:hover {
+ background: rgba(30, 41, 59, 0.95);
+ border-color: rgba(255, 255, 255, 0.3);
+ transform: translateY(-1px);
+}
+
+.infra-widget-btn.active {
+ background: #0284c7;
+ border-color: #38bdf8;
+ color: #ffffff;
+ box-shadow: 0 0 12px rgba(56, 189, 248, 0.5);
+}
+
+.infra-widget-btn.speed-active {
+ background: linear-gradient(135deg, #7c3aed 0%, #e11d48 100%);
+ border-color: #f43f5e;
+ color: #ffffff;
+ box-shadow: 0 0 14px rgba(244, 63, 94, 0.5);
+}
+
+/* Floating Bottom Speed Legend */
+.infra-widget-speed-legend {
+ position: absolute;
+ bottom: 16px;
+ left: 50%;
+ transform: translateX(-50%);
+ z-index: 1000;
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ background: rgba(15, 23, 42, 0.92);
+ backdrop-filter: blur(12px);
+ -webkit-backdrop-filter: blur(12px);
+ padding: 8px 18px;
+ border-radius: 30px;
+ border: 1px solid rgba(255, 255, 255, 0.15);
+ box-shadow: 0 6px 20px rgba(0, 0, 0, 0.5);
+ max-width: 95vw;
+ overflow-x: auto;
+}
+
+.speed-legend-title {
+ font-size: 0.76rem;
+ font-weight: 700;
+ color: #94a3b8;
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ white-space: nowrap;
+}
+
+.speed-legend-chips {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+
+.speed-legend-item {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ white-space: nowrap;
+}
+
+.speed-color-dot {
+ width: 12px;
+ height: 12px;
+ border-radius: 3px;
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.4);
+}
+
+.speed-label {
+ font-size: 0.74rem;
+ font-weight: 600;
+ color: #e2e8f0;
+}
+
+/* Modal Partager / Diffuser */
+.share-widget-modal-overlay {
+ position: fixed;
+ inset: 0;
+ background: rgba(0, 0, 0, 0.7);
+ backdrop-filter: blur(4px);
+ z-index: 9999;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 16px;
+}
+
+.share-widget-modal-card {
+ width: 100%;
+ max-width: 580px;
+ background: #ffffff;
+ border-radius: 12px;
+ box-shadow: 0 12px 36px rgba(0, 0, 0, 0.35);
+ overflow: hidden;
+ color: #1e293b;
+ display: flex;
+ flex-direction: column;
+}
+
+.share-widget-modal-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 16px 20px;
+ background: #f8fafc;
+ border-bottom: 1px solid #e2e8f0;
+}
+
+.share-widget-modal-header h3 {
+ margin: 0;
+ font-size: 1.1rem;
+ font-weight: 700;
+ color: #0f172a;
+}
+
+.share-widget-modal-body {
+ padding: 20px;
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ overflow-y: auto;
+ max-height: 75vh;
+}
+
+.share-url-box {
+ display: flex;
+ gap: 8px;
+ align-items: center;
+}
+
+.share-url-input {
+ flex: 1;
+ background: #f1f5f9;
+ border: 1px solid #cbd5e1;
+ border-radius: 6px;
+ padding: 8px 12px;
+ font-size: 0.84rem;
+ font-family: monospace;
+ color: #0f172a;
+ outline: none;
+}
+
+.copy-btn {
+ padding: 8px 14px;
+ border-radius: 6px;
+ background: #0284c7;
+ color: #ffffff;
+ border: none;
+ font-weight: 600;
+ font-size: 0.82rem;
+ cursor: pointer;
+ transition: background 0.2s;
+ display: flex;
+ align-items: center;
+ gap: 4px;
+}
+
+.copy-btn:hover {
+ background: #0369a1;
+}
+
+.iframe-snippet-box {
+ background: #0f172a;
+ color: #38bdf8;
+ padding: 12px;
+ border-radius: 6px;
+ font-family: monospace;
+ font-size: 0.78rem;
+ white-space: pre-wrap;
+ word-break: break-all;
+ border: 1px solid #334155;
+}
diff --git a/src/pages/infrastructure/InfrastructureWidget.js b/src/pages/infrastructure/InfrastructureWidget.js
new file mode 100644
index 0000000..e2d4f8b
--- /dev/null
+++ b/src/pages/infrastructure/InfrastructureWidget.js
@@ -0,0 +1,498 @@
+import React, { useState, useEffect, useRef } from 'react';
+import L from 'leaflet';
+import 'leaflet/dist/leaflet.css';
+import './InfrastructureWidget.css';
+import { getInfrastructureData } from '../../api/infrastructure';
+import { getAllGares } from '../../api/gares';
+import { getAllHoraires } from '../../api/horaires';
+import { getAllPerturbations } from '../../api/perturbations';
+import { doesHoraireCirculateOnDate } from '../perturbations/perturbationsUtils';
+import {
+ createGareIcon,
+ createSignalIcon,
+ createPancarteIcon,
+ createAiguillageIcon,
+ createTjdIcon,
+ createTrainRegulationIcon
+} from './InfrastructureIcons';
+import { SPEED_GRADIENT, getSpeedColor } from './InfrastructureCatalog';
+
+const TILE_LAYERS = {
+ positron: {
+ url: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png',
+ options: { maxZoom: 19, subdomains: 'abcd', attribution: '© OpenStreetMap © CARTO' }
+ },
+ dark: {
+ url: 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png',
+ options: { maxZoom: 19, subdomains: 'abcd', attribution: '© OpenStreetMap © CARTO' }
+ },
+ satellite: {
+ url: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
+ options: { maxZoom: 19, attribution: '© Esri' }
+ }
+};
+
+export default function InfrastructureWidget() {
+ const mapContainerRef = useRef(null);
+ const mapInstanceRef = useRef(null);
+ const tileLayerRef = useRef(null);
+
+ // Layers
+ const layerRailsRef = useRef(L.layerGroup());
+ const layerGaresRef = useRef(L.layerGroup());
+ const layerSignauxRef = useRef(L.layerGroup());
+ const layerPancartesRef = useRef(L.layerGroup());
+ const layerAiguillagesRef = useRef(L.layerGroup());
+ const layerTjdsRef = useRef(L.layerGroup());
+ const layerTrainsRef = useRef(L.layerGroup());
+
+ // URL parameters query parsing
+ const urlParams = new URLSearchParams(window.location.search);
+ const initialSpeedMode = urlParams.get('speed') === '1' || urlParams.get('mode') === 'speed';
+ const initialTheme = urlParams.get('theme') === 'light' ? 'positron' : 'dark';
+ const initialShowTrains = urlParams.get('trains') !== '0';
+ const initialShowGares = urlParams.get('gares') !== '0';
+
+ // State
+ const [speedMode, setSpeedMode] = useState(initialSpeedMode);
+ const [activeTheme, setActiveTheme] = useState(initialTheme);
+ const [showTrains, setShowTrains] = useState(initialShowTrains);
+ const [showGares, setShowGares] = useState(initialShowGares);
+
+ const [rails, setRails] = useState([]);
+ const [gares, setGares] = useState([]);
+ const [signaux, setSignaux] = useState([]);
+ const [pancartes, setPancartes] = useState([]);
+ const [aiguillages, setAiguillages] = useState([]);
+ const [tjds, setTjds] = useState([]);
+ const [horaires, setHoraires] = useState([]);
+ const [perturbations, setPerturbations] = useState([]);
+
+ const [currentTimeStr, setCurrentTimeStr] = useState('');
+ const [totalKm, setTotalKm] = useState(0);
+
+ // Clock
+ useEffect(() => {
+ const updateTime = () => {
+ const now = new Date();
+ setCurrentTimeStr(now.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit', second: '2-digit' }));
+ };
+ updateTime();
+ const interval = setInterval(updateTime, 1000);
+ return () => clearInterval(interval);
+ }, []);
+
+ // 1. Initialize Map
+ useEffect(() => {
+ if (!mapContainerRef.current || mapInstanceRef.current) return;
+
+ const map = L.map(mapContainerRef.current, {
+ center: [46.603354, 1.888334],
+ zoom: 6,
+ zoomControl: false,
+ attributionControl: false
+ });
+
+ L.control.zoom({ position: 'bottomright' }).addTo(map);
+
+ const tileConf = TILE_LAYERS[activeTheme] || TILE_LAYERS.dark;
+ tileLayerRef.current = L.tileLayer(tileConf.url, tileConf.options).addTo(map);
+
+ layerRailsRef.current.addTo(map);
+ layerGaresRef.current.addTo(map);
+ layerSignauxRef.current.addTo(map);
+ layerPancartesRef.current.addTo(map);
+ layerAiguillagesRef.current.addTo(map);
+ layerTjdsRef.current.addTo(map);
+ layerTrainsRef.current.addTo(map);
+
+ mapInstanceRef.current = map;
+
+ return () => {
+ map.remove();
+ mapInstanceRef.current = null;
+ };
+ }, []);
+
+ // 2. Load Data
+ useEffect(() => {
+ async function loadData() {
+ try {
+ const [infraRes, garesRes, horairesRes, pertRes] = await Promise.all([
+ getInfrastructureData(),
+ getAllGares(),
+ getAllHoraires(),
+ getAllPerturbations()
+ ]);
+
+ const infraData = infraRes.data || {};
+ const loadedRails = infraData.rails || [];
+ setRails(loadedRails);
+ setSignaux(infraData.signaux || []);
+ setPancartes(infraData.pancartes || []);
+ setAiguillages(infraData.aiguillages || []);
+ setTjds(infraData.tjds || []);
+
+ const loadedGares = garesRes.data || [];
+ setGares(loadedGares);
+ setHoraires(horairesRes.data || []);
+ setPerturbations(pertRes.data || []);
+
+ const km = loadedRails.reduce((acc, r) => acc + (Number(r.longueur_km) || 0), 0);
+ setTotalKm(Math.round(km * 10) / 10);
+
+ // Center map on network if available
+ if (mapInstanceRef.current) {
+ const allPoints = [];
+ loadedRails.forEach((r) => {
+ if (Array.isArray(r.points)) {
+ r.points.forEach((p) => allPoints.push(p));
+ }
+ });
+ loadedGares.forEach((g) => {
+ if (g.informations?.latitude && g.informations?.longitude) {
+ allPoints.push([Number(g.informations.latitude), Number(g.informations.longitude)]);
+ }
+ });
+
+ if (allPoints.length > 0) {
+ const bounds = L.latLngBounds(allPoints);
+ mapInstanceRef.current.fitBounds(bounds, { padding: [50, 50], maxZoom: 14 });
+ }
+ }
+ } catch (err) {
+ console.error('[InfrastructureWidget] Erreur chargement données:', err);
+ }
+ }
+
+ loadData();
+ }, []);
+
+ // 3. Tile layer theme switch
+ useEffect(() => {
+ if (!mapInstanceRef.current || !tileLayerRef.current) return;
+ const tileConf = TILE_LAYERS[activeTheme] || TILE_LAYERS.dark;
+ tileLayerRef.current.setUrl(tileConf.url);
+ }, [activeTheme]);
+
+ // 4. Render Rails (With Speed or Default Coloration)
+ useEffect(() => {
+ if (!mapInstanceRef.current) return;
+ layerRailsRef.current.clearLayers();
+
+ rails.forEach((rail) => {
+ if (!Array.isArray(rail.points) || rail.points.length < 2) return;
+
+ let strokeColor = '#0088ce';
+ let strokeWidth = 5;
+ let dashArray = null;
+
+ if (speedMode) {
+ // Coloration en fonction de la vitesse autorisée
+ strokeColor = getSpeedColor(rail.vitesse_max || 160);
+ strokeWidth = 6;
+ } else {
+ switch (rail.type_voie) {
+ case 'lgv':
+ strokeColor = '#e11d48';
+ strokeWidth = 6;
+ break;
+ case 'service':
+ strokeColor = '#f59e0b';
+ strokeWidth = 3.5;
+ break;
+ case 'evitement':
+ strokeColor = '#10b981';
+ strokeWidth = 4;
+ break;
+ case 'travaux':
+ strokeColor = '#f97316';
+ strokeWidth = 5;
+ dashArray = '6, 6';
+ break;
+ default:
+ strokeColor = '#0088ce';
+ strokeWidth = 5;
+ }
+ }
+
+ // Ballast / contour
+ const ballastLine = L.polyline(rail.points, {
+ color: '#0f172a',
+ weight: strokeWidth + 4,
+ opacity: 0.9,
+ lineCap: 'round',
+ lineJoin: 'round'
+ });
+
+ // Ligne principale
+ const mainLine = L.polyline(rail.points, {
+ color: strokeColor,
+ weight: strokeWidth,
+ opacity: 1,
+ dashArray: dashArray,
+ lineCap: 'round',
+ lineJoin: 'round'
+ });
+
+ const tooltipHtml = `
+ ${rail.nom || 'Voie ferrée'}
+
+ ${rail.type_voie ? rail.type_voie.toUpperCase() : 'VOIE'} • ${rail.longueur_km || 0} km
+
+
+ ⚡ Vitesse max : ${rail.vitesse_max || 160} km/h
+
+ `;
+
+ mainLine.bindTooltip(tooltipHtml, { sticky: true });
+
+ const group = L.featureGroup([ballastLine, mainLine]);
+ layerRailsRef.current.addLayer(group);
+ });
+ }, [rails, speedMode]);
+
+ // 5. Render Stations
+ useEffect(() => {
+ if (!mapInstanceRef.current) return;
+ layerGaresRef.current.clearLayers();
+ if (!showGares) return;
+
+ gares.forEach((gare) => {
+ const lat = Number(gare.informations?.latitude);
+ const lng = Number(gare.informations?.longitude);
+ if (!lat || !lng) return;
+
+ const marker = L.marker([lat, lng], {
+ icon: createGareIcon(gare, false),
+ zIndexOffset: 1000
+ });
+
+ marker.bindTooltip(`${gare.nom} ${gare.code_uic || ''} • ${gare.type || 'Gare'} `, {
+ sticky: true
+ });
+
+ layerGaresRef.current.addLayer(marker);
+ });
+ }, [gares, showGares]);
+
+ // 6. Render Signals, Signs & Switches
+ useEffect(() => {
+ if (!mapInstanceRef.current) return;
+ layerSignauxRef.current.clearLayers();
+ layerPancartesRef.current.clearLayers();
+ layerAiguillagesRef.current.clearLayers();
+ layerTjdsRef.current.clearLayers();
+
+ // Signaux
+ signaux.forEach((s) => {
+ if (!s.lat || !s.lng) return;
+ const marker = L.marker([Number(s.lat), Number(s.lng)], {
+ icon: createSignalIcon(s, false),
+ zIndexOffset: 2000
+ });
+ marker.bindTooltip(`🚦 ${s.nom} (${s.type})Orientation : ${s.orientation || 0}° `, {
+ sticky: true
+ });
+ layerSignauxRef.current.addLayer(marker);
+ });
+
+ // Pancartes
+ pancartes.forEach((p) => {
+ if (!p.lat || !p.lng) return;
+ const marker = L.marker([Number(p.lat), Number(p.lng)], {
+ icon: createPancarteIcon(p, false),
+ zIndexOffset: 2000
+ });
+ marker.bindTooltip(`🪧 ${p.nom || p.texte} (${p.type})`, { sticky: true });
+ layerPancartesRef.current.addLayer(marker);
+ });
+
+ // Aiguillages
+ aiguillages.forEach((a) => {
+ if (!a.lat || !a.lng) return;
+ const marker = L.marker([Number(a.lat), Number(a.lng)], {
+ icon: createAiguillageIcon(a, false),
+ zIndexOffset: 1500
+ });
+ marker.bindTooltip(`🔀 ${a.nom} (${a.type})Position active : ${a.position_active || 'directe'} `, {
+ sticky: true
+ });
+ layerAiguillagesRef.current.addLayer(marker);
+ });
+
+ // TJDs
+ tjds.forEach((t) => {
+ if (!t.lat || !t.lng) return;
+ const marker = L.marker([Number(t.lat), Number(t.lng)], {
+ icon: createTjdIcon(t, false),
+ zIndexOffset: 1500
+ });
+ marker.bindTooltip(`🔀 ${t.nom} (TJD)`, { sticky: true });
+ layerTjdsRef.current.addLayer(marker);
+ });
+ }, [signaux, pancartes, aiguillages, tjds]);
+
+ // 7. Render Real-time circulating trains
+ useEffect(() => {
+ if (!mapInstanceRef.current) return;
+ layerTrainsRef.current.clearLayers();
+ if (!showTrains) return;
+
+ const todayStr = new Date().toISOString().split('T')[0];
+ const activeTrains = horaires.filter((h) => doesHoraireCirculateOnDate(h, todayStr));
+
+ activeTrains.forEach((train) => {
+ const trainPerturbation = perturbations.find((p) => p.horaire_id === train.id);
+ const retardMin = trainPerturbation ? Number(trainPerturbation.retard_minutes) || 0 : 0;
+
+ let trainLat = null;
+ let trainLng = null;
+
+ const depGare = gares.find((g) => g.id === train.gare_depart_id || g.nom === train.gare_depart_nom);
+ if (depGare?.informations?.latitude && depGare?.informations?.longitude) {
+ trainLat = Number(depGare.informations.latitude);
+ trainLng = Number(depGare.informations.longitude);
+ }
+
+ if (trainLat && trainLng) {
+ const marker = L.marker([trainLat, trainLng], {
+ icon: createTrainRegulationIcon(train, retardMin, false),
+ zIndexOffset: 6000
+ });
+
+ marker.bindTooltip(
+ `🚆 Train ${train.numero_train || train.id} ${train.gare_depart_nom || ''} ➔ ${train.gare_arrivee_nom || ''}${retardMin > 0 ? `+${retardMin} min ` : ''}`,
+ { sticky: true }
+ );
+
+ layerTrainsRef.current.addLayer(marker);
+ }
+ });
+ }, [horaires, perturbations, gares, showTrains]);
+
+ // Handle Fullscreen
+ const toggleFullscreen = () => {
+ if (!document.fullscreenElement) {
+ document.documentElement.requestFullscreen().catch(() => {});
+ } else {
+ document.exitFullscreen().catch(() => {});
+ }
+ };
+
+ // Recenter map
+ const handleRecenter = () => {
+ if (!mapInstanceRef.current) return;
+ const allPoints = [];
+ rails.forEach((r) => {
+ if (Array.isArray(r.points)) r.points.forEach((p) => allPoints.push(p));
+ });
+ gares.forEach((g) => {
+ if (g.informations?.latitude && g.informations?.longitude) {
+ allPoints.push([Number(g.informations.latitude), Number(g.informations.longitude)]);
+ }
+ });
+ if (allPoints.length > 0) {
+ mapInstanceRef.current.fitBounds(L.latLngBounds(allPoints), { padding: [50, 50], maxZoom: 14 });
+ }
+ };
+
+ return (
+
+ {/* Top Floating Branding & Controls */}
+
+
+
+
+ FERROVIA INFRASTRUCTURE
+
+
{currentTimeStr}
+
+ {rails.length} voies • {totalKm} km • {gares.length} gares
+
+
+
+
+ setSpeedMode(!speedMode)}
+ title="Activer/Désactiver la coloration des voies par limitation de vitesse"
+ >
+
+ {speedMode ? 'Vitesses : ON' : 'Vitesses'}
+
+
+ setShowTrains(!showTrains)}
+ title="Afficher/Masquer les circulations en temps réel"
+ >
+
+ Trains
+
+
+ setShowGares(!showGares)}
+ title="Afficher/Masquer les gares"
+ >
+
+ Gares
+
+
+ setActiveTheme(activeTheme === 'dark' ? 'positron' : 'dark')}
+ title="Basculer le thème du fond de carte"
+ >
+
+ {activeTheme === 'dark' ? 'Clair' : 'Sombre'}
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* Speed Legend HUD at bottom */}
+ {speedMode && (
+
+
+
+ Vitesses maximales autorisées :
+
+
+ {SPEED_GRADIENT.map((tier, idx) => (
+
+
+ {tier.label}
+
+ ))}
+
+
+ )}
+
+ {/* Leaflet Map */}
+
+
+ );
+}
diff --git a/src/pages/infrastructure/SqlMigrationModal.js b/src/pages/infrastructure/SqlMigrationModal.js
new file mode 100644
index 0000000..dede9b7
--- /dev/null
+++ b/src/pages/infrastructure/SqlMigrationModal.js
@@ -0,0 +1,129 @@
+import React, { useState } from 'react';
+
+const SQL_MIGRATION_SCRIPT = `-- ============================================================================
+-- Migration : Infrastructure du réseau ferroviaire (Ferrovia Panel)
+-- ============================================================================
+
+-- 1. Table principale pour l'infrastructure (si stockage relationnel activé)
+CREATE TABLE IF NOT EXISTS "public"."infrastructure_reseau" (
+ "id" uuid DEFAULT gen_random_uuid() NOT NULL PRIMARY KEY,
+ "version" varchar(20) DEFAULT '1.0' NOT NULL,
+ "nom_reseau" varchar(255) DEFAULT 'Réseau Ferroviaire Principal',
+ "region" varchar(100),
+ "rails" jsonb DEFAULT '[]'::jsonb NOT NULL,
+ "signaux" jsonb DEFAULT '[]'::jsonb NOT NULL,
+ "pancartes" jsonb DEFAULT '[]'::jsonb NOT NULL,
+ "config" jsonb DEFAULT '{}'::jsonb NOT NULL,
+ "created_at" timestamptz NOT NULL DEFAULT now(),
+ "updated_at" timestamptz NOT NULL DEFAULT now()
+);
+
+-- Index pour recherche rapide
+CREATE INDEX IF NOT EXISTS "idx_infra_reseau_region" ON "public"."infrastructure_reseau" ("region");
+
+-- 2. Ajout des colonnes latitude & longitude dans la table gares (si non existantes)
+DO $$
+BEGIN
+ IF NOT EXISTS (
+ SELECT 1 FROM information_schema.columns
+ WHERE table_schema = 'public' AND table_name = 'gares' AND column_name = 'latitude'
+ ) THEN
+ ALTER TABLE "public"."gares" ADD COLUMN "latitude" double precision;
+ END IF;
+
+ IF NOT EXISTS (
+ SELECT 1 FROM information_schema.columns
+ WHERE table_schema = 'public' AND table_name = 'gares' AND column_name = 'longitude'
+ ) THEN
+ ALTER TABLE "public"."gares" ADD COLUMN "longitude" double precision;
+ END IF;
+END $$;
+
+-- 3. RLS (Row-Level Security)
+ALTER TABLE "public"."infrastructure_reseau" ENABLE ROW LEVEL SECURITY;
+
+CREATE POLICY "Lecture ouverte infrastructure_reseau"
+ ON "public"."infrastructure_reseau"
+ FOR SELECT
+ TO authenticated, anon
+ USING (true);
+
+CREATE POLICY "Écriture ouverte infrastructure_reseau"
+ ON "public"."infrastructure_reseau"
+ FOR ALL
+ TO authenticated, anon
+ USING (true)
+ WITH CHECK (true);
+`;
+
+export default function SqlMigrationModal({ isOpen, onClose }) {
+ const [copied, setCopied] = useState(false);
+
+ if (!isOpen) return null;
+
+ const handleCopy = () => {
+ navigator.clipboard.writeText(SQL_MIGRATION_SCRIPT);
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2500);
+ };
+
+ return (
+
+
e.stopPropagation()}>
+
+
Script SQL de Migration - Infrastructure du Réseau
+
+
+
+
+
+
+ Le panel synchronise automatiquement l'infrastructure dans la table parametres de votre Supabase.
+ Si vous souhaitez activer les tables relationnelles dédiées, vous pouvez exécuter le script SQL ci-dessous dans votre SQL Editor Supabase .
+
+
+ {SQL_MIGRATION_SCRIPT}
+
+
+
+
+
+ {copied ? 'Copié dans le presse-papier !' : 'Copier le script SQL'}
+
+
+ Fermer
+
+
+
+
+ );
+}
diff --git a/src/pages/router/PanelRouter.js b/src/pages/router/PanelRouter.js
index fd37a3a..3924b3e 100644
--- a/src/pages/router/PanelRouter.js
+++ b/src/pages/router/PanelRouter.js
@@ -24,6 +24,8 @@ import Messagerie from '../messagerie/Messagerie';
import AccesNonAutorise from '../acces-non-autorise/AccesNonAutorise';
import AttributionMaterielRoulant from '../attribution-materiel-roulant/AttributionMaterielRoulant';
import SystemeMaj from '../systeme-maj/SystemeMaj';
+import Infrastructure from '../infrastructure/Infrastructure';
+import InfrastructureWidget from '../infrastructure/InfrastructureWidget';
import { canPerform, getCurrentUser, hasAccessTo } from '../../utils/userUtils';
export default function PanelRouter() {
@@ -64,6 +66,8 @@ export default function PanelRouter() {
if (path.startsWith('/menu/edition/')) return denyIfNoAccess('menu', 'update') || ;
if (path.startsWith('/menu')) return denyIfNoAccess('menu') || ;
if (path.startsWith('/systeme-maj')) return denyIfNoAccess('systeme_maj') || ;
+ if (path.startsWith('/widget/infrastructure') || path.startsWith('/infrastructure/widget') || path.startsWith('/embed/infrastructure')) return ;
+ if (path.startsWith('/infrastructure')) return denyIfNoAccess('infrastructure') || ;
// default -> dashboard
return ;
diff --git a/src/utils/userUtils.js b/src/utils/userUtils.js
index 4ea8f9b..1bf220a 100644
--- a/src/utils/userUtils.js
+++ b/src/utils/userUtils.js
@@ -23,7 +23,8 @@ export const RBAC_MODULES = [
'parametres',
'utilisateurs_global',
'annonces_sonores',
- 'systeme_maj'
+ 'systeme_maj',
+ 'infrastructure'
];
const denyAll = Object.fromEntries(CRUD_ACTIONS.map((a) => [a, false]));
@@ -56,7 +57,8 @@ export const ROLE_PERMISSION_TEMPLATES = {
messagerie: fullAccess(),
parametres: fullAccess(),
utilisateurs_global: fullAccess(),
- annonces_sonores: fullAccess()
+ annonces_sonores: fullAccess(),
+ infrastructure: fullAccess()
},
exploitant: {
dashboard: readOnly(),
@@ -76,7 +78,8 @@ export const ROLE_PERMISSION_TEMPLATES = {
messagerie: fullAccess(),
parametres: { read: true, create: false, update: true, delete: false },
utilisateurs_global: denyAll,
- annonces_sonores: fullAccess()
+ annonces_sonores: fullAccess(),
+ infrastructure: fullAccess()
},
maintenance: {
dashboard: readOnly(),
@@ -95,7 +98,8 @@ export const ROLE_PERMISSION_TEMPLATES = {
messagerie: readOnly(),
parametres: denyAll,
utilisateurs_global: denyAll,
- annonces_sonores: denyAll
+ annonces_sonores: denyAll,
+ infrastructure: readOnly()
},
regulateur: {
dashboard: readOnly(),
@@ -114,7 +118,8 @@ export const ROLE_PERMISSION_TEMPLATES = {
messagerie: fullAccess(),
parametres: readOnly(),
utilisateurs_global: denyAll,
- annonces_sonores: fullAccess()
+ annonces_sonores: fullAccess(),
+ infrastructure: fullAccess()
},
utilisateur: {
dashboard: readOnly(),
@@ -133,7 +138,8 @@ export const ROLE_PERMISSION_TEMPLATES = {
messagerie: readOnly(),
parametres: denyAll,
utilisateurs_global: denyAll,
- annonces_sonores: denyAll
+ annonces_sonores: denyAll,
+ infrastructure: denyAll
}
};
@@ -242,7 +248,14 @@ export function getEffectivePermissions(user) {
export function canPerform(user, moduleKey, action) {
if (!user || !moduleKey || !action) return false;
const perms = getEffectivePermissions(user);
- return !!(perms[moduleKey] && perms[moduleKey][action]);
+ if (perms[moduleKey] && perms[moduleKey][action] !== undefined) {
+ return !!perms[moduleKey][action];
+ }
+ // Fallback pour infrastructure si non défini dans un ancien profil en cache
+ if (moduleKey === 'infrastructure') {
+ return !!(perms['parametres']?.[action] || perms['gares']?.[action] || user.role === 'admin' || user.role === 'gestionnaire' || user.role === 'exploitant');
+ }
+ return false;
}
/**