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 - +
+ + + +
+ ${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 +
+
+ +
+ + + + + + + + + + + +
+
+ + {/* 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}
+          
+
+
+ + +
+
+
+ ); +} 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; } /**