// ========================================== // 1. Config (Focused on Shanghai) // ========================================== const CONFIG = { MAPBOX_TOKEN: 'pk.eyJ1IjoieXlhaXl5IiwiYSI6ImNtaTVpMTVlaTJmdzMybW9zcmFieGxpdHUifQ.181d6E5fzLw1CEZMEPU53Q', API_BASE: '/api', // Shanghai City Center DEFAULT_CENTER: [121.4737, 31.2304], DEFAULT_ZOOM: 10.5, // Shanghai Coordinate Bounds [Southwest, Northeast] SHANGHAI_BOUNDS: [ [120.80, 30.60], // Southwest [122.50, 31.90] // Northeast ] }; // ========================================== // 2. Globals // ========================================== let chartInstance = null; let predictionChartInstance = null; let currentMarker = null; let mapInstance = null; let globalStationData = []; let animationFrameId = null; let isPredictionMode = false; let predictionMarker = null; let optimalMarker = null; // ========================================== // 3. API Logic // ========================================== async function fetchLocations() { console.log("Requesting backend data..."); const res = await fetch(`${CONFIG.API_BASE}/stations/locations`); if (!res.ok) throw new Error(`API Error: ${res.status}`); return await res.json(); } async function fetchStationDetail(id) { try { const res = await fetch(`${CONFIG.API_BASE}/stations/detail/${id}`); return await res.json(); } catch (e) { console.error("Fetch Detail Error:", e); return null; } } // Fetch AI Prediction Data async function fetchPrediction(id) { try { const res = await fetch(`${CONFIG.API_BASE}/predict/${id}?t=${Date.now()}`); const data = await res.json(); if (data.error) throw new Error(data.error); return data; } catch (e) { console.error("Prediction API Error:", e); alert("Prediction failed: " + e.message); return null; } } function loadSatellitePatch(lng, lat) { // Logic for loading static satellite imagery patch const img = document.getElementById('satellite-patch'); const placeholder = document.getElementById('sat-placeholder'); if(!img) return; img.style.display = 'none'; placeholder.style.display = 'flex'; placeholder.innerHTML = '

Loading...

