Fix dashboard map regressions and OpenSky fallback

This commit is contained in:
calesthio
2026-03-19 11:57:22 -07:00
parent 6514d7c00d
commit 7e3ead0e96
4 changed files with 136 additions and 44 deletions

View File

@@ -470,6 +470,8 @@ This is normal — the first sweep takes 3060 seconds to query all 27 sources
Expected behavior. Sources that require API keys will return structured errors if the key isn't set. The rest of the sweep continues normally. Check the Source Integrity section in the dashboard (or the server logs) to see which sources failed and why. The 3 most impactful free keys to add are `FRED_API_KEY`, `FIRMS_MAP_KEY`, and `EIA_API_KEY`. Expected behavior. Sources that require API keys will return structured errors if the key isn't set. The rest of the sweep continues normally. Check the Source Integrity section in the dashboard (or the server logs) to see which sources failed and why. The 3 most impactful free keys to add are `FRED_API_KEY`, `FIRMS_MAP_KEY`, and `EIA_API_KEY`.
OpenSky can also return `HTTP 429` when its public hotspots are queried too aggressively. Crucix does not try to evade that limit. Instead, it surfaces the throttle/error in source health and preserves the most recent non-empty air traffic snapshot from `runs/` so the dashboard flight layer does not suddenly go blank on a throttled sweep.
### Telegram bot not responding to commands ### Telegram bot not responding to commands
Make sure both `TELEGRAM_BOT_TOKEN` and `TELEGRAM_CHAT_ID` are set in `.env`. The bot only responds to messages from the configured chat ID (security measure). You should see `[Crucix] Telegram alerts enabled` and `[Crucix] Bot command polling started` in the server logs on startup. If not, double-check your token with `curl https://api.telegram.org/bot<YOUR_TOKEN>/getMe`. Make sure both `TELEGRAM_BOT_TOKEN` and `TELEGRAM_CHAT_ID` are set in `.env`. The bot only responds to messages from the configured chat ID (security measure). You should see `[Crucix] Telegram alerts enabled` and `[Crucix] Bot command polling started` in the server logs on startup. If not, double-check your token with `curl https://api.telegram.org/bot<YOUR_TOKEN>/getMe`.

View File

@@ -69,6 +69,7 @@ export async function briefing() {
const results = await Promise.all( const results = await Promise.all(
hotspotEntries.map(async ([key, box]) => { hotspotEntries.map(async ([key, box]) => {
const data = await getFlightsInArea(box.lamin, box.lomin, box.lamax, box.lomax); const data = await getFlightsInArea(box.lamin, box.lomin, box.lamax, box.lomax);
const error = data?.error || null;
const states = data?.states || []; const states = data?.states || [];
return { return {
region: box.label, region: box.label,
@@ -83,14 +84,25 @@ export async function briefing() {
// Flag potentially interesting (military often have no callsign or specific patterns) // Flag potentially interesting (military often have no callsign or specific patterns)
noCallsign: states.filter(s => !s[1]?.trim()).length, noCallsign: states.filter(s => !s[1]?.trim()).length,
highAltitude: states.filter(s => s[7] && s[7] > 12000).length, // >12km altitude highAltitude: states.filter(s => s[7] && s[7] > 12000).length, // >12km altitude
...(error ? { error } : {}),
}; };
}) })
); );
const hotspotErrors = results
.filter(r => r.error)
.map(r => ({ region: r.region, error: r.error }));
return { return {
source: 'OpenSky', source: 'OpenSky',
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
hotspots: results, hotspots: results,
...(hotspotErrors.length ? {
error: hotspotErrors.length === results.length
? `OpenSky unavailable across all hotspots: ${hotspotErrors[0].error}`
: `OpenSky unavailable for ${hotspotErrors.length}/${results.length} hotspots`,
hotspotErrors,
} : {}),
}; };
} }

View File

