perf(world): switch view variants in place — kill the tab-switch freeze
Switching the header variant tabs [Cloud|AI|Crypto|Finance|Tech|World] did a full window.location reload: it re-created the deck.gl WebGL context, respawned the ML workers, re-mounted every video iframe and cold-refetched ~48 endpoints. That cold-start churn WAS the freeze. SITE_VARIANT is now runtime-mutable (getSiteVariant / setSiteVariantRuntime). setSiteVariant switches IN PLACE — recompute the target's config and re-apply panels/map-layers/feeds/tabs/URL live (history.replaceState), exactly like the intra-variant panel toggle. Heavy singletons stay warm; the synchronous switch cost is a few ms (e2e-gated <500ms via window.__switchT) vs a full reload. Also: - Header controls (H-toggle switcher, search, settings, reset) are wired BEFORE `await this.mapReady` so the shell is interactive on first paint — they were dead until the 2.7 MB map chunk loaded (a cold H-toggle click was lost). - LiveNewsPanel pauses its YouTube player via IntersectionObserver when off-screen (matching LiveWebcamsPanel) — no decoding a hidden news embed. - device-tier: adaptive DPR/refresh/flow-arc knobs on low-end hardware. - Sentry deferred off the critical path (code-split, after first paint). e2e: variant-switch-inplace.spec.ts proves no reload (JS-context sentinel + same deck.gl canvas survive), panel-set swap, URL/active-tab re-point, across 3 switches.
This commit is contained in:
@@ -0,0 +1,120 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
// In-place variant switch (the tab-switch freeze fix).
|
||||
//
|
||||
// The header variant tabs [Cloud | AI | Crypto | Finance | Tech | World] used to
|
||||
// switch by setting window.location.href — a full page reload that re-created the
|
||||
// deck.gl WebGL context, respawned the ML workers, re-mounted the video iframes and
|
||||
// cold-refetched every feed. That churn WAS the freeze. The switch is now in place:
|
||||
// recompute the target variant's config and re-apply it live, like the intra-variant
|
||||
// panel toggle — no reload, heavy singletons kept warm.
|
||||
//
|
||||
// The airtight proof of "no reload": inject a sentinel into the JS context AFTER the
|
||||
// app boots. A full navigation destroys the context and wipes the sentinel; an
|
||||
// in-place switch preserves it. We also assert the SAME <canvas> survives (deck.gl
|
||||
// not torn down), the panel set swaps, the active tab + URL re-point, and the switch
|
||||
// is fast.
|
||||
|
||||
const TRAFFIC = {
|
||||
updatedAt: '2026-07-18T12:00:00Z',
|
||||
live: true,
|
||||
window: { minutes: 60, since: '2026-07-18T11:00:00Z', until: '2026-07-18T12:00:00Z' },
|
||||
points: [
|
||||
{ country: 'US', lat: 37.09, lon: -95.71, count: 42, byService: { models: 42 } },
|
||||
{ country: 'GB', lat: 55.38, lon: -3.44, count: 12, byService: { models: 12 } },
|
||||
],
|
||||
totals: { rps_1m: 0.9, rpm_60m: 54, top_countries: [{ country: 'US', count: 42 }, { country: 'GB', count: 12 }] },
|
||||
};
|
||||
|
||||
async function mockCloud(page: import('@playwright/test').Page): Promise<void> {
|
||||
const json = (body: unknown) => ({ status: 200, contentType: 'application/json', body: JSON.stringify(body) });
|
||||
await page.route('**/v1/world/cloud/traffic-globe', (r) => r.fulfill(json(TRAFFIC)));
|
||||
await page.route('**/v1/world/cloud/traffic', (r) => r.fulfill(json({ updatedAt: TRAFFIC.updatedAt, demo: false, arcs: [] })));
|
||||
await page.route('**/v1/world/cloud/chain-nodes', (r) => r.fulfill(json({ updatedAt: TRAFFIC.updatedAt, positionsModeled: true, networks: [] })));
|
||||
await page.route('**/v1/world/cloud/byo-gpu', (r) => r.fulfill(json({ updatedAt: TRAFFIC.updatedAt, demo: false, gpus: [] })));
|
||||
}
|
||||
|
||||
test.describe('Variant switch — in place, no reload', () => {
|
||||
test('switching tabs keeps the JS context, the globe canvas, and swaps the panel set', async ({ page }) => {
|
||||
await mockCloud(page);
|
||||
await page.goto('/?variant=cloud');
|
||||
|
||||
// Reveal the switcher (flagship cloud starts collapsed behind the H toggle).
|
||||
await page.locator('[data-hanzo-toggle]').click();
|
||||
const switcher = page.locator('.variant-switcher');
|
||||
await expect(switcher).toBeVisible();
|
||||
await expect(page.locator('.variant-option[data-variant="cloud"].active')).toHaveCount(1);
|
||||
|
||||
// The globe mounts ONE deck.gl <canvas>. Marking it lets us later prove it is the
|
||||
// SAME element after a switch (WebGL context never torn down). This is a bonus
|
||||
// proof layered on top of the airtight sentinel below: it only fires where a GL
|
||||
// context actually renders. Under headless swiftshader (CI) deck.gl may not create
|
||||
// a canvas at all, and in the immersive cloud view the globe is a fixed background
|
||||
// rather than a panel child — so we mark the canvas IF one is present and gate the
|
||||
// identity assertions on that, instead of blocking the whole test on GL rendering.
|
||||
const globe = page.locator('canvas').first();
|
||||
const canvasMarked = await globe
|
||||
.waitFor({ state: 'attached', timeout: 15000 })
|
||||
.then(() => globe.evaluate((c) => { (c as HTMLCanvasElement & { __globeMark?: string }).__globeMark = 'orig'; }))
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
const globeSurvived = async (): Promise<boolean> =>
|
||||
!canvasMarked ||
|
||||
(await globe.evaluate((c) => (c as HTMLCanvasElement & { __globeMark?: string }).__globeMark === 'orig').catch(() => false));
|
||||
|
||||
// Sentinel in the live JS context. A reload destroys the context → the sentinel
|
||||
// is gone. Survival across a tab click == no navigation happened.
|
||||
await page.evaluate(() => {
|
||||
(window as unknown as { __noReload?: string }).__noReload = 'sentinel';
|
||||
window.addEventListener('beforeunload', () => {
|
||||
(window as unknown as { __unloaded?: boolean }).__unloaded = true;
|
||||
});
|
||||
});
|
||||
|
||||
// Switch Cloud → AI.
|
||||
await page.locator('.variant-option[data-variant="ai"]').click();
|
||||
await page.waitForFunction(() => new URLSearchParams(location.search).get('variant') === 'ai', undefined, { timeout: 20000 });
|
||||
|
||||
// The switch's SYNCHRONOUS cost — the actual "is it a cold-start freeze?" measure —
|
||||
// is recorded on window.__switchT by setSiteVariant. This is the honest perf gate:
|
||||
// it times the switch code itself, immune to boot/GL thread-contention that inflates
|
||||
// wall-clock under headless software rendering (where the click merely queues behind
|
||||
// the cold globe render). A reload would cold-start everything; the in-place switch
|
||||
// is a few ms.
|
||||
const switchCost = await page.evaluate(() => (window as unknown as { __switchT?: { total?: number } }).__switchT?.total ?? -1);
|
||||
expect(switchCost, `in-place switch synchronous cost was ${switchCost}ms`).toBeGreaterThanOrEqual(0);
|
||||
expect(switchCost, `in-place switch synchronous cost was ${switchCost}ms`).toBeLessThan(500);
|
||||
|
||||
// The active tab + URL re-pointed to AI, with NO reload.
|
||||
await expect(page.locator('.variant-option[data-variant="ai"].active')).toHaveCount(1);
|
||||
await expect(page.locator('.variant-option[data-variant="cloud"].active')).toHaveCount(0);
|
||||
await expect(page).toHaveURL(/variant=ai/);
|
||||
|
||||
// The airtight assertions: the JS context survived (sentinel intact, no unload) —
|
||||
// a reload would have wiped both. Plus, where a GL canvas rendered, it is the very
|
||||
// same element (globe/WebGL never torn down).
|
||||
expect(await page.evaluate(() => (window as unknown as { __noReload?: string }).__noReload)).toBe('sentinel');
|
||||
expect(await page.evaluate(() => (window as unknown as { __unloaded?: boolean }).__unloaded)).toBeFalsy();
|
||||
expect(await globeSurvived()).toBe(true);
|
||||
|
||||
// The panel set swapped in place: an AI panel is now visible, the cloud-only
|
||||
// live-traffic panel is hidden (kept alive, just `.hidden`).
|
||||
await expect(page.locator('.panel[data-panel="ai-compute"]:not(.hidden)')).toHaveCount(1);
|
||||
await expect(page.locator('.panel[data-panel="traffic-globe"]:not(.hidden)')).toHaveCount(0);
|
||||
|
||||
// Two more switches to prove it holds across the family, still no reload.
|
||||
await page.locator('.variant-option[data-variant="finance"]').click();
|
||||
await expect(page).toHaveURL(/variant=finance/);
|
||||
await expect(page.locator('.variant-option[data-variant="finance"].active')).toHaveCount(1);
|
||||
// Finance markets panel shows; the AI compute panel is hidden.
|
||||
await expect(page.locator('.panel[data-panel="markets"]:not(.hidden)')).toHaveCount(1);
|
||||
|
||||
await page.locator('.variant-option[data-variant="full"]').click();
|
||||
await expect(page.locator('.variant-option[data-variant="full"].active')).toHaveCount(1);
|
||||
await expect(page).not.toHaveURL(/variant=/); // full is the canonical default — no param
|
||||
|
||||
// Still the same context + same globe canvas after three switches.
|
||||
expect(await page.evaluate(() => (window as unknown as { __noReload?: string }).__noReload)).toBe('sentinel');
|
||||
expect(await globeSurvived()).toBe(true);
|
||||
});
|
||||
});
|
||||
+289
-175
@@ -2,6 +2,8 @@ import './styles/try-hanzo.css';
|
||||
import type { NewsItem, Monitor, PanelConfig, MapLayers, RelatedAsset, InternetOutage, SocialUnrestEvent, MilitaryFlight, MilitaryVessel, MilitaryFlightCluster, MilitaryVesselCluster, CyberThreat } from '@/types';
|
||||
import {
|
||||
FEEDS,
|
||||
feedsFor,
|
||||
ALL_FEED_KEYS,
|
||||
INTEL_SOURCES,
|
||||
SECTORS,
|
||||
MARKET_SYMBOLS,
|
||||
@@ -9,8 +11,11 @@ import {
|
||||
DEFAULT_PANELS,
|
||||
DEFAULT_MAP_LAYERS,
|
||||
MOBILE_DEFAULT_MAP_LAYERS,
|
||||
variantConfig,
|
||||
STORAGE_KEYS,
|
||||
SITE_VARIANT,
|
||||
getSiteVariant,
|
||||
setSiteVariantRuntime,
|
||||
isHanzoBrandHost,
|
||||
isLuxFundHost,
|
||||
MONITOR_COLORS,
|
||||
@@ -380,7 +385,7 @@ export class App {
|
||||
// Finance variant → the Bloomberg-style terminal (charts ≫ news), code-split
|
||||
// so its TradingView embeds never bloat the entry bundle. Mounts over the
|
||||
// shell; the map is skipped for this variant (mountMap early-returns).
|
||||
if (SITE_VARIANT === 'finance') void this.mountFinanceTerminal();
|
||||
if (getSiteVariant() === 'finance') void this.mountFinanceTerminal();
|
||||
this.startHeaderClock();
|
||||
this.signalModal = new SignalModal();
|
||||
this.signalModal.setLocationClickHandler((lat, lon) => {
|
||||
@@ -406,12 +411,17 @@ export class App {
|
||||
this.setupTryHanzoMenu();
|
||||
this.setupAccountMenu();
|
||||
this.setupSearchModal();
|
||||
// Header/modal controls are map-independent — wire them BEFORE the map await so
|
||||
// the shell (H-toggle view switcher, search, settings, reset) is interactive on
|
||||
// first paint, exactly the "interactive while the map loads" intent below. Wiring
|
||||
// them after `await this.mapReady` left every header control dead until the ~2.7 MB
|
||||
// map chunk finished (a cold click on the H-toggle was silently lost).
|
||||
this.setupEventListeners();
|
||||
// The map-dependent wiring below needs the map mounted. Awaiting here lets
|
||||
// the shell paint and become interactive while the map chunk loads in parallel.
|
||||
await this.mapReady;
|
||||
this.setupMapLayerHandlers();
|
||||
this.setupCountryIntel();
|
||||
this.setupEventListeners();
|
||||
this.setupImmersive();
|
||||
// Capture ?country= BEFORE URL sync overwrites it
|
||||
const initState = parseMapUrlState(window.location.search, this.mapLayers);
|
||||
@@ -763,7 +773,7 @@ export class App {
|
||||
// route stays for API consumers; re-enable via localStorage hanzo-world-pizzint=1.
|
||||
private setupPizzIntIndicator(): void {
|
||||
if (localStorage.getItem('hanzo-world-pizzint') !== '1') return;
|
||||
if (SITE_VARIANT === 'tech' || SITE_VARIANT === 'finance' || SITE_VARIANT === 'ai' || SITE_VARIANT === 'crypto') return;
|
||||
if (getSiteVariant() === 'tech' || getSiteVariant() === 'finance' || getSiteVariant() === 'ai' || getSiteVariant() === 'crypto') return;
|
||||
|
||||
this.pizzintIndicator = new PizzIntIndicator();
|
||||
const headerLeft = this.container.querySelector('.header-left');
|
||||
@@ -1536,12 +1546,12 @@ export class App {
|
||||
}
|
||||
|
||||
private setupSearchModal(): void {
|
||||
const searchOptions = SITE_VARIANT === 'tech' || SITE_VARIANT === 'ai'
|
||||
const searchOptions = getSiteVariant() === 'tech' || getSiteVariant() === 'ai'
|
||||
? {
|
||||
placeholder: t('modals.search.placeholderTech'),
|
||||
hint: t('modals.search.hintTech'),
|
||||
}
|
||||
: SITE_VARIANT === 'finance' || SITE_VARIANT === 'crypto'
|
||||
: getSiteVariant() === 'finance' || getSiteVariant() === 'crypto'
|
||||
? {
|
||||
placeholder: t('modals.search.placeholderFinance'),
|
||||
hint: t('modals.search.hintFinance'),
|
||||
@@ -1552,7 +1562,7 @@ export class App {
|
||||
};
|
||||
this.searchModal = new SearchModal(this.container, searchOptions);
|
||||
|
||||
if (SITE_VARIANT === 'tech' || SITE_VARIANT === 'ai') {
|
||||
if (getSiteVariant() === 'tech' || getSiteVariant() === 'ai') {
|
||||
// Tech/AI variants: tech-specific sources
|
||||
this.searchModal.registerSource('techcompany', TECH_COMPANIES.map(c => ({
|
||||
id: c.id,
|
||||
@@ -1663,7 +1673,7 @@ export class App {
|
||||
})));
|
||||
}
|
||||
|
||||
if (SITE_VARIANT === 'finance' || SITE_VARIANT === 'crypto') {
|
||||
if (getSiteVariant() === 'finance' || getSiteVariant() === 'crypto') {
|
||||
// Finance/Crypto variants: market-specific sources
|
||||
this.searchModal.registerSource('exchange', STOCK_EXCHANGES.map(e => ({
|
||||
id: e.id,
|
||||
@@ -2600,12 +2610,15 @@ export class App {
|
||||
this.newsPanels['energy'] = energyPanel;
|
||||
this.panels['energy'] = energyPanel;
|
||||
|
||||
// Dynamically create NewsPanel instances for any FEEDS category.
|
||||
// If a category key collides with an existing data panel key (e.g. markets),
|
||||
// create a separate `${key}-news` panel to avoid clobbering the data panel.
|
||||
for (const key of Object.keys(FEEDS)) {
|
||||
// Dynamically create NewsPanel instances for EVERY variant's feed categories
|
||||
// (ALL_FEED_KEYS), not just the boot variant's — so an in-place variant switch
|
||||
// already has the target's feed panels mounted (they stay idle until their
|
||||
// category is loaded). If a category key collides with an existing data panel
|
||||
// key (e.g. markets), create a separate `${key}-news` panel to avoid clobbering it.
|
||||
for (const key of ALL_FEED_KEYS) {
|
||||
if (this.newsPanels[key]) continue;
|
||||
if (!Array.isArray((FEEDS as Record<string, unknown>)[key])) continue;
|
||||
// Every ALL_FEED_KEYS entry is a real feed array by construction (union of the
|
||||
// three feed maps), so no per-key array guard against the boot FEEDS is needed.
|
||||
const panelKey = this.panels[key] && !this.newsPanels[key] ? `${key}-news` : key;
|
||||
if (this.panels[panelKey]) continue;
|
||||
const panelConfig = DEFAULT_PANELS[panelKey] ?? DEFAULT_PANELS[key];
|
||||
@@ -2616,107 +2629,12 @@ export class App {
|
||||
this.panels[panelKey] = panel;
|
||||
}
|
||||
|
||||
// Geopolitical-only panels (not needed for tech variant)
|
||||
if (SITE_VARIANT === 'full') {
|
||||
const gdeltIntelPanel = new GdeltIntelPanel();
|
||||
this.panels['gdelt-intel'] = gdeltIntelPanel;
|
||||
|
||||
const ciiPanel = new CIIPanel();
|
||||
ciiPanel.setShareStoryHandler((code, name) => {
|
||||
this.openCountryStory(code, name);
|
||||
});
|
||||
this.panels['cii'] = ciiPanel;
|
||||
|
||||
const cascadePanel = new CascadePanel();
|
||||
this.panels['cascade'] = cascadePanel;
|
||||
|
||||
const satelliteFiresPanel = new SatelliteFiresPanel();
|
||||
this.panels['satellite-fires'] = satelliteFiresPanel;
|
||||
|
||||
const strategicRiskPanel = new StrategicRiskPanel();
|
||||
strategicRiskPanel.setLocationClickHandler((lat, lon) => {
|
||||
this.map?.setCenter(lat, lon, 4);
|
||||
});
|
||||
this.panels['strategic-risk'] = strategicRiskPanel;
|
||||
|
||||
const strategicPosturePanel = new StrategicPosturePanel();
|
||||
strategicPosturePanel.setLocationClickHandler((lat, lon) => {
|
||||
console.log('[App] StrategicPosture handler called:', { lat, lon, hasMap: !!this.map });
|
||||
this.map?.setCenter(lat, lon, 4);
|
||||
});
|
||||
this.panels['strategic-posture'] = strategicPosturePanel;
|
||||
|
||||
const ucdpEventsPanel = new UcdpEventsPanel();
|
||||
ucdpEventsPanel.setEventClickHandler((lat, lon) => {
|
||||
this.map?.setCenter(lat, lon, 5);
|
||||
});
|
||||
this.panels['ucdp-events'] = ucdpEventsPanel;
|
||||
|
||||
const displacementPanel = new DisplacementPanel();
|
||||
displacementPanel.setCountryClickHandler((lat, lon) => {
|
||||
this.map?.setCenter(lat, lon, 4);
|
||||
});
|
||||
this.panels['displacement'] = displacementPanel;
|
||||
|
||||
const climatePanel = new ClimateAnomalyPanel();
|
||||
climatePanel.setZoneClickHandler((lat, lon) => {
|
||||
this.map?.setCenter(lat, lon, 4);
|
||||
});
|
||||
this.panels['climate'] = climatePanel;
|
||||
|
||||
const populationExposurePanel = new PopulationExposurePanel();
|
||||
this.panels['population-exposure'] = populationExposurePanel;
|
||||
}
|
||||
|
||||
// GCC Investments Panel (finance variant)
|
||||
if (SITE_VARIANT === 'finance') {
|
||||
const investmentsPanel = new InvestmentsPanel((inv) => {
|
||||
focusInvestmentOnMap(this.map, this.mapLayers, inv.lat, inv.lon);
|
||||
});
|
||||
this.panels['gcc-investments'] = investmentsPanel;
|
||||
}
|
||||
|
||||
// Cloud flagship panels — the live-traffic globe's companion tiles: Cloud
|
||||
// metrics, router/Enso training + flywheel, AI compute, model mix, fleet,
|
||||
// uptime, and the caller's own org usage + bill.
|
||||
if (SITE_VARIANT === 'cloud') {
|
||||
this.panels['cloud-overview'] = new CloudOverviewPanel();
|
||||
this.panels['traffic-globe'] = new TrafficGlobePanel();
|
||||
this.panels['model-improvement'] = new ModelImprovementPanel();
|
||||
this.panels['enso-training'] = new EnsoTrainingPanel();
|
||||
this.panels['enso-flywheel'] = new EnsoFlywheelPanel();
|
||||
this.panels['enso-router'] = new EnsoRouterPanel();
|
||||
this.panels['ai-compute'] = new AiComputePanel();
|
||||
this.panels['model-usage'] = new ModelUsagePanel();
|
||||
const fleetPanel = new FleetPanel();
|
||||
fleetPanel.setLocationClickHandler((lat, lon) => {
|
||||
this.map?.setCenter(lat, lon, 4);
|
||||
});
|
||||
this.panels['fleet'] = fleetPanel;
|
||||
this.panels['live-activity'] = new LiveActivityPanel();
|
||||
// Per-org event-platform cards — the caller's own analytics + product
|
||||
// insights (api.hanzo.ai /v1/analytics + /v1/insights, org-scoped by bearer).
|
||||
this.panels['org-analytics'] = new OrgAnalyticsPanel();
|
||||
this.panels['org-insights'] = new OrgInsightsPanel();
|
||||
this.panels['my-usage'] = new MyUsagePanel();
|
||||
// Full Hanzo Cloud status page, embedded from status.hanzo.ai (public —
|
||||
// NOT admin-gated).
|
||||
this.panels['hanzo-status'] = new HanzoStatusPanel();
|
||||
}
|
||||
|
||||
// Live Hanzo inference telemetry: AI Compute (ai-pulse SSE) + Enso Flywheel
|
||||
// (routing ledger + evals). On the AI variant AND the default (full) world —
|
||||
// the platform's own compute/training pulse is front-page telemetry, not a
|
||||
// variant-only easter egg.
|
||||
if (SITE_VARIANT === 'ai' || SITE_VARIANT === 'full') {
|
||||
this.panels['ai-compute'] = new AiComputePanel();
|
||||
this.panels['enso-flywheel'] = new EnsoFlywheelPanel();
|
||||
}
|
||||
|
||||
// Chains widget — live block heights + peers (hanzo + crypto variants).
|
||||
if (SITE_VARIANT === 'cloud' || SITE_VARIANT === 'crypto') {
|
||||
this.panels['chains'] = new BlockchainPanel();
|
||||
}
|
||||
// Variant-specific panels (geopolitical / finance / cloud / ai / crypto). These
|
||||
// self-poll on construction (cloud SSE, chain RPCs…), so they are created LAZILY
|
||||
// per variant — never a boot-time union that would hammer every backend from the
|
||||
// wrong view. ensureVariantPanels is called here for the boot variant and again
|
||||
// by setSiteVariant for an in-place switch (deduped + kept alive on return).
|
||||
this.ensureVariantPanels(getSiteVariant());
|
||||
|
||||
const liveNewsPanel = new LiveNewsPanel();
|
||||
this.panels['live-news'] = liveNewsPanel;
|
||||
@@ -2835,10 +2753,104 @@ export class App {
|
||||
void this.mountAdminCloudPanels();
|
||||
}
|
||||
|
||||
// Code-split map mount. The MapContainer value (→ the ~2.7 MB mapbox-gl +
|
||||
// deck.gl "map" chunk) is loaded via dynamic import() so it never blocks first
|
||||
// paint. init() kicks this off after the shell paints and awaits it before any
|
||||
// map-dependent wiring runs. Idempotent: a second call is a no-op.
|
||||
// The variant → panel-set factory, in ONE place. Instantiates the panels a
|
||||
// variant owns, deduped (created at most once, then kept alive across switches)
|
||||
// and — when a grid already exists (an in-place switch, not boot) — appended so
|
||||
// they show. Panels shared by several variants (ai-compute, enso-flywheel,
|
||||
// chains) live under an OR just like the layers config, so each is created once.
|
||||
private ensureVariantPanels(variant: string): void {
|
||||
// Create + register only (deduped). Placement into the grid is done by the
|
||||
// ONE ordered-append path both callers already run afterward — createPanels'
|
||||
// panelOrder loop at boot, appendPanelsInOrder on a switch — so a panel is
|
||||
// never appended (or made draggable) twice.
|
||||
const add = (key: string, make: () => Panel): void => {
|
||||
if (this.panels[key]) return; // already mounted — keep it alive
|
||||
this.panels[key] = make();
|
||||
};
|
||||
|
||||
if (variant === 'full') {
|
||||
add('gdelt-intel', () => new GdeltIntelPanel());
|
||||
add('cii', () => {
|
||||
const p = new CIIPanel();
|
||||
p.setShareStoryHandler((code, name) => this.openCountryStory(code, name));
|
||||
return p;
|
||||
});
|
||||
add('cascade', () => new CascadePanel());
|
||||
add('satellite-fires', () => new SatelliteFiresPanel());
|
||||
add('strategic-risk', () => {
|
||||
const p = new StrategicRiskPanel();
|
||||
p.setLocationClickHandler((lat, lon) => this.map?.setCenter(lat, lon, 4));
|
||||
return p;
|
||||
});
|
||||
add('strategic-posture', () => {
|
||||
const p = new StrategicPosturePanel();
|
||||
p.setLocationClickHandler((lat, lon) => this.map?.setCenter(lat, lon, 4));
|
||||
return p;
|
||||
});
|
||||
add('ucdp-events', () => {
|
||||
const p = new UcdpEventsPanel();
|
||||
p.setEventClickHandler((lat, lon) => this.map?.setCenter(lat, lon, 5));
|
||||
return p;
|
||||
});
|
||||
add('displacement', () => {
|
||||
const p = new DisplacementPanel();
|
||||
p.setCountryClickHandler((lat, lon) => this.map?.setCenter(lat, lon, 4));
|
||||
return p;
|
||||
});
|
||||
add('climate', () => {
|
||||
const p = new ClimateAnomalyPanel();
|
||||
p.setZoneClickHandler((lat, lon) => this.map?.setCenter(lat, lon, 4));
|
||||
return p;
|
||||
});
|
||||
add('population-exposure', () => new PopulationExposurePanel());
|
||||
}
|
||||
|
||||
// GCC Investments Panel (finance variant)
|
||||
if (variant === 'finance') {
|
||||
add('gcc-investments', () => new InvestmentsPanel((inv) => {
|
||||
focusInvestmentOnMap(this.map, this.mapLayers, inv.lat, inv.lon);
|
||||
}));
|
||||
}
|
||||
|
||||
// Cloud flagship panels — the live-traffic globe's companion tiles: Cloud
|
||||
// metrics, router/Enso training + flywheel, AI compute, model mix, fleet,
|
||||
// uptime, and the caller's own org usage + bill.
|
||||
if (variant === 'cloud') {
|
||||
add('cloud-overview', () => new CloudOverviewPanel());
|
||||
add('traffic-globe', () => new TrafficGlobePanel());
|
||||
add('model-improvement', () => new ModelImprovementPanel());
|
||||
add('enso-training', () => new EnsoTrainingPanel());
|
||||
add('enso-router', () => new EnsoRouterPanel());
|
||||
add('model-usage', () => new ModelUsagePanel());
|
||||
add('fleet', () => {
|
||||
const p = new FleetPanel();
|
||||
p.setLocationClickHandler((lat, lon) => this.map?.setCenter(lat, lon, 4));
|
||||
return p;
|
||||
});
|
||||
add('live-activity', () => new LiveActivityPanel());
|
||||
// Per-org event-platform cards — the caller's own analytics + product
|
||||
// insights (api.hanzo.ai /v1/analytics + /v1/insights, org-scoped by bearer).
|
||||
add('org-analytics', () => new OrgAnalyticsPanel());
|
||||
add('org-insights', () => new OrgInsightsPanel());
|
||||
add('my-usage', () => new MyUsagePanel());
|
||||
// Full Hanzo Cloud status page, embedded from status.hanzo.ai (public — NOT admin-gated).
|
||||
add('hanzo-status', () => new HanzoStatusPanel());
|
||||
}
|
||||
|
||||
// Live Hanzo inference telemetry: AI Compute (ai-pulse SSE) + Enso Flywheel
|
||||
// (routing ledger + evals). On cloud + AI + the default (full) world — the
|
||||
// platform's own compute/training pulse is front-page telemetry.
|
||||
if (variant === 'cloud' || variant === 'ai' || variant === 'full') {
|
||||
add('ai-compute', () => new AiComputePanel());
|
||||
add('enso-flywheel', () => new EnsoFlywheelPanel());
|
||||
}
|
||||
|
||||
// Chains widget — live block heights + peers (cloud + crypto variants).
|
||||
if (variant === 'cloud' || variant === 'crypto') {
|
||||
add('chains', () => new BlockchainPanel());
|
||||
}
|
||||
}
|
||||
|
||||
// Code-split Bloomberg-style finance terminal — dynamic import() so its
|
||||
// TradingView embeds + terminal CSS never ship in the entry bundle. Mounts a
|
||||
// full-viewport terminal over the shell for the finance variant.
|
||||
@@ -2852,11 +2864,15 @@ export class App {
|
||||
}
|
||||
}
|
||||
|
||||
// Code-split map mount. The MapContainer value (→ the ~2.7 MB mapbox-gl +
|
||||
// deck.gl "map" chunk) is loaded via dynamic import() so it never blocks first
|
||||
// paint. init() kicks this off after the shell paints and awaits it before any
|
||||
// map-dependent wiring runs. Idempotent: a second call is a no-op.
|
||||
private async mountMap(): Promise<void> {
|
||||
if (this.map) return;
|
||||
// The finance variant renders the terminal (FinanceTerminal), not the globe —
|
||||
// skip the ~2.7 MB map load entirely there.
|
||||
if (SITE_VARIANT === 'finance') return;
|
||||
if (getSiteVariant() === 'finance') return;
|
||||
const mapContainer = document.getElementById('mapContainer') as HTMLElement | null;
|
||||
if (!mapContainer) return;
|
||||
const { MapContainer } = await import('@/components/MapContainer');
|
||||
@@ -2891,26 +2907,43 @@ export class App {
|
||||
// Constructed and appended only for the admin org; non-admins never receive
|
||||
// them (and every backing endpoint fail-closes 403). Idempotent.
|
||||
private async mountAdminCloudPanels(): Promise<void> {
|
||||
if (SITE_VARIANT !== 'cloud' || this.adminCloudMounted) return;
|
||||
if (getSiteVariant() !== 'cloud') return;
|
||||
// Panel key → label + lazy factory (one declaration, used by both the re-assert
|
||||
// branch and the initial-mount loop below). Lazy so a panel is constructed only
|
||||
// when actually mounted — the loop `continue`s on an already-present key, so
|
||||
// re-entry never re-news a heavy panel. Fleet lives in ONE panel now — the
|
||||
// always-mounted "Fleet & GPUs" (FleetPanel) renders the full platform fleet for
|
||||
// admins; the old admin-only CloudFleetPanel duplicate was removed. Clusters &
|
||||
// Nodes (DOKS nodes per cluster) + GPU Queue (gpu-jobs depth + what each worker is
|
||||
// serving) complete the SuperAdmin fleet view alongside it.
|
||||
const defs: Array<[string, string, () => Panel]> = [
|
||||
['cloud-services', 'Service status', () => new CloudServicesPanel()],
|
||||
['cloud-clusters', 'Clusters & Nodes', () => new ClusterPanel()],
|
||||
['cloud-queue', 'GPU Queue', () => new QueuePanel()],
|
||||
['llm-usage', 'LLM observability', () => new LlmUsagePanel()],
|
||||
['cloud-analytics', 'Web analytics', () => new CloudAnalyticsPanel()],
|
||||
['enso-benchmarks', 'Enso benchmarks', () => new EnsoBenchmarkPanel()],
|
||||
];
|
||||
|
||||
// Already mounted (e.g. switching back to cloud after the panelSettings reset):
|
||||
// re-assert their settings so the in-place switch keeps them, then re-apply.
|
||||
if (this.adminCloudMounted) {
|
||||
for (const [key, name] of defs) {
|
||||
if (this.panels[key] && !this.panelSettings[key]) this.panelSettings[key] = { name, enabled: true, priority: 1 };
|
||||
}
|
||||
saveToStorage(STORAGE_KEYS.panels, this.panelSettings);
|
||||
this.applyPanelSettings();
|
||||
this.renderPanelToggles();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!(await isAdmin())) return;
|
||||
const grid = document.getElementById('panelsGrid');
|
||||
if (!grid) return;
|
||||
this.adminCloudMounted = true;
|
||||
const defs: Array<[string, Panel, string]> = [
|
||||
['cloud-services', new CloudServicesPanel(), 'Service status'],
|
||||
// Fleet lives in ONE panel now — the always-mounted "Fleet & GPUs" (FleetPanel)
|
||||
// renders the full platform fleet for admins. The old admin-only "Fleet &
|
||||
// clusters" (CloudFleetPanel) was a byte-for-byte duplicate and was removed.
|
||||
// Clusters & Nodes (DOKS nodes per cluster) + GPU Queue (gpu-jobs depth + what
|
||||
// each worker is serving) complete the SuperAdmin fleet view alongside it.
|
||||
['cloud-clusters', new ClusterPanel(), 'Clusters & Nodes'],
|
||||
['cloud-queue', new QueuePanel(), 'GPU Queue'],
|
||||
['llm-usage', new LlmUsagePanel(), 'LLM observability'],
|
||||
['cloud-analytics', new CloudAnalyticsPanel(), 'Web analytics'],
|
||||
['enso-benchmarks', new EnsoBenchmarkPanel(), 'Enso benchmarks'],
|
||||
];
|
||||
for (const [key, panel, name] of defs) {
|
||||
for (const [key, name, make] of defs) {
|
||||
if (this.panels[key]) continue;
|
||||
const panel = make();
|
||||
this.panels[key] = panel;
|
||||
if (!this.panelSettings[key]) this.panelSettings[key] = { name, enabled: true, priority: 1 };
|
||||
const el = panel.getElement();
|
||||
@@ -2929,7 +2962,7 @@ export class App {
|
||||
if (this.initialUrlState?.mode) return this.initialUrlState.mode;
|
||||
const stored = localStorage.getItem(this.MAP_MODE_STORAGE_KEY);
|
||||
if (stored === '3d' || stored === '2d') return stored;
|
||||
return SITE_VARIANT === 'cloud' || SITE_VARIANT === 'ai' ? '3d' : '2d';
|
||||
return getSiteVariant() === 'cloud' || getSiteVariant() === 'ai' ? '3d' : '2d';
|
||||
}
|
||||
|
||||
private applyInitialUrlState(): void {
|
||||
@@ -3029,7 +3062,7 @@ export class App {
|
||||
}
|
||||
return {
|
||||
getState: () => ({
|
||||
variant: SITE_VARIANT,
|
||||
variant: getSiteVariant(),
|
||||
timeRange: this.currentTimeRange,
|
||||
mapMode: this.map?.getProjectionMode(),
|
||||
region: this.map?.getState().view,
|
||||
@@ -3288,24 +3321,103 @@ export class App {
|
||||
}
|
||||
|
||||
// The single variant-switch path (header tabs + analyst set_variant both route
|
||||
// here). SITE_VARIANT is an import-time const woven through ~30 modules, so a
|
||||
// true zero-reload in-place swap is out of scope for this release (see report).
|
||||
// The pragmatic switch instead makes the reload feel like only the panels
|
||||
// changed: it FLUSHES the exact live map view (camera + 2D/3D mode + layers +
|
||||
// time range) into the URL synchronously — closing the 250ms URL-sync debounce
|
||||
// gap — and stamps ?variant, so the post-reload restore is pixel-for-pixel and
|
||||
// the URL is shareable. PWA-precached, content-hashed assets keep the reload
|
||||
// sub-second.
|
||||
// here). It switches IN PLACE — no page reload — so the deck.gl WebGL context,
|
||||
// ML workers, and video iframes are never torn down (that cold-start churn was
|
||||
// the tab-switch freeze). It mirrors the intra-variant panel toggle: recompute
|
||||
// the target's config and re-apply it live. The heavy singletons stay warm; only
|
||||
// the panel set, map layers, feeds, tabs and URL change.
|
||||
private setSiteVariant(variant: string): boolean {
|
||||
if (!['full', 'tech', 'finance', 'cloud', 'hanzo', 'saas', 'ai', 'crypto'].includes(variant)) return false;
|
||||
if (variant === SITE_VARIANT) return true;
|
||||
localStorage.setItem('worldmonitor-variant', variant); // survives even a trimmed URL
|
||||
const u = new URL(this.getShareUrl() ?? window.location.href);
|
||||
u.searchParams.set('variant', variant);
|
||||
window.location.href = u.toString();
|
||||
const prev = getSiteVariant();
|
||||
const target = setSiteVariantRuntime(variant); // canonicalizes saas/hanzo → cloud
|
||||
if (!target) return false; // unknown variant — no-op
|
||||
if (target === prev) return true;
|
||||
|
||||
const cfg = variantConfig(target);
|
||||
// Perf probe: the in-place switch must stay cheap (a few ms) — the whole point is
|
||||
// that it does NOT cold-start. We record the synchronous cost on window for the
|
||||
// e2e to assert against directly (immune to boot/GL contention that pollutes
|
||||
// wall-clock), and it doubles as a live diagnostic for the perf work.
|
||||
const swT: Record<string, number> = ((window as unknown as { __switchT?: Record<string, number> }).__switchT = {});
|
||||
const swAll = performance.now();
|
||||
const step = (k: string, fn: () => void): void => { const s = performance.now(); fn(); swT[k] = Math.round(performance.now() - s); };
|
||||
|
||||
// 1) Instantiate the target variant's panels (lazy, deduped, appended live).
|
||||
step('ensurePanels', () => this.ensureVariantPanels(target));
|
||||
|
||||
// 2) Reset panel + layer settings to the target's defaults — the SAME semantics
|
||||
// as a boot-time variant change (see the App constructor), minus the reload.
|
||||
// Panel prefs are stored under one shared key, so a switch resets them just
|
||||
// as the reload path did — but the panels that live OUTSIDE any variant (the
|
||||
// analyst, desktop config, and user custom feeds) are carried forward so a
|
||||
// switch never drops them (keep-alive, matching the reload's re-mount).
|
||||
localStorage.removeItem(this.PANEL_ORDER_KEY);
|
||||
const prevSettings = this.panelSettings;
|
||||
const nextSettings: Record<string, PanelConfig> = { ...cfg.DEFAULT_PANELS };
|
||||
for (const [key, entry] of Object.entries(prevSettings)) {
|
||||
if (key.startsWith('custom:') || key === 'ai-analyst' || key === 'runtime-config') nextSettings[key] = entry;
|
||||
}
|
||||
if (!nextSettings['ai-analyst']) nextSettings['ai-analyst'] = { name: 'AI analyst', enabled: true, priority: 2 };
|
||||
if (this.isDesktopApp) nextSettings['runtime-config'] = { name: 'Desktop Configuration', enabled: true, priority: 2 };
|
||||
this.panelSettings = nextSettings;
|
||||
saveToStorage(STORAGE_KEYS.panels, this.panelSettings);
|
||||
|
||||
this.mapLayers = { ...(this.isMobile ? cfg.MOBILE_DEFAULT_MAP_LAYERS : cfg.DEFAULT_MAP_LAYERS) };
|
||||
saveToStorage(STORAGE_KEYS.mapLayers, this.mapLayers);
|
||||
step('setLayers', () => this.map?.setLayers(this.mapLayers));
|
||||
|
||||
// 3) Place any newly-needed panels into the grid, in the target's default order;
|
||||
// panels already mounted keep their slot (keep-alive). Then heal the map anchor.
|
||||
step('appendPanels', () => this.appendPanelsInOrder(Object.keys(cfg.DEFAULT_PANELS).filter((k) => k !== 'map')));
|
||||
step('healAnchor', () => this.healMapAnchor());
|
||||
|
||||
// 4) Apply visibility (shows the new set, hides the previous variant's panels)
|
||||
// and refresh the Panels menu to match. On cloud, (re-)mount the admin-only
|
||||
// console panels — idempotent, admin-gated, re-asserts their settings.
|
||||
step('applySettings', () => this.applyPanelSettings());
|
||||
step('renderToggles', () => this.renderPanelToggles());
|
||||
step('adminPanels', () => { void this.mountAdminCloudPanels(); });
|
||||
|
||||
// 5) Re-point the tab active state and the shareable URL — no reload.
|
||||
step('tabsActive', () => this.updateVariantTabsActive());
|
||||
const share = this.getShareUrl();
|
||||
if (share) history.replaceState(null, '', share);
|
||||
swT.total = Math.round(performance.now() - swAll);
|
||||
|
||||
// 6) Load the target variant's data (feeds resolve to feedsFor(getSiteVariant()),
|
||||
// layer data to the new mapLayers). loadAllData is in-flight-guarded.
|
||||
void this.loadAllData();
|
||||
return true;
|
||||
}
|
||||
|
||||
// Append the given panel keys to the grid IN ORDER, skipping any already placed
|
||||
// (they keep their live slot). Used by the in-place variant switch to add the
|
||||
// target variant's panels without disturbing the shared ones. live-news keeps its
|
||||
// must-be-first slot via the same rule createPanels uses.
|
||||
private appendPanelsInOrder(order: string[]): void {
|
||||
const grid = document.getElementById('panelsGrid');
|
||||
if (!grid) return;
|
||||
const keys = order.includes('live-news') ? ['live-news', ...order.filter((k) => k !== 'live-news')] : order;
|
||||
keys.forEach((key) => {
|
||||
const panel = this.panels[key];
|
||||
if (!panel) return;
|
||||
const el = panel.getElement();
|
||||
if (el.parentElement === grid) return; // already placed — keep-alive
|
||||
this.makeDraggable(el, key);
|
||||
grid.appendChild(el);
|
||||
});
|
||||
}
|
||||
|
||||
// Re-point the header tab active state to the live variant (used after an
|
||||
// in-place switch; the boot render sets it from the load-time value).
|
||||
private updateVariantTabsActive(): void {
|
||||
const active = getSiteVariant();
|
||||
this.container.querySelectorAll<HTMLElement>('.variant-option').forEach((el) => {
|
||||
const on = el.dataset.variant === active;
|
||||
el.classList.toggle('active', on);
|
||||
el.setAttribute('aria-selected', String(on));
|
||||
});
|
||||
}
|
||||
|
||||
private resetPanelLayout(): void {
|
||||
// One click back to the variant default. Clears every layout customization —
|
||||
// panel order, per-panel heights (spans), custom feed panels, and the
|
||||
@@ -3547,9 +3659,11 @@ export class App {
|
||||
this.container.querySelectorAll<HTMLAnchorElement>('.variant-option').forEach(link => {
|
||||
link.addEventListener('click', (e) => {
|
||||
const variant = link.dataset.variant;
|
||||
if (variant && variant !== SITE_VARIANT) {
|
||||
e.preventDefault();
|
||||
this.setSiteVariant(variant); // one switch path: exact map-state restore + ?variant
|
||||
// Always preventDefault: the switch is in-place, so the <a href="?variant=…">
|
||||
// must never navigate (a navigation is the reload we are eliminating).
|
||||
e.preventDefault();
|
||||
if (variant && variant !== getSiteVariant()) {
|
||||
this.setSiteVariant(variant); // one switch path: in-place, no reload
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -3570,7 +3684,7 @@ export class App {
|
||||
// Restore: reopen if previously opened; also open when the current view is NOT
|
||||
// the Hanzo default, so a deep-linked visitor can still reach the switcher.
|
||||
const stored = (() => { try { return localStorage.getItem('worldmonitor-hanzo-mode'); } catch { return null; } })();
|
||||
setMode(stored === '1' || (stored === null && SITE_VARIANT !== 'cloud'));
|
||||
setMode(stored === '1' || (stored === null && getSiteVariant() !== 'cloud'));
|
||||
hanzoToggle.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
setMode(!header?.classList.contains('hanzo-mode'));
|
||||
@@ -3949,7 +4063,7 @@ export class App {
|
||||
|
||||
private getAllSourceNames(): string[] {
|
||||
const sources = new Set<string>();
|
||||
Object.values(FEEDS).forEach(feeds => {
|
||||
Object.values(feedsFor(getSiteVariant())).forEach(feeds => {
|
||||
if (feeds) feeds.forEach(f => sources.add(f.name));
|
||||
});
|
||||
INTEL_SOURCES.forEach(f => sources.add(f.name));
|
||||
@@ -4037,16 +4151,16 @@ export class App {
|
||||
}
|
||||
|
||||
private applyPanelSettings(): void {
|
||||
Object.entries(this.panelSettings).forEach(([key, config]) => {
|
||||
if (key === 'map') {
|
||||
const mapSection = document.getElementById('mapSection');
|
||||
if (mapSection) {
|
||||
mapSection.classList.toggle('hidden', !config.enabled);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const panel = this.panels[key];
|
||||
panel?.toggle(config.enabled);
|
||||
// The map section toggles on its own settings entry.
|
||||
const mapSection = document.getElementById('mapSection');
|
||||
if (mapSection) mapSection.classList.toggle('hidden', this.panelSettings['map']?.enabled === false);
|
||||
|
||||
// Authoritative over EVERY mounted panel: visible iff its settings say enabled.
|
||||
// A panel that belongs only to another variant has no settings entry after an
|
||||
// in-place switch, so it is hidden — this is what makes the switch swap panel
|
||||
// sets (the same keep-alive CSS toggle the intra-variant Panels menu uses).
|
||||
Object.entries(this.panels).forEach(([key, panel]) => {
|
||||
panel.toggle(this.panelSettings[key]?.enabled ?? false);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -4085,24 +4199,24 @@ export class App {
|
||||
|
||||
// Load intelligence signals for CII calculation (protests, military, outages)
|
||||
// Only for geopolitical variant - tech variant doesn't need CII/focal points
|
||||
if (SITE_VARIANT === 'full') {
|
||||
if (getSiteVariant() === 'full') {
|
||||
tasks.push({ name: 'intelligence', task: runGuarded('intelligence', () => this.loadIntelligenceSignals()) });
|
||||
}
|
||||
|
||||
// Conditionally load non-intelligence layers
|
||||
// NOTE: outages, protests, military are handled by loadIntelligenceSignals() above
|
||||
// They update the map when layers are enabled, so no duplicate tasks needed here
|
||||
if (SITE_VARIANT === 'full') tasks.push({ name: 'firms', task: runGuarded('firms', () => this.loadFirmsData()) });
|
||||
if (getSiteVariant() === 'full') tasks.push({ name: 'firms', task: runGuarded('firms', () => this.loadFirmsData()) });
|
||||
if (this.mapLayers.natural) tasks.push({ name: 'natural', task: runGuarded('natural', () => this.loadNatural()) });
|
||||
if (this.mapLayers.weather) tasks.push({ name: 'weather', task: runGuarded('weather', () => this.loadWeatherAlerts()) });
|
||||
if (this.mapLayers.ais) tasks.push({ name: 'ais', task: runGuarded('ais', () => this.loadAisSignals()) });
|
||||
if (this.mapLayers.cables) tasks.push({ name: 'cables', task: runGuarded('cables', () => this.loadCableActivity()) });
|
||||
if (this.mapLayers.flights) tasks.push({ name: 'flights', task: runGuarded('flights', () => this.loadFlightDelays()) });
|
||||
if (CYBER_LAYER_ENABLED && this.mapLayers.cyberThreats) tasks.push({ name: 'cyberThreats', task: runGuarded('cyberThreats', () => this.loadCyberThreats()) });
|
||||
if (this.mapLayers.techEvents || SITE_VARIANT === 'tech') tasks.push({ name: 'techEvents', task: runGuarded('techEvents', () => this.loadTechEvents()) });
|
||||
if (this.mapLayers.techEvents || getSiteVariant() === 'tech') tasks.push({ name: 'techEvents', task: runGuarded('techEvents', () => this.loadTechEvents()) });
|
||||
|
||||
// Tech Readiness panel (tech + ai variants — every variant that registers it)
|
||||
if (SITE_VARIANT === 'tech' || SITE_VARIANT === 'ai') {
|
||||
if (getSiteVariant() === 'tech' || getSiteVariant() === 'ai') {
|
||||
tasks.push({ name: 'techReadiness', task: runGuarded('techReadiness', () => (this.panels['tech-readiness'] as TechReadinessPanel)?.refresh()) });
|
||||
}
|
||||
|
||||
@@ -4399,7 +4513,7 @@ export class App {
|
||||
|
||||
private async loadNews(): Promise<void> {
|
||||
// Build categories dynamically from whatever feeds the current variant exports
|
||||
const categories = Object.entries(FEEDS)
|
||||
const categories = Object.entries(feedsFor(getSiteVariant()))
|
||||
.filter((entry): entry is [string, typeof FEEDS[keyof typeof FEEDS]] => Array.isArray(entry[1]) && entry[1].length > 0)
|
||||
.map(([key, feeds]) => ({ key, feeds }));
|
||||
|
||||
@@ -4407,7 +4521,7 @@ export class App {
|
||||
// With the server-side feeds-batch each category costs ~1 request, so a
|
||||
// wide pipeline no longer bursts upstream APIs (the old per-feed path
|
||||
// remains the fallback and still honors the per-feed batches below).
|
||||
const maxCategoryConcurrency = SITE_VARIANT === 'finance' ? 4 : 12;
|
||||
const maxCategoryConcurrency = getSiteVariant() === 'finance' ? 4 : 12;
|
||||
const categoryConcurrency = Math.max(1, Math.min(maxCategoryConcurrency, categories.length));
|
||||
const categoryResults: PromiseSettledResult<NewsItem[]>[] = [];
|
||||
for (let i = 0; i < categories.length; i += categoryConcurrency) {
|
||||
@@ -4429,7 +4543,7 @@ export class App {
|
||||
});
|
||||
|
||||
// Intel (uses different source) - full variant only (defense/military news)
|
||||
if (SITE_VARIANT === 'full') {
|
||||
if (getSiteVariant() === 'full') {
|
||||
const enabledIntelSources = INTEL_SOURCES.filter(f => !this.disabledSources.has(f.name));
|
||||
const intelPanel = this.newsPanels['intel'];
|
||||
if (enabledIntelSources.length === 0) {
|
||||
@@ -4621,9 +4735,9 @@ export class App {
|
||||
}
|
||||
|
||||
private async loadTechEvents(): Promise<void> {
|
||||
console.log('[loadTechEvents] Called. SITE_VARIANT:', SITE_VARIANT, 'techEvents layer:', this.mapLayers.techEvents);
|
||||
console.log('[loadTechEvents] Called. variant:', getSiteVariant(), 'techEvents layer:', this.mapLayers.techEvents);
|
||||
// Only load for tech variant or if techEvents layer is enabled
|
||||
if (SITE_VARIANT !== 'tech' && !this.mapLayers.techEvents) {
|
||||
if (getSiteVariant() !== 'tech' && !this.mapLayers.techEvents) {
|
||||
console.log('[loadTechEvents] Skipping - not tech variant and layer disabled');
|
||||
return;
|
||||
}
|
||||
@@ -4663,7 +4777,7 @@ export class App {
|
||||
this.statusPanel?.updateFeed('Tech Events', { status: 'ok', itemCount: mapEvents.length });
|
||||
|
||||
// Register tech events as searchable source
|
||||
if (SITE_VARIANT === 'tech' && this.searchModal) {
|
||||
if (getSiteVariant() === 'tech' && this.searchModal) {
|
||||
this.searchModal.registerSource('techevent', mapEvents.map((e: { id: string; title: string; location: string; startDate: string }) => ({
|
||||
id: e.id,
|
||||
title: e.title,
|
||||
@@ -5527,7 +5641,7 @@ export class App {
|
||||
|
||||
// Refresh intelligence signals for CII (geopolitical variant only)
|
||||
// This handles outages, protests, military - updates map when layers enabled
|
||||
if (SITE_VARIANT === 'full') {
|
||||
if (getSiteVariant() === 'full') {
|
||||
this.scheduleRefresh('intelligence', () => {
|
||||
this.intelligenceCache = {}; // Clear cache to force fresh fetch
|
||||
return this.loadIntelligenceSignals();
|
||||
|
||||
+18
-12
@@ -70,7 +70,7 @@ import {
|
||||
STRATEGIC_WATERWAYS,
|
||||
ECONOMIC_CENTERS,
|
||||
AI_DATA_CENTERS,
|
||||
SITE_VARIANT,
|
||||
getSiteVariant,
|
||||
STARTUP_HUBS,
|
||||
ACCELERATORS,
|
||||
TECH_HQS,
|
||||
@@ -1097,8 +1097,8 @@ export class DeckGLMap {
|
||||
const boundsKey = `${bbox[0].toFixed(4)}:${bbox[1].toFixed(4)}:${bbox[2].toFixed(4)}:${bbox[3].toFixed(4)}`;
|
||||
const layers = this.state.layers;
|
||||
const useProtests = layers.protests && this.protestSuperclusterSource.length > 0;
|
||||
const useTechHQ = SITE_VARIANT === 'tech' && layers.techHQs;
|
||||
const useTechEvents = SITE_VARIANT === 'tech' && layers.techEvents && this.techEvents.length > 0;
|
||||
const useTechHQ = getSiteVariant() === 'tech' && layers.techHQs;
|
||||
const useTechEvents = getSiteVariant() === 'tech' && layers.techEvents && this.techEvents.length > 0;
|
||||
const useDatacenterClusters = layers.datacenters && zoom < 5;
|
||||
const layerMask = `${Number(useProtests)}${Number(useTechHQ)}${Number(useTechEvents)}${Number(useDatacenterClusters)}`;
|
||||
if (zoom === this.lastSCZoom && boundsKey === this.lastSCBoundsKey && layerMask === this.lastSCMask) return;
|
||||
@@ -1507,7 +1507,7 @@ export class DeckGLMap {
|
||||
}
|
||||
|
||||
// APT Groups layer (geopolitical variant only - always shown, no toggle)
|
||||
if (SITE_VARIANT !== 'tech') {
|
||||
if (getSiteVariant() !== 'tech') {
|
||||
layers.push(this.createAPTGroupsLayer());
|
||||
}
|
||||
|
||||
@@ -1530,7 +1530,7 @@ export class DeckGLMap {
|
||||
}
|
||||
|
||||
// Tech variant layers (Supercluster-based deck.gl layers for HQs and events)
|
||||
if (SITE_VARIANT === 'tech') {
|
||||
if (getSiteVariant() === 'tech') {
|
||||
if (mapLayers.startupHubs) {
|
||||
layers.push(this.createStartupHubsLayer());
|
||||
}
|
||||
@@ -3438,7 +3438,7 @@ export class DeckGLMap {
|
||||
toggles.className = 'layer-toggles deckgl-layer-toggles';
|
||||
this.layerPanelEl = toggles;
|
||||
|
||||
const layerConfig = SITE_VARIANT === 'tech'
|
||||
const layerConfig = getSiteVariant() === 'tech'
|
||||
? [
|
||||
{ key: 'startupHubs', label: t('components.deckgl.layers.startupHubs'), icon: '🚀' },
|
||||
{ key: 'techHQs', label: t('components.deckgl.layers.techHQs'), icon: '🏢' },
|
||||
@@ -3452,7 +3452,7 @@ export class DeckGLMap {
|
||||
{ key: 'natural', label: t('components.deckgl.layers.naturalEvents'), icon: '🌋' },
|
||||
{ key: 'fires', label: t('components.deckgl.layers.fires'), icon: '🔥' },
|
||||
]
|
||||
: SITE_VARIANT === 'finance'
|
||||
: getSiteVariant() === 'finance'
|
||||
? [
|
||||
{ key: 'stockExchanges', label: t('components.deckgl.layers.stockExchanges'), icon: '🏛' },
|
||||
{ key: 'financialCenters', label: t('components.deckgl.layers.financialCenters'), icon: '💰' },
|
||||
@@ -3826,9 +3826,9 @@ export class DeckGLMap {
|
||||
</div>
|
||||
`;
|
||||
|
||||
popup.innerHTML = SITE_VARIANT === 'tech'
|
||||
popup.innerHTML = getSiteVariant() === 'tech'
|
||||
? techHelpContent
|
||||
: SITE_VARIANT === 'finance'
|
||||
: getSiteVariant() === 'finance'
|
||||
? financeHelpContent
|
||||
: fullHelpContent;
|
||||
|
||||
@@ -3856,6 +3856,9 @@ export class DeckGLMap {
|
||||
}
|
||||
|
||||
private createLegend(): void {
|
||||
// Idempotent: an in-place variant switch re-runs this (via setLayers) to swap
|
||||
// the legend to the new variant's data classes, so drop any prior legend first.
|
||||
this.container.querySelector('.map-legend.deckgl-legend')?.remove();
|
||||
const legend = document.createElement('div');
|
||||
legend.className = 'map-legend deckgl-legend';
|
||||
|
||||
@@ -3874,7 +3877,7 @@ export class DeckGLMap {
|
||||
// Cloud (flagship) legend MUST match what the globe plots — the Hanzo Cloud data
|
||||
// classes, not the geopolitical default (which would mislabel traffic dots as
|
||||
// "high alert" etc.). Order mirrors visual prominence on the globe.
|
||||
const legendItems = SITE_VARIANT === 'cloud'
|
||||
const legendItems = getSiteVariant() === 'cloud'
|
||||
? [
|
||||
{ shape: shapes.heat(), label: 'Request origin · volume' },
|
||||
{ shape: shapes.circle('rgb(0, 200, 255)'), label: 'Validator node' },
|
||||
@@ -3882,7 +3885,7 @@ export class DeckGLMap {
|
||||
{ shape: shapes.circle('rgb(150, 100, 255)'), label: 'Cloud region' },
|
||||
{ shape: shapes.square('rgb(136, 68, 255)'), label: 'Datacenter · PoP' },
|
||||
]
|
||||
: SITE_VARIANT === 'tech'
|
||||
: getSiteVariant() === 'tech'
|
||||
? [
|
||||
{ shape: shapes.circle(isLight ? 'rgb(22, 163, 74)' : 'rgb(0, 255, 150)'), label: t('components.deckgl.legend.startupHub') },
|
||||
{ shape: shapes.circle('rgb(100, 200, 255)'), label: t('components.deckgl.legend.techHQ') },
|
||||
@@ -3890,7 +3893,7 @@ export class DeckGLMap {
|
||||
{ shape: shapes.circle('rgb(150, 100, 255)'), label: t('components.deckgl.legend.cloudRegion') },
|
||||
{ shape: shapes.square('rgb(136, 68, 255)'), label: t('components.deckgl.legend.datacenter') },
|
||||
]
|
||||
: SITE_VARIANT === 'finance'
|
||||
: getSiteVariant() === 'finance'
|
||||
? [
|
||||
{ shape: shapes.circle('rgb(255, 215, 80)'), label: t('components.deckgl.legend.stockExchange') },
|
||||
{ shape: shapes.circle('rgb(0, 220, 150)'), label: t('components.deckgl.legend.financialCenter') },
|
||||
@@ -4338,6 +4341,9 @@ export class DeckGLMap {
|
||||
public setLayers(layers: MapLayers): void {
|
||||
this.state.layers = layers;
|
||||
this.render(); // Debounced
|
||||
// Refresh the legend — its data classes are variant-dependent, and an in-place
|
||||
// variant switch re-points layers through here.
|
||||
this.createLegend();
|
||||
|
||||
// Update toggle checkboxes
|
||||
Object.entries(layers).forEach(([key, value]) => {
|
||||
|
||||
@@ -8,7 +8,7 @@ import { ingestNewsForCII } from '@/services/country-instability';
|
||||
import { getTheaterPostureSummaries } from '@/services/military-surge';
|
||||
import { isMobileDevice } from '@/utils';
|
||||
import { escapeHtml, sanitizeUrl } from '@/utils/sanitize';
|
||||
import { SITE_VARIANT } from '@/config';
|
||||
import { getSiteVariant } from '@/config';
|
||||
import { getPersistentCache, setPersistentCache } from '@/services/persistent-cache';
|
||||
import { t } from '@/services/i18n';
|
||||
import type { ClusteredEvent, FocalPoint, MilitaryFlight } from '@/types';
|
||||
@@ -277,7 +277,7 @@ export class InsightsPanel extends Panel {
|
||||
let signalSummary: ReturnType<typeof signalAggregator.getSummary>;
|
||||
let focalSummary: ReturnType<typeof focalPointDetector.analyze>;
|
||||
|
||||
if (SITE_VARIANT === 'full') {
|
||||
if (getSiteVariant() === 'full') {
|
||||
signalSummary = signalAggregator.getSummary();
|
||||
this.lastConvergenceZones = signalSummary.convergenceZones;
|
||||
if (signalSummary.totalSignals > 0) {
|
||||
@@ -341,8 +341,8 @@ export class InsightsPanel extends Panel {
|
||||
|
||||
// Pass focal point context + theater posture to AI for correlation-aware summarization
|
||||
// Tech variant: no geopolitical context, just tech news summarization
|
||||
const theaterContext = SITE_VARIANT === 'full' ? this.getTheaterPostureContext() : '';
|
||||
const geoContext = SITE_VARIANT === 'full'
|
||||
const theaterContext = getSiteVariant() === 'full' ? this.getTheaterPostureContext() : '';
|
||||
const geoContext = getSiteVariant() === 'full'
|
||||
? (focalSummary.aiContext || signalSummary.aiContext) + theaterContext
|
||||
: '';
|
||||
const result = await generateSummary(titles, (_step, _total, msg) => {
|
||||
@@ -406,7 +406,7 @@ export class InsightsPanel extends Panel {
|
||||
private renderWorldBrief(brief: string): string {
|
||||
return `
|
||||
<div class="insights-brief">
|
||||
<div class="insights-section-title">${SITE_VARIANT === 'tech' ? '🚀 TECH BRIEF' : '🌍 WORLD BRIEF'}</div>
|
||||
<div class="insights-section-title">${getSiteVariant() === 'tech' ? '🚀 TECH BRIEF' : '🌍 WORLD BRIEF'}</div>
|
||||
<div class="insights-brief-text">${escapeHtml(brief)}</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Panel } from './Panel';
|
||||
import { fetchLiveVideoId } from '@/services/live-news';
|
||||
import { isDesktopRuntime, getRemoteApiBaseUrl } from '@/services/runtime';
|
||||
import { t } from '../services/i18n';
|
||||
import { SITE_VARIANT } from '@/config';
|
||||
import { getSiteVariant } from '@/config';
|
||||
|
||||
// YouTube IFrame Player API types
|
||||
type YouTubePlayer = {
|
||||
@@ -70,10 +70,12 @@ const TECH_LIVE_CHANNELS: LiveChannel[] = [
|
||||
{ id: 'nasa', name: 'NASA TV', handle: '@NASA', fallbackVideoId: 'fO9e9jnhYK8', useFallbackOnly: true },
|
||||
];
|
||||
|
||||
// tech + ai + saas → tech/business channels; crypto/finance/full → world+finance set.
|
||||
const LIVE_CHANNELS = (SITE_VARIANT === 'tech' || SITE_VARIANT === 'ai' || SITE_VARIANT === 'saas')
|
||||
? TECH_LIVE_CHANNELS
|
||||
: FULL_LIVE_CHANNELS;
|
||||
// tech + ai → tech/business channels; crypto/finance/cloud/full → world+finance set.
|
||||
// Read live (getSiteVariant) so the channel set reflects the current variant.
|
||||
function liveChannels(): LiveChannel[] {
|
||||
const v = getSiteVariant();
|
||||
return v === 'tech' || v === 'ai' ? TECH_LIVE_CHANNELS : FULL_LIVE_CHANNELS;
|
||||
}
|
||||
|
||||
// Never-crash default: if a variant ever ships an empty channel list, the panel
|
||||
// still constructs (activeChannel stays a valid object) and renders a clean
|
||||
@@ -84,14 +86,19 @@ const EMPTY_CHANNEL: LiveChannel = { id: 'none', name: '—', handle: '', useFal
|
||||
// The variant's primary live channel, reused by the immersive video background so
|
||||
// the "live-news YouTube embed" is defined in exactly one place (this channel list).
|
||||
export function getDefaultLiveChannel(): { handle: string; videoId: string; name: string } {
|
||||
const c = LIVE_CHANNELS[0] ?? EMPTY_CHANNEL;
|
||||
const c = liveChannels()[0] ?? EMPTY_CHANNEL;
|
||||
return { handle: c.handle, videoId: c.fallbackVideoId ?? '', name: c.name };
|
||||
}
|
||||
|
||||
export class LiveNewsPanel extends Panel {
|
||||
private static apiPromise: Promise<void> | null = null;
|
||||
private activeChannel: LiveChannel = LIVE_CHANNELS[0] ?? EMPTY_CHANNEL;
|
||||
private activeChannel: LiveChannel = liveChannels()[0] ?? EMPTY_CHANNEL;
|
||||
private channelSwitcher: HTMLElement | null = null;
|
||||
// Pause the embed while the panel is off-screen (hidden by a variant switch, or
|
||||
// scrolled away) so it stops decoding — mirrors LiveWebcamsPanel. Starts true so
|
||||
// a panel that is never observed still plays.
|
||||
private observer: IntersectionObserver | null = null;
|
||||
private isIntersecting = true;
|
||||
private isMuted = true;
|
||||
private isPlaying = true;
|
||||
private wasPlayingBeforeIdle = true;
|
||||
@@ -136,6 +143,27 @@ export class LiveNewsPanel extends Panel {
|
||||
this.setupBridgeMessageListener();
|
||||
this.renderPlayer();
|
||||
this.setupIdleDetection();
|
||||
this.setupIntersectionObserver();
|
||||
}
|
||||
|
||||
// Pause playback when the panel isn't intersecting the viewport, resume when it
|
||||
// is. Same approach as LiveWebcamsPanel — it doesn't touch isPlaying (the user's
|
||||
// intent); syncPlayerState honors that intent when the panel returns to view.
|
||||
private setupIntersectionObserver(): void {
|
||||
this.observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
const wasIntersecting = this.isIntersecting;
|
||||
this.isIntersecting = entries.some((e) => e.isIntersecting);
|
||||
if (this.isIntersecting && !wasIntersecting) {
|
||||
this.syncPlayerState();
|
||||
} else if (!this.isIntersecting && wasIntersecting) {
|
||||
this.player?.pauseVideo?.();
|
||||
if (this.useDesktopEmbedProxy) this.postToEmbed({ type: 'pause' });
|
||||
}
|
||||
},
|
||||
{ threshold: 0.1 },
|
||||
);
|
||||
this.observer.observe(this.element);
|
||||
}
|
||||
|
||||
private get embedOrigin(): string {
|
||||
@@ -361,7 +389,7 @@ export class LiveNewsPanel extends Panel {
|
||||
this.channelSwitcher = document.createElement('div');
|
||||
this.channelSwitcher.className = 'live-news-switcher';
|
||||
|
||||
LIVE_CHANNELS.forEach(channel => {
|
||||
liveChannels().forEach(channel => {
|
||||
const btn = document.createElement('button');
|
||||
btn.className = `live-channel-btn ${channel.id === this.activeChannel.id ? 'active' : ''}`;
|
||||
btn.dataset.channelId = channel.id;
|
||||
@@ -743,6 +771,8 @@ export class LiveNewsPanel extends Panel {
|
||||
this.idleTimeout = null;
|
||||
}
|
||||
|
||||
this.observer?.disconnect();
|
||||
this.observer = null;
|
||||
document.removeEventListener('visibilitychange', this.boundVisibilityHandler);
|
||||
window.removeEventListener('message', this.boundMessageHandler);
|
||||
['mousedown', 'keydown', 'scroll', 'touchstart'].forEach(event => {
|
||||
|
||||
@@ -28,7 +28,7 @@ import {
|
||||
PORTS,
|
||||
SPACEPORTS,
|
||||
CRITICAL_MINERALS,
|
||||
SITE_VARIANT,
|
||||
getSiteVariant,
|
||||
// Tech variant data
|
||||
STARTUP_HUBS,
|
||||
ACCELERATORS,
|
||||
@@ -373,7 +373,7 @@ export class MapComponent {
|
||||
'sanctions', 'economic', 'waterways', // geopolitical/economic
|
||||
'natural', 'weather', // natural events
|
||||
];
|
||||
const layers = SITE_VARIANT === 'tech' ? techLayers : SITE_VARIANT === 'finance' ? financeLayers : fullLayers;
|
||||
const layers = getSiteVariant() === 'tech' ? techLayers : getSiteVariant() === 'finance' ? financeLayers : fullLayers;
|
||||
const layerLabelKeys: Partial<Record<keyof MapLayers, string>> = {
|
||||
hotspots: 'components.deckgl.layers.intelHotspots',
|
||||
conflicts: 'components.deckgl.layers.conflictZones',
|
||||
@@ -544,9 +544,9 @@ export class MapComponent {
|
||||
</div>
|
||||
`;
|
||||
|
||||
popup.innerHTML = SITE_VARIANT === 'tech'
|
||||
popup.innerHTML = getSiteVariant() === 'tech'
|
||||
? techHelpContent
|
||||
: SITE_VARIANT === 'finance'
|
||||
: getSiteVariant() === 'finance'
|
||||
? financeHelpContent
|
||||
: fullHelpContent;
|
||||
|
||||
@@ -585,7 +585,7 @@ export class MapComponent {
|
||||
const legend = document.createElement('div');
|
||||
legend.className = 'map-legend';
|
||||
|
||||
if (SITE_VARIANT === 'tech') {
|
||||
if (getSiteVariant() === 'tech') {
|
||||
// Tech variant legend
|
||||
legend.innerHTML = `
|
||||
<div class="map-legend-item"><span class="legend-dot" style="background:#8b5cf6"></span>${escapeHtml(t('components.deckgl.layers.techHQs').toUpperCase())}</div>
|
||||
@@ -1260,7 +1260,7 @@ export class MapComponent {
|
||||
}
|
||||
|
||||
// APT groups (geopolitical variant only)
|
||||
if (SITE_VARIANT !== 'tech') {
|
||||
if (getSiteVariant() !== 'tech') {
|
||||
this.renderAPTMarkers(projection);
|
||||
}
|
||||
|
||||
@@ -2131,7 +2131,7 @@ export class MapComponent {
|
||||
}
|
||||
|
||||
// Tech Hub Activity Markers (shows activity heatmap for tech hubs with news activity)
|
||||
if (SITE_VARIANT === 'tech' && this.techActivities.length > 0) {
|
||||
if (getSiteVariant() === 'tech' && this.techActivities.length > 0) {
|
||||
this.techActivities.forEach((activity) => {
|
||||
const pos = projection([activity.lon, activity.lat]);
|
||||
if (!pos) return;
|
||||
@@ -2173,7 +2173,7 @@ export class MapComponent {
|
||||
}
|
||||
|
||||
// Geo Hub Activity Markers (shows activity heatmap for geopolitical hubs - full variant)
|
||||
if (SITE_VARIANT === 'full' && this.geoActivities.length > 0) {
|
||||
if (getSiteVariant() === 'full' && this.geoActivities.length > 0) {
|
||||
this.geoActivities.forEach((activity) => {
|
||||
const pos = projection([activity.lon, activity.lat]);
|
||||
if (!pos) return;
|
||||
|
||||
@@ -6,7 +6,7 @@ import { formatTime, getCSSColor } from '@/utils';
|
||||
import { escapeHtml, sanitizeUrl } from '@/utils/sanitize';
|
||||
import { analysisWorker, enrichWithVelocity, enrichWithVelocityML, getClusterAssetContext, MAX_DISTANCE_KM, activityTracker, generateSummary, translateText } from '@/services';
|
||||
import { getSourcePropagandaRisk, getSourceTier, getSourceType } from '@/config/feeds';
|
||||
import { SITE_VARIANT } from '@/config';
|
||||
import { getSiteVariant } from '@/config';
|
||||
import { t, getCurrentLanguage } from '@/services/i18n';
|
||||
|
||||
/** Threshold for enabling virtual scrolling */
|
||||
@@ -138,7 +138,7 @@ export class NewsPanel extends Panel {
|
||||
|
||||
// Check cache first (include variant, version, and language)
|
||||
const currentLang = getCurrentLanguage();
|
||||
const cacheKey = `panel_summary_v3_${SITE_VARIANT}_${this.panelId}_${currentLang}`;
|
||||
const cacheKey = `panel_summary_v3_${getSiteVariant()}_${this.panelId}_${currentLang}`;
|
||||
const cached = this.getCachedSummary(cacheKey);
|
||||
if (cached) {
|
||||
this.showSummary(cached);
|
||||
|
||||
+17
-6
@@ -888,12 +888,23 @@ const FINANCE_FEEDS: Record<string, Feed[]> = {
|
||||
// security/github/layoffs…); the Crypto variant reuses the finance feed set
|
||||
// (crypto/markets/economic/regulation/centralbanks…). Panel selection per
|
||||
// variant lives in panels.ts — feeds only supply the underlying data.
|
||||
export const FEEDS =
|
||||
SITE_VARIANT === 'tech' || SITE_VARIANT === 'ai'
|
||||
? TECH_FEEDS
|
||||
: SITE_VARIANT === 'finance' || SITE_VARIANT === 'crypto' || SITE_VARIANT === 'fund'
|
||||
? FINANCE_FEEDS
|
||||
: FULL_FEEDS;
|
||||
// feedsFor resolves a variant name to its feed set (by-name, not load-time-fixed)
|
||||
// so an in-place variant switch can re-point the news feeds without a reload.
|
||||
export function feedsFor(variant: string): Record<string, Feed[]> {
|
||||
if (variant === 'tech' || variant === 'ai') return TECH_FEEDS;
|
||||
if (variant === 'finance' || variant === 'crypto' || variant === 'fund') return FINANCE_FEEDS;
|
||||
return FULL_FEEDS;
|
||||
}
|
||||
|
||||
// Load-time snapshot for the initial variant (boot-only reads).
|
||||
export const FEEDS = feedsFor(SITE_VARIANT);
|
||||
|
||||
// Every feed-category key across all variants. The app creates a NewsPanel per
|
||||
// key ONCE at boot so a later in-place switch already has the target variant's
|
||||
// feed panels mounted (they only fetch when their category is loaded).
|
||||
export const ALL_FEED_KEYS: string[] = [
|
||||
...new Set([...Object.keys(FULL_FEEDS), ...Object.keys(TECH_FEEDS), ...Object.keys(FINANCE_FEEDS)]),
|
||||
];
|
||||
|
||||
export const INTEL_SOURCES: Feed[] = [
|
||||
// Defense & Security (Tier 1)
|
||||
|
||||
+4
-1
@@ -4,7 +4,7 @@
|
||||
// VITE_VARIANT=full → worldmonitor.app (geopolitical)
|
||||
// VITE_VARIANT=finance → finance.worldmonitor.app (markets/trading)
|
||||
|
||||
export { SITE_VARIANT, isHanzoBrandHost, isLuxFundHost } from './variant';
|
||||
export { SITE_VARIANT, getSiteVariant, setSiteVariantRuntime, isHanzoBrandHost, isLuxFundHost } from './variant';
|
||||
|
||||
// Shared base configuration (always included)
|
||||
export {
|
||||
@@ -41,6 +41,7 @@ export {
|
||||
DEFAULT_PANELS,
|
||||
DEFAULT_MAP_LAYERS,
|
||||
MOBILE_DEFAULT_MAP_LAYERS,
|
||||
variantConfig,
|
||||
} from './panels';
|
||||
|
||||
// ============================================
|
||||
@@ -52,6 +53,8 @@ export {
|
||||
// These are large data files that should be tree-shaken in tech builds
|
||||
export {
|
||||
FEEDS,
|
||||
feedsFor,
|
||||
ALL_FEED_KEYS,
|
||||
INTEL_SOURCES,
|
||||
} from './feeds';
|
||||
|
||||
|
||||
+32
-3
@@ -698,9 +698,38 @@ const CRYPTO_MOBILE_MAP_LAYERS: MapLayers = {
|
||||
// ============================================
|
||||
// VARIANT-AWARE EXPORTS
|
||||
// ============================================
|
||||
export const DEFAULT_PANELS = SITE_VARIANT === 'tech' ? TECH_PANELS : SITE_VARIANT === 'finance' ? FINANCE_PANELS : SITE_VARIANT === 'fund' ? FUND_PANELS : SITE_VARIANT === 'cloud' ? CLOUD_PANELS : SITE_VARIANT === 'ai' ? AI_PANELS : SITE_VARIANT === 'crypto' ? CRYPTO_PANELS : FULL_PANELS;
|
||||
export const DEFAULT_MAP_LAYERS = SITE_VARIANT === 'tech' ? TECH_MAP_LAYERS : SITE_VARIANT === 'finance' ? FINANCE_MAP_LAYERS : SITE_VARIANT === 'fund' ? FUND_MAP_LAYERS : SITE_VARIANT === 'cloud' ? CLOUD_MAP_LAYERS : SITE_VARIANT === 'ai' ? AI_MAP_LAYERS : SITE_VARIANT === 'crypto' ? CRYPTO_MAP_LAYERS : FULL_MAP_LAYERS;
|
||||
export const MOBILE_DEFAULT_MAP_LAYERS = SITE_VARIANT === 'tech' ? TECH_MOBILE_MAP_LAYERS : SITE_VARIANT === 'finance' ? FINANCE_MOBILE_MAP_LAYERS : SITE_VARIANT === 'fund' ? FUND_MOBILE_MAP_LAYERS : SITE_VARIANT === 'cloud' ? CLOUD_MOBILE_MAP_LAYERS : SITE_VARIANT === 'ai' ? AI_MOBILE_MAP_LAYERS : SITE_VARIANT === 'crypto' ? CRYPTO_MOBILE_MAP_LAYERS : FULL_MOBILE_MAP_LAYERS;
|
||||
// variantConfig resolves a variant name to its full config triple (by-name, not
|
||||
// load-time-fixed) so an in-place variant switch can apply the target's panels +
|
||||
// map layers without a reload. The boot-time exports below are just this called
|
||||
// with the load-time variant — one lookup, one source of truth.
|
||||
export interface VariantPanelConfig {
|
||||
DEFAULT_PANELS: Record<string, PanelConfig>;
|
||||
DEFAULT_MAP_LAYERS: MapLayers;
|
||||
MOBILE_DEFAULT_MAP_LAYERS: MapLayers;
|
||||
}
|
||||
|
||||
export function variantConfig(variant: string): VariantPanelConfig {
|
||||
switch (variant) {
|
||||
case 'tech':
|
||||
return { DEFAULT_PANELS: TECH_PANELS, DEFAULT_MAP_LAYERS: TECH_MAP_LAYERS, MOBILE_DEFAULT_MAP_LAYERS: TECH_MOBILE_MAP_LAYERS };
|
||||
case 'finance':
|
||||
return { DEFAULT_PANELS: FINANCE_PANELS, DEFAULT_MAP_LAYERS: FINANCE_MAP_LAYERS, MOBILE_DEFAULT_MAP_LAYERS: FINANCE_MOBILE_MAP_LAYERS };
|
||||
case 'fund':
|
||||
return { DEFAULT_PANELS: FUND_PANELS, DEFAULT_MAP_LAYERS: FUND_MAP_LAYERS, MOBILE_DEFAULT_MAP_LAYERS: FUND_MOBILE_MAP_LAYERS };
|
||||
case 'cloud':
|
||||
return { DEFAULT_PANELS: CLOUD_PANELS, DEFAULT_MAP_LAYERS: CLOUD_MAP_LAYERS, MOBILE_DEFAULT_MAP_LAYERS: CLOUD_MOBILE_MAP_LAYERS };
|
||||
case 'ai':
|
||||
return { DEFAULT_PANELS: AI_PANELS, DEFAULT_MAP_LAYERS: AI_MAP_LAYERS, MOBILE_DEFAULT_MAP_LAYERS: AI_MOBILE_MAP_LAYERS };
|
||||
case 'crypto':
|
||||
return { DEFAULT_PANELS: CRYPTO_PANELS, DEFAULT_MAP_LAYERS: CRYPTO_MAP_LAYERS, MOBILE_DEFAULT_MAP_LAYERS: CRYPTO_MOBILE_MAP_LAYERS };
|
||||
default:
|
||||
return { DEFAULT_PANELS: FULL_PANELS, DEFAULT_MAP_LAYERS: FULL_MAP_LAYERS, MOBILE_DEFAULT_MAP_LAYERS: FULL_MOBILE_MAP_LAYERS };
|
||||
}
|
||||
}
|
||||
|
||||
export const DEFAULT_PANELS = variantConfig(SITE_VARIANT).DEFAULT_PANELS;
|
||||
export const DEFAULT_MAP_LAYERS = variantConfig(SITE_VARIANT).DEFAULT_MAP_LAYERS;
|
||||
export const MOBILE_DEFAULT_MAP_LAYERS = variantConfig(SITE_VARIANT).MOBILE_DEFAULT_MAP_LAYERS;
|
||||
|
||||
// Monitor palette — fixed category colors persisted to localStorage (not theme-dependent)
|
||||
export const MONITOR_COLORS = [
|
||||
|
||||
@@ -67,6 +67,34 @@ export const SITE_VARIANT: string = (() => {
|
||||
return build === 'saas' || build === 'hanzo' ? 'cloud' : build || 'full';
|
||||
})();
|
||||
|
||||
// The LIVE variant. SITE_VARIANT is the load-time snapshot (kept for boot-only
|
||||
// reads); currentVariant is the mutable value that an in-place tab switch
|
||||
// updates so the app can change view without a page reload. Every read that must
|
||||
// reflect the current view calls getSiteVariant() — one value, one source.
|
||||
let currentVariant = SITE_VARIANT;
|
||||
|
||||
// getSiteVariant returns the live variant. Prefer this over SITE_VARIANT anywhere
|
||||
// the value is read at call/render time (it stays correct across an in-place switch).
|
||||
export function getSiteVariant(): string {
|
||||
return currentVariant;
|
||||
}
|
||||
|
||||
// setSiteVariantRuntime switches the live variant in place (no reload). Normalizes
|
||||
// via the same canonicalizer as boot (saas/hanzo → cloud), persists the choice so
|
||||
// it survives a later navigation, and returns the applied variant — or null when
|
||||
// the value is unknown (caller no-ops).
|
||||
export function setSiteVariantRuntime(v: string): string | null {
|
||||
const nv = normVariant(v);
|
||||
if (!nv) return null;
|
||||
currentVariant = nv;
|
||||
try {
|
||||
localStorage.setItem('worldmonitor-variant', nv);
|
||||
} catch {
|
||||
/* private mode — the URL still carries the choice */
|
||||
}
|
||||
return nv;
|
||||
}
|
||||
|
||||
// Default basemap style when the user has not picked one. Every variant opens on
|
||||
// the dotted-land "cybermap" globe — the glowing dot lattice is the cinematic hero
|
||||
// across all modes. Users can still switch to dark / satellite / terrain via the
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
// panel-drag.ts, which reads getLayoutMode()/getCellSize() and calls back here to
|
||||
// persist. Grid mode is left byte-for-byte untouched so it can never regress.
|
||||
|
||||
import { SITE_VARIANT } from '../config/variant';
|
||||
import { getSiteVariant } from '../config/variant';
|
||||
|
||||
export type LayoutMode = 'grid' | 'free';
|
||||
|
||||
@@ -42,7 +42,11 @@ interface LayoutState {
|
||||
}
|
||||
|
||||
// Per-variant so the tech / finance / full dashboards each keep their own layout.
|
||||
const STORAGE_KEY = `worldmonitor-layout:${SITE_VARIANT}`;
|
||||
// Read live (getSiteVariant) so an in-place variant switch persists under the key
|
||||
// of the variant currently on screen.
|
||||
function layoutKey(): string {
|
||||
return `worldmonitor-layout:${getSiteVariant()}`;
|
||||
}
|
||||
|
||||
// px — the grid column-track floor, exposed to CSS as `--panel-col-min` (the
|
||||
// variable the base .panels-grid rule and the footer dock already read). 160
|
||||
@@ -72,7 +76,7 @@ const GRID_SELECTOR = '.panels-grid';
|
||||
function readState(): LayoutState {
|
||||
const base: LayoutState = { mode: 'grid', userSet: false, cellSize: DEFAULT_CELL_SIZE, free: {}, gridCols: {} };
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
const raw = localStorage.getItem(layoutKey());
|
||||
if (!raw) return base;
|
||||
const parsed = JSON.parse(raw) as Partial<LayoutState>;
|
||||
return {
|
||||
@@ -92,7 +96,7 @@ function readState(): LayoutState {
|
||||
|
||||
function writeState(next: LayoutState): void {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
|
||||
localStorage.setItem(layoutKey(), JSON.stringify(next));
|
||||
} catch {
|
||||
/* private mode — layout simply won't persist */
|
||||
}
|
||||
@@ -215,7 +219,7 @@ export function setCellSize(px: number): void {
|
||||
/** Clear every layout-engine customization (mode, cell size, free + grid geometry). */
|
||||
export function resetLayout(): void {
|
||||
try {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
localStorage.removeItem(layoutKey());
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
import { getDefaultLiveChannel } from '@/components/LiveNewsPanel';
|
||||
import { fetchLiveVideoId } from '@/services/live-news';
|
||||
import { SITE_VARIANT } from '@/config/variant';
|
||||
import { getSiteVariant } from '@/config/variant';
|
||||
|
||||
export type ImmersiveBackground = 'map' | 'video';
|
||||
|
||||
@@ -63,7 +63,7 @@ export class ImmersiveController {
|
||||
// Never chosen: the flagship Cloud view opens on the immersive globe — the
|
||||
// full-viewport 3D dot map is the hero, panels float over it — instead of
|
||||
// demoting it to a corner tile in the grid. Other variants still default to grid.
|
||||
return SITE_VARIANT === 'cloud';
|
||||
return getSiteVariant() === 'cloud';
|
||||
} catch { return false; }
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { PredictionMarket } from '@/types';
|
||||
import { createCircuitBreaker } from '@/utils';
|
||||
import { SITE_VARIANT } from '@/config';
|
||||
import { getSiteVariant } from '@/config';
|
||||
import { isDesktopRuntime } from '@/services/runtime';
|
||||
import { tryInvokeTauri } from '@/services/tauri-bridge';
|
||||
|
||||
@@ -223,7 +223,7 @@ async function fetchTopMarkets(): Promise<PredictionMarket[]> {
|
||||
|
||||
export async function fetchPredictions(): Promise<PredictionMarket[]> {
|
||||
return breaker.execute(async () => {
|
||||
const tags = SITE_VARIANT === 'tech' ? TECH_TAGS : GEOPOLITICAL_TAGS;
|
||||
const tags = getSiteVariant() === 'tech' ? TECH_TAGS : GEOPOLITICAL_TAGS;
|
||||
|
||||
const eventResults = await Promise.all(tags.map(tag => fetchEventsByTag(tag, 20)));
|
||||
|
||||
|
||||
+4
-4
@@ -1,5 +1,5 @@
|
||||
import type { Feed, NewsItem } from '@/types';
|
||||
import { SITE_VARIANT } from '@/config';
|
||||
import { SITE_VARIANT, getSiteVariant } from '@/config';
|
||||
import { chunkArray, fetchWithProxy } from '@/utils';
|
||||
import { classifyByKeyword, classifyWithAI } from './threat-classifier';
|
||||
import { getPersistentCache, setPersistentCache } from './persistent-cache';
|
||||
@@ -250,7 +250,7 @@ function storeFeedItems(feed: Feed, feedScope: string, raws: RawFeedItem[]): New
|
||||
// The Go backend classifies and geo-locates (internal/world/enrich.go, proven
|
||||
// identical to the old browser code). Falling back to the local classifier
|
||||
// keeps a feed usable if it ever arrives unenriched.
|
||||
const threat = (srvThreat as NewsItem['threat']) ?? classifyByKeyword(title, SITE_VARIANT);
|
||||
const threat = (srvThreat as NewsItem['threat']) ?? classifyByKeyword(title, getSiteVariant());
|
||||
const isAlert = threat!.level === 'critical' || threat!.level === 'high';
|
||||
return {
|
||||
source: feed.name,
|
||||
@@ -281,7 +281,7 @@ function storeFeedItems(feed: Feed, feedScope: string, raws: RawFeedItem[]): New
|
||||
|
||||
for (const item of aiCandidates) {
|
||||
if (!canQueueAiClassification(item.title)) continue;
|
||||
classifyWithAI(item.title, SITE_VARIANT).then((aiResult) => {
|
||||
classifyWithAI(item.title, getSiteVariant()).then((aiResult) => {
|
||||
if (aiResult && aiResult.confidence > item.threat.confidence) {
|
||||
item.threat = aiResult;
|
||||
item.isAlert = aiResult.level === 'critical' || aiResult.level === 'high';
|
||||
@@ -350,7 +350,7 @@ async function fetchFeedsViaBatch(feeds: Feed[]): Promise<NewsItem[][]> {
|
||||
const res = await fetchWithProxy('/v1/world/feeds-batch', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ urls: group.map(p => p.url), variant: SITE_VARIANT }),
|
||||
body: JSON.stringify({ urls: group.map(p => p.url), variant: getSiteVariant() }),
|
||||
}, 28_000);
|
||||
if (!res.ok) throw new Error(`feeds-batch HTTP ${res.status}`);
|
||||
const data = await res.json() as {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
import { mlWorker } from './ml-worker';
|
||||
import { SITE_VARIANT } from '@/config';
|
||||
import { getSiteVariant } from '@/config';
|
||||
import { BETA_MODE } from '@/config/beta';
|
||||
import { isFeatureAvailable } from './runtime-config';
|
||||
|
||||
@@ -25,7 +25,7 @@ async function tryGroq(headlines: string[], geoContext?: string, lang?: string):
|
||||
const response = await fetch('/v1/world/groq-summarize', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ headlines, mode: 'brief', geoContext, variant: SITE_VARIANT, lang }),
|
||||
body: JSON.stringify({ headlines, mode: 'brief', geoContext, variant: getSiteVariant(), lang }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -54,7 +54,7 @@ async function tryOpenRouter(headlines: string[], geoContext?: string, lang?: st
|
||||
const response = await fetch('/v1/world/openrouter-summarize', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ headlines, mode: 'brief', geoContext, variant: SITE_VARIANT, lang }),
|
||||
body: JSON.stringify({ headlines, mode: 'brief', geoContext, variant: getSiteVariant(), lang }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { MapLayers } from '@/types';
|
||||
import type { MapView, TimeRange } from '@/components/Map';
|
||||
import type { MapProjectionMode } from '@/components/MapContainer';
|
||||
import { SITE_VARIANT } from '@/config/variant';
|
||||
import { getSiteVariant } from '@/config/variant';
|
||||
|
||||
const LAYER_KEYS: (keyof MapLayers)[] = [
|
||||
'conflicts',
|
||||
@@ -139,8 +139,9 @@ export function buildMapUrl(
|
||||
// shares the same builder) would strip ?variant= and silently drop a crypto/
|
||||
// finance/tech/ai/saas viewer back to the default 'full' dashboard on reload.
|
||||
// 'full' is the canonical default and needs no param — keeps default URLs clean.
|
||||
if (SITE_VARIANT && SITE_VARIANT !== 'full') {
|
||||
params.set('variant', SITE_VARIANT);
|
||||
const variant = getSiteVariant();
|
||||
if (variant && variant !== 'full') {
|
||||
params.set('variant', variant);
|
||||
}
|
||||
|
||||
if (state.center) {
|
||||
|
||||
Reference in New Issue
Block a user