'; img.src = `https://api.mapbox.com/styles/v1/mapbox/satellite-v9/static/${lng},${lat},16,0,0/320x200?access_token=${CONFIG.MAPBOX_TOKEN}`; img.onload = () => { img.style.display = 'block'; placeholder.style.display = 'none'; }; } // ========================================== // 4. Chart Logic (Normal & Prediction) // ========================================== function renderChart(recordData) { const ctx = document.getElementById('energyChart').getContext('2d'); if (chartInstance) chartInstance.destroy(); chartInstance = new Chart(ctx, { type: 'line', data: { labels: recordData.map((_, i) => i), datasets: [ { label: 'Traffic', data: recordData, borderColor: '#00cec9', backgroundColor: 'rgba(0, 206, 201, 0.1)', borderWidth: 1.5, fill: true, pointRadius: 0, tension: 0.3 }, { label: 'Current', data: [], type: 'scatter', pointRadius: 6, pointBackgroundColor: '#ffffff', pointBorderColor: '#e84393', pointBorderWidth: 3 } ] }, options: { responsive: true, maintainAspectRatio: false, animation: false, plugins: { legend: { display: false } }, scales: { x: { display: false }, y: { grid: { color: 'rgba(255,255,255,0.05)' }, ticks: { color: '#64748b', font: {size: 10} } } } } }); } function updateChartCursor(timeIndex) { if (chartInstance && chartInstance.data.datasets[0].data.length > timeIndex) { const yValue = chartInstance.data.datasets[0].data[timeIndex]; chartInstance.data.datasets[1].data = [{x: timeIndex, y: yValue}]; chartInstance.update('none'); } } // Render AI Prediction Comparison Chart function renderPredictionChart(realData, predData) { const canvas = document.getElementById('predictionChart'); if (!canvas) return; const ctx = canvas.getContext('2d'); if (predictionChartInstance) { predictionChartInstance.destroy(); } // Generate X-axis labels (e.g., H0, H1...) const labels = realData.map((_, i) => `H${i}`); predictionChartInstance = new Chart(ctx, { type: 'line', data: { labels: labels, datasets: [ { label: 'Real Traffic', data: realData, borderColor: 'rgba(0, 206, 201, 0.8)', // Cyan backgroundColor: 'rgba(0, 206, 201, 0.1)', borderWidth: 1.5, pointRadius: 0, fill: true, tension: 0.3 }, { label: 'AI Prediction', data: predData, borderColor: '#f39c12', // Orange backgroundColor: 'transparent', borderWidth: 2, borderDash: [5, 5], // Dashed line effect pointRadius: 0, fill: false, tension: 0.3 } ] }, options: { responsive: true, maintainAspectRatio: false, interaction: { mode: 'index', intersect: false, // Tooltip shows both values simultaneously }, plugins: { legend: { display: true, labels: { color: '#e0e0e0', font: { size: 10 } } } }, scales: { x: { display: true, grid: { color: 'rgba(255,255,255,0.05)' }, ticks: { color: '#64748b', font: {size: 9}, maxTicksLimit: 14 } }, y: { grid: { color: 'rgba(255,255,255,0.1)' }, ticks: { color: '#888', font: {size: 10} }, beginAtZero: true } } } }); } // ========================================== // 5. Map Manager // ========================================== function initMap() { mapboxgl.accessToken = CONFIG.MAPBOX_TOKEN; mapInstance = new mapboxgl.Map({ container: 'map', style: 'mapbox://styles/mapbox/satellite-streets-v12', center: CONFIG.DEFAULT_CENTER, zoom: CONFIG.DEFAULT_ZOOM, pitch: 60, bearing: -15, antialias: true, maxBounds: CONFIG.SHANGHAI_BOUNDS, minZoom: 9 }); mapInstance.addControl(new mapboxgl.NavigationControl(), 'top-right'); return mapInstance; } function setupMapEnvironment(map) { map.addSource('mapbox-dem', { 'type': 'raster-dem', 'url': 'mapbox://mapbox.mapbox-terrain-dem-v1', 'tileSize': 512, 'maxzoom': 14 }); map.setTerrain({ 'source': 'mapbox-dem', 'exaggeration': 1.5 }); map.addLayer({ 'id': 'sky', 'type': 'sky', 'paint': { 'sky-type': 'atmosphere', 'sky-atmosphere-sun': [0.0, 0.0], 'sky-atmosphere-sun-intensity': 15 } }); if (map.setFog) { map.setFog({ 'range': [0.5, 10], 'color': '#240b36', 'horizon-blend': 0.1, 'high-color': '#0f172a', 'space-color': '#000000', 'star-intensity': 0.6 }); } const labelLayerId = map.getStyle().layers.find(l => l.type === 'symbol' && l.layout['text-field']).id; if (!map.getLayer('3d-buildings')) { map.addLayer({ 'id': '3d-buildings', 'source': 'composite', 'source-layer': 'building', 'filter': ['==', 'extrude', 'true'], 'type': 'fill-extrusion', 'minzoom': 11, 'paint': { 'fill-extrusion-color': ['interpolate', ['linear'], ['get', 'height'], 0, '#0f0c29', 30, '#1e2a4a', 200, '#4b6cb7'], 'fill-extrusion-height': ['get', 'height'], 'fill-extrusion-base': ['get', 'min_height'], 'fill-extrusion-opacity': 0.6 } }, labelLayerId); } } function updateGeoJSONData(map, stations, mode = 'avg', timeIndex = 0) { const pointFeatures = []; const polygonFeatures = []; const r = 0.00025; // Marker radius stations.forEach(s => { const lng = s.loc[0], lat = s.loc[1]; let valH = (mode === 'avg') ? (s.val_h || 0) : ((s.vals && s.vals[timeIndex]) !== undefined ? s.vals[timeIndex] : 0); let valC = (s.val_c !== undefined) ? s.val_c : 0; const props = { id: s.id, load_avg: valH, load_std: valC }; pointFeatures.push({ type: 'Feature', geometry: { type: 'Point', coordinates: [lng, lat] }, properties: props }); polygonFeatures.push({ type: 'Feature', geometry: { type: 'Polygon', coordinates: [[ [lng-r, lat-r], [lng+r, lat-r], [lng+r, lat+r], [lng-r, lat+r], [lng-r, lat-r] ]] }, properties: props }); }); if (map.getSource('stations-points')) { map.getSource('stations-points').setData({ type: 'FeatureCollection', features: pointFeatures }); map.getSource('stations-polygons').setData({ type: 'FeatureCollection', features: polygonFeatures }); } return { points: { type: 'FeatureCollection', features: pointFeatures }, polys: { type: 'FeatureCollection', features: polygonFeatures } }; } function addStationLayers(map, geoData, statsLoad, statsColor) { map.addSource('stations-points', { type: 'geojson', data: geoData.points }); map.addSource('stations-polygons', { type: 'geojson', data: geoData.polys }); map.addLayer({ id: 'stations-heatmap', type: 'heatmap', source: 'stations-points', maxzoom: 14, paint: { 'heatmap-weight': ['interpolate', ['linear'], ['get', 'load_avg'], statsLoad.min, 0, statsLoad.max, 1], 'heatmap-intensity': ['interpolate', ['linear'], ['zoom'], 0, 1, 13, 3], 'heatmap-color': ['interpolate', ['linear'], ['heatmap-density'], 0, 'rgba(0,0,0,0)', 0.2, '#0984e3', 0.4, '#00cec9', 0.6, '#a29bfe', 0.8, '#fd79a8', 1, '#ffffff'], 'heatmap-radius': ['interpolate', ['linear'], ['zoom'], 0, 2, 13, 25], 'heatmap-opacity': ['interpolate', ['linear'], ['zoom'], 12, 1, 14, 0] } }); map.addLayer({ id: 'stations-2d-dots', type: 'circle', source: 'stations-points', minzoom: 12, paint: { 'circle-radius': 3, 'circle-color': ['step', ['get', 'load_std'], '#1e1e2e', statsColor.t1, '#0984e3', statsColor.t2, '#00cec9', statsColor.t3, '#fd79a8', statsColor.t4, '#e84393'], 'circle-stroke-width': 1, 'circle-stroke-color': '#fff', 'circle-opacity': 0.8 } }); map.addLayer({ id: 'stations-3d-pillars', type: 'fill-extrusion', source: 'stations-polygons', minzoom: 12, paint: { 'fill-extrusion-color': ['step', ['get', 'load_std'], '#1e1e2e', statsColor.t1, '#0984e3', statsColor.t2, '#00cec9', statsColor.t3, '#fd79a8', statsColor.t4, '#e84393'], 'fill-extrusion-height': ['interpolate', ['linear'], ['get', 'load_avg'], 0, 0, statsLoad.min, 5, statsLoad.max, 300], 'fill-extrusion-opacity': 0.7 } }); map.addLayer({ id: 'stations-hitbox', type: 'circle', source: 'stations-points', paint: { 'circle-radius': 10, 'circle-color': 'transparent', 'circle-opacity': 0 } }); } // ========================================== // 6. Map Interactions // ========================================== function setupInteraction(map) { const popup = new mapboxgl.Popup({ closeButton: false, closeOnClick: false, className: 'cyber-popup' }); map.on('mouseenter', 'stations-hitbox', (e) => { map.getCanvas().style.cursor = 'pointer'; if (isPredictionMode) return; const props = e.features[0].properties; const coordinates = e.features[0].geometry.coordinates.slice(); while (Math.abs(e.lngLat.lng - coordinates[0]) > 180) { coordinates[0] += e.lngLat.lng > coordinates[0] ? 360 : -360; } popup.setLngLat(coordinates) .setHTML(`
Station ${props.id}
Load: ${props.load_avg.toFixed(2)}
Stability: ${props.load_std.toFixed(4)}
`).addTo(map); }); map.on('mouseleave', 'stations-hitbox', () => { if (!isPredictionMode) map.getCanvas().style.cursor = ''; popup.remove(); }); // Core Interaction Logic map.on('click', 'stations-hitbox', async (e) => { const coordinates = e.features[0].geometry.coordinates.slice(); const id = e.features[0].properties.id; // 1. Prediction Mode Logic if (isPredictionMode) { const predPanel = document.getElementById('prediction-panel'); const predIdDisplay = document.getElementById('pred-station-id'); const siteMapContainer = document.getElementById('site-map-container'); const siteMapImg = document.getElementById('site-map-img'); predPanel.classList.add('active'); const rightBtn = document.getElementById('toggle-right-btn'); if (rightBtn) rightBtn.classList.add('active'); predIdDisplay.innerHTML = ` ${id} > SYSTEM READY: Inference in progress...
[Cloud resource limited, please standby]
`; // Clear previous optimal site marker when a new station is clicked if (optimalMarker) { optimalMarker.remove(); optimalMarker = null; } // Drop orange selection pin and draw 3x3 grid if (!predictionMarker) { predictionMarker = new mapboxgl.Marker({ color: '#f39c12' }) .setLngLat(coordinates).addTo(map); } else { predictionMarker.setLngLat(coordinates); } updatePredictionGrid(map, coordinates[0], coordinates[1]); if (siteMapContainer) siteMapContainer.style.display = 'none'; if (siteMapImg) siteMapImg.src = ''; if(predictionChartInstance) { predictionChartInstance.destroy(); predictionChartInstance = null; } // Call Prediction API const result = await fetchPrediction(id); if(result && result.status === "success") { predIdDisplay.innerText = id; renderPredictionChart(result.real, result.prediction); // Render returned Base64 site heatmap and mark optimal location if (result.site_map_b64 && siteMapContainer && siteMapImg) { siteMapImg.src = `data:image/png;base64,${result.site_map_b64}`; siteMapContainer.style.display = 'block'; // Typewriter Effect for AI Explanation const explanationBox = document.getElementById('site-explanation'); if (explanationBox && result.explanation) { explanationBox.style.display = 'block'; // Reset content and add blinking cursor explanationBox.innerHTML = `> SYSTEM LOG: AI DECISION
_`; const textTarget = document.getElementById('typewriter-text'); const fullText = result.explanation; let charIndex = 0; function typeWriter() { if (charIndex < fullText.length) { textTarget.innerHTML += fullText.charAt(charIndex); charIndex++; // Randomize typing speed for realistic terminal feel setTimeout(typeWriter, Math.random() * 20 + 10); } } typeWriter(); } // Mark green optimal Pin on physical map coordinates if (result.best_loc) { // Remove orange marker to avoid overlap if (predictionMarker) { predictionMarker.remove(); predictionMarker = null; } // Create custom "Green Pulse" DOM element defined in CSS const customPin = document.createElement('div'); customPin.className = 'optimal-pulse-pin'; optimalMarker = new mapboxgl.Marker(customPin) .setLngLat(result.best_loc) .setPopup(new mapboxgl.Popup({ offset: 25, closeButton: false, className: 'cyber-popup' }) .setHTML('
🌟 Best LSI Site
')) .addTo(map); optimalMarker.togglePopup(); // Smoothly fly to the optimal site location map.flyTo({ center: result.best_loc, zoom: 16.5, speed: 1.2 }); } } } else { predIdDisplay.innerText = `${id} (Failed)`; } return; } // 2. Standard Detail Mode Logic if (currentMarker) currentMarker.remove(); currentMarker = new mapboxgl.Marker().setLngLat(coordinates).addTo(map); const pitch = map.getPitch(); map.flyTo({ center: coordinates, zoom: 15, pitch: pitch > 10 ? 60 : 0, speed: 1.5 }); document.getElementById('selected-id').innerText = id; try { document.getElementById('station-details').innerHTML = '

Loading details...

'; const detailData = await fetchStationDetail(id); if (detailData) { const stats = detailData.stats || {avg:0, std:0}; document.getElementById('station-details').innerHTML = `

