refactor(world): remove ~1.6k lines of dead code + fix 5 invalid alert backgrounds
Grep-verified dead (zero instantiations, zero non-barrel imports) — deleted end to end: - 11 orphaned panel components never built in createPanels/ensureVariantPanels: Robotics/Quantum/PostQuantum/Weather/Sports/SpaceWeather (the "domain-lens" set), plus Regulation (superseded by the NewsPanel 'regulation' feed), GeoHubs, TechHubs, VerificationChecklist, and CountryIntelModal (superseded by CountryBriefPage). - 5 dead backing services (robotics/quantum/post-quantum/space-weather/sports) + config/post-quantum.ts (only importer was the dead service). - The 6 dead domain-lens panel keys in FULL_PANELS — they rendered Panels-menu toggles and Add-widget entries that did NOTHING (applyPanelSettings/getElement hit undefined), and their now-orphaned `.domain-*` CSS block. - Barrel exports for the deleted panels. KEPT (verified live, NOT panels): config/quantum.ts + config/robotics.ts + services/ weather.ts (map layers), and services/geo-hub-index + tech-hub-index (feed the map's geo/tech activity markers via geo-activity/tech-activity — the audit missed the relative import; the build caught it before commit). Relocated `StockIndexData` into its only consumer, CountryBriefPage. CSS: `rgba(var(--semantic-critical), 0.2)` is invalid (the token is a hex, not an r,g,b triple) so 5 alert-fill backgrounds silently rendered nothing → `color-mix(in srgb, var(--semantic-critical) 20%, transparent)`. Build + globe/panel e2e green. No feature loss.
@@ -0,0 +1,94 @@
|
||||
// Low-end laptop profiler for world.hanzo.ai
|
||||
// Usage: node profile.mjs <url> <label> [cpuThrottle=6]
|
||||
// Measures under Chrome DevTools CPU throttling via CDP.
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
const url = process.argv[2] || 'https://world.hanzo.ai/';
|
||||
const label = process.argv[3] || 'run';
|
||||
const throttle = Number(process.argv[4] || 6);
|
||||
|
||||
const browser = await chromium.launch({ args: ['--enable-precise-memory-info'] });
|
||||
const context = await browser.newContext({ viewport: { width: 1440, height: 900 } });
|
||||
const page = await context.newPage();
|
||||
|
||||
// Track network transfer sizes by type
|
||||
const transfer = { js: 0, css: 0, other: 0, jsCount: 0, eagerJs: 0 };
|
||||
const seen = new Set();
|
||||
page.on('response', async (resp) => {
|
||||
try {
|
||||
const u = resp.url();
|
||||
if (seen.has(u)) return; seen.add(u);
|
||||
const hdr = resp.headers();
|
||||
const enc = hdr['content-encoding'] || '';
|
||||
const len = Number(hdr['content-length'] || 0);
|
||||
const ct = hdr['content-type'] || '';
|
||||
let bytes = len;
|
||||
if (!bytes) { try { bytes = (await resp.body()).length; } catch { bytes = 0; } }
|
||||
if (/javascript/.test(ct) || u.endsWith('.js')) { transfer.js += bytes; transfer.jsCount++; }
|
||||
else if (/css/.test(ct) || u.endsWith('.css')) { transfer.css += bytes; }
|
||||
else transfer.other += bytes;
|
||||
} catch {}
|
||||
});
|
||||
|
||||
const client = await context.newCDPSession(page);
|
||||
await client.send('Emulation.setCPUThrottlingRate', { rate: throttle });
|
||||
|
||||
const t0 = Date.now();
|
||||
await page.goto(url, { waitUntil: 'commit', timeout: 120000 });
|
||||
|
||||
// Inject longtask + paint observers ASAP
|
||||
await page.addInitScript(() => {
|
||||
window.__lt = { total: 0, count: 0, max: 0 };
|
||||
try {
|
||||
new PerformanceObserver((l) => { for (const e of l.getEntries()) { window.__lt.total += e.duration; window.__lt.count++; window.__lt.max = Math.max(window.__lt.max, e.duration); } }).observe({ entryTypes: ['longtask'] });
|
||||
} catch {}
|
||||
});
|
||||
|
||||
// Wait for the app shell / map container to exist and network to settle a bit
|
||||
let ttiApprox = null;
|
||||
try {
|
||||
await page.waitForSelector('#app .header, #app .main-content, #mapContainer', { timeout: 60000 });
|
||||
ttiApprox = Date.now() - t0;
|
||||
} catch {}
|
||||
|
||||
// Let it run to steady state under throttle
|
||||
await page.waitForTimeout(9000);
|
||||
|
||||
// Paint + nav timings
|
||||
const timings = await page.evaluate(() => {
|
||||
const nav = performance.getEntriesByType('navigation')[0] || {};
|
||||
const fcp = (performance.getEntriesByType('paint').find(p => p.name === 'first-contentful-paint') || {}).startTime || null;
|
||||
return {
|
||||
domContentLoaded: nav.domContentLoadedEventEnd || null,
|
||||
loadEvent: nav.loadEventEnd || null,
|
||||
fcp,
|
||||
longtasks: window.__lt || null,
|
||||
scripts: performance.getEntriesByType('resource').filter(r => r.initiatorType === 'script' || /\.js(\?|$)/.test(r.name)).length,
|
||||
};
|
||||
});
|
||||
|
||||
// FPS sample over 4s (rAF frame deltas) — the idle/spin steady-state frame cost
|
||||
const fps = await page.evaluate(() => new Promise((resolve) => {
|
||||
const deltas = []; let last = performance.now(); let frames = 0; const start = last;
|
||||
function tick(now) { deltas.push(now - last); last = now; frames++; if (now - start < 4000) requestAnimationFrame(tick); else {
|
||||
deltas.sort((a,b)=>a-b);
|
||||
const avg = deltas.reduce((a,b)=>a+b,0)/deltas.length;
|
||||
const p95 = deltas[Math.floor(deltas.length*0.95)] || avg;
|
||||
resolve({ fps: +(1000/avg).toFixed(1), avgFrameMs: +avg.toFixed(1), p95FrameMs: +p95.toFixed(1), frames });
|
||||
} }
|
||||
requestAnimationFrame(tick);
|
||||
}));
|
||||
|
||||
const out = {
|
||||
label, url, cpuThrottle: throttle,
|
||||
transferKB: { js: Math.round(transfer.js/1024), css: Math.round(transfer.css/1024), jsFiles: transfer.jsCount },
|
||||
fcpMs: timings.fcp ? Math.round(timings.fcp) : null,
|
||||
domContentLoadedMs: timings.domContentLoaded ? Math.round(timings.domContentLoaded) : null,
|
||||
loadEventMs: timings.loadEvent ? Math.round(timings.loadEvent) : null,
|
||||
shellVisibleMs: ttiApprox,
|
||||
longTasks: timings.longtasks,
|
||||
steadyState: fps,
|
||||
};
|
||||
console.log('PROFILE_JSON ' + JSON.stringify(out));
|
||||
console.log(JSON.stringify(out, null, 2));
|
||||
await browser.close();
|
||||
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"metaTitle": {
|
||||
"type": "string"
|
||||
},
|
||||
"keywords": {
|
||||
"type": "string"
|
||||
},
|
||||
"audience": {
|
||||
"type": "string"
|
||||
},
|
||||
"pubDate": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"modifiedDate": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"author": {
|
||||
"type": "string"
|
||||
},
|
||||
"authorUrl": {
|
||||
"type": "string",
|
||||
"format": "uri"
|
||||
},
|
||||
"authorBio": {
|
||||
"type": "string"
|
||||
},
|
||||
"heroImage": {
|
||||
"type": "string"
|
||||
},
|
||||
"$schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"title",
|
||||
"description",
|
||||
"metaTitle",
|
||||
"keywords",
|
||||
"audience",
|
||||
"pubDate"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export default new Map();
|
||||
@@ -0,0 +1 @@
|
||||
export default new Map();
|
||||
@@ -0,0 +1,163 @@
|
||||
declare module 'astro:content' {
|
||||
export interface RenderResult {
|
||||
Content: import('astro/runtime/server/index.js').AstroComponentFactory;
|
||||
headings: import('astro').MarkdownHeading[];
|
||||
remarkPluginFrontmatter: Record<string, any>;
|
||||
}
|
||||
interface Render {
|
||||
'.md': Promise<RenderResult>;
|
||||
}
|
||||
|
||||
export interface RenderedContent {
|
||||
html: string;
|
||||
metadata?: {
|
||||
imagePaths: Array<string>;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
type Flatten<T> = T extends { [K: string]: infer U } ? U : never;
|
||||
|
||||
export type CollectionKey = keyof DataEntryMap;
|
||||
export type CollectionEntry<C extends CollectionKey> = Flatten<DataEntryMap[C]>;
|
||||
|
||||
type AllValuesOf<T> = T extends any ? T[keyof T] : never;
|
||||
|
||||
export type ReferenceDataEntry<
|
||||
C extends CollectionKey,
|
||||
E extends keyof DataEntryMap[C] = string,
|
||||
> = {
|
||||
collection: C;
|
||||
id: E;
|
||||
};
|
||||
|
||||
export type ReferenceLiveEntry<C extends keyof LiveContentConfig['collections']> = {
|
||||
collection: C;
|
||||
id: string;
|
||||
};
|
||||
|
||||
export function getCollection<C extends keyof DataEntryMap, E extends CollectionEntry<C>>(
|
||||
collection: C,
|
||||
filter?: (entry: CollectionEntry<C>) => entry is E,
|
||||
): Promise<E[]>;
|
||||
export function getCollection<C extends keyof DataEntryMap>(
|
||||
collection: C,
|
||||
filter?: (entry: CollectionEntry<C>) => unknown,
|
||||
): Promise<CollectionEntry<C>[]>;
|
||||
|
||||
export function getLiveCollection<C extends keyof LiveContentConfig['collections']>(
|
||||
collection: C,
|
||||
filter?: LiveLoaderCollectionFilterType<C>,
|
||||
): Promise<
|
||||
import('astro').LiveDataCollectionResult<LiveLoaderDataType<C>, LiveLoaderErrorType<C>>
|
||||
>;
|
||||
|
||||
export function getEntry<
|
||||
C extends keyof DataEntryMap,
|
||||
E extends keyof DataEntryMap[C] | (string & {}),
|
||||
>(
|
||||
entry: ReferenceDataEntry<C, E>,
|
||||
): E extends keyof DataEntryMap[C]
|
||||
? Promise<DataEntryMap[C][E]>
|
||||
: Promise<CollectionEntry<C> | undefined>;
|
||||
export function getEntry<
|
||||
C extends keyof DataEntryMap,
|
||||
E extends keyof DataEntryMap[C] | (string & {}),
|
||||
>(
|
||||
collection: C,
|
||||
id: E,
|
||||
): E extends keyof DataEntryMap[C]
|
||||
? string extends keyof DataEntryMap[C]
|
||||
? Promise<DataEntryMap[C][E]> | undefined
|
||||
: Promise<DataEntryMap[C][E]>
|
||||
: Promise<CollectionEntry<C> | undefined>;
|
||||
export function getLiveEntry<C extends keyof LiveContentConfig['collections']>(
|
||||
collection: C,
|
||||
filter: string | LiveLoaderEntryFilterType<C>,
|
||||
): Promise<import('astro').LiveDataEntryResult<LiveLoaderDataType<C>, LiveLoaderErrorType<C>>>;
|
||||
|
||||
/** Resolve an array of entry references from the same collection */
|
||||
export function getEntries<C extends keyof DataEntryMap>(
|
||||
entries: ReferenceDataEntry<C, keyof DataEntryMap[C]>[],
|
||||
): Promise<CollectionEntry<C>[]>;
|
||||
|
||||
export function render<C extends keyof DataEntryMap>(
|
||||
entry: DataEntryMap[C][string],
|
||||
): Promise<RenderResult>;
|
||||
|
||||
export function reference<
|
||||
C extends
|
||||
| keyof DataEntryMap
|
||||
// Allow generic `string` to avoid excessive type errors in the config
|
||||
// if `dev` is not running to update as you edit.
|
||||
// Invalid collection names will be caught at build time.
|
||||
| (string & {}),
|
||||
>(
|
||||
collection: C,
|
||||
): import('astro/zod').ZodPipe<
|
||||
import('astro/zod').ZodString,
|
||||
import('astro/zod').ZodTransform<
|
||||
C extends keyof DataEntryMap
|
||||
? {
|
||||
collection: C;
|
||||
id: string;
|
||||
}
|
||||
: never,
|
||||
string
|
||||
>
|
||||
>;
|
||||
|
||||
type ReturnTypeOrOriginal<T> = T extends (...args: any[]) => infer R ? R : T;
|
||||
type InferEntrySchema<C extends keyof DataEntryMap> = import('astro/zod').infer<
|
||||
ReturnTypeOrOriginal<Required<ContentConfig['collections'][C]>['schema']>
|
||||
>;
|
||||
type ExtractLoaderConfig<T> = T extends { loader: infer L } ? L : never;
|
||||
type InferLoaderSchema<
|
||||
C extends keyof DataEntryMap,
|
||||
L = ExtractLoaderConfig<ContentConfig['collections'][C]>,
|
||||
> = L extends { schema: import('astro/zod').ZodSchema }
|
||||
? import('astro/zod').infer<L['schema']>
|
||||
: any;
|
||||
|
||||
type DataEntryMap = {
|
||||
"blog": Record<string, {
|
||||
id: string;
|
||||
body?: string;
|
||||
collection: "blog";
|
||||
data: InferEntrySchema<"blog">;
|
||||
rendered?: RenderedContent;
|
||||
filePath?: string;
|
||||
}>;
|
||||
|
||||
};
|
||||
|
||||
type ExtractLoaderTypes<T> = T extends import('astro/loaders').LiveLoader<
|
||||
infer TData,
|
||||
infer TEntryFilter,
|
||||
infer TCollectionFilter,
|
||||
infer TError
|
||||
>
|
||||
? { data: TData; entryFilter: TEntryFilter; collectionFilter: TCollectionFilter; error: TError }
|
||||
: { data: never; entryFilter: never; collectionFilter: never; error: never };
|
||||
type ExtractEntryFilterType<T> = ExtractLoaderTypes<T>['entryFilter'];
|
||||
type ExtractCollectionFilterType<T> = ExtractLoaderTypes<T>['collectionFilter'];
|
||||
type ExtractErrorType<T> = ExtractLoaderTypes<T>['error'];
|
||||
type ExtractDataType<T> = ExtractLoaderTypes<T>['data'];
|
||||
|
||||
type LiveLoaderDataType<C extends keyof LiveContentConfig['collections']> =
|
||||
LiveContentConfig['collections'][C]['schema'] extends undefined
|
||||
? ExtractDataType<LiveContentConfig['collections'][C]['loader']>
|
||||
: import('astro/zod').infer<
|
||||
Exclude<LiveContentConfig['collections'][C]['schema'], undefined>
|
||||
>;
|
||||
type LiveLoaderEntryFilterType<C extends keyof LiveContentConfig['collections']> =
|
||||
ExtractEntryFilterType<LiveContentConfig['collections'][C]['loader']>;
|
||||
type LiveLoaderCollectionFilterType<C extends keyof LiveContentConfig['collections']> =
|
||||
ExtractCollectionFilterType<LiveContentConfig['collections'][C]['loader']>;
|
||||
type LiveLoaderErrorType<C extends keyof LiveContentConfig['collections']> = ExtractErrorType<
|
||||
LiveContentConfig['collections'][C]['loader']
|
||||
>;
|
||||
|
||||
export type ContentConfig = typeof import("../src/content.config.js");
|
||||
export type LiveContentConfig = never;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
/// <reference types="astro/client" />
|
||||
/// <reference path="content.d.ts" />
|
||||
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 170 KiB |
|
After Width: | Height: | Size: 92 KiB |
|
After Width: | Height: | Size: 190 KiB |
|
After Width: | Height: | Size: 338 KiB |
|
After Width: | Height: | Size: 270 KiB |
|
After Width: | Height: | Size: 211 KiB |
|
After Width: | Height: | Size: 152 KiB |
|
After Width: | Height: | Size: 164 KiB |
|
After Width: | Height: | Size: 200 KiB |
|
After Width: | Height: | Size: 149 KiB |
|
After Width: | Height: | Size: 190 KiB |
|
After Width: | Height: | Size: 131 KiB |
|
After Width: | Height: | Size: 169 KiB |
|
After Width: | Height: | Size: 203 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 219 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1,4 @@
|
||||
User-agent: *
|
||||
Allow: /
|
||||
|
||||
Sitemap: https://www.world.hanzo.ai/blog/sitemap-index.xml
|
||||
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:news="http://www.google.com/schemas/sitemap-news/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml" xmlns:image="http://www.google.com/schemas/sitemap-image/1.1" xmlns:video="http://www.google.com/schemas/sitemap-video/1.1"><url><loc>https://www.world.hanzo.ai/blog/</loc><lastmod>2026-03-19T00:00:00.000Z</lastmod></url><url><loc>https://www.world.hanzo.ai/blog/posts/ai-powered-intelligence-without-the-cloud/</loc><lastmod>2026-03-07T00:00:00.000Z</lastmod></url><url><loc>https://www.world.hanzo.ai/blog/posts/build-on-worldmonitor-developer-api-open-source/</loc><lastmod>2026-03-09T00:00:00.000Z</lastmod></url><url><loc>https://www.world.hanzo.ai/blog/posts/command-palette-search-everything-instantly/</loc><lastmod>2026-03-06T00:00:00.000Z</lastmod></url><url><loc>https://www.world.hanzo.ai/blog/posts/cyber-threat-intelligence-for-security-teams/</loc><lastmod>2026-02-24T00:00:00.000Z</lastmod></url><url><loc>https://www.world.hanzo.ai/blog/posts/five-dashboards-one-platform-worldmonitor-variants/</loc><lastmod>2026-02-12T00:00:00.000Z</lastmod></url><url><loc>https://www.world.hanzo.ai/blog/posts/live-webcams-from-geopolitical-hotspots/</loc><lastmod>2026-03-01T00:00:00.000Z</lastmod></url><url><loc>https://www.world.hanzo.ai/blog/posts/monitor-global-supply-chains-and-commodity-disruptions/</loc><lastmod>2026-02-26T00:00:00.000Z</lastmod></url><url><loc>https://www.world.hanzo.ai/blog/posts/natural-disaster-monitoring-earthquakes-fires-volcanoes/</loc><lastmod>2026-02-19T00:00:00.000Z</lastmod></url><url><loc>https://www.world.hanzo.ai/blog/posts/osint-for-everyone-open-source-intelligence-democratized/</loc><lastmod>2026-02-17T00:00:00.000Z</lastmod></url><url><loc>https://www.world.hanzo.ai/blog/posts/prediction-markets-ai-forecasting-geopolitics/</loc><lastmod>2026-03-03T00:00:00.000Z</lastmod></url><url><loc>https://www.world.hanzo.ai/blog/posts/real-time-market-intelligence-for-traders-and-analysts/</loc><lastmod>2026-02-21T00:00:00.000Z</lastmod></url><url><loc>https://www.world.hanzo.ai/blog/posts/satellite-imagery-orbital-surveillance/</loc><lastmod>2026-02-28T00:00:00.000Z</lastmod></url><url><loc>https://www.world.hanzo.ai/blog/posts/track-global-conflicts-in-real-time/</loc><lastmod>2026-02-14T00:00:00.000Z</lastmod></url><url><loc>https://www.world.hanzo.ai/blog/posts/tracking-global-trade-routes-chokepoints-freight-costs/</loc><lastmod>2026-03-15T00:00:00.000Z</lastmod></url><url><loc>https://www.world.hanzo.ai/blog/posts/what-is-worldmonitor-real-time-global-intelligence/</loc><lastmod>2026-02-10T00:00:00.000Z</lastmod></url><url><loc>https://www.world.hanzo.ai/blog/posts/worldmonitor-in-21-languages-global-intelligence-for-everyone/</loc><lastmod>2026-03-04T00:00:00.000Z</lastmod></url><url><loc>https://www.world.hanzo.ai/blog/posts/worldmonitor-vs-traditional-intelligence-tools/</loc><lastmod>2026-03-11T00:00:00.000Z</lastmod></url></urlset>
|
||||
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?><sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"><sitemap><loc>https://www.world.hanzo.ai/blog/sitemap-0.xml</loc></sitemap></sitemapindex>
|
||||
@@ -5,7 +5,17 @@ import type { CountryScore } from '@/services/country-instability';
|
||||
import type { PredictionMarket, NewsItem } from '@/types';
|
||||
import type { AssetType } from '@/types';
|
||||
import type { CountryBriefSignals } from '@/App';
|
||||
import type { StockIndexData } from '@/components/CountryIntelModal';
|
||||
// Local to its only consumer now that the superseded CountryIntelModal is gone.
|
||||
export interface StockIndexData {
|
||||
available: boolean;
|
||||
code: string;
|
||||
symbol: string;
|
||||
indexName: string;
|
||||
price: string;
|
||||
weekChangePercent: string;
|
||||
currency: string;
|
||||
cached?: boolean;
|
||||
}
|
||||
import { getNearbyInfrastructure, haversineDistanceKm } from '@/services/related-assets';
|
||||
import { PORTS } from '@/config/ports';
|
||||
import type { Port } from '@/config/ports';
|
||||
|
||||
@@ -1,283 +0,0 @@
|
||||
/**
|
||||
* CountryIntelModal - Shows AI-generated intelligence brief when user clicks a country
|
||||
*/
|
||||
import { escapeHtml } from '@/utils/sanitize';
|
||||
import { t } from '@/services/i18n';
|
||||
import { sanitizeUrl } from '@/utils/sanitize';
|
||||
import { getCSSColor } from '@/utils';
|
||||
import type { CountryScore } from '@/services/country-instability';
|
||||
import type { PredictionMarket } from '@/types';
|
||||
|
||||
interface CountryIntelData {
|
||||
brief: string;
|
||||
country: string;
|
||||
code: string;
|
||||
cached?: boolean;
|
||||
generatedAt?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface StockIndexData {
|
||||
available: boolean;
|
||||
code: string;
|
||||
symbol: string;
|
||||
indexName: string;
|
||||
price: string;
|
||||
weekChangePercent: string;
|
||||
currency: string;
|
||||
cached?: boolean;
|
||||
}
|
||||
|
||||
interface ActiveSignals {
|
||||
protests: number;
|
||||
militaryFlights: number;
|
||||
militaryVessels: number;
|
||||
outages: number;
|
||||
earthquakes: number;
|
||||
}
|
||||
|
||||
export class CountryIntelModal {
|
||||
private overlay: HTMLElement;
|
||||
private contentEl: HTMLElement;
|
||||
private headerEl: HTMLElement;
|
||||
private onCloseCallback?: () => void;
|
||||
private onShareStory?: (code: string, name: string) => void;
|
||||
private currentCode: string | null = null;
|
||||
private currentName: string | null = null;
|
||||
|
||||
constructor() {
|
||||
this.overlay = document.createElement('div');
|
||||
this.overlay.className = 'country-intel-overlay';
|
||||
this.overlay.innerHTML = `
|
||||
<div class="country-intel-modal">
|
||||
<div class="country-intel-header">
|
||||
<div class="country-intel-title"></div>
|
||||
<button class="country-intel-close">×</button>
|
||||
</div>
|
||||
<div class="country-intel-content"></div>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(this.overlay);
|
||||
|
||||
this.headerEl = this.overlay.querySelector('.country-intel-title')!;
|
||||
this.contentEl = this.overlay.querySelector('.country-intel-content')!;
|
||||
|
||||
this.overlay.querySelector('.country-intel-close')?.addEventListener('click', () => this.hide());
|
||||
this.overlay.addEventListener('click', (e) => {
|
||||
if ((e.target as HTMLElement).classList.contains('country-intel-overlay')) this.hide();
|
||||
});
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape' && this.overlay.classList.contains('active')) this.hide();
|
||||
});
|
||||
}
|
||||
|
||||
private countryFlag(code: string): string {
|
||||
try {
|
||||
return code
|
||||
.toUpperCase()
|
||||
.split('')
|
||||
.map((c) => String.fromCodePoint(0x1f1e6 + c.charCodeAt(0) - 65))
|
||||
.join('');
|
||||
} catch {
|
||||
return '🌍';
|
||||
}
|
||||
}
|
||||
|
||||
private levelBadge(level: string): string {
|
||||
const varMap: Record<string, string> = {
|
||||
critical: '--semantic-critical',
|
||||
high: '--semantic-high',
|
||||
elevated: '--semantic-elevated',
|
||||
normal: '--semantic-normal',
|
||||
low: '--semantic-low',
|
||||
};
|
||||
const color = getCSSColor(varMap[level] || '--text-dim');
|
||||
return `<span class="cii-badge" style="background:${color}20;color:${color};border:1px solid ${color}40">${level.toUpperCase()}</span>`;
|
||||
}
|
||||
|
||||
private scoreBar(score: number): string {
|
||||
const pct = Math.min(100, Math.max(0, score));
|
||||
const color = pct >= 70 ? getCSSColor('--semantic-critical') : pct >= 50 ? getCSSColor('--semantic-high') : pct >= 30 ? getCSSColor('--semantic-elevated') : getCSSColor('--semantic-normal');
|
||||
return `
|
||||
<div class="cii-score-bar">
|
||||
<div class="cii-score-fill" style="width:${pct}%;background:${color}"></div>
|
||||
</div>
|
||||
<span class="cii-score-value">${score}/100</span>
|
||||
`;
|
||||
}
|
||||
|
||||
public showLoading(): void {
|
||||
this.currentCode = '__loading__';
|
||||
this.headerEl.innerHTML = `
|
||||
<span class="country-flag">🌍</span>
|
||||
<span class="country-name">${t('modals.countryIntel.identifying')}</span>
|
||||
`;
|
||||
this.contentEl.innerHTML = `
|
||||
<div class="intel-brief-section">
|
||||
<div class="intel-brief-loading">
|
||||
<div class="intel-skeleton"></div>
|
||||
<div class="intel-skeleton short"></div>
|
||||
<span class="intel-loading-text">${t('modals.countryIntel.locating')}</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
this.overlay.classList.add('active');
|
||||
}
|
||||
|
||||
public show(country: string, code: string, score: CountryScore | null, signals?: ActiveSignals): void {
|
||||
this.currentCode = code;
|
||||
this.currentName = country;
|
||||
const flag = this.countryFlag(code);
|
||||
let html = '';
|
||||
this.overlay.classList.add('active');
|
||||
|
||||
this.headerEl.innerHTML = `
|
||||
<span class="country-flag">${flag}</span>
|
||||
<span class="country-name">${escapeHtml(country)}</span>
|
||||
${score ? this.levelBadge(score.level) : ''}
|
||||
<button class="country-intel-share-btn" title="${t('modals.story.shareTitle')}"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12v7a2 2 0 002 2h12a2 2 0 002-2v-7"/><polyline points="16 6 12 2 8 6"/><line x1="12" y1="2" x2="12" y2="15"/></svg></button>
|
||||
`;
|
||||
|
||||
if (score) {
|
||||
html += `
|
||||
<div class="cii-section">
|
||||
<div class="cii-label">${t('modals.countryIntel.instabilityIndex')} ${this.scoreBar(score.score)}</div>
|
||||
<div class="cii-components">
|
||||
<span title="${t('common.unrest')}">📢 ${score.components.unrest.toFixed(0)}</span>
|
||||
<span title="${t('common.conflict')}">⚔ ${score.components.conflict.toFixed(0)}</span>
|
||||
<span title="${t('common.security')}">🛡️ ${score.components.security.toFixed(0)}</span>
|
||||
<span title="${t('common.information')}">📡 ${score.components.information.toFixed(0)}</span>
|
||||
<span class="cii-trend ${score.trend}">${score.trend === 'rising' ? '↗' : score.trend === 'falling' ? '↘' : '→'} ${score.trend}</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
const chips: string[] = [];
|
||||
if (signals) {
|
||||
if (signals.protests > 0) chips.push(`<span class="signal-chip protest">📢 ${signals.protests} ${t('modals.countryIntel.protests')}</span>`);
|
||||
if (signals.militaryFlights > 0) chips.push(`<span class="signal-chip military">✈️ ${signals.militaryFlights} ${t('modals.countryIntel.militaryAircraft')}</span>`);
|
||||
if (signals.militaryVessels > 0) chips.push(`<span class="signal-chip military">⚓ ${signals.militaryVessels} ${t('modals.countryIntel.militaryVessels')}</span>`);
|
||||
if (signals.outages > 0) chips.push(`<span class="signal-chip outage">🌐 ${signals.outages} ${t('modals.countryIntel.outages')}</span>`);
|
||||
if (signals.earthquakes > 0) chips.push(`<span class="signal-chip quake">🌍 ${signals.earthquakes} ${t('modals.countryIntel.earthquakes')}</span>`);
|
||||
}
|
||||
chips.push(`<span class="signal-chip stock-loading">📈 ${t('modals.countryIntel.loadingIndex')}</span>`);
|
||||
html += `<div class="active-signals">${chips.join('')}</div>`;
|
||||
|
||||
html += `<div class="country-markets-section"><span class="intel-loading-text">${t('modals.countryIntel.loadingMarkets')}</span></div>`;
|
||||
|
||||
html += `
|
||||
<div class="intel-brief-section">
|
||||
<div class="intel-brief-loading">
|
||||
<div class="intel-skeleton"></div>
|
||||
<div class="intel-skeleton short"></div>
|
||||
<div class="intel-skeleton"></div>
|
||||
<div class="intel-skeleton short"></div>
|
||||
<span class="intel-loading-text">${t('modals.countryIntel.generatingBrief')}</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
this.contentEl.innerHTML = html;
|
||||
|
||||
const shareBtn = this.headerEl.querySelector('.country-intel-share-btn');
|
||||
shareBtn?.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
if (this.currentCode && this.currentName && this.onShareStory) {
|
||||
this.onShareStory(this.currentCode, this.currentName);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public updateBrief(data: CountryIntelData & { skipped?: boolean; reason?: string; fallback?: boolean }): void {
|
||||
if (this.currentCode !== data.code && this.currentCode !== '__loading__') return;
|
||||
|
||||
// If modal closed, don't update
|
||||
if (!this.isVisible()) return;
|
||||
|
||||
if (data.error || data.skipped || !data.brief) {
|
||||
const msg = data.error || data.reason || t('modals.countryIntel.unavailable');
|
||||
const briefSection = this.contentEl.querySelector('.intel-brief-section');
|
||||
if (briefSection) {
|
||||
briefSection.innerHTML = `<div class="intel-error">${escapeHtml(msg)}</div>`;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const briefSection = this.contentEl.querySelector('.intel-brief-section');
|
||||
if (!briefSection) return;
|
||||
|
||||
const formatted = this.formatBrief(data.brief);
|
||||
briefSection.innerHTML = `
|
||||
<div class="intel-brief">${formatted}</div>
|
||||
<div class="intel-footer">
|
||||
${data.cached ? `<span class="intel-cached">📋 ${t('modals.countryIntel.cached')}</span>` : `<span class="intel-fresh">✨ ${t('modals.countryIntel.fresh')}</span>`}
|
||||
<span class="intel-timestamp">${data.generatedAt ? new Date(data.generatedAt).toLocaleTimeString() : ''}</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
public updateMarkets(markets: PredictionMarket[]): void {
|
||||
const section = this.contentEl.querySelector('.country-markets-section');
|
||||
if (!section) return;
|
||||
|
||||
if (markets.length === 0) {
|
||||
section.innerHTML = `<span class="intel-loading-text" style="opacity:0.5">${t('modals.countryIntel.noMarkets')}</span>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const items = markets.map(market => {
|
||||
const href = sanitizeUrl(market.url || '#') || '#';
|
||||
return `
|
||||
<div class="market-item">
|
||||
<a href="${href}" target="_blank" rel="noopener noreferrer" class="prediction-market-card">
|
||||
<div class="market-provider">Polymarket</div>
|
||||
<div class="market-question">${escapeHtml(market.title)}</div>
|
||||
<div class="market-prob">${(market.yesPrice * 100).toFixed(1)}%</div>
|
||||
</a>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
section.innerHTML = `<div class="markets-label">📊 ${t('modals.countryIntel.predictionMarkets')}</div>${items}`;
|
||||
}
|
||||
|
||||
public updateStock(data: StockIndexData): void {
|
||||
const el = this.contentEl.querySelector('.stock-loading');
|
||||
if (!el) return;
|
||||
|
||||
if (!data.available) {
|
||||
el.remove();
|
||||
return;
|
||||
}
|
||||
|
||||
const pct = parseFloat(data.weekChangePercent);
|
||||
const sign = pct >= 0 ? '+' : '';
|
||||
const cls = pct >= 0 ? 'stock-up' : 'stock-down';
|
||||
const arrow = pct >= 0 ? '📈' : '📉';
|
||||
el.className = `signal-chip stock ${cls}`;
|
||||
el.innerHTML = `${arrow} ${escapeHtml(data.indexName)}: ${sign}${data.weekChangePercent}% (1W)`;
|
||||
}
|
||||
|
||||
private formatBrief(text: string): string {
|
||||
return escapeHtml(text)
|
||||
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/\n\n/g, '</p><p>')
|
||||
.replace(/\n/g, '<br>')
|
||||
.replace(/^/, '<p>')
|
||||
.replace(/$/, '</p>');
|
||||
}
|
||||
|
||||
public hide(): void {
|
||||
this.overlay.classList.remove('active');
|
||||
this.currentCode = null;
|
||||
this.onCloseCallback?.();
|
||||
}
|
||||
|
||||
public onClose(cb: () => void): void {
|
||||
this.onCloseCallback = cb;
|
||||
}
|
||||
|
||||
public isVisible(): boolean {
|
||||
return this.overlay.classList.contains('active');
|
||||
}
|
||||
}
|
||||
@@ -1,123 +0,0 @@
|
||||
import { Panel } from './Panel';
|
||||
import type { GeoHubActivity } from '@/services/geo-activity';
|
||||
import { escapeHtml, sanitizeUrl } from '@/utils/sanitize';
|
||||
import { t } from '@/services/i18n';
|
||||
import { getCSSColor } from '@/utils';
|
||||
|
||||
const COUNTRY_FLAGS: Record<string, string> = {
|
||||
'USA': '🇺🇸', 'Russia': '🇷🇺', 'China': '🇨🇳', 'UK': '🇬🇧', 'Belgium': '🇧🇪',
|
||||
'Israel': '🇮🇱', 'Iran': '🇮🇷', 'Ukraine': '🇺🇦', 'Taiwan': '🇹🇼', 'Japan': '🇯🇵',
|
||||
'South Korea': '🇰🇷', 'North Korea': '🇰🇵', 'India': '🇮🇳', 'Saudi Arabia': '🇸🇦',
|
||||
'Turkey': '🇹🇷', 'France': '🇫🇷', 'Germany': '🇩🇪', 'Egypt': '🇪🇬', 'Pakistan': '🇵🇰',
|
||||
'Palestine': '🇵🇸', 'Yemen': '🇾🇪', 'Syria': '🇸🇾', 'Lebanon': '🇱🇧',
|
||||
'Sudan': '🇸🇩', 'Ethiopia': '🇪🇹', 'Myanmar': '🇲🇲', 'Austria': '🇦🇹',
|
||||
'International': '🌐',
|
||||
};
|
||||
|
||||
const TYPE_ICONS: Record<string, string> = {
|
||||
capital: '🏛️',
|
||||
conflict: '⚔️',
|
||||
strategic: '⚓',
|
||||
organization: '🏢',
|
||||
};
|
||||
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
capital: 'Capital',
|
||||
conflict: 'Conflict Zone',
|
||||
strategic: 'Strategic',
|
||||
organization: 'Organization',
|
||||
};
|
||||
|
||||
export class GeoHubsPanel extends Panel {
|
||||
private activities: GeoHubActivity[] = [];
|
||||
private onHubClick?: (hub: GeoHubActivity) => void;
|
||||
|
||||
constructor() {
|
||||
super({
|
||||
id: 'geo-hubs',
|
||||
title: t('panels.geoHubs'),
|
||||
showCount: true,
|
||||
infoTooltip: t('components.geoHubs.infoTooltip', {
|
||||
highColor: getCSSColor('--semantic-critical'),
|
||||
elevatedColor: getCSSColor('--semantic-high'),
|
||||
lowColor: getCSSColor('--text-dim'),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
public setOnHubClick(handler: (hub: GeoHubActivity) => void): void {
|
||||
this.onHubClick = handler;
|
||||
}
|
||||
|
||||
public setActivities(activities: GeoHubActivity[]): void {
|
||||
this.activities = activities.slice(0, 10);
|
||||
this.setCount(this.activities.length);
|
||||
this.render();
|
||||
}
|
||||
|
||||
private getFlag(country: string): string {
|
||||
return COUNTRY_FLAGS[country] || '🌐';
|
||||
}
|
||||
|
||||
private getTypeIcon(type: string): string {
|
||||
return TYPE_ICONS[type] || '📍';
|
||||
}
|
||||
|
||||
private getTypeLabel(type: string): string {
|
||||
return TYPE_LABELS[type] || type;
|
||||
}
|
||||
|
||||
private render(): void {
|
||||
if (this.activities.length === 0) {
|
||||
this.showError(t('common.noActiveGeoHubs'));
|
||||
return;
|
||||
}
|
||||
|
||||
const html = this.activities.map((hub, index) => {
|
||||
const trendIcon = hub.trend === 'rising' ? '↑' : hub.trend === 'falling' ? '↓' : '';
|
||||
const breakingTag = hub.hasBreaking ? '<span class="hub-breaking geo">ALERT</span>' : '';
|
||||
const topStory = hub.topStories[0];
|
||||
|
||||
return `
|
||||
<div class="geo-hub-item ${hub.activityLevel}" data-hub-id="${escapeHtml(hub.hubId)}" data-index="${index}">
|
||||
<div class="hub-rank">${index + 1}</div>
|
||||
<span class="geo-hub-indicator ${hub.activityLevel}"></span>
|
||||
<div class="hub-info">
|
||||
<div class="hub-header">
|
||||
<span class="hub-name">${escapeHtml(hub.name)}</span>
|
||||
<span class="hub-flag">${this.getFlag(hub.country)}</span>
|
||||
${breakingTag}
|
||||
</div>
|
||||
<div class="hub-meta">
|
||||
<span class="hub-news-count">${hub.newsCount} ${hub.newsCount === 1 ? t('components.geoHubs.story') : t('components.geoHubs.stories')}</span>
|
||||
${trendIcon ? `<span class="hub-trend ${hub.trend}">${trendIcon}</span>` : ''}
|
||||
<span class="geo-hub-type">${this.getTypeIcon(hub.type)} ${this.getTypeLabel(hub.type)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hub-score geo">${Math.round(hub.score)}</div>
|
||||
</div>
|
||||
${topStory ? `
|
||||
<a class="hub-top-story geo" href="${sanitizeUrl(topStory.link)}" target="_blank" rel="noopener" data-hub-id="${escapeHtml(hub.hubId)}">
|
||||
${escapeHtml(topStory.title.length > 80 ? topStory.title.slice(0, 77) + '...' : topStory.title)}
|
||||
</a>
|
||||
` : ''}
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
this.setContent(html);
|
||||
this.bindEvents();
|
||||
}
|
||||
|
||||
private bindEvents(): void {
|
||||
const items = this.content.querySelectorAll<HTMLDivElement>('.geo-hub-item');
|
||||
items.forEach((item) => {
|
||||
item.addEventListener('click', () => {
|
||||
const hubId = item.dataset.hubId;
|
||||
const hub = this.activities.find(a => a.hubId === hubId);
|
||||
if (hub && this.onHubClick) {
|
||||
this.onHubClick(hub);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
import { Panel } from './Panel';
|
||||
import { escapeHtml } from '@/utils/sanitize';
|
||||
import { getPostQuantumData, type PostQuantumData } from '@/services/post-quantum';
|
||||
import type { PQReadinessStatus } from '@/config/post-quantum';
|
||||
|
||||
const STATUS_SEV: Record<PQReadinessStatus, string> = {
|
||||
'pq-native': 'sev-info',
|
||||
deployed: 'sev-low',
|
||||
'in-progress': 'sev-elevated',
|
||||
planned: 'sev-high',
|
||||
lagging: 'sev-critical',
|
||||
};
|
||||
|
||||
const STATUS_LABEL: Record<PQReadinessStatus, string> = {
|
||||
'pq-native': 'PQ-NATIVE',
|
||||
deployed: 'DEPLOYED',
|
||||
'in-progress': 'IN PROGRESS',
|
||||
planned: 'PLANNED',
|
||||
lagging: 'LAGGING',
|
||||
};
|
||||
|
||||
export class PostQuantumPanel extends Panel {
|
||||
constructor() {
|
||||
super({
|
||||
id: 'post-quantum',
|
||||
title: 'Post-Quantum Readiness',
|
||||
showCount: true,
|
||||
infoTooltip: 'Hanzo World Model — Post-Quantum lens: NIST PQC standards (ML-KEM/ML-DSA/SLH-DSA), the NSA CNSA 2.0 timeline, and deployment status across governments, clouds and chains. Lux & Hanzo are PQ-native.',
|
||||
});
|
||||
this.render(getPostQuantumData());
|
||||
}
|
||||
|
||||
public refresh(): void {
|
||||
this.render(getPostQuantumData());
|
||||
}
|
||||
|
||||
private render(data: PostQuantumData): void {
|
||||
this.setCount(data.readiness.length);
|
||||
this.setDataBadge('cached', 'curated');
|
||||
|
||||
const standardRows = data.standards.map((s) => `
|
||||
<div class="domain-item">
|
||||
<div class="domain-item-title">🔐 ${escapeHtml(s.id)} · ${escapeHtml(s.name)} <span class="domain-tag">${escapeHtml(s.status)}</span></div>
|
||||
<div class="domain-item-meta">${escapeHtml(s.basedOn)} · ${escapeHtml(s.kind)} · ${s.year} — ${escapeHtml(s.note)}</div>
|
||||
</div>`).join('');
|
||||
|
||||
const readinessRows = data.readiness.map((r) => `
|
||||
<div class="domain-item">
|
||||
<div class="domain-item-title">${escapeHtml(r.org)} <span class="domain-tag ${STATUS_SEV[r.status]}">${STATUS_LABEL[r.status]}</span></div>
|
||||
<div class="domain-item-meta">${escapeHtml(r.algorithms.join(', '))} — ${escapeHtml(r.detail)}</div>
|
||||
</div>`).join('');
|
||||
|
||||
const timelineRows = data.timeline.map((m) => `
|
||||
<div class="domain-item">
|
||||
<div class="domain-item-title">📅 CNSA 2.0 · ${m.year}</div>
|
||||
<div class="domain-item-meta">${escapeHtml(m.milestone)}</div>
|
||||
</div>`).join('');
|
||||
|
||||
this.setContent(`
|
||||
<div class="domain-panel">
|
||||
<div class="domain-insight">${escapeHtml(data.insight)}</div>
|
||||
<div class="domain-warn">⚠️ ${escapeHtml(data.harvestNote)}</div>
|
||||
<div class="domain-section-title">NIST PQC standards</div>
|
||||
<div class="domain-list">${standardRows}</div>
|
||||
<div class="domain-section-title">Readiness tracker</div>
|
||||
<div class="domain-list">${readinessRows}</div>
|
||||
<div class="domain-section-title">CNSA 2.0 timeline</div>
|
||||
<div class="domain-list">${timelineRows}</div>
|
||||
<div class="domain-footer"><span>NIST FIPS 203/204/205 · NSA CNSA 2.0</span><span>curated</span></div>
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
import { Panel } from './Panel';
|
||||
import { escapeHtml, sanitizeUrl } from '@/utils/sanitize';
|
||||
import { getQuantumData, type QuantumData } from '@/services/quantum';
|
||||
|
||||
const MODALITY_ICON: Record<string, string> = {
|
||||
superconducting: '❄️',
|
||||
'trapped-ion': '⚛️',
|
||||
'neutral-atom': '🔵',
|
||||
photonic: '💡',
|
||||
'silicon-spin': '🔶',
|
||||
annealing: '🌀',
|
||||
topological: '🪢',
|
||||
};
|
||||
|
||||
export class QuantumPanel extends Panel {
|
||||
private loading = false;
|
||||
private lastFetch = 0;
|
||||
private readonly REFRESH_MS = 60 * 60 * 1000;
|
||||
|
||||
constructor() {
|
||||
super({
|
||||
id: 'quantum',
|
||||
title: 'Quantum Computing',
|
||||
showCount: true,
|
||||
infoTooltip: 'Hanzo World Model — Quantum lens: live arXiv quant-ph research plus a curated registry of hardware players and their best publicly-announced scale (snapshot, with year).',
|
||||
});
|
||||
void this.refresh();
|
||||
}
|
||||
|
||||
public async refresh(): Promise<void> {
|
||||
if (this.loading) return;
|
||||
if (this.lastFetch > 0 && Date.now() - this.lastFetch < this.REFRESH_MS) return;
|
||||
this.loading = true;
|
||||
try {
|
||||
const data = await getQuantumData();
|
||||
this.lastFetch = Date.now();
|
||||
this.setCount(data.papers.length + data.players.length);
|
||||
this.setDataBadge(data.papers.length > 0 ? 'live' : 'cached');
|
||||
this.render(data);
|
||||
} catch (e) {
|
||||
console.error('[Quantum] refresh failed:', e);
|
||||
this.showError();
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private render(data: QuantumData): void {
|
||||
const papers = data.papers.slice(0, 8);
|
||||
const players = [...data.players].sort((a, b) => (b.qubits ?? -1) - (a.qubits ?? -1));
|
||||
|
||||
const paperRows = papers.map((p) => {
|
||||
const url = sanitizeUrl(p.link);
|
||||
const date = p.published.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||||
return `<a class="domain-item" href="${url}" target="_blank" rel="noopener">
|
||||
<div class="domain-item-title">${escapeHtml(p.title)}</div>
|
||||
<div class="domain-item-meta">🔬 quant-ph · ${date}</div>
|
||||
</a>`;
|
||||
}).join('');
|
||||
|
||||
const playerRows = players.map((p) => {
|
||||
const icon = MODALITY_ICON[p.modality] || '⚛️';
|
||||
const link = p.url ? ` · <a href="${sanitizeUrl(p.url)}" target="_blank" rel="noopener">site ↗</a>` : '';
|
||||
return `<div class="domain-item">
|
||||
<div class="domain-item-title">${icon} ${escapeHtml(p.name)} <span class="domain-tag">${escapeHtml(p.metric)}</span></div>
|
||||
<div class="domain-item-meta">${escapeHtml(p.modality)} · ${escapeHtml(p.city)}, ${escapeHtml(p.country)} · as of ${p.asOf}${link}</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
this.setContent(`
|
||||
<div class="domain-panel">
|
||||
<div class="domain-insight">${escapeHtml(data.insight)}</div>
|
||||
<div class="domain-section-title">Latest research (arXiv quant-ph)</div>
|
||||
<div class="domain-list">${paperRows || '<div class="domain-empty">Research feed unavailable</div>'}</div>
|
||||
<div class="domain-section-title">Hardware players & scale</div>
|
||||
<div class="domain-list">${playerRows}</div>
|
||||
<div class="domain-footer"><span>arXiv quant-ph + curated registry</span><span>${new Date(this.lastFetch).toLocaleTimeString()}</span></div>
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -1,320 +0,0 @@
|
||||
import { Panel } from './Panel';
|
||||
import type { AIRegulation, RegulatoryAction, CountryRegulationProfile } from '@/types';
|
||||
import {
|
||||
AI_REGULATIONS,
|
||||
COUNTRY_REGULATION_PROFILES,
|
||||
getUpcomingDeadlines,
|
||||
getRecentActions,
|
||||
} from '@/config';
|
||||
import { escapeHtml, sanitizeUrl } from '@/utils/sanitize';
|
||||
import { t } from '@/services/i18n';
|
||||
import { getCSSColor } from '@/utils';
|
||||
|
||||
export class RegulationPanel extends Panel {
|
||||
private viewMode: 'timeline' | 'deadlines' | 'regulations' | 'countries' = 'timeline';
|
||||
|
||||
constructor(id: string) {
|
||||
super({ id, title: t('panels.regulation') });
|
||||
this.render();
|
||||
}
|
||||
|
||||
protected render(): void {
|
||||
this.content.innerHTML = `
|
||||
<div class="regulation-panel">
|
||||
<div class="regulation-header">
|
||||
<h3>AI Regulation Dashboard</h3>
|
||||
<div class="regulation-tabs">
|
||||
<button class="tab ${this.viewMode === 'timeline' ? 'active' : ''}" data-view="timeline">Timeline</button>
|
||||
<button class="tab ${this.viewMode === 'deadlines' ? 'active' : ''}" data-view="deadlines">Deadlines</button>
|
||||
<button class="tab ${this.viewMode === 'regulations' ? 'active' : ''}" data-view="regulations">Regulations</button>
|
||||
<button class="tab ${this.viewMode === 'countries' ? 'active' : ''}" data-view="countries">Countries</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="regulation-content">
|
||||
${this.renderContent()}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Add event listeners for tabs
|
||||
this.content.querySelectorAll('.tab').forEach(tab => {
|
||||
tab.addEventListener('click', (e) => {
|
||||
const target = e.target as HTMLElement;
|
||||
const view = target.dataset.view as typeof this.viewMode;
|
||||
if (view) {
|
||||
this.viewMode = view;
|
||||
this.render();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private renderContent(): string {
|
||||
switch (this.viewMode) {
|
||||
case 'timeline':
|
||||
return this.renderTimeline();
|
||||
case 'deadlines':
|
||||
return this.renderDeadlines();
|
||||
case 'regulations':
|
||||
return this.renderRegulations();
|
||||
case 'countries':
|
||||
return this.renderCountries();
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
private renderTimeline(): string {
|
||||
const recentActions = getRecentActions(12); // Last 12 months
|
||||
|
||||
if (recentActions.length === 0) {
|
||||
return '<div class="empty-state">No recent regulatory actions</div>';
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="timeline-view">
|
||||
<div class="timeline-header">
|
||||
<h4>Recent Regulatory Actions (Last 12 Months)</h4>
|
||||
<span class="count">${recentActions.length} actions</span>
|
||||
</div>
|
||||
<div class="timeline-list">
|
||||
${recentActions.map(action => this.renderTimelineItem(action)).join('')}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderTimelineItem(action: RegulatoryAction): string {
|
||||
const date = new Date(action.date);
|
||||
const formattedDate = date.toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
});
|
||||
|
||||
const typeIcons: Record<RegulatoryAction['type'], string> = {
|
||||
'law-passed': '📜',
|
||||
'executive-order': '🏛️',
|
||||
'guideline': '📋',
|
||||
'enforcement': '⚖️',
|
||||
'consultation': '💬',
|
||||
};
|
||||
|
||||
const impactColors: Record<RegulatoryAction['impact'], string> = {
|
||||
high: getCSSColor('--semantic-critical'),
|
||||
medium: getCSSColor('--semantic-elevated'),
|
||||
low: getCSSColor('--semantic-normal'),
|
||||
};
|
||||
|
||||
return `
|
||||
<div class="timeline-item impact-${action.impact}">
|
||||
<div class="timeline-marker">
|
||||
<span class="timeline-icon">${typeIcons[action.type]}</span>
|
||||
<div class="timeline-line"></div>
|
||||
</div>
|
||||
<div class="timeline-content">
|
||||
<div class="timeline-header-row">
|
||||
<span class="timeline-date">${formattedDate}</span>
|
||||
<span class="timeline-country">${escapeHtml(action.country)}</span>
|
||||
<span class="timeline-impact" style="color: ${impactColors[action.impact]}">${action.impact.toUpperCase()}</span>
|
||||
</div>
|
||||
<h5>${escapeHtml(action.title)}</h5>
|
||||
<p>${escapeHtml(action.description)}</p>
|
||||
${action.source ? `<span class="timeline-source">Source: ${escapeHtml(action.source)}</span>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderDeadlines(): string {
|
||||
const upcomingDeadlines = getUpcomingDeadlines();
|
||||
|
||||
if (upcomingDeadlines.length === 0) {
|
||||
return '<div class="empty-state">No upcoming compliance deadlines in the next 12 months</div>';
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="deadlines-view">
|
||||
<div class="deadlines-header">
|
||||
<h4>Upcoming Compliance Deadlines</h4>
|
||||
<span class="count">${upcomingDeadlines.length} deadlines</span>
|
||||
</div>
|
||||
<div class="deadlines-list">
|
||||
${upcomingDeadlines.map(reg => this.renderDeadlineItem(reg)).join('')}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderDeadlineItem(regulation: AIRegulation): string {
|
||||
const deadline = new Date(regulation.complianceDeadline!);
|
||||
const now = new Date();
|
||||
const daysUntil = Math.ceil((deadline.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
|
||||
|
||||
const formattedDate = deadline.toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
});
|
||||
|
||||
const urgencyClass = daysUntil < 90 ? 'urgent' : daysUntil < 180 ? 'warning' : 'normal';
|
||||
|
||||
return `
|
||||
<div class="deadline-item ${urgencyClass}">
|
||||
<div class="deadline-countdown">
|
||||
<div class="days-until">${daysUntil}</div>
|
||||
<div class="days-label">days</div>
|
||||
</div>
|
||||
<div class="deadline-content">
|
||||
<h5>${escapeHtml(regulation.shortName)}</h5>
|
||||
<p class="deadline-name">${escapeHtml(regulation.name)}</p>
|
||||
<div class="deadline-meta">
|
||||
<span class="deadline-date">📅 ${formattedDate}</span>
|
||||
<span class="deadline-country">🌍 ${escapeHtml(regulation.country)}</span>
|
||||
</div>
|
||||
${regulation.penalties ? `<p class="deadline-penalties">⚠️ Penalties: ${escapeHtml(regulation.penalties)}</p>` : ''}
|
||||
<div class="deadline-scope">
|
||||
${regulation.scope.map(s => `<span class="scope-tag">${escapeHtml(s)}</span>`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderRegulations(): string {
|
||||
const activeRegulations = AI_REGULATIONS.filter(r => r.status === 'active');
|
||||
const proposedRegulations = AI_REGULATIONS.filter(r => r.status === 'proposed');
|
||||
|
||||
return `
|
||||
<div class="regulations-view">
|
||||
<div class="regulations-section">
|
||||
<h4>Active Regulations (${activeRegulations.length})</h4>
|
||||
<div class="regulations-list">
|
||||
${activeRegulations.map(reg => this.renderRegulationCard(reg)).join('')}
|
||||
</div>
|
||||
</div>
|
||||
<div class="regulations-section">
|
||||
<h4>Proposed Regulations (${proposedRegulations.length})</h4>
|
||||
<div class="regulations-list">
|
||||
${proposedRegulations.map(reg => this.renderRegulationCard(reg)).join('')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderRegulationCard(regulation: AIRegulation): string {
|
||||
const typeColors: Record<AIRegulation['type'], string> = {
|
||||
comprehensive: getCSSColor('--semantic-low'),
|
||||
sectoral: getCSSColor('--semantic-high'),
|
||||
voluntary: getCSSColor('--semantic-normal'),
|
||||
proposed: getCSSColor('--semantic-elevated'),
|
||||
};
|
||||
|
||||
const effectiveDate = regulation.effectiveDate
|
||||
? new Date(regulation.effectiveDate).toLocaleDateString('en-US', { year: 'numeric', month: 'short' })
|
||||
: 'TBD';
|
||||
const regulationLink = regulation.link ? sanitizeUrl(regulation.link) : '';
|
||||
|
||||
return `
|
||||
<div class="regulation-card">
|
||||
<div class="regulation-card-header">
|
||||
<h5>${escapeHtml(regulation.shortName)}</h5>
|
||||
<span class="regulation-type" style="background-color: ${typeColors[regulation.type]}">${regulation.type}</span>
|
||||
</div>
|
||||
<p class="regulation-full-name">${escapeHtml(regulation.name)}</p>
|
||||
<div class="regulation-meta">
|
||||
<span>🌍 ${escapeHtml(regulation.country)}</span>
|
||||
<span>📅 ${effectiveDate}</span>
|
||||
<span class="status-badge status-${regulation.status}">${regulation.status}</span>
|
||||
</div>
|
||||
${regulation.description ? `<p class="regulation-description">${escapeHtml(regulation.description)}</p>` : ''}
|
||||
<div class="regulation-provisions">
|
||||
<strong>Key Provisions:</strong>
|
||||
<ul>
|
||||
${regulation.keyProvisions.slice(0, 3).map(p => `<li>${escapeHtml(p)}</li>`).join('')}
|
||||
${regulation.keyProvisions.length > 3 ? `<li class="more-provisions">+${regulation.keyProvisions.length - 3} more...</li>` : ''}
|
||||
</ul>
|
||||
</div>
|
||||
<div class="regulation-scope">
|
||||
${regulation.scope.map(s => `<span class="scope-tag">${escapeHtml(s)}</span>`).join('')}
|
||||
</div>
|
||||
${regulationLink ? `<a href="${regulationLink}" target="_blank" rel="noopener noreferrer" class="regulation-link">Learn More →</a>` : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderCountries(): string {
|
||||
const profiles = COUNTRY_REGULATION_PROFILES.sort((a, b) => {
|
||||
const stanceOrder: Record<CountryRegulationProfile['stance'], number> = {
|
||||
strict: 0,
|
||||
moderate: 1,
|
||||
permissive: 2,
|
||||
undefined: 3,
|
||||
};
|
||||
return stanceOrder[a.stance] - stanceOrder[b.stance];
|
||||
});
|
||||
|
||||
return `
|
||||
<div class="countries-view">
|
||||
<div class="countries-header">
|
||||
<h4>Global Regulatory Landscape</h4>
|
||||
<div class="stance-legend">
|
||||
<span class="legend-item"><span class="color-box strict"></span> Strict</span>
|
||||
<span class="legend-item"><span class="color-box moderate"></span> Moderate</span>
|
||||
<span class="legend-item"><span class="color-box permissive"></span> Permissive</span>
|
||||
<span class="legend-item"><span class="color-box undefined"></span> Undefined</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="countries-list">
|
||||
${profiles.map(profile => this.renderCountryCard(profile)).join('')}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderCountryCard(profile: CountryRegulationProfile): string {
|
||||
const stanceColors: Record<CountryRegulationProfile['stance'], string> = {
|
||||
strict: getCSSColor('--semantic-critical'),
|
||||
moderate: getCSSColor('--semantic-elevated'),
|
||||
permissive: getCSSColor('--semantic-normal'),
|
||||
undefined: getCSSColor('--text-muted'),
|
||||
};
|
||||
|
||||
const activeCount = profile.activeRegulations.length;
|
||||
const proposedCount = profile.proposedRegulations.length;
|
||||
|
||||
return `
|
||||
<div class="country-card stance-${profile.stance}">
|
||||
<div class="country-card-header" style="border-left: 4px solid ${stanceColors[profile.stance]}">
|
||||
<h5>${escapeHtml(profile.country)}</h5>
|
||||
<span class="stance-badge" style="background-color: ${stanceColors[profile.stance]}">${profile.stance.toUpperCase()}</span>
|
||||
</div>
|
||||
<p class="country-summary">${escapeHtml(profile.summary)}</p>
|
||||
<div class="country-stats">
|
||||
<div class="stat">
|
||||
<span class="stat-value">${activeCount}</span>
|
||||
<span class="stat-label">Active</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat-value">${proposedCount}</span>
|
||||
<span class="stat-label">Proposed</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat-value">${new Date(profile.lastUpdated).toLocaleDateString('en-US', { month: 'short', year: 'numeric' })}</span>
|
||||
<span class="stat-label">Updated</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
public updateData(): void {
|
||||
this.render();
|
||||
}
|
||||
|
||||
public setView(view: 'timeline' | 'deadlines' | 'regulations' | 'countries'): void {
|
||||
this.viewMode = view;
|
||||
this.render();
|
||||
}
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
import { Panel } from './Panel';
|
||||
import { escapeHtml, sanitizeUrl } from '@/utils/sanitize';
|
||||
import { getRoboticsData, type RoboticsData } from '@/services/robotics';
|
||||
|
||||
const CATEGORY_ICON: Record<string, string> = {
|
||||
humanoid: '🤖',
|
||||
quadruped: '🐕',
|
||||
industrial: '🦾',
|
||||
platform: '🧠',
|
||||
research: '🔬',
|
||||
};
|
||||
|
||||
export class RoboticsPanel extends Panel {
|
||||
private loading = false;
|
||||
private lastFetch = 0;
|
||||
private readonly REFRESH_MS = 60 * 60 * 1000;
|
||||
|
||||
constructor() {
|
||||
super({
|
||||
id: 'robotics',
|
||||
title: 'Robotics',
|
||||
showCount: true,
|
||||
infoTooltip: 'Hanzo World Model — Robotics lens: live arXiv cs.RO research plus a curated registry of humanoid, quadruped, industrial and platform labs.',
|
||||
});
|
||||
void this.refresh();
|
||||
}
|
||||
|
||||
public async refresh(): Promise<void> {
|
||||
if (this.loading) return;
|
||||
if (this.lastFetch > 0 && Date.now() - this.lastFetch < this.REFRESH_MS) return;
|
||||
this.loading = true;
|
||||
try {
|
||||
const data = await getRoboticsData();
|
||||
this.lastFetch = Date.now();
|
||||
this.setCount(data.papers.length + data.orgs.length);
|
||||
this.setDataBadge(data.papers.length > 0 ? 'live' : 'cached');
|
||||
this.render(data);
|
||||
} catch (e) {
|
||||
console.error('[Robotics] refresh failed:', e);
|
||||
this.showError();
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private render(data: RoboticsData): void {
|
||||
const papers = data.papers.slice(0, 8);
|
||||
const orgs = [...data.orgs].sort((a, b) => a.category.localeCompare(b.category));
|
||||
|
||||
const paperRows = papers.map((p) => {
|
||||
const url = sanitizeUrl(p.link);
|
||||
const date = p.published.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||||
return `<a class="domain-item" href="${url}" target="_blank" rel="noopener">
|
||||
<div class="domain-item-title">${escapeHtml(p.title)}</div>
|
||||
<div class="domain-item-meta">🔬 ${escapeHtml(p.categories[0] || 'cs.RO')} · ${date}</div>
|
||||
</a>`;
|
||||
}).join('');
|
||||
|
||||
const orgRows = orgs.map((o) => {
|
||||
const icon = CATEGORY_ICON[o.category] || '🤖';
|
||||
const link = o.url ? ` · <a href="${sanitizeUrl(o.url)}" target="_blank" rel="noopener">site ↗</a>` : '';
|
||||
return `<div class="domain-item">
|
||||
<div class="domain-item-title">${icon} ${escapeHtml(o.name)} <span class="domain-tag">${escapeHtml(o.category)}</span></div>
|
||||
<div class="domain-item-meta">${escapeHtml(o.focus)} · ${escapeHtml(o.city)}, ${escapeHtml(o.country)}${link}</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
this.setContent(`
|
||||
<div class="domain-panel">
|
||||
<div class="domain-insight">${escapeHtml(data.insight)}</div>
|
||||
<div class="domain-section-title">Latest research (arXiv cs.RO)</div>
|
||||
<div class="domain-list">${paperRows || '<div class="domain-empty">Research feed unavailable</div>'}</div>
|
||||
<div class="domain-section-title">Major labs & companies</div>
|
||||
<div class="domain-list">${orgRows}</div>
|
||||
<div class="domain-footer"><span>arXiv cs.RO + curated registry</span><span>${new Date(this.lastFetch).toLocaleTimeString()}</span></div>
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
import { Panel } from './Panel';
|
||||
import { escapeHtml } from '@/utils/sanitize';
|
||||
import { fetchSpaceWeather, type SpaceWeatherState } from '@/services/space-weather';
|
||||
|
||||
function kpClass(kp: number | null): string {
|
||||
if (kp === null) return 'sev-info';
|
||||
if (kp >= 8) return 'sev-critical';
|
||||
if (kp >= 7) return 'sev-high';
|
||||
if (kp >= 5) return 'sev-elevated';
|
||||
if (kp >= 4) return 'sev-low';
|
||||
return 'sev-info';
|
||||
}
|
||||
|
||||
export class SpaceWeatherPanel extends Panel {
|
||||
private loading = false;
|
||||
private lastFetch = 0;
|
||||
private readonly REFRESH_MS = 10 * 60 * 1000;
|
||||
|
||||
constructor() {
|
||||
super({
|
||||
id: 'space-weather',
|
||||
title: 'Space Weather',
|
||||
showCount: true,
|
||||
infoTooltip: 'Hanzo World Model — Space Weather lens: NOAA SWPC planetary Kp index and geomagnetic/solar advisories. Drives satellite, GPS, grid and HF-comms risk worldwide.',
|
||||
});
|
||||
void this.refresh();
|
||||
}
|
||||
|
||||
public async refresh(): Promise<void> {
|
||||
if (this.loading) return;
|
||||
if (this.lastFetch > 0 && Date.now() - this.lastFetch < this.REFRESH_MS) return;
|
||||
this.loading = true;
|
||||
try {
|
||||
const state = await fetchSpaceWeather();
|
||||
this.lastFetch = Date.now();
|
||||
this.setCount(state.alerts.length);
|
||||
this.setDataBadge(state.kp !== null || state.alerts.length > 0 ? 'live' : 'unavailable');
|
||||
this.render(state);
|
||||
} catch (e) {
|
||||
console.error('[SpaceWeather] refresh failed:', e);
|
||||
this.showError();
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private render(state: SpaceWeatherState): void {
|
||||
const alerts = state.alerts.map((a) => `
|
||||
<div class="domain-item">
|
||||
<div class="domain-item-title">📡 ${escapeHtml(a.message)}</div>
|
||||
<div class="domain-item-meta">${a.issued.toLocaleString()}</div>
|
||||
</div>`).join('');
|
||||
|
||||
this.setContent(`
|
||||
<div class="domain-panel">
|
||||
<div class="domain-insight">${escapeHtml(state.insight)}</div>
|
||||
<div class="domain-item">
|
||||
<div class="domain-item-title">🧭 Planetary Kp <span class="domain-tag ${kpClass(state.kp)}">${state.kp ?? '—'}</span></div>
|
||||
<div class="domain-item-meta">${escapeHtml(state.stormLevel)}${state.kpTime ? ' · ' + state.kpTime.toLocaleString() : ''}</div>
|
||||
</div>
|
||||
<div class="domain-section-title">Advisories</div>
|
||||
<div class="domain-list">${alerts || '<div class="domain-empty">No active advisories</div>'}</div>
|
||||
<div class="domain-footer"><span>NOAA SWPC</span><span>${new Date(this.lastFetch).toLocaleTimeString()}</span></div>
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
import { Panel } from './Panel';
|
||||
import { escapeHtml, sanitizeUrl } from '@/utils/sanitize';
|
||||
import { fetchSportsEvents, sportsInsight, type SportEvent } from '@/services/sports';
|
||||
|
||||
export class SportsPanel extends Panel {
|
||||
private loading = false;
|
||||
private lastFetch = 0;
|
||||
private readonly REFRESH_MS = 2 * 60 * 1000;
|
||||
|
||||
constructor() {
|
||||
super({
|
||||
id: 'sports',
|
||||
title: 'Sports & Events',
|
||||
showCount: true,
|
||||
infoTooltip: 'Hanzo World Model — Sports lens: live and scheduled major events across the NFL, NBA, MLB, NHL, Premier League and UEFA Champions League (ESPN public API).',
|
||||
});
|
||||
void this.refresh();
|
||||
}
|
||||
|
||||
public async refresh(): Promise<void> {
|
||||
if (this.loading) return;
|
||||
if (this.lastFetch > 0 && Date.now() - this.lastFetch < this.REFRESH_MS) return;
|
||||
this.loading = true;
|
||||
try {
|
||||
const events = await fetchSportsEvents();
|
||||
this.lastFetch = Date.now();
|
||||
this.setCount(events.length);
|
||||
this.setDataBadge(events.length > 0 ? 'live' : 'unavailable');
|
||||
this.render(events);
|
||||
} catch (e) {
|
||||
console.error('[Sports] refresh failed:', e);
|
||||
this.showError();
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private renderEvent(e: SportEvent): string {
|
||||
const live = e.state === 'in';
|
||||
const badge = live
|
||||
? '<span class="domain-tag sev-high">LIVE</span>'
|
||||
: e.state === 'post'
|
||||
? '<span class="domain-tag">FINAL</span>'
|
||||
: `<span class="domain-tag">${escapeHtml(e.status)}</span>`;
|
||||
const score = e.state === 'pre'
|
||||
? escapeHtml(e.status)
|
||||
: `${escapeHtml(e.away.name)} ${escapeHtml(e.away.score)} — ${escapeHtml(e.home.name)} ${escapeHtml(e.home.score)}`;
|
||||
const title = e.url
|
||||
? `<a href="${sanitizeUrl(e.url)}" target="_blank" rel="noopener">${escapeHtml(e.shortName || e.name)} ↗</a>`
|
||||
: escapeHtml(e.shortName || e.name);
|
||||
return `<div class="domain-item">
|
||||
<div class="domain-item-title">${title} ${badge}</div>
|
||||
<div class="domain-item-meta">${escapeHtml(e.league)} · ${score}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
private render(events: SportEvent[]): void {
|
||||
if (events.length === 0) {
|
||||
this.setContent(`<div class="domain-panel"><div class="domain-empty">No events available right now</div></div>`);
|
||||
return;
|
||||
}
|
||||
const rows = events.slice(0, 40).map((e) => this.renderEvent(e)).join('');
|
||||
this.setContent(`
|
||||
<div class="domain-panel">
|
||||
<div class="domain-insight">${escapeHtml(sportsInsight(events))}</div>
|
||||
<div class="domain-list">${rows}</div>
|
||||
<div class="domain-footer"><span>ESPN</span><span>${new Date(this.lastFetch).toLocaleTimeString()}</span></div>
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
import { Panel } from './Panel';
|
||||
import { t } from '@/services/i18n';
|
||||
import type { TechHubActivity } from '@/services/tech-activity';
|
||||
import { escapeHtml, sanitizeUrl } from '@/utils/sanitize';
|
||||
import { getCSSColor } from '@/utils';
|
||||
|
||||
const COUNTRY_FLAGS: Record<string, string> = {
|
||||
'USA': '🇺🇸', 'United States': '🇺🇸',
|
||||
'UK': '🇬🇧', 'United Kingdom': '🇬🇧',
|
||||
'China': '🇨🇳',
|
||||
'India': '🇮🇳',
|
||||
'Israel': '🇮🇱',
|
||||
'Germany': '🇩🇪',
|
||||
'France': '🇫🇷',
|
||||
'Canada': '🇨🇦',
|
||||
'Japan': '🇯🇵',
|
||||
'South Korea': '🇰🇷',
|
||||
'Singapore': '🇸🇬',
|
||||
'Australia': '🇦🇺',
|
||||
'Netherlands': '🇳🇱',
|
||||
'Sweden': '🇸🇪',
|
||||
'Switzerland': '🇨🇭',
|
||||
'Brazil': '🇧🇷',
|
||||
'Indonesia': '🇮🇩',
|
||||
'UAE': '🇦🇪',
|
||||
'Estonia': '🇪🇪',
|
||||
'Ireland': '🇮🇪',
|
||||
'Finland': '🇫🇮',
|
||||
'Spain': '🇪🇸',
|
||||
'Italy': '🇮🇹',
|
||||
'Poland': '🇵🇱',
|
||||
'Mexico': '🇲🇽',
|
||||
'Argentina': '🇦🇷',
|
||||
'Chile': '🇨🇱',
|
||||
'Colombia': '🇨🇴',
|
||||
'Nigeria': '🇳🇬',
|
||||
'Kenya': '🇰🇪',
|
||||
'South Africa': '🇿🇦',
|
||||
'Egypt': '🇪🇬',
|
||||
'Taiwan': '🇹🇼',
|
||||
'Vietnam': '🇻🇳',
|
||||
'Thailand': '🇹🇭',
|
||||
'Malaysia': '🇲🇾',
|
||||
'Philippines': '🇵🇭',
|
||||
'New Zealand': '🇳🇿',
|
||||
'Austria': '🇦🇹',
|
||||
'Belgium': '🇧🇪',
|
||||
'Denmark': '🇩🇰',
|
||||
'Norway': '🇳🇴',
|
||||
'Portugal': '🇵🇹',
|
||||
'Czech Republic': '🇨🇿',
|
||||
'Romania': '🇷🇴',
|
||||
'Ukraine': '🇺🇦',
|
||||
'Russia': '🇷🇺',
|
||||
'Turkey': '🇹🇷',
|
||||
'Saudi Arabia': '🇸🇦',
|
||||
'Qatar': '🇶🇦',
|
||||
'Pakistan': '🇵🇰',
|
||||
'Bangladesh': '🇧🇩',
|
||||
};
|
||||
|
||||
export class TechHubsPanel extends Panel {
|
||||
private activities: TechHubActivity[] = [];
|
||||
private onHubClick?: (hub: TechHubActivity) => void;
|
||||
|
||||
constructor() {
|
||||
super({
|
||||
id: 'tech-hubs',
|
||||
title: t('panels.techHubs'),
|
||||
showCount: true,
|
||||
infoTooltip: t('components.techHubs.infoTooltip', {
|
||||
highColor: getCSSColor('--semantic-normal'),
|
||||
elevatedColor: getCSSColor('--semantic-elevated'),
|
||||
lowColor: getCSSColor('--text-dim'),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
public setOnHubClick(handler: (hub: TechHubActivity) => void): void {
|
||||
this.onHubClick = handler;
|
||||
}
|
||||
|
||||
public setActivities(activities: TechHubActivity[]): void {
|
||||
this.activities = activities.slice(0, 10);
|
||||
this.setCount(this.activities.length);
|
||||
this.render();
|
||||
}
|
||||
|
||||
private getFlag(country: string): string {
|
||||
return COUNTRY_FLAGS[country] || '🌐';
|
||||
}
|
||||
|
||||
private render(): void {
|
||||
if (this.activities.length === 0) {
|
||||
this.showError(t('common.noActiveTechHubs'));
|
||||
return;
|
||||
}
|
||||
|
||||
const html = this.activities.map((hub, index) => {
|
||||
const trendIcon = hub.trend === 'rising' ? '↑' : hub.trend === 'falling' ? '↓' : '';
|
||||
const breakingTag = hub.hasBreaking ? '<span class="hub-breaking">ALERT</span>' : '';
|
||||
const topStory = hub.topStories[0];
|
||||
|
||||
return `
|
||||
<div class="tech-hub-item ${hub.activityLevel}" data-hub-id="${escapeHtml(hub.hubId)}" data-index="${index}">
|
||||
<div class="hub-rank">${index + 1}</div>
|
||||
<span class="hub-indicator ${hub.activityLevel}"></span>
|
||||
<div class="hub-info">
|
||||
<div class="hub-header">
|
||||
<span class="hub-name">${escapeHtml(hub.city)}</span>
|
||||
<span class="hub-flag">${this.getFlag(hub.country)}</span>
|
||||
${breakingTag}
|
||||
</div>
|
||||
<div class="hub-meta">
|
||||
<span class="hub-news-count">${hub.newsCount} ${hub.newsCount === 1 ? 'story' : 'stories'}</span>
|
||||
${trendIcon ? `<span class="hub-trend ${hub.trend}">${trendIcon}</span>` : ''}
|
||||
<span class="hub-tier">${hub.tier}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hub-score">${Math.round(hub.score)}</div>
|
||||
</div>
|
||||
${topStory ? `
|
||||
<a class="hub-top-story" href="${sanitizeUrl(topStory.link)}" target="_blank" rel="noopener" data-hub-id="${escapeHtml(hub.hubId)}">
|
||||
${escapeHtml(topStory.title.length > 80 ? topStory.title.slice(0, 77) + '...' : topStory.title)}
|
||||
</a>
|
||||
` : ''}
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
this.setContent(html);
|
||||
this.bindEvents();
|
||||
}
|
||||
|
||||
private bindEvents(): void {
|
||||
const items = this.content.querySelectorAll<HTMLDivElement>('.tech-hub-item');
|
||||
items.forEach((item) => {
|
||||
item.addEventListener('click', () => {
|
||||
const hubId = item.dataset.hubId;
|
||||
const hub = this.activities.find(a => a.hubId === hubId);
|
||||
if (hub && this.onHubClick) {
|
||||
this.onHubClick(hub);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,163 +0,0 @@
|
||||
import { h, Component } from 'preact';
|
||||
|
||||
export interface VerificationCheck {
|
||||
id: string;
|
||||
label: string;
|
||||
checked: boolean;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
export interface VerificationResult {
|
||||
score: number; // 0-100
|
||||
checks: VerificationCheck[];
|
||||
verdict: 'verified' | 'likely' | 'uncertain' | 'unreliable';
|
||||
notes: string[];
|
||||
}
|
||||
|
||||
const VERIFICATION_TEMPLATE: VerificationCheck[] = [
|
||||
{ id: 'recency', label: 'Recent timestamp confirmed', checked: false, icon: '🕐' },
|
||||
{ id: 'geolocation', label: 'Location verified', checked: false, icon: '📍' },
|
||||
{ id: 'source', label: 'Primary source identified', checked: false, icon: '📰' },
|
||||
{ id: 'crossref', label: 'Cross-referenced with other sources', checked: false, icon: '🔗' },
|
||||
{ id: 'no_ai', label: 'No AI generation artifacts', checked: false, icon: '🤖' },
|
||||
{ id: 'no_recrop', label: 'Not recycled/old footage', checked: false, icon: '🔄' },
|
||||
{ id: 'metadata', label: 'Metadata verified', checked: false, icon: '📋' },
|
||||
{ id: 'context', label: 'Context established', checked: false, icon: '📖' },
|
||||
];
|
||||
|
||||
export class VerificationChecklist extends Component {
|
||||
private checks: VerificationCheck[] = VERIFICATION_TEMPLATE.map(c => ({ ...c }));
|
||||
private notes: string[] = [];
|
||||
private manualNote: string = '';
|
||||
|
||||
private toggleCheck(id: string): void {
|
||||
this.checks = this.checks.map(c =>
|
||||
c.id === id ? { ...c, checked: !c.checked } : c
|
||||
);
|
||||
this.setState({});
|
||||
}
|
||||
|
||||
private addNote(): void {
|
||||
if (this.manualNote.trim()) {
|
||||
this.notes = [...this.notes, this.manualNote.trim()];
|
||||
this.manualNote = '';
|
||||
this.setState({});
|
||||
}
|
||||
}
|
||||
|
||||
private calculateResult(): VerificationResult {
|
||||
const checkedCount = this.checks.filter(c => c.checked).length;
|
||||
const score = Math.round((checkedCount / this.checks.length) * 100);
|
||||
|
||||
let verdict: VerificationResult['verdict'];
|
||||
if (score >= 90) verdict = 'verified';
|
||||
else if (score >= 70) verdict = 'likely';
|
||||
else if (score >= 40) verdict = 'uncertain';
|
||||
else verdict = 'unreliable';
|
||||
|
||||
return { score, checks: this.checks, verdict, notes: this.notes };
|
||||
}
|
||||
|
||||
private reset(): void {
|
||||
this.checks = VERIFICATION_TEMPLATE.map(c => ({ ...c }));
|
||||
this.notes = [];
|
||||
this.manualNote = '';
|
||||
this.setState({});
|
||||
}
|
||||
|
||||
render() {
|
||||
const result = this.calculateResult();
|
||||
|
||||
const verdictColors: Record<string, string> = {
|
||||
verified: '#22c55e',
|
||||
likely: '#84cc16',
|
||||
uncertain: '#eab308',
|
||||
unreliable: '#ef4444',
|
||||
};
|
||||
|
||||
const verdictLabels: Record<string, string> = {
|
||||
verified: 'VERIFIED',
|
||||
likely: 'LIKELY AUTHENTIC',
|
||||
uncertain: 'UNCERTAIN',
|
||||
unreliable: 'UNRELIABLE',
|
||||
};
|
||||
|
||||
return h('div', { class: 'verification-checklist' },
|
||||
h('div', { class: 'checklist-header' },
|
||||
h('h3', null, 'Information Verification Checklist'),
|
||||
h('p', { class: 'hint' }, 'Based on Bellingcat\'s OSH Framework'),
|
||||
),
|
||||
h('div', {
|
||||
class: 'score-display',
|
||||
style: `background-color: ${verdictColors[result.verdict]}20; border-color: ${verdictColors[result.verdict]}`,
|
||||
},
|
||||
h('div', { class: 'score-value' }, `${result.score}%`),
|
||||
h('div', { class: 'score-label', style: `color: ${verdictColors[result.verdict]}` },
|
||||
verdictLabels[result.verdict],
|
||||
),
|
||||
),
|
||||
h('div', { class: 'checks-grid' },
|
||||
...this.checks.map(check =>
|
||||
h('label', { key: check.id, class: `check-item ${check.checked ? 'checked' : ''}` },
|
||||
h('input', {
|
||||
type: 'checkbox',
|
||||
checked: check.checked,
|
||||
onChange: () => this.toggleCheck(check.id),
|
||||
}),
|
||||
h('span', { class: 'icon' }, check.icon),
|
||||
h('span', { class: 'label' }, check.label),
|
||||
)
|
||||
),
|
||||
),
|
||||
h('div', { class: 'notes-section' },
|
||||
h('h4', null, 'Verification Notes'),
|
||||
h('div', { class: 'notes-list' },
|
||||
this.notes.length === 0
|
||||
? h('p', { class: 'empty' }, 'No notes added')
|
||||
: this.notes.map((note, i) =>
|
||||
h('div', { key: i, class: 'note-item' }, `• ${note}`)
|
||||
),
|
||||
),
|
||||
h('div', { class: 'add-note' },
|
||||
h('input', {
|
||||
type: 'text',
|
||||
value: this.manualNote,
|
||||
onInput: (e: Event) => { this.manualNote = (e.target as HTMLInputElement).value; },
|
||||
placeholder: 'Add verification note...',
|
||||
onKeyPress: (e: KeyboardEvent) => { if (e.key === 'Enter') this.addNote(); },
|
||||
}),
|
||||
h('button', { onClick: () => this.addNote() }, 'Add'),
|
||||
),
|
||||
),
|
||||
h('div', { class: 'checklist-actions' },
|
||||
h('button', { class: 'reset-btn', onClick: () => this.reset() }, 'Reset Checklist'),
|
||||
),
|
||||
h('style', null, `
|
||||
.verification-checklist { background: var(--bg); border-radius: 8px; padding: 16px; max-width: 400px; }
|
||||
.checklist-header h3 { margin: 0 0 4px; font-size: 14px; color: var(--accent); }
|
||||
.hint { margin: 0; font-size: 11px; color: var(--text-muted); }
|
||||
.score-display { margin: 16px 0; padding: 16px; border-radius: 8px; border: 2px solid; text-align: center; }
|
||||
.score-value { font-size: 32px; font-weight: 700; color: var(--accent); }
|
||||
.score-label { font-size: 12px; font-weight: 600; text-transform: uppercase; }
|
||||
.checks-grid { display: flex; flex-direction: column; gap: 8px; margin: 16px 0; }
|
||||
.check-item { display: flex; align-items: center; gap: 8px; padding: 8px; background: var(--surface-hover); border-radius: 4px; cursor: pointer; transition: background 0.2s; }
|
||||
.check-item:hover { background: var(--border); }
|
||||
.check-item.checked { background: color-mix(in srgb, var(--semantic-normal) 15%, var(--bg)); }
|
||||
.check-item input { width: 16px; height: 16px; }
|
||||
.icon { font-size: 14px; }
|
||||
.label { font-size: 12px; color: var(--text); }
|
||||
.notes-section { margin-top: 16px; padding-top: 16px; border-top: 1px solid var(--border); }
|
||||
.notes-section h4 { margin: 0 0 8px; font-size: 12px; color: var(--text-dim); }
|
||||
.notes-list { max-height: 100px; overflow-y: auto; }
|
||||
.note-item { font-size: 11px; color: var(--text-faint); padding: 4px 0; }
|
||||
.empty { font-size: 11px; color: var(--text-ghost); font-style: italic; }
|
||||
.add-note { display: flex; gap: 8px; margin-top: 8px; }
|
||||
.add-note input { flex: 1; padding: 6px 8px; background: var(--surface-hover); border: 1px solid var(--border-strong); border-radius: 4px; color: var(--text); font-size: 12px; }
|
||||
.add-note button { padding: 6px 12px; background: var(--border-strong); border: none; border-radius: 4px; color: var(--accent); font-size: 12px; cursor: pointer; }
|
||||
.checklist-actions { margin-top: 16px; padding-top: 16px; border-top: 1px solid var(--border); }
|
||||
.reset-btn { width: 100%; padding: 8px; background: var(--border); border: none; border-radius: 4px; color: var(--text-dim); font-size: 12px; cursor: pointer; }
|
||||
.reset-btn:hover { background: var(--border-strong); color: var(--text-faint); }
|
||||
`),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
import { Panel } from './Panel';
|
||||
import { escapeHtml, sanitizeUrl } from '@/utils/sanitize';
|
||||
import { getGlobalWeatherData, type GlobalWeatherData, type SevereWeatherKind } from '@/services/global-weather';
|
||||
import type { WorldFeedSeverity } from '@/services/world-feed';
|
||||
|
||||
const KIND_ICON: Record<SevereWeatherKind, string> = {
|
||||
cyclone: '🌀',
|
||||
flood: '🌊',
|
||||
drought: '☀️',
|
||||
wildfire: '🔥',
|
||||
storm: '⛈️',
|
||||
other: '⚠️',
|
||||
};
|
||||
|
||||
const SEV_CLASS: Record<WorldFeedSeverity, string> = {
|
||||
critical: 'sev-critical',
|
||||
high: 'sev-high',
|
||||
elevated: 'sev-elevated',
|
||||
low: 'sev-low',
|
||||
info: 'sev-info',
|
||||
};
|
||||
|
||||
export class WeatherPanel extends Panel {
|
||||
private loading = false;
|
||||
private lastFetch = 0;
|
||||
private readonly REFRESH_MS = 10 * 60 * 1000;
|
||||
|
||||
constructor() {
|
||||
super({
|
||||
id: 'weather',
|
||||
title: 'Severe Weather',
|
||||
showCount: true,
|
||||
infoTooltip: 'Hanzo World Model — Weather lens: worldwide severe weather from GDACS (cyclones, floods, droughts, wildfires) plus US National Weather Service alerts.',
|
||||
});
|
||||
void this.refresh();
|
||||
}
|
||||
|
||||
public async refresh(): Promise<void> {
|
||||
if (this.loading) return;
|
||||
if (this.lastFetch > 0 && Date.now() - this.lastFetch < this.REFRESH_MS) return;
|
||||
this.loading = true;
|
||||
try {
|
||||
const data = await getGlobalWeatherData();
|
||||
this.lastFetch = Date.now();
|
||||
this.setCount(data.events.length);
|
||||
this.setDataBadge(data.events.length > 0 ? 'live' : 'cached');
|
||||
this.render(data);
|
||||
} catch (e) {
|
||||
console.error('[Weather] refresh failed:', e);
|
||||
this.showError();
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private render(data: GlobalWeatherData): void {
|
||||
if (data.events.length === 0) {
|
||||
this.setContent(`
|
||||
<div class="domain-panel">
|
||||
<div class="domain-insight">${escapeHtml(data.insight)}</div>
|
||||
<div class="domain-empty">No active severe-weather events</div>
|
||||
</div>`);
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = data.events.slice(0, 40).map((e) => {
|
||||
const icon = KIND_ICON[e.kind] || '⚠️';
|
||||
const date = e.time.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||||
const title = e.url
|
||||
? `<a href="${sanitizeUrl(e.url)}" target="_blank" rel="noopener">${escapeHtml(e.title)} ↗</a>`
|
||||
: escapeHtml(e.title);
|
||||
return `<div class="domain-item">
|
||||
<div class="domain-item-title">${icon} ${title} <span class="domain-tag ${SEV_CLASS[e.severity]}">${e.severity}</span></div>
|
||||
<div class="domain-item-meta">${escapeHtml(e.region || e.kind)} · ${e.source} · ${date}</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
this.setContent(`
|
||||
<div class="domain-panel">
|
||||
<div class="domain-insight">${escapeHtml(data.insight)}</div>
|
||||
<div class="domain-list">${rows}</div>
|
||||
<div class="domain-footer"><span>GDACS + NWS</span><span>${new Date(this.lastFetch).toLocaleTimeString()}</span></div>
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -50,12 +50,6 @@ export * from './DisplacementPanel';
|
||||
export * from './ClimateAnomalyPanel';
|
||||
export * from './PopulationExposurePanel';
|
||||
export * from './InvestmentsPanel';
|
||||
export * from './RoboticsPanel';
|
||||
export * from './QuantumPanel';
|
||||
export * from './PostQuantumPanel';
|
||||
export * from './WeatherPanel';
|
||||
export * from './SportsPanel';
|
||||
export * from './SpaceWeatherPanel';
|
||||
export * from './LanguageSelector';
|
||||
export { SentimentPanel } from './SentimentPanel';
|
||||
export { TraderDeskPanel } from './TraderDeskPanel';
|
||||
|
||||
@@ -61,13 +61,6 @@ const FULL_PANELS: Record<string, PanelConfig> = {
|
||||
displacement: { name: 'UNHCR Displacement', enabled: true, priority: 2 },
|
||||
climate: { name: 'Climate Anomalies', enabled: true, priority: 2 },
|
||||
'population-exposure': { name: 'Population Exposure', enabled: true, priority: 2 },
|
||||
// Hanzo World Model domain lenses — realtime feeds for app-builders on hanzo.ai.
|
||||
robotics: { name: 'Robotics', enabled: true, priority: 2 },
|
||||
quantum: { name: 'Quantum Computing', enabled: true, priority: 2 },
|
||||
'post-quantum': { name: 'Post-Quantum Readiness', enabled: true, priority: 2 },
|
||||
weather: { name: 'Severe Weather', enabled: true, priority: 2 },
|
||||
sports: { name: 'Sports & Events', enabled: true, priority: 2 },
|
||||
'space-weather': { name: 'Space Weather', enabled: true, priority: 2 },
|
||||
};
|
||||
|
||||
const FULL_MAP_LAYERS: MapLayers = {
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
// Curated Post-Quantum readiness reference data.
|
||||
//
|
||||
// Tracks the migration to quantum-resistant cryptography: NIST PQC standards,
|
||||
// the NSA CNSA 2.0 timeline, and public deployment status across governments,
|
||||
// clouds, browsers, messengers, and chains. Reference data (published facts),
|
||||
// not a live feed. Lux/Hanzo are post-quantum-native — included as the
|
||||
// reference implementation of a PQ-from-genesis stack.
|
||||
|
||||
export type PQStandardKind = 'kem' | 'signature' | 'hash-signature';
|
||||
export type PQStandardStatus = 'standardized' | 'draft' | 'selected';
|
||||
|
||||
export interface PQStandard {
|
||||
id: string; // FIPS number
|
||||
name: string; // ML-KEM, ML-DSA, ...
|
||||
basedOn: string; // Kyber, Dilithium, ...
|
||||
kind: PQStandardKind;
|
||||
status: PQStandardStatus;
|
||||
year: number;
|
||||
note: string;
|
||||
}
|
||||
|
||||
// NIST post-quantum standards (FIPS 203/204/205 finalized Aug 2024).
|
||||
export const PQC_STANDARDS: PQStandard[] = [
|
||||
{ id: 'FIPS 203', name: 'ML-KEM', basedOn: 'CRYSTALS-Kyber', kind: 'kem', status: 'standardized', year: 2024, note: 'Module-lattice key encapsulation — the primary PQ KEM.' },
|
||||
{ id: 'FIPS 204', name: 'ML-DSA', basedOn: 'CRYSTALS-Dilithium', kind: 'signature', status: 'standardized', year: 2024, note: 'Module-lattice signatures — the primary PQ signature.' },
|
||||
{ id: 'FIPS 205', name: 'SLH-DSA', basedOn: 'SPHINCS+', kind: 'hash-signature', status: 'standardized', year: 2024, note: 'Stateless hash-based signatures — conservative backup.' },
|
||||
{ id: 'FIPS 206', name: 'FN-DSA', basedOn: 'Falcon', kind: 'signature', status: 'draft', year: 2025, note: 'Compact lattice signatures — draft in progress.' },
|
||||
{ id: 'HQC', name: 'HQC-KEM', basedOn: 'HQC (code-based)', kind: 'kem', status: 'selected', year: 2025, note: 'Selected as a code-based KEM backup to ML-KEM.' },
|
||||
];
|
||||
|
||||
// NSA CNSA 2.0 migration timeline (target years for US National Security Systems).
|
||||
export interface CNSAMilestone {
|
||||
year: number;
|
||||
milestone: string;
|
||||
}
|
||||
|
||||
export const CNSA2_TIMELINE: CNSAMilestone[] = [
|
||||
{ year: 2025, milestone: 'PQC support required in new software/firmware signing.' },
|
||||
{ year: 2027, milestone: 'PQC becomes default for new networking equipment.' },
|
||||
{ year: 2030, milestone: 'PQC default across most NSS deployments.' },
|
||||
{ year: 2033, milestone: 'Exclusive PQC; classical (RSA/ECC) deprecated for NSS.' },
|
||||
];
|
||||
|
||||
export type PQOrgType = 'government' | 'cloud' | 'browser' | 'messaging' | 'blockchain' | 'standard';
|
||||
// pq-native = built PQ from the start; deployed = shipping in production;
|
||||
// in-progress = rolling out; planned = committed; lagging = no public plan.
|
||||
export type PQReadinessStatus = 'pq-native' | 'deployed' | 'in-progress' | 'planned' | 'lagging';
|
||||
|
||||
export interface PQReadiness {
|
||||
id: string;
|
||||
org: string;
|
||||
type: PQOrgType;
|
||||
status: PQReadinessStatus;
|
||||
algorithms: string[];
|
||||
detail: string;
|
||||
asOf: number;
|
||||
}
|
||||
|
||||
export const PQ_READINESS: PQReadiness[] = [
|
||||
{ id: 'lux', org: 'Lux Network', type: 'blockchain', status: 'pq-native', algorithms: ['ML-DSA-65', 'Pulsar (Ring-LWE)', 'BLS'], detail: 'Post-quantum from genesis: Quasar consensus triple-seals with BLS + Pulsar threshold + ML-DSA-65. No harvest-now-decrypt-later exposure.', asOf: 2026 },
|
||||
{ id: 'hanzo', org: 'Hanzo AI', type: 'blockchain', status: 'pq-native', algorithms: ['ML-DSA-65', 'ML-KEM'], detail: 'PQ-native infrastructure and identity; ships quantum-resistant primitives across the stack.', asOf: 2026 },
|
||||
{ id: 'nsa-cnsa2', org: 'US Government (NSA CNSA 2.0)', type: 'government', status: 'in-progress', algorithms: ['ML-KEM', 'ML-DSA', 'SLH-DSA'], detail: 'CNSA 2.0 mandates PQC across National Security Systems by 2033.', asOf: 2025 },
|
||||
{ id: 'cloudflare', org: 'Cloudflare', type: 'cloud', status: 'deployed', algorithms: ['X25519+ML-KEM'], detail: 'Hybrid post-quantum key agreement live for a large share of TLS traffic.', asOf: 2024 },
|
||||
{ id: 'google-chrome', org: 'Google Chrome', type: 'browser', status: 'deployed', algorithms: ['X25519+ML-KEM'], detail: 'Hybrid ML-KEM enabled by default in TLS.', asOf: 2024 },
|
||||
{ id: 'apple-imessage', org: 'Apple iMessage (PQ3)', type: 'messaging', status: 'deployed', algorithms: ['ML-KEM'], detail: 'PQ3 protocol brings level-3 post-quantum security with ongoing rekeying.', asOf: 2024 },
|
||||
{ id: 'signal', org: 'Signal (PQXDH)', type: 'messaging', status: 'deployed', algorithms: ['X25519+ML-KEM'], detail: 'PQXDH adds post-quantum protection to the initial key exchange.', asOf: 2023 },
|
||||
{ id: 'aws', org: 'AWS', type: 'cloud', status: 'in-progress', algorithms: ['ML-KEM'], detail: 'Hybrid PQ TLS across KMS/ACM and s2n-tls endpoints.', asOf: 2024 },
|
||||
{ id: 'microsoft', org: 'Microsoft', type: 'cloud', status: 'in-progress', algorithms: ['ML-KEM', 'ML-DSA'], detail: 'SymCrypt PQC and Windows/TLS integration rolling out.', asOf: 2025 },
|
||||
{ id: 'nist', org: 'NIST', type: 'standard', status: 'deployed', algorithms: ['ML-KEM', 'ML-DSA', 'SLH-DSA'], detail: 'Published FIPS 203/204/205; FN-DSA + HQC in the pipeline.', asOf: 2024 },
|
||||
];
|
||||
|
||||
// The core threat driving urgency: encrypted data captured today can be
|
||||
// decrypted once a cryptographically-relevant quantum computer exists.
|
||||
export const HARVEST_NOW_DECRYPT_LATER =
|
||||
'Adversaries harvest encrypted traffic today to decrypt once a cryptographically-relevant quantum computer arrives. Data with a long secrecy lifetime must migrate to PQC now.';
|
||||
|
||||
export function pqReadinessRank(status: PQReadinessStatus): number {
|
||||
switch (status) {
|
||||
case 'pq-native': return 5;
|
||||
case 'deployed': return 4;
|
||||
case 'in-progress': return 3;
|
||||
case 'planned': return 2;
|
||||
case 'lagging': return 1;
|
||||
default: return 0;
|
||||
}
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
import {
|
||||
PQC_STANDARDS,
|
||||
CNSA2_TIMELINE,
|
||||
PQ_READINESS,
|
||||
HARVEST_NOW_DECRYPT_LATER,
|
||||
pqReadinessRank,
|
||||
type PQStandard,
|
||||
type PQReadiness,
|
||||
type CNSAMilestone,
|
||||
} from '@/config/post-quantum';
|
||||
import { registerWorldFeed, type WorldFeed, type WorldFeedItem, type WorldFeedSeverity } from './world-feed';
|
||||
|
||||
export interface PostQuantumData {
|
||||
standards: PQStandard[];
|
||||
timeline: CNSAMilestone[];
|
||||
readiness: PQReadiness[];
|
||||
harvestNote: string;
|
||||
insight: string;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
function computeInsight(readiness: PQReadiness[]): string {
|
||||
const ready = readiness.filter((r) => r.status === 'pq-native' || r.status === 'deployed').length;
|
||||
const standardized = PQC_STANDARDS.filter((s) => s.status === 'standardized').length;
|
||||
const nowYear = new Date().getFullYear();
|
||||
const nextMilestone = CNSA2_TIMELINE.find((m) => m.year >= nowYear);
|
||||
const countdown = nextMilestone ? `CNSA 2.0 ${nextMilestone.year} (${nextMilestone.year - nowYear}y)` : 'CNSA 2.0 complete';
|
||||
return `${standardized} NIST standards live · ${ready}/${readiness.length} orgs PQ-ready · next ${countdown}`;
|
||||
}
|
||||
|
||||
export function getPostQuantumData(): PostQuantumData {
|
||||
const readiness = [...PQ_READINESS].sort((a, b) => pqReadinessRank(b.status) - pqReadinessRank(a.status));
|
||||
return {
|
||||
standards: PQC_STANDARDS,
|
||||
timeline: CNSA2_TIMELINE,
|
||||
readiness,
|
||||
harvestNote: HARVEST_NOW_DECRYPT_LATER,
|
||||
insight: computeInsight(readiness),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
function readinessSeverity(r: PQReadiness): WorldFeedSeverity {
|
||||
switch (r.status) {
|
||||
case 'pq-native': return 'info';
|
||||
case 'deployed': return 'low';
|
||||
case 'in-progress': return 'elevated';
|
||||
case 'planned': return 'high';
|
||||
default: return 'critical';
|
||||
}
|
||||
}
|
||||
|
||||
async function buildFeed(): Promise<WorldFeed> {
|
||||
const data = getPostQuantumData();
|
||||
const standardItems: WorldFeedItem[] = data.standards.map((s) => ({
|
||||
id: `pq:standard:${s.id}`,
|
||||
title: `${s.id} · ${s.name} (${s.basedOn})`,
|
||||
summary: s.note,
|
||||
category: 'standard',
|
||||
tags: [s.kind, s.status],
|
||||
meta: { year: s.year },
|
||||
}));
|
||||
const readinessItems: WorldFeedItem[] = data.readiness.map((r) => ({
|
||||
id: `pq:readiness:${r.id}`,
|
||||
title: `${r.org} — ${r.status}`,
|
||||
summary: r.detail,
|
||||
category: r.type,
|
||||
severity: readinessSeverity(r),
|
||||
tags: r.algorithms,
|
||||
meta: { asOf: r.asOf },
|
||||
}));
|
||||
const timelineItems: WorldFeedItem[] = data.timeline.map((m) => ({
|
||||
id: `pq:cnsa2:${m.year}`,
|
||||
title: `CNSA 2.0 · ${m.year}`,
|
||||
summary: m.milestone,
|
||||
category: 'timeline',
|
||||
timestamp: new Date(Date.UTC(m.year, 0, 1)).toISOString(),
|
||||
}));
|
||||
return {
|
||||
domain: 'post-quantum',
|
||||
label: 'Post-Quantum Readiness',
|
||||
updatedAt: data.updatedAt.toISOString(),
|
||||
source: 'NIST PQC (FIPS 203/204/205) + NSA CNSA 2.0 + curated deployment tracker',
|
||||
live: false,
|
||||
insight: data.insight,
|
||||
items: [...standardItems, ...readinessItems, ...timelineItems],
|
||||
};
|
||||
}
|
||||
|
||||
export function getPostQuantumFeed(): Promise<WorldFeed> {
|
||||
return buildFeed();
|
||||
}
|
||||
|
||||
registerWorldFeed('post-quantum', getPostQuantumFeed);
|
||||
@@ -1,78 +0,0 @@
|
||||
import { fetchArxivPapers, getArxivStatus, type ArxivPaper } from './arxiv';
|
||||
import { QUANTUM_PLAYERS, type QuantumPlayer } from '@/config/quantum';
|
||||
import { registerWorldFeed, type WorldFeed, type WorldFeedItem } from './world-feed';
|
||||
|
||||
export interface QuantumData {
|
||||
papers: ArxivPaper[];
|
||||
players: QuantumPlayer[];
|
||||
insight: string;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
function topScale(players: QuantumPlayer[]): QuantumPlayer | null {
|
||||
return players
|
||||
.filter((p) => p.qubits !== null)
|
||||
.sort((a, b) => (b.qubits ?? 0) - (a.qubits ?? 0))[0] ?? null;
|
||||
}
|
||||
|
||||
function computeInsight(papers: QuantumData['papers']): string {
|
||||
const leader = topScale(QUANTUM_PLAYERS);
|
||||
const scale = leader ? `${leader.qubits!.toLocaleString()} qubits (${leader.name})` : 'multiple architectures';
|
||||
const modalities = new Set(QUANTUM_PLAYERS.map((p) => p.modality)).size;
|
||||
const weekAgo = Date.now() - 7 * 24 * 60 * 60 * 1000;
|
||||
const recent = papers.filter((p) => p.published.getTime() >= weekAgo).length;
|
||||
return `Max announced: ${scale} · ${modalities} modalities · ${recent} new quant-ph papers/wk`;
|
||||
}
|
||||
|
||||
export async function getQuantumData(): Promise<QuantumData> {
|
||||
const papers = await fetchArxivPapers('quant-ph', 30);
|
||||
return {
|
||||
papers,
|
||||
players: QUANTUM_PLAYERS,
|
||||
insight: computeInsight(papers),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
async function buildFeed(): Promise<WorldFeed> {
|
||||
const data = await getQuantumData();
|
||||
const paperItems: WorldFeedItem[] = data.papers.slice(0, 20).map((p) => ({
|
||||
id: `quantum:paper:${p.id}`,
|
||||
title: p.title,
|
||||
summary: p.summary.slice(0, 400),
|
||||
category: 'research',
|
||||
url: p.link,
|
||||
timestamp: p.published.toISOString(),
|
||||
tags: p.categories,
|
||||
}));
|
||||
const playerItems: WorldFeedItem[] = data.players.map((p) => ({
|
||||
id: `quantum:player:${p.id}`,
|
||||
title: p.name,
|
||||
summary: `${p.metric} — ${p.modality} (${p.city}, as of ${p.asOf})`,
|
||||
category: p.modality,
|
||||
url: p.url,
|
||||
lat: p.lat,
|
||||
lon: p.lon,
|
||||
tags: [p.country, p.modality],
|
||||
meta: { qubits: p.qubits, asOf: p.asOf, milestone: p.milestone },
|
||||
}));
|
||||
return {
|
||||
domain: 'quantum',
|
||||
label: 'Quantum Computing',
|
||||
updatedAt: data.updatedAt.toISOString(),
|
||||
source: 'arXiv quant-ph + curated hardware registry',
|
||||
live: data.papers.length > 0,
|
||||
insight: data.insight,
|
||||
items: [...paperItems, ...playerItems],
|
||||
};
|
||||
}
|
||||
|
||||
export function getQuantumFeed(): Promise<WorldFeed> {
|
||||
return buildFeed();
|
||||
}
|
||||
|
||||
export function getQuantumStatus(): string {
|
||||
return getArxivStatus('quant-ph');
|
||||
}
|
||||
|
||||
registerWorldFeed('quantum', getQuantumFeed);
|
||||
@@ -1,76 +0,0 @@
|
||||
import { fetchArxivPapers, getArxivStatus, type ArxivPaper } from './arxiv';
|
||||
import { ROBOTICS_ORGS, type RoboticsOrg } from '@/config/robotics';
|
||||
import { registerWorldFeed, type WorldFeed, type WorldFeedItem } from './world-feed';
|
||||
|
||||
export interface RoboticsData {
|
||||
papers: ArxivPaper[];
|
||||
orgs: RoboticsOrg[];
|
||||
insight: string;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
const HUMANOID_TERMS = ['humanoid', 'bipedal', 'manipulation', 'dexterous', 'locomotion', 'whole-body'];
|
||||
|
||||
function computeInsight(papers: ArxivPaper[]): string {
|
||||
if (papers.length === 0) return `${ROBOTICS_ORGS.length} labs tracked · research feed unavailable`;
|
||||
const weekAgo = Date.now() - 7 * 24 * 60 * 60 * 1000;
|
||||
const recent = papers.filter((p) => p.published.getTime() >= weekAgo).length;
|
||||
const humanoid = papers.filter((p) => {
|
||||
const t = `${p.title} ${p.summary}`.toLowerCase();
|
||||
return HUMANOID_TERMS.some((term) => t.includes(term));
|
||||
}).length;
|
||||
const humanoidOrgs = ROBOTICS_ORGS.filter((o) => o.category === 'humanoid').length;
|
||||
return `${recent} new papers this week · ${humanoid}/${papers.length} on embodied/humanoid · ${humanoidOrgs} humanoid labs`;
|
||||
}
|
||||
|
||||
export async function getRoboticsData(): Promise<RoboticsData> {
|
||||
const papers = await fetchArxivPapers('cs.RO', 30);
|
||||
return {
|
||||
papers,
|
||||
orgs: ROBOTICS_ORGS,
|
||||
insight: computeInsight(papers),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
async function buildFeed(): Promise<WorldFeed> {
|
||||
const data = await getRoboticsData();
|
||||
const paperItems: WorldFeedItem[] = data.papers.slice(0, 20).map((p) => ({
|
||||
id: `robotics:paper:${p.id}`,
|
||||
title: p.title,
|
||||
summary: p.summary.slice(0, 400),
|
||||
category: 'research',
|
||||
url: p.link,
|
||||
timestamp: p.published.toISOString(),
|
||||
tags: p.categories,
|
||||
}));
|
||||
const orgItems: WorldFeedItem[] = data.orgs.map((o) => ({
|
||||
id: `robotics:org:${o.id}`,
|
||||
title: o.name,
|
||||
summary: o.focus,
|
||||
category: o.category,
|
||||
url: o.url,
|
||||
lat: o.lat,
|
||||
lon: o.lon,
|
||||
tags: [o.country, o.category],
|
||||
}));
|
||||
return {
|
||||
domain: 'robotics',
|
||||
label: 'Robotics',
|
||||
updatedAt: data.updatedAt.toISOString(),
|
||||
source: 'arXiv cs.RO + curated robotics registry',
|
||||
live: data.papers.length > 0,
|
||||
insight: data.insight,
|
||||
items: [...paperItems, ...orgItems],
|
||||
};
|
||||
}
|
||||
|
||||
export function getRoboticsFeed(): Promise<WorldFeed> {
|
||||
return buildFeed();
|
||||
}
|
||||
|
||||
export function getRoboticsStatus(): string {
|
||||
return getArxivStatus('cs.RO');
|
||||
}
|
||||
|
||||
registerWorldFeed('robotics', getRoboticsFeed);
|
||||
@@ -1,176 +0,0 @@
|
||||
import { createCircuitBreaker } from '@/utils';
|
||||
import { registerWorldFeed, type WorldFeed, type WorldFeedItem, type WorldFeedSeverity } from './world-feed';
|
||||
|
||||
// NOAA Space Weather Prediction Center — free, no key, CORS-enabled.
|
||||
// Geomagnetic conditions affect satellites, GPS, power grids, aviation and HF
|
||||
// comms: a genuinely global world-model signal.
|
||||
const KP_URL = 'https://services.swpc.noaa.gov/products/noaa-planetary-k-index.json';
|
||||
const ALERTS_URL = 'https://services.swpc.noaa.gov/products/alerts.json';
|
||||
|
||||
export interface SpaceWeatherAlert {
|
||||
id: string;
|
||||
message: string;
|
||||
issued: Date;
|
||||
}
|
||||
|
||||
export interface SpaceWeatherState {
|
||||
kp: number | null;
|
||||
kpTime: Date | null;
|
||||
stormLevel: string; // 'Quiet', 'G1 (Minor)', ... 'G5 (Extreme)'
|
||||
alerts: SpaceWeatherAlert[];
|
||||
insight: string;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
const breaker = createCircuitBreaker<SpaceWeatherState>({ name: 'NOAA Space Weather', cacheTtlMs: 5 * 60 * 1000 });
|
||||
|
||||
function stormLevel(kp: number | null): string {
|
||||
if (kp === null) return 'Unknown';
|
||||
if (kp >= 9) return 'G5 (Extreme)';
|
||||
if (kp >= 8) return 'G4 (Severe)';
|
||||
if (kp >= 7) return 'G3 (Strong)';
|
||||
if (kp >= 6) return 'G2 (Moderate)';
|
||||
if (kp >= 5) return 'G1 (Minor)';
|
||||
if (kp >= 4) return 'Unsettled';
|
||||
return 'Quiet';
|
||||
}
|
||||
|
||||
function kpSeverity(kp: number | null): WorldFeedSeverity {
|
||||
if (kp === null) return 'info';
|
||||
if (kp >= 8) return 'critical';
|
||||
if (kp >= 7) return 'high';
|
||||
if (kp >= 5) return 'elevated';
|
||||
if (kp >= 4) return 'low';
|
||||
return 'info';
|
||||
}
|
||||
|
||||
function toUtcDate(raw: unknown): Date | null {
|
||||
if (!raw) return null;
|
||||
const s = String(raw).replace(' ', 'T');
|
||||
return new Date(s.endsWith('Z') ? s : s + 'Z');
|
||||
}
|
||||
|
||||
function parseKp(raw: unknown): { kp: number | null; time: Date | null } {
|
||||
if (!Array.isArray(raw) || raw.length === 0) return { kp: null, time: null };
|
||||
const last = raw[raw.length - 1];
|
||||
let kpRaw: unknown;
|
||||
let timeRaw: unknown;
|
||||
if (Array.isArray(last)) {
|
||||
// Legacy [time_tag, Kp, a_running, station_count] rows (with header row).
|
||||
timeRaw = last[0];
|
||||
kpRaw = last[1];
|
||||
} else if (last && typeof last === 'object') {
|
||||
const rec = last as { Kp?: unknown; kp?: unknown; kp_index?: unknown; time_tag?: unknown };
|
||||
kpRaw = rec.Kp ?? rec.kp ?? rec.kp_index;
|
||||
timeRaw = rec.time_tag;
|
||||
} else {
|
||||
return { kp: null, time: null };
|
||||
}
|
||||
const kp = Number(kpRaw);
|
||||
return { kp: Number.isFinite(kp) ? kp : null, time: toUtcDate(timeRaw) };
|
||||
}
|
||||
|
||||
const DESCRIPTIVE = /^(ALERT|WARNING|WATCH|SUMMARY|EXTENDED WARNING|CANCEL WARNING|CONTINUED ALERT):/i;
|
||||
|
||||
function parseAlerts(raw: unknown): SpaceWeatherAlert[] {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
const wanted = /ALERT|WARNING|WATCH/i;
|
||||
const out: SpaceWeatherAlert[] = [];
|
||||
for (const item of raw) {
|
||||
if (!item || typeof item !== 'object') continue;
|
||||
const rec = item as { product_id?: string; issue_datetime?: string; message?: string };
|
||||
const message = String(rec.message ?? '').trim();
|
||||
if (!message || !wanted.test(message)) continue;
|
||||
// Prefer the descriptive line (e.g. "ALERT: Geomagnetic K-index of 5");
|
||||
// the leading "Space Weather Message Code: ..." line is not human-friendly.
|
||||
const lines = message.split('\n').map((l) => l.trim()).filter(Boolean);
|
||||
const headline = lines.find((l) => DESCRIPTIVE.test(l)) ?? lines[0] ?? message;
|
||||
out.push({
|
||||
id: `swpc:${rec.product_id ?? headline}:${rec.issue_datetime ?? ''}`,
|
||||
message: headline.slice(0, 160),
|
||||
issued: toUtcDate(rec.issue_datetime) ?? new Date(),
|
||||
});
|
||||
}
|
||||
const sorted = out.sort((a, b) => b.issued.getTime() - a.issued.getTime());
|
||||
const seen = new Set<string>();
|
||||
const deduped: SpaceWeatherAlert[] = [];
|
||||
for (const a of sorted) {
|
||||
if (seen.has(a.message)) continue;
|
||||
seen.add(a.message);
|
||||
deduped.push(a);
|
||||
if (deduped.length >= 12) break;
|
||||
}
|
||||
return deduped;
|
||||
}
|
||||
|
||||
export async function fetchSpaceWeather(): Promise<SpaceWeatherState> {
|
||||
return breaker.execute(async () => {
|
||||
const [kpRes, alertsRes] = await Promise.allSettled([
|
||||
fetch(KP_URL, { headers: { Accept: 'application/json' } }).then((r) => {
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||
return r.json();
|
||||
}),
|
||||
fetch(ALERTS_URL, { headers: { Accept: 'application/json' } }).then((r) => {
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||
return r.json();
|
||||
}),
|
||||
]);
|
||||
|
||||
if (kpRes.status !== 'fulfilled' && alertsRes.status !== 'fulfilled') {
|
||||
throw new Error('space weather sources unavailable');
|
||||
}
|
||||
|
||||
const { kp, time } = kpRes.status === 'fulfilled' ? parseKp(kpRes.value) : { kp: null, time: null };
|
||||
const alerts = alertsRes.status === 'fulfilled' ? parseAlerts(alertsRes.value) : [];
|
||||
const level = stormLevel(kp);
|
||||
|
||||
return {
|
||||
kp,
|
||||
kpTime: time,
|
||||
stormLevel: level,
|
||||
alerts,
|
||||
insight: `Kp ${kp ?? '—'} · ${level} · ${alerts.length} active advisories`,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
}, { kp: null, kpTime: null, stormLevel: 'Unknown', alerts: [], insight: 'unavailable', updatedAt: new Date() });
|
||||
}
|
||||
|
||||
async function buildFeed(): Promise<WorldFeed> {
|
||||
const state = await fetchSpaceWeather();
|
||||
const items: WorldFeedItem[] = [
|
||||
{
|
||||
id: 'space-weather:kp',
|
||||
title: `Planetary Kp ${state.kp ?? '—'} — ${state.stormLevel}`,
|
||||
summary: 'Global geomagnetic activity index (NOAA SWPC).',
|
||||
category: 'geomagnetic',
|
||||
severity: kpSeverity(state.kp),
|
||||
timestamp: (state.kpTime ?? state.updatedAt).toISOString(),
|
||||
},
|
||||
...state.alerts.map((a) => ({
|
||||
id: `space-weather:${a.id}`,
|
||||
title: a.message,
|
||||
category: 'advisory',
|
||||
severity: 'elevated' as WorldFeedSeverity,
|
||||
timestamp: a.issued.toISOString(),
|
||||
})),
|
||||
];
|
||||
return {
|
||||
domain: 'space-weather',
|
||||
label: 'Space Weather',
|
||||
updatedAt: state.updatedAt.toISOString(),
|
||||
source: 'NOAA SWPC (planetary K-index + alerts)',
|
||||
live: state.kp !== null || state.alerts.length > 0,
|
||||
insight: state.insight,
|
||||
items,
|
||||
};
|
||||
}
|
||||
|
||||
export function getSpaceWeatherFeed(): Promise<WorldFeed> {
|
||||
return buildFeed();
|
||||
}
|
||||
|
||||
export function getSpaceWeatherStatus(): string {
|
||||
return breaker.getStatus();
|
||||
}
|
||||
|
||||
registerWorldFeed('space-weather', getSpaceWeatherFeed);
|
||||