feat: phase 14 — BTC technicals, central bank rates, 3 datasets, dashboard UX (+5 = 84 tools)

New tools:
- intel_btc_technicals: SMA-50/200, Mayer Multiple, golden/death cross, ATH distance
- intel_central_bank_rates: 15 banks (Fed/ECB/BoE via FRED, 12 curated fallback)
- intel_trade_routes: 19 maritime chokepoints with oil flow and vessel transit data
- intel_cloud_regions: 28 AWS/Azure/GCP regions with coordinates
- intel_financial_centers: GFCI top 20 with rankings and specializations

Dashboard additions:
- BTC Technicals drawer section (price, SMA, Mayer, cross signal)
- Central Bank Rates drawer section (15 banks, sorted by rate)
- Trade route markers on infrastructure map layer (chokepoint/canal/route icons)
- Regional presets (Globe/Americas/Europe/MENA/Asia-Pac/Africa) with localStorage persistence
- Time window filter (1h/6h/24h/7d/All) filtering earthquakes and news by timestamp
- Static datasets served via /api/static for instant boot

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Marc Shade
2026-02-26 17:04:17 -05:00
co-authored by Claude Opus 4.6
parent 39e687b054
commit 4dda867968
9 changed files with 796 additions and 13 deletions
+180 -2
View File
@@ -140,6 +140,14 @@ a { color: var(--accent); text-decoration: none; }
padding: 5px 0; cursor: pointer; transition: opacity 0.2s;
}
.layer-row:hover { opacity: 0.85; }
.rgn-btn {
font-family: var(--mono); font-size: 0.6rem; letter-spacing: 0.5px;
padding: 3px 7px; border: 1px solid rgba(255,255,255,0.12); border-radius: 4px;
background: rgba(255,255,255,0.04); color: var(--dim); cursor: pointer;
transition: all 0.15s;
}
.rgn-btn:hover { background: rgba(255,255,255,0.1); color: var(--text); }
.rgn-btn.active { background: rgba(0,229,255,0.15); color: var(--accent); border-color: rgba(0,229,255,0.3); }
.layer-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
.layer-label { font-size: 0.75rem; color: var(--text); flex: 1; }
.layer-count {
@@ -403,6 +411,21 @@ a { color: var(--accent); text-decoration: none; }
cursor: pointer; transition: transform 0.15s, opacity 0.15s;
}
.mk-waterway:hover { transform: rotate(45deg) scale(1.5); opacity: 1; }
.mk-chokepoint {
width: 8px; height: 8px; background: #ff5722; border-radius: 50%; opacity: 0.9;
border: 1.5px solid rgba(255,87,34,0.6); cursor: pointer; transition: transform 0.15s;
}
.mk-chokepoint:hover { transform: scale(1.8); }
.mk-canal {
width: 8px; height: 8px; background: #ffab00; border-radius: 2px; opacity: 0.9;
border: 1.5px solid rgba(255,171,0,0.6); cursor: pointer; transition: transform 0.15s;
}
.mk-canal:hover { transform: scale(1.8); }
.mk-sealane {
width: 6px; height: 6px; background: #00bfa5; border-radius: 50%; opacity: 0.7;
border: 1px solid rgba(0,191,165,0.5); cursor: pointer; transition: transform 0.15s;
}
.mk-sealane:hover { transform: scale(1.8); }
.cable-corridor { cursor: pointer; transition: fill-opacity 0.15s; }
.cable-corridor:hover { fill-opacity: 0.1 !important; stroke-opacity: 0.5 !important; }
.mk-plane {
@@ -654,6 +677,27 @@ a { color: var(--accent); text-decoration: none; }
<span class="layer-count" id="cntNews">0</span>
<div class="toggle on" style="--lc:var(--accent)"><div class="knob"></div></div>
</div>
<div style="border-top:1px solid rgba(255,255,255,0.08);margin-top:4px;padding-top:6px">
<div style="font-size:0.55rem;letter-spacing:1px;color:rgba(255,255,255,0.35);margin-bottom:4px">REGION</div>
<div id="regionPresets" style="display:flex;flex-wrap:wrap;gap:2px">
<button class="rgn-btn active" data-region="global">Globe</button>
<button class="rgn-btn" data-region="americas">Americas</button>
<button class="rgn-btn" data-region="europe">Europe</button>
<button class="rgn-btn" data-region="mena">MENA</button>
<button class="rgn-btn" data-region="asia">Asia-Pac</button>
<button class="rgn-btn" data-region="africa">Africa</button>
</div>
</div>
<div style="border-top:1px solid rgba(255,255,255,0.08);margin-top:4px;padding-top:6px">
<div style="font-size:0.55rem;letter-spacing:1px;color:rgba(255,255,255,0.35);margin-bottom:4px">TIME WINDOW</div>
<div id="timeFilter" style="display:flex;gap:2px">
<button class="rgn-btn" data-hours="1">1h</button>
<button class="rgn-btn" data-hours="6">6h</button>
<button class="rgn-btn active" data-hours="24">24h</button>
<button class="rgn-btn" data-hours="168">7d</button>
<button class="rgn-btn" data-hours="0">All</button>
</div>
</div>
</aside>
<!-- DATA DRAWER -->
@@ -1271,7 +1315,9 @@ document.addEventListener('keydown', function(e) {
function updateMapQuakes(data) {
mapLayers.quakes.clearLayers();
if (!data || data.error) { $('#cntQuakes').textContent = '0'; return; }
var quakes = data.earthquakes || [];
var quakes = (data.earthquakes || []).filter(function(q) {
return _withinTimeWindow(q.time || q.timestamp);
});
$('#cntQuakes').textContent = quakes.length;
quakes.slice(0, 100).forEach(function(q) {
if (q.latitude == null || q.longitude == null) return;
@@ -1490,6 +1536,27 @@ function updateMapInfra(data) {
mk.bindTooltip('<b>' + esc(w.name) + '</b><br><span style="opacity:0.7">' + esc(w.throughput || '') + '</span>', { className: 'mk-tip', direction: 'top', offset: [0, -6] });
mk.addTo(mapLayers.infra);
});
// Trade routes / maritime chokepoints (anchor markers)
var trs = (data.trade_routes && data.trade_routes.routes) || [];
trs.forEach(function(tr) {
if (tr.lat == null || tr.lon == null) return;
total++;
var iconCls = tr.type === 'chokepoint' ? 'mk-chokepoint' : tr.type === 'canal' ? 'mk-canal' : 'mk-sealane';
var mk = L.marker([tr.lat, tr.lon], {
icon: L.divIcon({ className: iconCls, iconSize: [8, 8], iconAnchor: [4, 4] })
});
mk.bindTooltip('<b>' + esc(tr.name) + '</b><br>' +
'<span style="opacity:0.7">' + esc(tr.type) + ' · ' + (tr.oil_flow_mbd || 0) + ' mbd oil · ~' + (tr.daily_vessel_transits || 0) + ' vessels/day</span>',
{ className: 'mk-tip', direction: 'top', offset: [0, -5] });
mk.on('click', function() {
showDetail('trade_route', {
name: tr.name, type: tr.type, oil_flow_mbd: tr.oil_flow_mbd,
daily_transits: tr.daily_vessel_transits, trade_value_pct: tr.trade_value_pct,
countries: (tr.countries || []).join(', '), notes: tr.notes
});
});
mk.addTo(mapLayers.infra);
});
// Submarine cable corridors (shaded rectangles)
var cables = (data.cable_corridors && data.cable_corridors.corridors) || [];
var cableStyle = { color: '#4da8ff', weight: 1, opacity: 0.2, fillColor: '#4da8ff', fillOpacity: 0.04, dashArray: '4 3', className: 'cable-corridor', interactive: false };
@@ -1677,7 +1744,9 @@ function _geolocateArticle(article) {
function updateMapNews(data) {
mapLayers.news.clearLayers();
if (!data || data.error) { $('#cntNews').textContent = '0'; return; }
var articles = data.articles || data.items || [];
var articles = (data.articles || data.items || []).filter(function(a) {
return _withinTimeWindow(a.published || a.pub_date || a.timestamp);
});
var placed = 0;
articles.forEach(function(a) {
var geo = _geolocateArticle(a);
@@ -2488,6 +2557,46 @@ function updateDrawer(data) {
});
}
// ── BTC TECHNICALS ──
if (data.btc_technicals && !data.btc_technicals.error && data.btc_technicals.price) {
var bt = data.btc_technicals;
h += '<div class="sh">BTC TECHNICALS</div>';
var crossCls = bt.cross_signal === 'golden_cross' ? 'good' : bt.cross_signal === 'death_cross' ? 'crit' : 'dim';
var crossLabel = bt.cross_signal === 'golden_cross' ? 'GOLDEN CROSS' : bt.cross_signal === 'death_cross' ? 'DEATH CROSS' : 'NEUTRAL';
h += '<div class="mini-boxes" style="margin:6px 0">';
h += '<div class="mini-box"><div class="v">$' + num(bt.price) + '</div><div class="l">BTC Price</div></div>';
h += '<div class="mini-box"><div class="v">' + (bt.mayer_multiple || '—') + '</div><div class="l">Mayer Multiple</div></div>';
h += '<div class="mini-box"><div class="v ' + crossCls + '">' + crossLabel + '</div><div class="l">Signal</div></div>';
h += '</div>';
h += '<div style="font-size:0.65rem;padding:2px 0"><span class="bright">SMA-50</span> <span class="dim">$' + num(bt.sma_50) + '</span></div>';
if (bt.sma_200) h += '<div style="font-size:0.65rem;padding:1px 0"><span class="bright">SMA-200</span> <span class="dim">$' + num(bt.sma_200) + '</span></div>';
if (bt.change_7d_pct != null) {
var c7 = bt.change_7d_pct >= 0 ? 'good' : 'crit';
h += '<div style="font-size:0.65rem;padding:1px 0"><span class="bright">7d</span> <span class="' + c7 + '">' + (bt.change_7d_pct > 0 ? '+' : '') + bt.change_7d_pct + '%</span>';
if (bt.change_30d_pct != null) {
var c30 = bt.change_30d_pct >= 0 ? 'good' : 'crit';
h += ' <span class="bright">30d</span> <span class="' + c30 + '">' + (bt.change_30d_pct > 0 ? '+' : '') + bt.change_30d_pct + '%</span>';
}
h += '</div>';
}
if (bt.ath_distance_pct != null) h += '<div style="font-size:0.65rem;padding:1px 0"><span class="bright">From ATH</span> <span class="dim">' + bt.ath_distance_pct + '%</span></div>';
}
// ── CENTRAL BANK RATES ──
if (data.central_bank_rates && !data.central_bank_rates.error && (data.central_bank_rates.rates || []).length > 0) {
var cbr = data.central_bank_rates;
h += '<div class="sh">CENTRAL BANK RATES</div>';
h += '<div class="dim" style="font-size:0.6rem;padding:2px 0">' + cbr.count + ' banks' + (cbr.fred_available ? ' (FRED live)' : ' (curated)') + '</div>';
cbr.rates.forEach(function(r) {
var rateCls = r.rate >= 10 ? 'crit' : r.rate >= 5 ? 'warn' : r.rate >= 2 ? 'bright' : 'good';
h += '<div style="font-size:0.65rem;padding:1px 0">';
h += '<span class="' + rateCls + '" style="min-width:40px;display:inline-block;text-align:right">' + r.rate.toFixed(2) + '%</span> ';
h += '<span class="bright">' + esc(r.label) + '</span>';
if (r.country) h += ' <span class="dim">(' + esc(r.country) + ')</span>';
h += '</div>';
});
}
// ── DATA FRESHNESS ──
if (data.cache_freshness && Object.keys(data.cache_freshness).length > 0) {
var cf = data.cache_freshness;
@@ -2550,8 +2659,11 @@ function updateTicker(data) {
// ════════════ MASTER UPDATE ════════════
function refreshDashboard(data) { updateAll(data); }
function updateAll(data) {
latestData = data;
window._lastSSEData = data;
// Map layers (each wrapped to prevent cascade failures)
var conflictData = (data.acled_events && !data.acled_events.error && data.acled_events.count > 0) ? data.acled_events
@@ -2781,6 +2893,72 @@ window.addEventListener('resize', _syncLayersTop);
$('#hudStats').innerHTML = '<div class="stat-pill"><span class="l" style="animation:pulse-dot 2s ease-in-out infinite">LOADING LIVE FEEDS</span></div>';
$('#drawerBody').innerHTML = '<div style="padding:30px;text-align:center"><div class="load-ring" style="width:28px;height:28px;margin:0 auto 10px"></div><div class="dim" style="font-size:0.6rem;letter-spacing:1.5px">LOADING INTELLIGENCE FEEDS</div></div>';
// ═══════════ REGIONAL PRESETS ═══════════
var REGION_VIEWS = {
global: { center: [20, 0], zoom: 3 },
americas: { center: [10, -80], zoom: 4 },
europe: { center: [50, 15], zoom: 5 },
mena: { center: [28, 40], zoom: 5 },
asia: { center: [20, 110], zoom: 4 },
africa: { center: [0, 25], zoom: 4 },
};
$$('#regionPresets .rgn-btn').forEach(function(btn) {
btn.addEventListener('click', function() {
var region = btn.dataset.region;
var view = REGION_VIEWS[region];
if (!view) return;
$$('#regionPresets .rgn-btn').forEach(function(b) { b.classList.remove('active'); });
btn.classList.add('active');
map.flyTo(view.center, view.zoom, { duration: 1.2 });
try { localStorage.setItem('phoenix-region', region); } catch(e) {}
});
});
// Restore saved region
try {
var savedRegion = localStorage.getItem('phoenix-region');
if (savedRegion && REGION_VIEWS[savedRegion]) {
$$('#regionPresets .rgn-btn').forEach(function(b) {
b.classList.toggle('active', b.dataset.region === savedRegion);
});
var sv = REGION_VIEWS[savedRegion];
map.setView(sv.center, sv.zoom);
}
} catch(e) {}
// ═══════════ TIME WINDOW FILTER ═══════════
var _timeFilterHours = 24;
$$('#timeFilter .rgn-btn').forEach(function(btn) {
btn.addEventListener('click', function() {
_timeFilterHours = parseInt(btn.dataset.hours) || 0;
$$('#timeFilter .rgn-btn').forEach(function(b) { b.classList.remove('active'); });
btn.classList.add('active');
try { localStorage.setItem('phoenix-time-filter', String(_timeFilterHours)); } catch(e) {}
// Re-render with current data if available
if (window._lastSSEData) refreshDashboard(window._lastSSEData);
});
});
// Restore saved time filter
try {
var savedTime = localStorage.getItem('phoenix-time-filter');
if (savedTime !== null) {
_timeFilterHours = parseInt(savedTime) || 0;
$$('#timeFilter .rgn-btn').forEach(function(b) {
b.classList.toggle('active', b.dataset.hours === savedTime);
});
}
} catch(e) {}
// Time filter helper: check if an ISO timestamp is within the active window
function _withinTimeWindow(isoTimestamp) {
if (_timeFilterHours === 0) return true; // "All" = no filter
if (!isoTimestamp) return true; // No timestamp = show
try {
var ts = new Date(isoTimestamp).getTime();
var cutoff = Date.now() - (_timeFilterHours * 3600000);
return ts >= cutoff;
} catch(e) { return true; }
}
// Load static geospatial data immediately (bases, ports, nuclear facilities).
fetch('/api/static')
.then(function(r) { return r.json(); })