Longitude: ${detailData.loc[0].toFixed(4)}

Latitude: ${detailData.loc[1].toFixed(4)}


Avg Load: ${stats.avg.toFixed(4)}

Stability: ${stats.std.toFixed(4)}

`; if (detailData.bs_record) { renderChart(detailData.bs_record); } } } catch (err) { console.error("Failed to fetch clicked station details:", err); document.getElementById('station-details').innerHTML = '

Error loading data

'; } }); } // Prediction Mode State Control function setupPredictionMode(map) { const predictBtn = document.getElementById('predict-toggle'); const predPanel = document.getElementById('prediction-panel'); const closePredBtn = document.getElementById('close-pred-btn'); if (!predictBtn) return; predictBtn.addEventListener('click', () => { // Enforce 2D view check for prediction mode const pitch = map.getPitch(); if (pitch > 10) { alert("Prediction Mode is only available in 2D View. Please switch to 2D first."); return; } isPredictionMode = !isPredictionMode; if (isPredictionMode) { predictBtn.classList.add('predict-on'); predictBtn.innerHTML = '🔮 Mode: ON'; map.getCanvas().style.cursor = 'crosshair'; } else { predictBtn.classList.remove('predict-on'); predictBtn.innerHTML = '🔮 Prediction Mode'; map.getCanvas().style.cursor = ''; predPanel.classList.remove('active'); // Reset UI state when exiting prediction predPanel.classList.remove('collapsed'); const rightBtn = document.getElementById('toggle-right-btn'); if(rightBtn) { rightBtn.innerText = '▶'; rightBtn.classList.remove('active'); rightBtn.classList.remove('collapsed'); } // Clear markers and grids clearPredictionExtras(map); } }); if (closePredBtn) { closePredBtn.addEventListener('click', () => { predPanel.classList.remove('active'); const rightBtn = document.getElementById('toggle-right-btn'); if (rightBtn) rightBtn.classList.remove('active'); predictBtn.click(); // Trigger toggle to clean up state }); } } // Dynamic 3x3 grid matching the 256px satellite patch bounds function updatePredictionGrid(map, centerLng, centerLat) { const features = []; const gridSize = 3; const offset = Math.floor(gridSize / 2); // Precise Web Mercator projection span calculation at Zoom 15 const zoom = 15; // Total Longitude span for 256 pixels at this zoom const lonSpan = 360 / Math.pow(2, zoom); // Latitude span (scaled by local latitude) const latSpan = lonSpan * Math.cos(centerLat * Math.PI / 180); // Actual step sizes for 3x3 division const stepLon = lonSpan / gridSize; const stepLat = latSpan / gridSize; for (let i = 0; i < gridSize; i++) { for (let j = 0; j < gridSize; j++) { // Center point of each micro-grid cell const cLng = centerLng + (j - offset) * stepLon; const cLat = centerLat - (i - offset) * stepLat; const wLon = stepLon / 2; const wLat = stepLat / 2; features.push({ 'type': 'Feature', 'geometry': { 'type': 'Polygon', 'coordinates': [[ [cLng - wLon, cLat - wLat], [cLng + wLon, cLat - wLat], [cLng + wLon, cLat + wLat], [cLng - wLon, cLat + wLat], [cLng - wLon, cLat - wLat] ]] } }); } } const geojson = { 'type': 'FeatureCollection', 'features': features }; if (map.getSource('pred-grid-source')) { map.getSource('pred-grid-source').setData(geojson); } else { map.addSource('pred-grid-source', { type: 'geojson', data: geojson }); map.addLayer({ 'id': 'pred-grid-fill', 'type': 'fill', 'source': 'pred-grid-source', 'paint': { 'fill-color': '#f39c12', 'fill-opacity': 0.1 } }); map.addLayer({ 'id': 'pred-grid-line', 'type': 'line', 'source': 'pred-grid-source', 'paint': { 'line-color': '#f39c12', 'line-width': 2, 'line-dasharray': [2, 2] } }); } } // Cleanup prediction visual elements function clearPredictionExtras(map) { if (predictionMarker) { predictionMarker.remove(); predictionMarker = null; } if (optimalMarker) { optimalMarker.remove(); optimalMarker = null; } // ====== 新增:清理绿色点 ====== if (map.getSource('pred-grid-source')) { map.getSource('pred-grid-source').setData({ type: 'FeatureCollection', features: [] }); } } // ========================================== // 7. Timeline Logic // ========================================== function setupTimeLapse(map, globalData) { const playBtn = document.getElementById('play-btn'); const slider = document.getElementById('time-slider'); const display = document.getElementById('time-display'); if (!playBtn || !slider) return; const totalHours = (globalData.length > 0 && globalData[0].vals) ? globalData[0].vals.length : 672; slider.max = totalHours - 1; let isPlaying = false; let speed = 100; const updateTime = (val) => { const day = Math.floor(val / 24) + 1; const hour = val % 24; display.innerText = `Day ${day.toString().padStart(2, '0')} - ${hour.toString().padStart(2, '0')}:00`; updateGeoJSONData(map, globalData, 'time', val); updateChartCursor(val); }; const play = () => { let val = parseInt(slider.value); val = (val + 1) % totalHours; slider.value = val; updateTime(val); if (isPlaying) animationFrameId = setTimeout(() => requestAnimationFrame(play), speed); }; playBtn.onclick = () => { isPlaying = !isPlaying; playBtn.innerText = isPlaying ? '⏸' : '▶'; if (isPlaying) play(); else clearTimeout(animationFrameId); }; slider.oninput = (e) => { isPlaying = false; if(animationFrameId) clearTimeout(animationFrameId); playBtn.innerText = '▶'; updateTime(parseInt(e.target.value)); }; } // ========================================== // 8. UI Controls // ========================================== function setupModeToggle(map) { const btn = document.getElementById('view-toggle'); const timePanel = document.querySelector('.time-panel'); let is3D = true; if (!btn) return; btn.onclick = () => { // Prevent switching to 3D mode if Prediction Mode is active if (isPredictionMode) { alert("Please exit Prediction Mode before switching to 3D."); return; } is3D = !is3D; if (is3D) { // Switch to 3D View: Show pillars and tilt camera if(map.getLayer('stations-3d-pillars')) map.setLayoutProperty('stations-3d-pillars', 'visibility', 'visible'); map.easeTo({ pitch: 60, bearing: -15 }); btn.innerHTML = '👁️ View: 3D'; if (timePanel) { timePanel.style.display = 'flex'; setTimeout(() => { timePanel.style.opacity = '1'; }, 10); } } else { // Switch to 2D View: Hide pillars and reset camera pitch if(map.getLayer('stations-3d-pillars')) map.setLayoutProperty('stations-3d-pillars', 'visibility', 'none'); map.easeTo({ pitch: 0, bearing: 0 }); btn.innerHTML = '🗺️ View: 2D'; if (timePanel) { timePanel.style.display = 'none'; timePanel.style.opacity = '0'; } // Stop timelapse playback when entering 2D mode const playBtn = document.getElementById('play-btn'); if (playBtn && playBtn.innerText === '⏸') playBtn.click(); } }; } function setupDataToggle(map) { const btn = document.getElementById('data-toggle'); const layers = ['stations-3d-pillars', 'stations-2d-dots', 'stations-heatmap', 'stations-hitbox']; let isVisible = true; if(btn) btn.onclick = () => { isVisible = !isVisible; const val = isVisible ? 'visible' : 'none'; layers.forEach(id => { if(map.getLayer(id)) map.setLayoutProperty(id, 'visibility', val); }); btn.innerHTML = isVisible ? '📡 Toggle Data' : '🚫 Toggle Data'; btn.style.opacity = isVisible ? '1' : '0.6'; }; } function setupFilterMenu(map, statsColor) { const btn = document.getElementById('filter-btn'); const menu = document.getElementById('filter-menu'); if (!btn || !menu) return; const levels = [ { label: "Level 5: Highly Unstable", color: "#e84393", filter: ['>=', 'load_std', statsColor.t4] }, { label: "Level 4: Volatile", color: "#fd79a8", filter: ['all', ['>=', 'load_std', statsColor.t3], ['<', 'load_std', statsColor.t4]] }, { label: "Level 3: Normal", color: "#00cec9", filter: ['all', ['>=', 'load_std', statsColor.t2], ['<', 'load_std', statsColor.t3]] }, { label: "Level 2: Stable", color: "#0984e3", filter: ['all', ['>=', 'load_std', statsColor.t1], ['<', 'load_std', statsColor.t2]] }, { label: "Level 1: Highly Stable", color: "#1e1e2e", filter: ['<', 'load_std', statsColor.t1] } ]; menu.innerHTML = ''; levels.forEach((lvl, index) => { const item = document.createElement('div'); item.className = 'filter-item'; item.innerHTML = `
${lvl.label}`; // Muti Select item.onclick = (e) => { e.stopPropagation(); item.classList.toggle('selected'); const activeFilters = []; const allItems = menu.querySelectorAll('.filter-item'); allItems.forEach((el, i) => { if (el.classList.contains('selected')) { activeFilters.push(levels[i].filter); } }); if (activeFilters.length === 0) { applyFilter(map, null); } else { const combinedFilter = ['any', ...activeFilters]; applyFilter(map, combinedFilter); } }; menu.appendChild(item); }); btn.onclick = (e) => { e.stopPropagation(); menu.classList.toggle('active'); }; document.addEventListener('click', (e) => { if (!menu.contains(e.target) && !btn.contains(e.target)) menu.classList.remove('active'); }); } function applyFilter(map, filterExpression) { const targetLayers = ['stations-3d-pillars', 'stations-2d-dots', 'stations-heatmap', 'stations-hitbox']; targetLayers.forEach(layerId => { if (map.getLayer(layerId)) map.setFilter(layerId, filterExpression); }); } function setupSearch(map, globalData) { const input = document.getElementById('search-input'); const btn = document.getElementById('search-btn'); const clearBtn = document.getElementById('clear-search-btn'); const keepCheck = document.getElementById('keep-markers-check'); if (!input || !btn) return; let searchMarkers = []; const clearAllMarkers = () => { searchMarkers.forEach(marker => marker.remove()); searchMarkers = []; }; const performSearch = async () => { const queryId = input.value.trim(); if (!queryId) return; const target = globalData.find(s => String(s.id) === String(queryId)); if (target) { if (!keepCheck.checked) { clearAllMarkers(); } // Fly to searched station and switch to high-detail view map.flyTo({ center: target.loc, zoom: 16, pitch: 60, essential: true }); document.getElementById('selected-id').innerText = target.id; try { const detailData = await fetchStationDetail(target.id); if (detailData) { const stats = detailData.stats || {avg:0, std:0}; document.getElementById('station-details').innerHTML = `

