Karaku9's picture
Update script.js
7a28597 verified
// ==========================================
// 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 = '<p>Loading...</p>';
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(`
<div style="font-weight:bold; color:#fff; border-bottom:1px solid #444; padding-bottom:2px; margin-bottom:2px;">Station ${props.id}</div>
<div style="color:#00cec9;">Load: <span style="color:#fff;">${props.load_avg.toFixed(2)}</span></div>
<div style="color:#fd79a8;">Stability: <span style="color:#fff;">${props.load_std.toFixed(4)}</span></div>
`).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}
<span style="display: block; font-size: 15px; color: #00cec9; margin-top: 10px; font-family: 'Courier New', monospace; font-weight: normal; letter-spacing: 0; text-transform: none; line-height: 1.0;">
> SYSTEM READY: Inference in progress...<br>
<span style="color: #94a3b8; font-size: 14px;">[Cloud resource limited, please standby]</span>
</span>
`;
// 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 = `<strong>> SYSTEM LOG: AI DECISION</strong><br><span id="typewriter-text"></span><span class="cursor" style="animation: blink 1s step-end infinite;">_</span>`;
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('<div style="color:#2ecc71; font-weight:bold; font-size:14px;">🌟 Best LSI Site</div>'))
.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 = '<p class="placeholder-text">Loading details...</p>';
const detailData = await fetchStationDetail(id);
if (detailData) {
const stats = detailData.stats || {avg:0, std:0};
document.getElementById('station-details').innerHTML =
`<div style="margin-top:10px;">
<p><strong>Longitude:</strong> ${detailData.loc[0].toFixed(4)}</p>
<p><strong>Latitude:</strong> ${detailData.loc[1].toFixed(4)}</p>
<hr style="border:0; border-top:1px solid #444; margin:5px 0;">
<p><strong>Avg Load:</strong> <span style="color:#00cec9">${stats.avg.toFixed(4)}</span></p>
<p><strong>Stability:</strong> <span style="color:#fd79a8">${stats.std.toFixed(4)}</span></p>
</div>`;
if (detailData.bs_record) {
renderChart(detailData.bs_record);
}
}
} catch (err) {
console.error("Failed to fetch clicked station details:", err);
document.getElementById('station-details').innerHTML = '<p style="color:red">Error loading data</p>';
}
});
}
// 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 = '<span class="icon">🔮</span> Mode: ON';
map.getCanvas().style.cursor = 'crosshair';
} else {
predictBtn.classList.remove('predict-on');
predictBtn.innerHTML = '<span class="icon">🔮</span> 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 = '<span class="icon">👁️</span> 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 = '<span class="icon">🗺️</span> 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 ? '<span class="icon">📡</span> Toggle Data' : '<span class="icon">🚫</span> 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 = `<div class="color-box" style="background:${lvl.color}; box-shadow: 0 0 5px ${lvl.color};"></div><span>${lvl.label}</span>`;
// 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 =
`<div style="margin-top:10px;">
<p><strong>Longitude:</strong> ${detailData.loc[0].toFixed(4)}</p>
<p><strong>Latitude:</strong> ${detailData.loc[1].toFixed(4)}</p>
<hr style="border:0; border-top:1px solid #444; margin:5px 0;">
<p><strong>Avg Load:</strong> <span style="color:#00cec9">${stats.avg.toFixed(4)}</span></p>
<p><strong>Stability:</strong> <span style="color:#fd79a8">${stats.std.toFixed(4)}</span></p>
</div>`;
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 = '<h2>Error Loading Data</h2>';
}
});
};