@@ -5,7 +5,7 @@
// //
// Exports synthesize(), generateIdeas(), fetchAllNews() for use by server.mjs // Exports synthesize(), generateIdeas(), fetchAllNews() for use by server.mjs
import { readFileSync, writeFileSync } from 'fs'; import { existsSync, readFileSync, readdirSync, writeFileSync } from 'fs';
import { dirname, join } from 'path'; import { dirname, join } from 'path';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
import { exec } from 'child_process'; import { exec } from 'child_process';
@@ -102,6 +102,49 @@ function sanitizeExternalUrl(raw) {
} }
} }
function sumAirHotspots(hotspots = []) {
return hotspots.reduce((sum, hotspot) => sum + (hotspot.totalAircraft || 0), 0);
}
function summarizeAirHotspots(hotspots = []) {
return hotspots.map(h => ({
region: h.region,
total: h.totalAircraft || 0,
noCallsign: h.noCallsign || 0,
highAlt: h.highAltitude || 0,
top: Object.entries(h.byCountry || {}).sort((a, b) => b[1] - a[1]).slice(0, 5),
}));
}
function loadOpenSkyFallback(currentTimestamp) {
const runsDir = join(ROOT, 'runs');
if (!existsSync(runsDir)) return null;
const currentMs = currentTimestamp ? new Date(currentTimestamp).getTime() : NaN;
const files = readdirSync(runsDir)
.filter(name => /^briefing_.*\.json$/.test(name))
.sort()
.reverse();
for (const file of files) {
const filePath = join(runsDir, file);
try {
const prior = JSON.parse(readFileSync(filePath, 'utf8'));
const priorTimestamp = prior.sources?.OpenSky?.timestamp || prior.crucix?.timestamp || null;
if (priorTimestamp && Number.isFinite(currentMs) && new Date(priorTimestamp).getTime() >= currentMs) continue;
const hotspots = prior.sources?.OpenSky?.hotspots || [];
if (sumAirHotspots(hotspots) > 0) {
return { file, timestamp: priorTimestamp, hotspots };
}
} catch {
// Ignore unreadable historical runs and continue searching backward.
}
}
return null;
}
// === RSS Fetching === // === RSS Fetching ===
async function fetchRSS(url, source) { async function fetchRSS(url, source) {
try { try {
@@ -326,11 +369,12 @@ export function generateIdeas(V2) {
// === Synthesize raw sweep data into dashboard format === // === Synthesize raw sweep data into dashboard format ===
export async function synthesize(data) { export async function synthesize(data) {
const air = (data.sources.OpenSky?.hotspots || []).map(h => ({ const liveAirHotspots = data.sources.OpenSky?.hotspots || [];
region: h.region, total: h.totalAircraft || 0, noCallsign: h.noCallsign || 0, const airFallback = sumAirHotspots(liveAirHotspots) > 0
highAlt: h.highAltitude || 0, ? null
top: Object.entries(h.byCountry || {}).sort((a, b) => b[1] - a[1]).slice(0, 5) : loadOpenSkyFallback(data.sources.OpenSky?.timestamp || data.crucix?.timestamp);
})); const effectiveAirHotspots = airFallback?.hotspots || liveAirHotspots;
const air = summarizeAirHotspots(effectiveAirHotspots);
const thermal = (data.sources.FIRMS?.hotspots || []).map(h => ({ const thermal = (data.sources.FIRMS?.hotspots || []).map(h => ({
region: h.region, det: h.totalDetections || 0, night: h.nightDetections || 0, region: h.region, det: h.totalDetections || 0, night: h.nightDetections || 0,
hc: h.highConfidence || 0, hc: h.highConfidence || 0,
@@ -511,6 +555,14 @@ export async function synthesize(data) {
const V2 = { const V2 = {
meta: data.crucix, air, thermal, tSignals, chokepoints, nuke, nukeSignals, meta: data.crucix, air, thermal, tSignals, chokepoints, nuke, nukeSignals,
airMeta: {
fallback: Boolean(airFallback),
liveTotal: sumAirHotspots(liveAirHotspots),
timestamp: airFallback?.timestamp || data.sources.OpenSky?.timestamp || data.crucix?.timestamp || null,
source: airFallback ? 'OpenSky fallback' : 'OpenSky',
...(airFallback ? { fallbackFile: airFallback.file } : {}),
...(data.sources.OpenSky?.error ? { error: data.sources.OpenSky.error } : {}),
},
sdr: { total: sdrNet.totalReceivers || 0, online: sdrNet.online || 0, zones: sdrZones }, sdr: { total: sdrNet.totalReceivers || 0, online: sdrNet.online || 0, zones: sdrZones },
tg: { posts: tgData.totalPosts || 0, urgent: tgUrgent, topPosts: tgTop }, tg: { posts: tgData.totalPosts || 0, urgent: tgUrgent, topPosts: tgTop },
who, fred, energy, bls, treasury, gscpi, defense, noaa, epa, acled, gdelt, space, health, news, who, fred, energy, bls, treasury, gscpi, defense, noaa, epa, acled, gdelt, space, health, news,

View File

@@ -97,6 +97,7 @@ html,body{height:100%;background:var(--bg);color:var(--text);font-family:var(--s
.econ-row .eval{font-family:var(--mono);font-weight:600} .econ-row .eval{font-family:var(--mono);font-weight:600}
/* CENTER: MAP */ /* CENTER: MAP */
.map-region-bar{display:flex;align-items:center;gap:8px;flex-wrap:wrap;padding:10px 12px;border:1px solid var(--border);background:var(--panel);backdrop-filter:blur(20px)}
.map-container{flex:1;min-height:560px;border:1px solid var(--border);background:radial-gradient(ellipse at center,rgba(4,12,20,1),rgba(2,4,8,1));position:relative;overflow:hidden} .map-container{flex:1;min-height:560px;border:1px solid var(--border);background:radial-gradient(ellipse at center,rgba(4,12,20,1),rgba(2,4,8,1));position:relative;overflow:hidden}
#globeViz{width:100%;height:100%;cursor:grab} #globeViz{width:100%;height:100%;cursor:grab}
#globeViz:active{cursor:grabbing} #globeViz:active{cursor:grabbing}
@@ -272,6 +273,7 @@ body.low-perf .ticker-wrap::-webkit-scrollbar-thumb{background:rgba(100,240,200,
.topbar{padding:10px 12px} .topbar{padding:10px 12px}
.top-left,.top-center,.top-right{width:100%} .top-left,.top-center,.top-right{width:100%}
.top-center{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:6px} .top-center{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:6px}
.map-region-bar{display:none}
.top-right{gap:6px} .top-right{gap:6px}
.region-btn,.meta-pill,.alert-badge,.guide-btn{font-size:10px} .region-btn,.meta-pill,.alert-badge,.guide-btn{font-size:10px}
.grid{display:flex;flex-direction:column} .grid{display:flex;flex-direction:column}
@@ -331,6 +333,7 @@ body.low-perf .ticker-wrap::-webkit-scrollbar-thumb{background:rgba(100,240,200,
<div class="grid"> <div class="grid">
<div class="col" id="leftRail"></div> <div class="col" id="leftRail"></div>
<div class="col" id="centerCol"> <div class="col" id="centerCol">
<div class="map-region-bar" id="mapRegionBar"></div>
<div class="map-container" id="mapContainer"> <div class="map-container" id="mapContainer">
<div id="globeViz"></div> <div id="globeViz"></div>
<svg id="flatMapSvg" style="display:none;width:100%;height:100%;position:absolute;top:0;left:0;cursor:grab"></svg> <svg id="flatMapSvg" style="display:none;width:100%;height:100%;position:absolute;top:0;left:0;cursor:grab"></svg>
@@ -389,6 +392,7 @@ let globeInitialized = false;
let flightsVisible = true; let flightsVisible = true;
let lowPerfMode = localStorage.getItem('crucix_low_perf') === 'true'; let lowPerfMode = localStorage.getItem('crucix_low_perf') === 'true';
let isFlat = shouldStartFlat(); let isFlat = shouldStartFlat();
let currentRegion = 'world';
let flatSvg, flatProjection, flatPath, flatG, flatZoom, flatW, flatH; let flatSvg, flatProjection, flatPath, flatG, flatZoom, flatW, flatH;
const signalGuideItems = [ const signalGuideItems = [
{ {
@@ -554,7 +558,26 @@ function togglePerfMode(){
} }
// === TOPBAR === // === TOPBAR ===
function getRegionControlsMarkup(){
return ['world','americas','europe','middleEast','asiaPacific','africa'].map(r=>
`<button class="region-btn ${r===currentRegion?'active':''}" data-region="${r}" onclick="setRegion('${r}')">${r==='middleEast'?'MIDDLE EAST':r==='asiaPacific'?'ASIA PACIFIC':r.toUpperCase()}</button>`
).join('');
}
function renderRegionControls(){
const mapRegionBar = document.getElementById('mapRegionBar');
if(!mapRegionBar) return;
if(isMobileLayout()){
mapRegionBar.innerHTML = '';
mapRegionBar.style.display = 'none';
return;
}
mapRegionBar.innerHTML = getRegionControlsMarkup();
mapRegionBar.style.display = 'flex';
}
function renderTopbar(){ function renderTopbar(){
const mobile = isMobileLayout();
const ts = new Date(D.meta.timestamp); const ts = new Date(D.meta.timestamp);
const d = ts.toLocaleDateString('en-US',{month:'short',day:'numeric',year:'numeric'}).toUpperCase(); const d = ts.toLocaleDateString('en-US',{month:'short',day:'numeric',year:'numeric'}).toUpperCase();
const timeStr = ts.toLocaleTimeString('en-US',{hour:'2-digit',minute:'2-digit',hour12:true}); const timeStr = ts.toLocaleTimeString('en-US',{hour:'2-digit',minute:'2-digit',hour12:true});
@@ -563,11 +586,7 @@ function renderTopbar(){
<span class="brand">CRUCIX MONITOR</span> <span class="brand">CRUCIX MONITOR</span>
<span class="regime-chip"><span class="blink"></span>WARTIME STAGFLATION RISK</span> <span class="regime-chip"><span class="blink"></span>WARTIME STAGFLATION RISK</span>
</div> </div>
<div class="top-center"> ${mobile ? `<div class="top-center">${getRegionControlsMarkup()}</div>` : ''}
${['world','americas','europe','middleEast','asiaPacific','africa'].map(r=>
`<button class="region-btn ${r==='world'?'active':''}" data-region="${r}" onclick="setRegion('${r}')">${r==='middleEast'?'MIDDLE EAST':r==='asiaPacific'?'ASIA PACIFIC':r.toUpperCase()}</button>`
).join('')}
</div>
<div class="top-right"> <div class="top-right">
<button class="meta-pill perf-pill" onclick="togglePerfMode()" title="Reduce visual effects and start mobile in flat mode">${t('dashboard.perf','PERF')} <span class="v" id="perfStatus">${lowPerfMode?t('dashboard.perfLow','LOW'):t('dashboard.perfHigh','HIGH')}</span></button> <button class="meta-pill perf-pill" onclick="togglePerfMode()" title="Reduce visual effects and start mobile in flat mode">${t('dashboard.perf','PERF')} <span class="v" id="perfStatus">${lowPerfMode?t('dashboard.perfLow','LOW'):t('dashboard.perfHigh','HIGH')}</span></button>
<span class="meta-pill">${t('dashboard.sweep','SWEEP')} <span class="v">${(D.meta.totalDurationMs/1000).toFixed(1)}s</span></span> <span class="meta-pill">${t('dashboard.sweep','SWEEP')} <span class="v">${(D.meta.totalDurationMs/1000).toFixed(1)}s</span></span>
@@ -577,6 +596,7 @@ function renderTopbar(){
<button class="guide-btn" onclick="openGlossary()">${t('dashboard.guideBtn','What Signals Mean')}</button> <button class="guide-btn" onclick="openGlossary()">${t('dashboard.guideBtn','What Signals Mean')}</button>
<span class="alert-badge">${t('dashboard.highAlert','HIGH ALERT')}</span> <span class="alert-badge">${t('dashboard.highAlert','HIGH ALERT')}</span>
</div>`; </div>`;
renderRegionControls();
} }
// === LEFT RAIL === // === LEFT RAIL ===
@@ -1077,6 +1097,13 @@ function toggleFlights() {
flightsVisible = !flightsVisible; flightsVisible = !flightsVisible;
const btn = document.getElementById('flightToggle'); const btn = document.getElementById('flightToggle');
btn.classList.toggle('off', !flightsVisible); btn.classList.toggle('off', !flightsVisible);
if(isFlat){
if(flatG){
flatG.selectAll('*').remove();
drawFlatMap();
}
return;
}
if(!globe){ if(!globe){
return; return;
} }
@@ -1149,13 +1176,6 @@ function initFlatMap(){
flatG.selectAll('.marker-circle').attr('r',function(){return +this.dataset.baseR/Math.sqrt(k)}); flatG.selectAll('.marker-circle').attr('r',function(){return +this.dataset.baseR/Math.sqrt(k)});
flatG.selectAll('.marker-label').style('font-size',Math.max(7,9/Math.sqrt(k))+'px') flatG.selectAll('.marker-label').style('font-size',Math.max(7,9/Math.sqrt(k))+'px')
.style('display',k>=2.5?'block':'none'); .style('display',k>=2.5?'block':'none');
// Priority-based visibility: hide low-priority markers at low zoom
flatG.selectAll('[data-priority]').style('display',function(){
const p=+this.dataset.priority;
if(p<=1) return 'block';
if(p<=2) return k>=2?'block':'none';
return k>=3.5?'block':'none';
});
}); });
flatSvg.call(flatZoom); flatSvg.call(flatZoom);
drawFlatMap(); drawFlatMap();
@@ -1184,12 +1204,14 @@ function plotFlatMarkers(){
}; };
// Air // Air
const airCoords=[{lat:30,lon:44},{lat:24,lon:120},{lat:49,lon:32},{lat:57,lon:24},{lat:14,lon:114},{lat:37,lon:127},{lat:25,lon:-80},{lat:4,lon:2},{lat:-34,lon:18},{lat:10,lon:51}]; const airCoords=[{lat:30,lon:44},{lat:24,lon:120},{lat:49,lon:32},{lat:57,lon:24},{lat:14,lon:114},{lat:37,lon:127},{lat:25,lon:-80},{lat:4,lon:2},{lat:-34,lon:18},{lat:10,lon:51}];
D.air.forEach((a,i)=>{ if(flightsVisible){
const c=airCoords[i];if(!c)return; D.air.forEach((a,i)=>{
const g=addPt(c.lat,c.lon,4+a.total/40,'rgba(100,240,200,0.7)','rgba(100,240,200,0.3)', const c=airCoords[i];if(!c)return;
ev=>showPopup(ev,a.region,`${a.total} aircraft<br>No callsign: ${a.noCallsign}<br>High alt: ${a.highAlt}`,'Air Activity'),1); const g=addPt(c.lat,c.lon,4+a.total/40,'rgba(100,240,200,0.7)','rgba(100,240,200,0.3)',
if(g) g.append('text').attr('class','marker-label').attr('x',10).attr('y',3).attr('fill','var(--dim)').attr('font-size','9px').attr('font-family','var(--mono)').text(a.region.replace(' Region','')+' '+a.total); ev=>showPopup(ev,a.region,`${a.total} aircraft<br>No callsign: ${a.noCallsign}<br>High alt: ${a.highAlt}`,'Air Activity'),1);
}); if(g) g.append('text').attr('class','marker-label').attr('x',10).attr('y',3).attr('fill','var(--dim)').attr('font-size','9px').attr('font-family','var(--mono)').text(a.region.replace(' Region','')+' '+a.total);
});
}
// Thermal // Thermal
D.thermal.forEach(t=>t.fires.forEach(f=>{ D.thermal.forEach(t=>t.fires.forEach(f=>{
addPt(f.lat,f.lon,2+Math.min(f.frp/50,5),'rgba(255,95,99,0.6)','rgba(255,95,99,0.2)', addPt(f.lat,f.lon,2+Math.min(f.frp/50,5),'rgba(255,95,99,0.6)','rgba(255,95,99,0.2)',
@@ -1237,25 +1259,27 @@ function plotFlatMarkers(){
g.append('circle').attr('r',r*0.4).attr('fill','rgba(255,120,80,0.3)'); g.append('circle').attr('r',r*0.4).attr('fill','rgba(255,120,80,0.3)');
}); });
// Flight corridors // Flight corridors
const airCoordsFlight=[{lat:30,lon:44},{lat:24,lon:120},{lat:49,lon:32},{lat:57,lon:24},{lat:14,lon:114},{lat:37,lon:127},{lat:25,lon:-80},{lat:4,lon:2},{lat:-34,lon:18},{lat:10,lon:51}]; if(flightsVisible){
const hubs=[{lat:40.6,lon:-73.8},{lat:51.5,lon:-0.5},{lat:25.3,lon:55.4},{lat:1.4,lon:103.8},{lat:-33.9,lon:151.2},{lat:-23.4,lon:-46.5}]; const airCoordsFlight=[{lat:30,lon:44},{lat:24,lon:120},{lat:49,lon:32},{lat:57,lon:24},{lat:14,lon:114},{lat:37,lon:127},{lat:25,lon:-80},{lat:4,lon:2},{lat:-34,lon:18},{lat:10,lon:51}];
const cG=flatG.append('g').attr('class','corridors-layer'); const hubs=[{lat:40.6,lon:-73.8},{lat:51.5,lon:-0.5},{lat:25.3,lon:55.4},{lat:1.4,lon:103.8},{lat:-33.9,lon:151.2},{lat:-23.4,lon:-46.5}];
for(let i=0;i<D.air.length;i++){for(let j=i+1;j<D.air.length;j++){ const cG=flatG.append('g').attr('class','corridors-layer');
const a=D.air[i],b=D.air[j],from=airCoordsFlight[i],to=airCoordsFlight[j]; for(let i=0;i<D.air.length;i++){for(let j=i+1;j<D.air.length;j++){
if(!from||!to)continue;const traffic=a.total+b.total;if(traffic<30)continue; const a=D.air[i],b=D.air[j],from=airCoordsFlight[i],to=airCoordsFlight[j];
const ncR=(a.noCallsign+b.noCallsign)/Math.max(traffic,1); if(!from||!to)continue;const traffic=a.total+b.total;if(traffic<30)continue;
const clr=ncR>0.15?'rgba(255,95,99,0.4)':ncR>0.05?'rgba(255,184,76,0.35)':'rgba(100,240,200,0.25)'; const ncR=(a.noCallsign+b.noCallsign)/Math.max(traffic,1);
const interp=d3.geoInterpolate([from.lon,from.lat],[to.lon,to.lat]); const clr=ncR>0.15?'rgba(255,95,99,0.4)':ncR>0.05?'rgba(255,184,76,0.35)':'rgba(100,240,200,0.25)';
const coords=[];for(let k=0;k<=40;k++)coords.push(interp(k/40)); const interp=d3.geoInterpolate([from.lon,from.lat],[to.lon,to.lat]);
const feat={type:'Feature',geometry:{type:'LineString',coordinates:coords}}; const coords=[];for(let k=0;k<=40;k++)coords.push(interp(k/40));
cG.append('path').datum(feat).attr('d',flatPath).attr('fill','none').attr('stroke',clr).attr('stroke-width',Math.max(0.8,Math.min(3,traffic/80))); const feat={type:'Feature',geometry:{type:'LineString',coordinates:coords}};
}} cG.append('path').datum(feat).attr('d',flatPath).attr('fill','none').attr('stroke',clr).attr('stroke-width',Math.max(0.8,Math.min(3,traffic/80)));
D.air.forEach((a,i)=>{if(!airCoordsFlight[i]||a.total<25)return;hubs.forEach(hub=>{ }}
if(Math.abs(airCoordsFlight[i].lat-hub.lat)+Math.abs(airCoordsFlight[i].lon-hub.lon)<20)return; D.air.forEach((a,i)=>{if(!airCoordsFlight[i]||a.total<25)return;hubs.forEach(hub=>{
const interp=d3.geoInterpolate([airCoordsFlight[i].lon,airCoordsFlight[i].lat],[hub.lon,hub.lat]); if(Math.abs(airCoordsFlight[i].lat-hub.lat)+Math.abs(airCoordsFlight[i].lon-hub.lon)<20)return;
const coords=[];for(let k=0;k<=40;k++)coords.push(interp(k/40)); const interp=d3.geoInterpolate([airCoordsFlight[i].lon,airCoordsFlight[i].lat],[hub.lon,hub.lat]);
cG.append('path').datum({type:'Feature',geometry:{type:'LineString',coordinates:coords}}).attr('d',flatPath).attr('fill','none').attr('stroke','rgba(100,240,200,0.15)').attr('stroke-width',0.6); const coords=[];for(let k=0;k<=40;k++)coords.push(interp(k/40));
})}); cG.append('path').datum({type:'Feature',geometry:{type:'LineString',coordinates:coords}}).attr('d',flatPath).attr('fill','none').attr('stroke','rgba(100,240,200,0.15)').attr('stroke-width',0.6);
})});
}
} }
// Update setRegion for flat mode // Update setRegion for flat mode
@@ -1265,6 +1289,7 @@ const _origSetRegion = setRegion;
const _origMapZoom = mapZoom; const _origMapZoom = mapZoom;
function setRegion(r){ function setRegion(r){
currentRegion = r;
document.querySelectorAll('.region-btn').forEach(b=>b.classList.toggle('active',b.dataset.region===r)); document.querySelectorAll('.region-btn').forEach(b=>b.classList.toggle('active',b.dataset.region===r));
closePopup(); closePopup();
if(isFlat && flatSvg && flatZoom){ if(isFlat && flatSvg && flatZoom){
@@ -1553,7 +1578,7 @@ function runBoot(){
document.querySelectorAll('.mbar span,.smb span').forEach(bar=>{const w=bar.style.width;bar.style.width='0%';gsap.to(bar,{width:w,duration:1,ease:'power2.out'})}); document.querySelectorAll('.mbar span,.smb span').forEach(bar=>{const w=bar.style.width;bar.style.width='0%';gsap.to(bar,{width:w,duration:1,ease:'power2.out'})});
document.querySelectorAll('.spark-bar').forEach(bar=>{const h=bar.style.height;bar.style.height='0%';gsap.to(bar,{height:h,duration:0.8,ease:'power2.out'})}); document.querySelectorAll('.spark-bar').forEach(bar=>{const h=bar.style.height;bar.style.height='0%';gsap.to(bar,{height:h,duration:0.8,ease:'power2.out'})});
},1000); },1000);
},4.0); },[],4.0);
} }
function isMobileLayout(){ return window.innerWidth <= 1100; } function isMobileLayout(){ return window.innerWidth <= 1100; }
@@ -1644,6 +1669,7 @@ function syncResponsiveLayout(force=false){
const mobileNow = isMobileLayout(); const mobileNow = isMobileLayout();
if(force || lastResponsiveMobile === null || mobileNow !== lastResponsiveMobile){ if(force || lastResponsiveMobile === null || mobileNow !== lastResponsiveMobile){
lastResponsiveMobile = mobileNow; lastResponsiveMobile = mobileNow;
renderTopbar();
renderLeftRail(); renderLeftRail();
renderLower(); renderLower();
renderRight(); renderRight();