Files
2026-03-12 01:22:15 +01:00

558 lines
20 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ADIF Limitaciones Temporales de Velocidad</title>
<!-- Leaflet -->
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<!-- Leaflet MarkerCluster -->
<link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster@1.5.3/dist/MarkerCluster.css" />
<link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster@1.5.3/dist/MarkerCluster.Default.css" />
<script src="https://unpkg.com/leaflet.markercluster@1.5.3/dist/leaflet.markercluster.js"></script>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: 'Segoe UI', system-ui, sans-serif;
display: flex;
flex-direction: column;
height: 100vh;
background: #1a1a2e;
color: #e0e0e0;
}
header {
background: #16213e;
padding: 10px 18px;
display: flex;
align-items: center;
gap: 14px;
border-bottom: 2px solid #0f3460;
flex-shrink: 0;
z-index: 1000;
}
header h1 {
font-size: 1.1rem;
font-weight: 600;
color: #e94560;
letter-spacing: 0.5px;
}
header .subtitle { font-size: 0.78rem; color: #888; }
#counter-badge {
margin-left: auto;
background: #0f3460;
padding: 4px 12px;
border-radius: 20px;
font-size: 0.82rem;
color: #a8d8ea;
white-space: nowrap;
}
.main-layout { display: flex; flex: 1; overflow: hidden; }
/* ── Sidebar ── */
#sidebar {
width: 300px;
min-width: 260px;
background: #16213e;
border-right: 1px solid #0f3460;
display: flex;
flex-direction: column;
overflow: hidden;
z-index: 500;
}
.panel-section {
padding: 11px 14px;
border-bottom: 1px solid #0f3460;
flex-shrink: 0;
}
.panel-section h2 {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 1px;
color: #555;
margin-bottom: 8px;
}
/* Layer toggle buttons */
.layer-btns { display: flex; gap: 6px; flex-wrap: wrap; }
.layer-btn {
padding: 5px 10px;
border-radius: 6px;
font-size: 0.75rem;
cursor: pointer;
border: 1.5px solid #1a4a80;
background: #0f3460;
color: #a8d8ea;
transition: background 0.15s, border-color 0.15s;
font-weight: 500;
}
.layer-btn.active { background: #e94560; border-color: #e94560; color: #fff; }
.layer-btn:hover:not(.active) { border-color: #a8d8ea; }
/* Overlay toggle */
.overlay-row {
display: flex;
align-items: center;
gap: 10px;
margin-top: 6px;
}
.toggle-switch {
position: relative; width: 38px; height: 20px; cursor: pointer;
}
.toggle-switch input { opacity: 0; width: 0; height: 0; }
.toggle-slider {
position: absolute; inset: 0;
background: #0f3460; border-radius: 20px;
transition: background 0.2s;
}
.toggle-slider:before {
content: ''; position: absolute;
width: 14px; height: 14px; border-radius: 50%;
background: #888; left: 3px; top: 3px;
transition: transform 0.2s, background 0.2s;
}
.toggle-switch input:checked + .toggle-slider { background: #0f3460; }
.toggle-switch input:checked + .toggle-slider:before {
transform: translateX(18px); background: #e94560;
}
.overlay-label { font-size: 0.82rem; color: #ccc; }
.overlay-sublabel { font-size: 0.7rem; color: #666; }
/* Search */
#search-input {
width: 100%;
padding: 7px 10px;
background: #0f3460;
border: 1px solid #1a4a80;
border-radius: 6px;
color: #e0e0e0;
font-size: 0.85rem;
outline: none;
}
#search-input:focus { border-color: #e94560; }
#search-input::placeholder { color: #555; }
/* Speed chips */
.speed-filters { display: flex; flex-wrap: wrap; gap: 6px; }
.speed-chip {
padding: 4px 9px;
border-radius: 12px;
font-size: 0.73rem;
cursor: pointer;
border: 2px solid transparent;
font-weight: 700;
color: #fff;
user-select: none;
transition: opacity 0.2s, border-color 0.2s;
}
.speed-chip.inactive { opacity: 0.3; }
.speed-chip:hover { border-color: rgba(255,255,255,0.45); }
/* Legend */
.legend-grid {
display: grid;
grid-template-columns: 13px 1fr;
gap: 5px 8px;
align-items: center;
}
.legend-dot {
width: 13px; height: 13px; border-radius: 50%;
border: 1.5px solid rgba(255,255,255,0.25);
}
.legend-label { font-size: 0.78rem; color: #aaa; }
/* Results list */
#results-list { flex: 1; overflow-y: auto; padding: 6px 0; }
#results-list::-webkit-scrollbar { width: 5px; }
#results-list::-webkit-scrollbar-thumb { background: #0f3460; border-radius: 3px; }
.result-item {
padding: 8px 14px;
border-bottom: 1px solid #0d1b38;
cursor: pointer;
transition: background 0.12s;
}
.result-item:hover { background: #0f3460; }
.ri-line {
font-size: 0.68rem; color: #777;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
margin-bottom: 2px;
}
.ri-station {
font-size: 0.83rem; font-weight: 500; color: #ddd;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.ri-meta { margin-top: 4px; display: flex; gap: 6px; align-items: center; }
.ri-speed-badge {
font-size: 0.71rem; padding: 1px 7px;
border-radius: 9px; color: #fff; font-weight: 700; flex-shrink: 0;
}
.ri-reason {
font-size: 0.69rem; color: #666;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
/* Map */
#map { flex: 1; }
/* Leaflet layer control ajuste dark */
.leaflet-control-layers {
background: #16213e !important;
color: #ccc !important;
border: 1px solid #0f3460 !important;
}
.leaflet-control-layers label { color: #ccc !important; }
.leaflet-control-layers-separator { border-top-color: #0f3460 !important; }
/* Popup */
.ltv-popup { min-width: 240px; font-family: 'Segoe UI', system-ui, sans-serif; }
.ltv-popup .pop-header {
background: #16213e; color: #e94560;
padding: 7px 10px; font-weight: 700; font-size: 0.82rem;
border-radius: 3px 3px 0 0;
margin: -5px -12px 8px -12px;
}
.ltv-popup table { width: 100%; border-collapse: collapse; font-size: 0.8rem; }
.ltv-popup td:first-child { color: #888; padding: 3px 8px 3px 0; white-space: nowrap; vertical-align: top; }
.ltv-popup td:last-child { color: #111; font-weight: 500; }
.ltv-popup .speed-big { font-size: 1.5rem; font-weight: 800; line-height: 1; }
</style>
</head>
<body>
<header>
<div>
<h1>🚆 ADIF Limitaciones Temporales de Velocidad</h1>
<div class="subtitle">Red ferroviaria española &nbsp;·&nbsp; Datos LTV activos</div>
</div>
<div id="counter-badge">Cargando…</div>
</header>
<div class="main-layout">
<div id="sidebar">
<!-- Mapa base -->
<div class="panel-section">
<h2>Mapa base</h2>
<div class="layer-btns">
<button class="layer-btn active" data-base="osm">OSM Estándar</button>
<button class="layer-btn" data-base="topo">OSM Topo</button>
<button class="layer-btn" data-base="dark">Dark</button>
<button class="layer-btn" data-base="satellite">Satélite</button>
</div>
</div>
<!-- Capa ferroviaria -->
<div class="panel-section">
<h2>Capa ferroviaria OpenRailwayMap</h2>
<div class="overlay-row">
<label class="toggle-switch">
<input type="checkbox" id="toggle-railway" checked />
<span class="toggle-slider"></span>
</label>
<div>
<div class="overlay-label">Infraestructura ferroviaria</div>
<div class="overlay-sublabel">Líneas, estaciones y señales</div>
</div>
</div>
<div style="margin-top:8px;" class="layer-btns" id="orm-style-btns">
<button class="layer-btn active" data-orm="standard">Estándar</button>
<button class="layer-btn" data-orm="maxspeed">Velocidades</button>
<button class="layer-btn" data-orm="signals">Señales</button>
<button class="layer-btn" data-orm="electrification">Electrif.</button>
</div>
</div>
<!-- Búsqueda -->
<div class="panel-section">
<h2>Buscar restricción</h2>
<input id="search-input" type="text" placeholder="Línea, estación, motivo…" />
</div>
<!-- Filtro velocidad -->
<div class="panel-section">
<h2>Filtrar por velocidad</h2>
<div class="speed-filters" id="speed-filters"></div>
</div>
<!-- Leyenda -->
<div class="panel-section">
<h2>Leyenda LTV</h2>
<div class="legend-grid" id="legend-grid"></div>
</div>
<div id="results-list"></div>
</div>
<div id="map"></div>
</div>
<script>
// ── Rangos de velocidad ────────────────────────────────────────────────────
const SPEED_RANGES = [
{ max: 30, color: '#d63031', label: '≤ 30 km/h' },
{ max: 60, color: '#e17055', label: '31 60 km/h' },
{ max: 100, color: '#fdcb6e', label: '61 100 km/h' },
{ max: 160, color: '#00b894', label: '101 160 km/h' },
{ max: Infinity, color: '#74b9ff', label: '> 160 km/h' },
];
function speedColor(s) {
return SPEED_RANGES.find(r => s <= r.max)?.color ?? '#74b9ff';
}
function rangeLabel(s) {
return SPEED_RANGES.find(r => s <= r.max)?.label ?? '';
}
function makeIcon(color) {
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="22" height="30" viewBox="0 0 22 30">
<path d="M11 0C5 0 0 5 0 11c0 8.3 11 19 11 19S22 19.3 22 11C22 5 17 0 11 0z"
fill="${color}" stroke="rgba(0,0,0,0.5)" stroke-width="1.2"/>
<circle cx="11" cy="11" r="4.5" fill="rgba(255,255,255,0.9)"/>
</svg>`;
return L.divIcon({
html: svg, className: '',
iconSize: [22, 30], iconAnchor: [11, 30], popupAnchor: [0, -30],
});
}
// ── Capas base ─────────────────────────────────────────────────────────────
const baseLayers = {
osm: L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
maxZoom: 19,
}),
topo: L.tileLayer('https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png', {
attribution: '&copy; <a href="https://opentopomap.org">OpenTopoMap</a>',
maxZoom: 17,
}),
dark: L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
attribution: '&copy; <a href="https://carto.com/">CARTO</a>',
subdomains: 'abcd', maxZoom: 19,
}),
satellite: L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', {
attribution: '&copy; <a href="https://www.esri.com/">Esri</a>',
maxZoom: 19,
}),
};
// ── Capas OpenRailwayMap ───────────────────────────────────────────────────
const ORM_ATTR = 'Mapa: &copy; <a href="https://www.openstreetmap.org/copyright">OSM</a> | '
+ 'Ferroviario: &copy; <a href="https://www.openrailwaymap.org/">OpenRailwayMap</a>';
const ormLayers = {
standard: L.tileLayer('https://{s}.tiles.openrailwaymap.org/standard/{z}/{x}/{y}.png', { attribution: ORM_ATTR, maxZoom: 19, opacity: 0.85 }),
maxspeed: L.tileLayer('https://{s}.tiles.openrailwaymap.org/maxspeed/{z}/{x}/{y}.png', { attribution: ORM_ATTR, maxZoom: 19, opacity: 0.85 }),
signals: L.tileLayer('https://{s}.tiles.openrailwaymap.org/signals/{z}/{x}/{y}.png', { attribution: ORM_ATTR, maxZoom: 19, opacity: 0.85 }),
electrification:L.tileLayer('https://{s}.tiles.openrailwaymap.org/electrification/{z}/{x}/{y}.png',{ attribution: ORM_ATTR, maxZoom: 19, opacity: 0.85 }),
};
// ── Inicializar mapa ───────────────────────────────────────────────────────
const map = L.map('map', { zoomControl: true }).setView([40.4, -3.7], 6);
let currentBase = baseLayers.osm;
let currentOrm = ormLayers.standard;
let ormVisible = true;
currentBase.addTo(map);
currentOrm.addTo(map);
// ── Cluster ────────────────────────────────────────────────────────────────
const clusterGroup = L.markerClusterGroup({
maxClusterRadius: 40,
iconCreateFunction: cluster => {
const count = cluster.getChildCount();
const size = count < 10 ? 30 : count < 50 ? 36 : 44;
return L.divIcon({
html: `<div style="
background: rgba(233,69,96,0.85);
border: 2px solid rgba(255,255,255,0.6);
border-radius: 50%;
width:${size}px; height:${size}px;
display:flex; align-items:center; justify-content:center;
color:#fff; font-weight:700; font-size:${size < 36 ? 11 : 13}px;
box-shadow: 0 2px 8px rgba(0,0,0,0.4);
">${count}</div>`,
className: '', iconSize: [size, size],
});
},
});
map.addLayer(clusterGroup);
// ── Estado ─────────────────────────────────────────────────────────────────
let allItems = [];
let activeRanges = new Set(SPEED_RANGES.map(r => r.label));
let searchText = '';
let markerMap = new Map();
// ── Construir UI estática ──────────────────────────────────────────────────
const legendGrid = document.getElementById('legend-grid');
const filtersDiv = document.getElementById('speed-filters');
SPEED_RANGES.forEach(r => {
legendGrid.innerHTML +=
`<div class="legend-dot" style="background:${r.color}"></div>
<div class="legend-label">${r.label}</div>`;
const chip = document.createElement('div');
chip.className = 'speed-chip';
chip.style.background = r.color;
chip.textContent = r.label;
chip.dataset.range = r.label;
chip.addEventListener('click', () => {
if (activeRanges.has(r.label)) {
activeRanges.delete(r.label);
chip.classList.add('inactive');
} else {
activeRanges.add(r.label);
chip.classList.remove('inactive');
}
applyFilters();
});
filtersDiv.appendChild(chip);
});
// ── Popup ──────────────────────────────────────────────────────────────────
function buildPopup(item) {
const color = speedColor(item.speed);
return `<div class="ltv-popup">
<div class="pop-header">${item.trayectoEstacion || '—'}</div>
<table>
<tr><td>Línea</td><td>${item.line}</td></tr>
<tr><td>PK inicio</td><td>${item.pk}</td></tr>
<tr><td>PK fin</td><td>${item.pkEnd}</td></tr>
<tr><td>Vía</td><td>${item.track}</td></tr>
<tr><td>Velocidad</td><td>
<span class="speed-big" style="color:${color}">${item.speed}</span>
<small style="color:#888"> km/h</small>
</td></tr>
<tr><td>Motivo</td><td>${item.reason}</td></tr>
<tr><td>Código LTV</td><td style="font-family:monospace">${item.codigoLtv}</td></tr>
${item.horarioLtv ? `<tr><td>Horario</td><td>${item.horarioLtv}</td></tr>` : ''}
</table>
</div>`;
}
// ── Filtros ────────────────────────────────────────────────────────────────
function matchesFilters(item) {
if (!activeRanges.has(rangeLabel(item.speed))) return false;
if (searchText) {
const q = searchText.toLowerCase();
return (
item.line?.toLowerCase().includes(q) ||
item.trayectoEstacion?.toLowerCase().includes(q) ||
item.reason?.toLowerCase().includes(q) ||
item.codigoLtv?.toLowerCase().includes(q)
);
}
return true;
}
function applyFilters() {
clusterGroup.clearLayers();
const list = document.getElementById('results-list');
list.innerHTML = '';
let count = 0;
allItems.forEach((item, idx) => {
if (!matchesFilters(item)) return;
count++;
const marker = markerMap.get(idx);
if (marker) clusterGroup.addLayer(marker);
const color = speedColor(item.speed);
const div = document.createElement('div');
div.className = 'result-item';
div.innerHTML = `
<div class="ri-line">${item.line}</div>
<div class="ri-station">${item.trayectoEstacion || 'Sin estación'}</div>
<div class="ri-meta">
<span class="ri-speed-badge" style="background:${color}">${item.speed} km/h</span>
<span class="ri-reason" title="${item.reason}">${item.reason}</span>
</div>`;
div.addEventListener('click', () => {
const m = markerMap.get(idx);
if (!m) return;
map.setView([item.lat, item.lng], 14);
clusterGroup.zoomToShowLayer(m, () => m.openPopup());
});
list.appendChild(div);
});
document.getElementById('counter-badge').textContent =
`${count} restricción${count !== 1 ? 'es' : ''}`;
}
// ── Cargar JSON ────────────────────────────────────────────────────────────
fetch('adif.json')
.then(r => r.json())
.then(data => {
allItems = data.items;
allItems.forEach((item, idx) => {
if (!item.lat || !item.lng) return;
const marker = L.marker([item.lat, item.lng], { icon: makeIcon(speedColor(item.speed)) })
.bindPopup(buildPopup(item), { maxWidth: 320 });
markerMap.set(idx, marker);
});
applyFilters();
})
.catch(() => {
document.getElementById('counter-badge').textContent = 'Error al cargar datos';
});
// ── Controles de capa base ─────────────────────────────────────────────────
document.querySelectorAll('[data-base]').forEach(btn => {
btn.addEventListener('click', () => {
const key = btn.dataset.base;
if (!baseLayers[key] || currentBase === baseLayers[key]) return;
map.removeLayer(currentBase);
currentBase = baseLayers[key];
currentBase.addTo(map);
currentBase.bringToBack();
// asegurar que ORM queda encima
if (ormVisible) currentOrm.bringToFront();
document.querySelectorAll('[data-base]').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
});
});
// ── Controles ORM style ────────────────────────────────────────────────────
document.querySelectorAll('[data-orm]').forEach(btn => {
btn.addEventListener('click', () => {
const key = btn.dataset.orm;
if (!ormLayers[key] || currentOrm === ormLayers[key]) return;
map.removeLayer(currentOrm);
currentOrm = ormLayers[key];
if (ormVisible) currentOrm.addTo(map);
document.querySelectorAll('[data-orm]').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
});
});
// ── Toggle capa ferroviaria ────────────────────────────────────────────────
document.getElementById('toggle-railway').addEventListener('change', e => {
ormVisible = e.target.checked;
if (ormVisible) {
currentOrm.addTo(map);
} else {
map.removeLayer(currentOrm);
}
});
// ── Búsqueda ───────────────────────────────────────────────────────────────
document.getElementById('search-input').addEventListener('input', e => {
searchText = e.target.value.trim();
applyFilters();
});
</script>
</body>
</html>