Longitude: ${detailData.loc[0].toFixed(4)}

Latitude: ${detailData.loc[1].toFixed(4)}


Avg Load: ${stats.avg.toFixed(4)}

Stability: ${stats.std.toFixed(4)}

`; if (detailData.bs_record) renderChart(detailData.bs_record); } } catch (e) { console.error("Fetch details failed", e); } // Create red highlight marker for searched target const marker = new mapboxgl.Marker({ color: '#ff0000', scale: 0.8 }) .setLngLat(target.loc) .setPopup(new mapboxgl.Popup({ offset: 25 }).setText(`Station ID: ${target.id}`)) .addTo(map); searchMarkers.push(marker); } else { alert("Station ID not found!"); } }; btn.onclick = performSearch; input.addEventListener('keypress', (e) => { if (e.key === 'Enter') performSearch(); }); if (clearBtn) { clearBtn.onclick = () => { clearAllMarkers(); input.value = ''; }; } } // Sidebar & Panel Toggle Logic function setupPanelToggles(map) { const leftSidebar = document.querySelector('.sidebar'); const leftToggleBtn = document.getElementById('toggle-left-btn'); if (leftToggleBtn && leftSidebar) { leftToggleBtn.addEventListener('click', () => { leftSidebar.classList.toggle('collapsed'); leftToggleBtn.classList.toggle('collapsed'); leftToggleBtn.innerText = leftSidebar.classList.contains('collapsed') ? '▶' : '◀'; setTimeout(() => map.resize(), 300); }); } const rightSidebar = document.getElementById('prediction-panel'); const rightToggleBtn = document.getElementById('toggle-right-btn'); if (rightToggleBtn && rightSidebar) { rightToggleBtn.addEventListener('click', () => { rightSidebar.classList.toggle('collapsed'); rightToggleBtn.classList.toggle('collapsed'); rightToggleBtn.innerText = rightSidebar.classList.contains('collapsed') ? '◀' : '▶'; setTimeout(() => map.resize(), 300); }); } } // ========================================== // 9. Main Entry Point // ========================================== window.onload = async () => { const map = initMap(); map.on('load', async () => { setupMapEnvironment(map); try { // Load initial station metadata const data = await fetchLocations(); globalStationData = data.stations; document.getElementById('total-stations').innerText = globalStationData.length; // Initialize Map Layers with empty data initially addStationLayers(map, {points: {type:'FeatureCollection', features:[]}, polys: {type:'FeatureCollection', features:[]} }, data.stats_height, data.stats_color); // Immediately load data for T=0 (initial state) updateGeoJSONData(map, globalStationData, 'time', 0); updateChartCursor(0); // Start Time Lapse setupTimeLapse(map, globalStationData); // Bind Interactions setupPredictionMode(map); // Initialize AI Prediction events setupInteraction(map); // Initialize standard map clicks/popups setupModeToggle(map); // 2D/3D View switch setupDataToggle(map); // Layer visibility switch setupFilterMenu(map, data.stats_color); // Load-stability filters setupSearch(map, globalStationData); // Search bar logic // Initialize sidebar collapse/expand controls setupPanelToggles(map); // Remove Loading Screen document.getElementById('loading').style.display = 'none'; } catch (e) { console.error(e); alert('System Initialization Failed. Check Console.'); document.getElementById('loading').innerHTML = '

Error Loading Data

'; } }); };