import type { MapLayers } from '@/types'; export const EMBEDDABLE_LAYERS = [ { id: 'conflicts', mapLayer: 'conflicts', label: 'Conflicts' }, { id: 'earthquakes', mapLayer: 'natural', label: 'Earthquakes' }, { id: 'protests', mapLayer: 'protests', label: 'Protests' }, { id: 'weather', mapLayer: 'weather', label: 'Weather' }, { id: 'cables', mapLayer: 'cables', label: 'Undersea Cables' }, { id: 'pipelines', mapLayer: 'pipelines', label: 'Pipelines' }, { id: 'waterways', mapLayer: 'waterways', label: 'Chokepoints' }, { id: 'tradeRoutes', mapLayer: 'tradeRoutes', label: 'Trade Routes' }, { id: 'economic', mapLayer: 'economic', label: 'Economic Centers' }, { id: 'stockExchanges', mapLayer: 'stockExchanges', label: 'Stock Exchanges' }, { id: 'financialCenters', mapLayer: 'financialCenters', label: 'Financial Centers' }, { id: 'centralBanks', mapLayer: 'centralBanks', label: 'Central Banks' }, { id: 'commodityHubs', mapLayer: 'commodityHubs', label: 'Commodity Hubs' }, { id: 'gulfInvestments', mapLayer: 'gulfInvestments', label: 'GCC Investments' }, ] as const; export type EmbedLayerId = typeof EMBEDDABLE_LAYERS[number]['id']; export type EmbedTheme = 'dark' | 'light'; export type EmbedVariant = 'full' | 'tech' | 'finance' | 'commodity' | 'happy' | 'energy'; export interface EmbedCenter { lat: number; lon: number; } export interface EmbedMapState { layers: MapLayers; layerIds: EmbedLayerId[]; center: EmbedCenter; zoom: number; theme: EmbedTheme; variant: EmbedVariant; } export const DEFAULT_EMBED_LAYER_IDS: EmbedLayerId[] = ['conflicts', 'earthquakes', 'weather']; export const DEFAULT_EMBED_CENTER: EmbedCenter = { lat: 20, lon: 0 }; export const DEFAULT_EMBED_ZOOM = 1; export const DEFAULT_EMBED_THEME: EmbedTheme = 'dark'; export const DEFAULT_EMBED_VARIANT: EmbedVariant = 'full'; const EMBED_LAYER_BY_ID = new Map( EMBEDDABLE_LAYERS.map((layer) => [layer.id.toLowerCase(), layer]), ); const EMBED_LAYER_ALIASES = new Map(([ ['natural', 'earthquakes'], ['earthquake', 'earthquakes'], ['conflict', 'conflicts'], ['protest', 'protests'], ['cable', 'cables'], ['pipeline', 'pipelines'], ['chokepoints', 'waterways'], ['chokepoint', 'waterways'], ['waterway', 'waterways'], ['trade-routes', 'tradeRoutes'], ['trade-routes-layer', 'tradeRoutes'], ['tradeRoute', 'tradeRoutes'], ['trade-route', 'tradeRoutes'], ['economy', 'economic'], ['stock-exchanges', 'stockExchanges'], ['stockExchange', 'stockExchanges'], ['stock-exchange', 'stockExchanges'], ['financial-centers', 'financialCenters'], ['financialCenter', 'financialCenters'], ['financial-center', 'financialCenters'], ['central-banks', 'centralBanks'], ['centralBank', 'centralBanks'], ['central-bank', 'centralBanks'], ['commodity-hubs', 'commodityHubs'], ['commodityHub', 'commodityHubs'], ['commodity-hub', 'commodityHubs'], ['gcc-investments', 'gulfInvestments'], ['gulf-investments', 'gulfInvestments'], ['gulfInvestment', 'gulfInvestments'], ['gulf-investment', 'gulfInvestments'], ] as const).map(([alias, id]) => [alias.toLowerCase(), id])); const VALID_THEMES = new Set(['dark', 'light']); const VALID_VARIANTS = new Set(['full', 'tech', 'finance', 'commodity', 'happy', 'energy']); export function createBlankMapLayers(): MapLayers { return { conflicts: false, bases: false, cables: false, pipelines: false, hotspots: false, ais: false, nuclear: false, irradiators: false, radiationWatch: false, sanctions: false, weather: false, economic: false, waterways: false, outages: false, cyberThreats: false, datacenters: false, protests: false, flights: false, military: false, natural: false, spaceports: false, minerals: false, fires: false, ucdpEvents: false, displacement: false, climate: false, startupHubs: false, cloudRegions: false, accelerators: false, techHQs: false, techEvents: false, stockExchanges: false, financialCenters: false, centralBanks: false, commodityHubs: false, gulfInvestments: false, positiveEvents: false, kindness: false, happiness: false, speciesRecovery: false, renewableInstallations: false, tradeRoutes: false, iranAttacks: false, gpsJamming: false, satellites: false, ciiChoropleth: false, resilienceScore: false, dayNight: false, miningSites: false, processingPlants: false, commodityPorts: false, webcams: false, diseaseOutbreaks: false, storageFacilities: false, fuelShortages: false, liveTankers: false, }; } export function mapLayersFromEmbedIds(layerIds: readonly EmbedLayerId[]): MapLayers { const layers = createBlankMapLayers(); for (const id of layerIds) { const layer = EMBED_LAYER_BY_ID.get(id.toLowerCase()); if (layer) layers[layer.mapLayer] = true; } return layers; } export function parseEmbedLayerIds(value: string | null): EmbedLayerId[] { if (value === null) return [...DEFAULT_EMBED_LAYER_IDS]; const seen = new Set(); for (const rawPart of value.split(',')) { const normalized = rawPart.trim().toLowerCase(); if (!normalized || normalized === 'none') continue; const id = EMBED_LAYER_BY_ID.get(normalized)?.id ?? EMBED_LAYER_ALIASES.get(normalized); if (id) seen.add(id); } return [...seen]; } function parseCenter(value: string | null): EmbedCenter { if (!value) return { ...DEFAULT_EMBED_CENTER }; const parts = value.split(','); const latRaw = Number(parts[0]?.trim()); const lonRaw = Number(parts[1]?.trim()); if (!Number.isFinite(latRaw) || !Number.isFinite(lonRaw)) return { ...DEFAULT_EMBED_CENTER }; return { lat: clamp(latRaw, -90, 90), lon: clamp(lonRaw, -180, 180), }; } function parseZoom(value: string | null): number { if (!value) return DEFAULT_EMBED_ZOOM; const parsed = Number(value); if (!Number.isFinite(parsed)) return DEFAULT_EMBED_ZOOM; return clamp(parsed, 1, 10); } function parseTheme(value: string | null): EmbedTheme { return VALID_THEMES.has(value as EmbedTheme) ? value as EmbedTheme : DEFAULT_EMBED_THEME; } function parseVariant(value: string | null): EmbedVariant { return VALID_VARIANTS.has(value as EmbedVariant) ? value as EmbedVariant : DEFAULT_EMBED_VARIANT; } function clamp(value: number, min: number, max: number): number { return Math.max(min, Math.min(max, value)); } function normalizeCenter(center: EmbedCenter | null | undefined): EmbedCenter { if (!center || !Number.isFinite(center.lat) || !Number.isFinite(center.lon)) { return { ...DEFAULT_EMBED_CENTER }; } return { lat: clamp(center.lat, -90, 90), lon: clamp(center.lon, -180, 180), }; } export function parseEmbedParams(search: string | URLSearchParams): EmbedMapState { const params = typeof search === 'string' ? new URLSearchParams(search) : search; const layerIds = parseEmbedLayerIds(params.get('layers')); return { layers: mapLayersFromEmbedIds(layerIds), layerIds, center: parseCenter(params.get('center')), zoom: parseZoom(params.get('zoom')), theme: parseTheme(params.get('theme')), variant: parseVariant(params.get('variant')), }; } export function buildEmbedMapUrl( baseUrl: string, state: { layerIds?: readonly EmbedLayerId[]; layers?: MapLayers; center?: EmbedCenter | null; zoom?: number; theme?: EmbedTheme; variant?: EmbedVariant; } = {}, ): string { const url = new URL(baseUrl, 'https://www.worldmonitor.app'); const layerIds = state.layerIds ?? embedLayerIdsFromMapLayers(state.layers ?? mapLayersFromEmbedIds(DEFAULT_EMBED_LAYER_IDS)); const center = normalizeCenter(state.center ?? DEFAULT_EMBED_CENTER); const zoom = Number.isFinite(state.zoom) ? clamp(state.zoom as number, 1, 10) : DEFAULT_EMBED_ZOOM; const theme = VALID_THEMES.has(state.theme as EmbedTheme) ? state.theme as EmbedTheme : DEFAULT_EMBED_THEME; const variant = VALID_VARIANTS.has(state.variant as EmbedVariant) ? state.variant as EmbedVariant : DEFAULT_EMBED_VARIANT; url.search = ''; url.searchParams.set('layers', [...new Set(layerIds)].join(',')); url.searchParams.set('center', `${roundCoord(center.lat)},${roundCoord(center.lon)}`); url.searchParams.set('zoom', roundZoom(zoom)); url.searchParams.set('theme', theme); url.searchParams.set('variant', variant); return url.toString(); } export function embedLayerIdsFromMapLayers(layers: MapLayers): EmbedLayerId[] { return EMBEDDABLE_LAYERS .filter((layer) => layers[layer.mapLayer]) .map((layer) => layer.id); } export function buildEmbedIframeSnippet(url: string, options: { width?: string; height?: string } = {}): string { const width = sanitizeCssDimension(options.width ?? '100%'); const height = sanitizePixelDimension(options.height ?? '420', 120, 1200); return ``; } export function buildWorldMonitorAttributionUrl(baseUrl: string, referrerHost: string | null): string { const url = new URL(baseUrl, 'https://www.worldmonitor.app'); url.searchParams.set('utm_source', 'embed'); url.searchParams.set('utm_medium', 'iframe'); url.searchParams.set('utm_campaign', referrerHost ? referrerHost.slice(0, 80) : 'direct'); return url.toString(); } function roundCoord(value: number): string { return String(Math.round(value * 1000) / 1000); } function roundZoom(value: number): string { return String(Math.round(value * 100) / 100); } function sanitizeCssDimension(value: string): string { const trimmed = value.trim(); if (/^\d+(?:\.\d+)?%$/.test(trimmed)) return trimmed; return `${sanitizePixelDimension(trimmed, 120, 1200)}px`; } function sanitizePixelDimension(value: string, min: number, max: number): string { const numeric = Number(value.trim().replace(/px$/i, '')); if (!Number.isFinite(numeric)) return '420'; return String(Math.max(min, Math.min(max, Math.round(numeric)))); } function escapeAttribute(value: string): string { return value .replace(/&/g, '&') .replace(/"/g, '"') .replace(//g, '>'); }