diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7f2de8e22..204098e91 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -257,6 +257,14 @@ For endpoints that deal with non-JSON payloads (XML feeds, binary data, HTML emb - Should update at least daily for real-time relevance - Must include geographic coordinates or be geo-locatable +### Country boundary overrides + +Country outlines are loaded from `public/data/countries.geojson`. Optional higher-resolution overrides (sourced from [Natural Earth](https://www.naturalearthdata.com/)) live in `public/data/country-boundary-overrides.geojson`. The app loads overrides after the main file and replaces geometry for any country whose `ISO3166-1-Alpha-2` (or `ISO_A2`) matches. To refresh the Pakistan boundary from Natural Earth, run: + +```bash +node scripts/fetch-pakistan-boundary-override.mjs +``` + ## Adding RSS Feeds To add new RSS feeds: diff --git a/package.json b/package.json index 097417ebd..94e3a488d 100644 --- a/package.json +++ b/package.json @@ -13,9 +13,10 @@ "dev:finance": "cross-env VITE_VARIANT=finance vite", "dev:happy": "cross-env VITE_VARIANT=happy vite", "dev:commodity": "cross-env VITE_VARIANT=commodity vite", + "build:pro": "cd pro-test && npm install && npm run build", "build": "tsc && vite build", "build:sidecar-sebuf": "node scripts/build-sidecar-sebuf.mjs", - "build:desktop": "node scripts/build-sidecar-sebuf.mjs && tsc && vite build", + "build:desktop": "node scripts/build-sidecar-sebuf.mjs && node scripts/build-sidecar-handlers.mjs && tsc && vite build", "build:full": "cross-env-shell VITE_VARIANT=full \"tsc && vite build\"", "build:tech": "cross-env-shell VITE_VARIANT=tech \"tsc && vite build\"", "build:finance": "cross-env-shell VITE_VARIANT=finance \"tsc && vite build\"", diff --git a/public/data/country-boundary-overrides.geojson b/public/data/country-boundary-overrides.geojson new file mode 100644 index 000000000..015c3fb77 --- /dev/null +++ b/public/data/country-boundary-overrides.geojson @@ -0,0 +1 @@ +{"type":"FeatureCollection","features":[{"type":"Feature","properties":{"name":"Pakistan","ISO3166-1-Alpha-2":"PK","ISO3166-1-Alpha-3":"PAK"},"geometry":{"type":"Polygon","coordinates":[[[76.766895,35.661719],[76.812793,35.571826],[76.882227,35.435742],[76.927734,35.346631],[76.978906,35.246436],[77.004492,35.196338],[77.048633,35.109912],[77.030664,35.062354],[77.000879,34.991992],[76.891699,34.938721],[76.78291,34.900195],[76.75752,34.877832],[76.749023,34.847559],[76.696289,34.786914],[76.594434,34.73584],[76.509961,34.740869],[76.456738,34.756104],[76.172461,34.667725],[76.041016,34.669922],[75.938281,34.612549],[75.862109,34.560254],[75.70918,34.503076],[75.605566,34.502734],[75.452539,34.536719],[75.264063,34.601367],[75.1875,34.639014],[75.118457,34.636816],[74.951855,34.64585],[74.78877,34.677734],[74.594141,34.715771],[74.497949,34.732031],[74.300391,34.765381],[74.171973,34.720898],[74.055859,34.680664],[73.96123,34.653467],[73.883105,34.529053],[73.850098,34.485303],[73.812109,34.422363],[73.794531,34.378223],[73.809961,34.325342],[73.924609,34.287842],[73.972363,34.236621],[73.979492,34.191309],[73.938281,34.144775],[73.903906,34.108008],[73.904102,34.075684],[73.922363,34.043066],[73.949902,34.018799],[74.112598,34.003711],[74.208984,34.003418],[74.246484,33.990186],[74.250879,33.946094],[74.215625,33.886572],[74.078418,33.838672],[74.000977,33.788184],[73.976465,33.721289],[73.977539,33.667822],[74.004004,33.632422],[74.069727,33.591699],[74.13125,33.545068],[74.15,33.506982],[74.142578,33.455371],[74.117773,33.384131],[74.050391,33.30127],[73.994238,33.242188],[73.989844,33.221191],[74.003809,33.189453],[74.049121,33.143408],[74.12627,33.075439],[74.22207,33.020312],[74.283594,33.005127],[74.303613,32.991797],[74.322754,32.927979],[74.32998,32.86084],[74.305469,32.810449],[74.35459,32.768701],[74.483398,32.770996],[74.588281,32.753223],[74.632422,32.770898],[74.663281,32.757666],[74.643359,32.607715],[74.657813,32.518945],[74.685742,32.493799],[74.788867,32.457812],[74.987305,32.462207],[75.104102,32.420361],[75.233691,32.372119],[75.302637,32.318896],[75.333496,32.279199],[75.324707,32.215283],[75.254102,32.140332],[75.13877,32.104785],[75.071484,32.089355],[74.739453,31.948828],[74.635742,31.889746],[74.555566,31.818555],[74.525977,31.765137],[74.509961,31.712939],[74.581836,31.523926],[74.593945,31.465381],[74.534961,31.261377],[74.517676,31.185596],[74.539746,31.132666],[74.610352,31.112842],[74.625781,31.06875],[74.632812,31.034668],[74.509766,30.959668],[74.380371,30.893408],[74.339355,30.893555],[74.215625,30.768994],[74.008984,30.519678],[73.899316,30.435352],[73.891602,30.394043],[73.882715,30.352148],[73.924609,30.281641],[73.933398,30.22207],[73.886523,30.162012],[73.80918,30.093359],[73.658008,30.033203],[73.46748,29.97168],[73.381641,29.934375],[73.317285,29.772998],[73.257812,29.610693],[73.231152,29.550635],[73.12832,29.363916],[72.94873,29.088818],[72.90332,29.02876],[72.625586,28.896143],[72.341895,28.751904],[72.291992,28.697266],[72.233887,28.56582],[72.179199,28.421777],[72.128516,28.346338],[71.948047,28.177295],[71.888867,28.047461],[71.870313,27.9625],[71.716699,27.915088],[71.542969,27.869873],[71.290137,27.855273],[71.184766,27.831641],[70.874902,27.714453],[70.797949,27.709619],[70.737402,27.729004],[70.691602,27.768994],[70.649121,27.835352],[70.629102,27.937451],[70.569238,27.983789],[70.488574,28.023145],[70.403711,28.025049],[70.318457,27.981641],[70.244336,27.934131],[70.193945,27.894873],[70.144531,27.849023],[70.049805,27.694727],[69.896289,27.473633],[69.724805,27.312695],[69.661328,27.264502],[69.621582,27.228076],[69.567969,27.174609],[69.537012,27.122949],[69.494531,26.95415],[69.47002,26.804443],[69.48125,26.770996],[69.506934,26.742676],[69.600586,26.699121],[69.735938,26.627051],[69.911426,26.586133],[70.059375,26.57876],[70.114648,26.548047],[70.147656,26.506445],[70.156836,26.471436],[70.149219,26.347559],[70.132617,26.214795],[70.077734,26.071973],[70.078613,25.990039],[70.100195,25.910059],[70.264648,25.706543],[70.325195,25.685742],[70.448535,25.681348],[70.505859,25.685303],[70.569531,25.705957],[70.614844,25.691895],[70.648438,25.666943],[70.657227,25.625781],[70.652051,25.4229],[70.702539,25.331055],[70.800488,25.205859],[70.877734,25.062988],[70.950879,24.891602],[71.020703,24.757666],[71.047852,24.687744],[71.002344,24.653906],[70.976367,24.61875],[70.969824,24.571875],[70.979297,24.522461],[70.973242,24.487402],[71.00625,24.444336],[71.045313,24.42998],[71.044043,24.400098],[70.982813,24.361035],[70.928125,24.362354],[70.88623,24.34375],[70.805078,24.261963],[70.767285,24.24541],[70.716309,24.237988],[70.659473,24.246094],[70.579297,24.279053],[70.555859,24.331104],[70.565039,24.385791],[70.546777,24.418311],[70.489258,24.412158],[70.289062,24.356299],[70.098242,24.2875],[70.065137,24.240576],[70.021094,24.191553],[69.933789,24.171387],[69.805176,24.165234],[69.716211,24.172607],[69.63418,24.225195],[69.55918,24.273096],[69.443457,24.275391],[69.235059,24.268262],[69.119531,24.268652],[69.051563,24.286328],[68.98457,24.273096],[68.900781,24.292432],[68.863477,24.266504],[68.82832,24.264014],[68.8,24.309082],[68.781152,24.313721],[68.758984,24.307227],[68.739648,24.291992],[68.728125,24.265625],[68.724121,23.964697],[68.586621,23.966602],[68.488672,23.967236],[68.38125,23.950879],[68.28252,23.927979],[68.23418,23.900537],[68.165039,23.857324],[68.148828,23.797217],[68.115527,23.753369],[68.067773,23.818359],[68.037012,23.848242],[68.001465,23.826074],[67.950977,23.828613],[67.859961,23.902686],[67.819043,23.828076],[67.668457,23.810986],[67.649512,23.867285],[67.645801,23.919873],[67.563086,23.881836],[67.503613,23.940039],[67.476855,24.018262],[67.453906,24.039893],[67.427637,24.064844],[67.365234,24.091602],[67.309375,24.174805],[67.304297,24.262891],[67.288672,24.367773],[67.171484,24.756104],[67.100586,24.791943],[66.703027,24.860937],[66.682227,24.928857],[66.709863,25.111328],[66.698633,25.226318],[66.569922,25.378516],[66.533887,25.484375],[66.428613,25.575342],[66.324219,25.601807],[66.219043,25.589893],[66.162305,25.553906],[66.131152,25.493262],[66.356445,25.507373],[66.407129,25.485059],[66.467676,25.445312],[66.40293,25.446826],[66.32832,25.465771],[66.234668,25.464355],[65.883594,25.419629],[65.679688,25.355273],[65.40625,25.374316],[65.061328,25.311084],[64.77666,25.307324],[64.658984,25.184082],[64.594043,25.206299],[64.54375,25.23667],[64.152051,25.333447],[64.124902,25.373926],[64.059375,25.40293],[63.987305,25.351172],[63.935547,25.342529],[63.720898,25.385889],[63.556641,25.353174],[63.495703,25.29751],[63.491406,25.21084],[63.285742,25.227588],[63.17002,25.254883],[63.015039,25.224658],[62.664746,25.264795],[62.572461,25.254736],[62.444727,25.197266],[62.391211,25.152539],[62.315332,25.134912],[62.24873,25.197363],[62.198633,25.224854],[62.152148,25.206641],[62.089453,25.155322],[61.90791,25.131299],[61.743652,25.138184],[61.566895,25.186328],[61.587891,25.202344],[61.61543,25.286133],[61.640137,25.584619],[61.671387,25.692383],[61.661816,25.75127],[61.668652,25.768994],[61.737695,25.821094],[61.754395,25.843359],[61.780762,25.99585],[61.809961,26.165283],[61.842383,26.225928],[61.869824,26.242432],[62.089063,26.318262],[62.125977,26.368994],[62.239355,26.357031],[62.249609,26.369238],[62.259668,26.42749],[62.312305,26.490869],[62.385059,26.542627],[62.439258,26.561035],[62.636426,26.593652],[62.751563,26.63916],[62.786621,26.643896],[63.092969,26.632324],[63.157813,26.649756],[63.168066,26.665576],[63.186133,26.837598],[63.241602,26.864746],[63.250391,26.879248],[63.231445,26.998145],[63.24209,27.077686],[63.305176,27.124561],[63.301563,27.151465],[63.25625,27.20791],[63.196094,27.243945],[63.166797,27.25249],[62.91543,27.218408],[62.811621,27.229443],[62.762988,27.250195],[62.752734,27.265625],[62.7625,27.300195],[62.764258,27.356738],[62.800879,27.444531],[62.812012,27.497021],[62.782324,27.800537],[62.739746,28.002051],[62.7625,28.202051],[62.758008,28.243555],[62.749414,28.252881],[62.717578,28.252783],[62.564551,28.235156],[62.433887,28.363867],[62.353027,28.414746],[62.130566,28.478809],[62.033008,28.491016],[61.889844,28.546533],[61.758008,28.667676],[61.623047,28.791602],[61.56875,28.870898],[61.508594,29.006055],[61.337891,29.26499],[61.339453,29.331787],[61.318359,29.372607],[61.152148,29.542725],[61.03418,29.663428],[60.843359,29.858691],[61.224414,29.749414],[61.521484,29.665674],[62.000977,29.53042],[62.373438,29.425391],[62.476562,29.40835],[63.567578,29.497998],[63.970996,29.430078],[64.09873,29.391943],[64.117969,29.414258],[64.172168,29.460352],[64.266113,29.506934],[64.39375,29.544336],[64.521094,29.564502],[64.703516,29.567139],[64.827344,29.56416],[64.918945,29.552783],[65.095508,29.559473],[65.180469,29.577637],[65.470996,29.651562],[65.666211,29.701318],[65.961621,29.778906],[66.177051,29.835596],[66.23125,29.865723],[66.286914,29.92002],[66.313379,29.968555],[66.247168,30.043506],[66.238477,30.109619],[66.281836,30.193457],[66.305469,30.321143],[66.300977,30.502979],[66.286914,30.60791],[66.346875,30.802783],[66.397168,30.912207],[66.497363,30.964551],[66.566797,30.996582],[66.595801,31.019971],[66.624219,31.046045],[66.731348,31.194531],[66.829297,31.263672],[66.924316,31.305615],[67.027734,31.300244],[67.115918,31.24292],[67.287305,31.217822],[67.452832,31.234619],[67.596387,31.277686],[67.661523,31.312988],[67.737891,31.343945],[67.733496,31.379248],[67.64707,31.409961],[67.597559,31.45332],[67.578223,31.506494],[67.626758,31.53877],[67.739844,31.548193],[68.017188,31.677979],[68.130176,31.763281],[68.161035,31.802979],[68.213965,31.807373],[68.319824,31.767676],[68.443262,31.754492],[68.520703,31.794141],[68.597656,31.802979],[68.673242,31.759717],[68.713672,31.708057],[68.782324,31.646436],[68.868945,31.634229],[68.973438,31.667383],[69.083105,31.738477],[69.186914,31.838086],[69.279297,31.936816],[69.256543,32.249463],[69.241406,32.433545],[69.289941,32.530566],[69.359473,32.590332],[69.405371,32.682715],[69.40459,32.764258],[69.453125,32.832812],[69.501563,33.020068],[69.567773,33.06416],[69.703711,33.094727],[69.920117,33.1125],[70.090234,33.198096],[70.261133,33.289014],[70.28418,33.369043],[70.219727,33.454687],[70.13418,33.620752],[70.056641,33.719873],[69.868066,33.897656],[69.889648,34.007275],[69.994727,34.051807],[70.253613,33.975977],[70.325684,33.961133],[70.415723,33.950439],[70.654004,33.952295],[70.848438,33.981885],[71.051563,34.049707],[71.091309,34.120264],[71.089063,34.204053],[71.092383,34.273242],[71.095703,34.369434],[71.022949,34.431152],[70.978906,34.486279],[70.965625,34.530371],[71.016309,34.554639],[71.065625,34.599609],[71.113281,34.681592],[71.225781,34.779541],[71.294141,34.867725],[71.358105,34.909619],[71.455078,34.966943],[71.51709,35.051123],[71.545508,35.101416],[71.60166,35.150684],[71.620508,35.183008],[71.605273,35.211768],[71.577246,35.247998],[71.545508,35.288867],[71.545508,35.328516],[71.571973,35.37041],[71.600586,35.40791],[71.587402,35.46084],[71.571973,35.546826],[71.519043,35.59751],[71.483594,35.7146],[71.427539,35.83374],[71.397559,35.880176],[71.342871,35.938525],[71.220215,36.000684],[71.185059,36.04209],[71.23291,36.121777],[71.312598,36.171191],[71.463281,36.293262],[71.545898,36.377686],[71.620508,36.436475],[71.716406,36.426562],[71.772656,36.431836],[71.822266,36.486084],[71.920703,36.53418],[72.095605,36.63374],[72.156738,36.700879],[72.249805,36.734717],[72.326953,36.742383],[72.431152,36.76582],[72.531348,36.802002],[72.622852,36.82959],[72.766211,36.83501],[72.99375,36.851611],[73.116797,36.868555],[73.411133,36.881689],[73.731836,36.887793],[73.769141,36.888477],[73.907813,36.85293],[74.001855,36.823096],[74.038867,36.825732],[74.194727,36.896875],[74.431055,36.983691],[74.541406,37.022168],[74.600586,37.03667],[74.692188,37.035742],[74.766016,37.012744],[74.841211,36.979102],[74.889258,36.952441],[74.949121,36.968359],[75.053906,36.987158],[75.145215,36.973242],[75.34668,36.913477],[75.376855,36.883691],[75.424219,36.738232],[75.460254,36.725049],[75.57373,36.759326],[75.667188,36.741992],[75.772168,36.694922],[75.840234,36.649707],[75.884961,36.600732],[75.933008,36.521582],[75.951855,36.458105],[75.974414,36.382422],[75.968652,36.168848],[75.934082,36.133936],[75.904883,36.088477],[75.912305,36.048975],[75.945117,36.017578],[76.010449,35.996338],[76.070898,35.983008],[76.10332,35.949219],[76.147852,35.829004],[76.177832,35.810547],[76.25166,35.810937],[76.385742,35.837158],[76.502051,35.878223],[76.55127,35.887061],[76.563477,35.772998],[76.631836,35.729395],[76.727539,35.678662],[76.766895,35.661719]]]}}]} diff --git a/scripts/fetch-pakistan-boundary-override.mjs b/scripts/fetch-pakistan-boundary-override.mjs new file mode 100644 index 000000000..f3626ce14 --- /dev/null +++ b/scripts/fetch-pakistan-boundary-override.mjs @@ -0,0 +1,57 @@ +#!/usr/bin/env node +/** + * Fetches Pakistan's boundary from Natural Earth 50m Admin 0 Countries and writes + * public/data/country-boundary-overrides.geojson. + * + * Note: downloads the full NE 50m countries file (~24 MB) to extract Pakistan. + * + * Usage: node scripts/fetch-pakistan-boundary-override.mjs + * Requires network access. + */ + +import { writeFileSync, mkdirSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const NE_50M_URL = 'https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_50m_admin_0_countries.geojson'; +const OUT_DIR = join(__dirname, '..', 'public', 'data'); +const OUT_FILE = join(OUT_DIR, 'country-boundary-overrides.geojson'); + +async function main() { + console.log('Fetching Natural Earth 50m countries...'); + const resp = await fetch(NE_50M_URL, { signal: AbortSignal.timeout(60_000) }); + if (!resp.ok) { + throw new Error(`Fetch failed: ${resp.status} ${resp.statusText}`); + } + const data = await resp.json(); + if (!data?.features?.length) { + throw new Error('Invalid GeoJSON: no features'); + } + const pak = data.features.find((f) => f.properties?.ISO_A2 === 'PK' || f.properties?.['ISO3166-1-Alpha-2'] === 'PK'); + if (!pak) { + throw new Error('Pakistan (PK) feature not found in Natural Earth data'); + } + const override = { + type: 'FeatureCollection', + features: [ + { + type: 'Feature', + properties: { + name: 'Pakistan', + 'ISO3166-1-Alpha-2': 'PK', + 'ISO3166-1-Alpha-3': 'PAK', + }, + geometry: pak.geometry, + }, + ], + }; + mkdirSync(OUT_DIR, { recursive: true }); + writeFileSync(OUT_FILE, JSON.stringify(override) + '\n', 'utf8'); + console.log('Wrote', OUT_FILE); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/server/worldmonitor/conflict/v1/list-ucdp-events.ts b/server/worldmonitor/conflict/v1/list-ucdp-events.ts index ee020f019..d97f7309f 100644 --- a/server/worldmonitor/conflict/v1/list-ucdp-events.ts +++ b/server/worldmonitor/conflict/v1/list-ucdp-events.ts @@ -3,6 +3,7 @@ import type { ListUcdpEventsRequest, ListUcdpEventsResponse, UcdpViolenceEvent, + UcdpViolenceType, } from '../../../../src/generated/server/worldmonitor/conflict/v1/service_server'; import { getCachedJson } from '../../../_shared/redis'; @@ -11,10 +12,118 @@ const MAX_AGE_MS = 25 * 60 * 60 * 1000; // 25h — reject if cron hasn't refresh let fallback: { events: UcdpViolenceEvent[]; ts: number } | null = null; +const CHROME_UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'; +const UCDP_PAGE_SIZE = 1000; +const MAX_PAGES = 4; +const MAX_EVENTS = 2000; +const TRAILING_WINDOW_MS = 365 * 24 * 60 * 60 * 1000; +const DIRECT_FETCH_COOLDOWN_MS = 10 * 60 * 1000; // 10min between direct fetches +let lastDirectFetchMs = 0; + +const VIOLENCE_TYPE_MAP: Record = { + 1: 'UCDP_VIOLENCE_TYPE_STATE_BASED', + 2: 'UCDP_VIOLENCE_TYPE_NON_STATE', + 3: 'UCDP_VIOLENCE_TYPE_ONE_SIDED', +}; + +function buildVersionCandidates(): string[] { + const year = new Date().getFullYear() - 2000; + return [...new Set([`${year}.1`, `${year - 1}.1`, '25.1', '24.1'])]; +} + +async function fetchGedPage(version: string, page: number, token: string): Promise { + const headers: Record = { Accept: 'application/json', 'User-Agent': CHROME_UA }; + if (token) headers['x-ucdp-access-token'] = token; + const resp = await fetch( + `https://ucdpapi.pcr.uu.se/api/gedevents/${version}?pagesize=${UCDP_PAGE_SIZE}&page=${page}`, + { headers, signal: AbortSignal.timeout(30_000) }, + ); + if (!resp.ok) throw new Error(`UCDP API ${resp.status}`); + return resp.json(); +} + +async function fetchDirectFromUcdp(): Promise { + const token = (process.env.UCDP_ACCESS_TOKEN || '').trim(); + const candidates = buildVersionCandidates(); + + let version = ''; + let page0: { Result?: unknown[]; TotalPages?: number } | null = null; + + for (const v of candidates) { + try { + const data = await fetchGedPage(v, 0, token) as { Result?: unknown[]; TotalPages?: number }; + if (Array.isArray(data?.Result) && data.Result.length > 0) { + version = v; + page0 = data; + break; + } + } catch { /* try next */ } + } + + if (!version || !page0) return []; + + const totalPages = Math.max(1, Number(page0.TotalPages) || 1); + const newestPage = totalPages - 1; + + const pageResults = await Promise.allSettled( + Array.from({ length: Math.min(MAX_PAGES, totalPages) }, (_, i) => { + const page = newestPage - i; + if (page < 0) return Promise.resolve(null); + if (page === 0) return Promise.resolve(page0); + return fetchGedPage(version, page, token); + }), + ); + + const allEvents: unknown[] = []; + let latestMs = NaN; + + for (const r of pageResults) { + if (r.status !== 'fulfilled' || !r.value) continue; + const events = Array.isArray((r.value as { Result?: unknown[] }).Result) + ? (r.value as { Result: unknown[] }).Result : []; + allEvents.push(...events); + for (const e of events) { + const ms = Date.parse(String((e as { date_start?: string }).date_start)); + if (Number.isFinite(ms) && (!Number.isFinite(latestMs) || ms > latestMs)) latestMs = ms; + } + } + + const cutoff = Number.isFinite(latestMs) ? latestMs - TRAILING_WINDOW_MS : 0; + const mapped: UcdpViolenceEvent[] = []; + + for (const raw of allEvents) { + const e = raw as Record; + const dateStart = Date.parse(String(e.date_start)); + if (!Number.isFinite(dateStart) || dateStart < cutoff) continue; + + mapped.push({ + id: String(e.id || ''), + dateStart, + dateEnd: Date.parse(String(e.date_end)) || 0, + location: { + latitude: Number(e.latitude) || 0, + longitude: Number(e.longitude) || 0, + }, + country: String(e.country || ''), + sideA: String(e.side_a || '').substring(0, 200), + sideB: String(e.side_b || '').substring(0, 200), + deathsBest: Number(e.best) || 0, + deathsLow: Number(e.low) || 0, + deathsHigh: Number(e.high) || 0, + violenceType: VIOLENCE_TYPE_MAP[Number(e.type_of_violence)] || 'UCDP_VIOLENCE_TYPE_UNSPECIFIED', + sourceOriginal: String(e.source_original || '').substring(0, 300), + }); + } + + mapped.sort((a, b) => b.dateStart - a.dateStart); + return mapped.slice(0, MAX_EVENTS); +} + export async function listUcdpEvents( _ctx: ServerContext, req: ListUcdpEventsRequest, ): Promise { + // 1. Try Redis cache (cloud path) try { const raw = await getCachedJson(CACHE_KEY, true) as { events?: UcdpViolenceEvent[]; fetchedAt?: number } | null; if (raw?.events?.length && (!raw.fetchedAt || (Date.now() - raw.fetchedAt) < MAX_AGE_MS)) { @@ -25,11 +134,26 @@ export async function listUcdpEvents( } } catch { /* fall through */ } + // 2. In-memory fallback from a previous successful fetch if (fallback && (Date.now() - fallback.ts) < 12 * 60 * 60 * 1000) { let events = fallback.events; if (req.country) events = events.filter((e) => e.country === req.country); return { events, pagination: undefined }; } + // 3. Direct UCDP API fetch (desktop sidecar path — no Redis available) + if (Date.now() - lastDirectFetchMs > DIRECT_FETCH_COOLDOWN_MS) { + try { + const events = await fetchDirectFromUcdp(); + lastDirectFetchMs = Date.now(); // only after successful fetch + if (events.length > 0) { + fallback = { events, ts: Date.now() }; + let filtered = events; + if (req.country) filtered = filtered.filter((e) => e.country === req.country); + return { events: filtered, pagination: undefined }; + } + } catch { /* fall through to empty */ } + } + return { events: [], pagination: undefined }; } diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 186481002..20a96153f 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -14,7 +14,6 @@ "windows": [ { "title": "World Monitor", - "titleBarStyle": "Overlay", "width": 1440, "height": 900, "minWidth": 1200, diff --git a/src/app/panel-layout.ts b/src/app/panel-layout.ts index bc6d0f842..e33aad80d 100644 --- a/src/app/panel-layout.ts +++ b/src/app/panel-layout.ts @@ -50,6 +50,7 @@ import { BETA_MODE } from '@/config/beta'; import { t } from '@/services/i18n'; import { getCurrentTheme } from '@/utils'; import { trackCriticalBannerAction } from '@/services/analytics'; +import { getSecretState } from '@/services/runtime-config'; export interface PanelLayoutCallbacks { openCountryStory: (code: string, name: string) => void; @@ -110,6 +111,7 @@ export class PanelLayoutManager implements AppModule { renderLayout(): void { this.ctx.container.innerHTML = ` + ${this.ctx.isDesktopApp ? '
' : ''}
-
+
@@ -716,12 +718,18 @@ export class PanelLayoutManager implements AppModule { }), ); + const _wmKeyPresent = getSecretState('WORLDMONITOR_API_KEY').present; + this.lazyPanel('oref-sirens', () => import('@/components/OrefSirensPanel').then(m => new m.OrefSirensPanel()), + undefined, + !_wmKeyPresent ? [t('premium.features.orefSirens1'), t('premium.features.orefSirens2')] : undefined, ); this.lazyPanel('telegram-intel', () => import('@/components/TelegramIntelPanel').then(m => new m.TelegramIntelPanel()), + undefined, + !_wmKeyPresent ? [t('premium.features.telegramIntel1'), t('premium.features.telegramIntel2')] : undefined, ); } @@ -1168,11 +1176,16 @@ export class PanelLayoutManager implements AppModule { key: string, loader: () => Promise, setup?: (panel: T) => void, + lockedFeatures?: string[], ): void { loader().then(async (panel) => { this.ctx.panels[key] = panel as unknown as import('@/components/Panel').Panel; - await replayPendingCalls(key, panel); - if (setup) setup(panel); + if (lockedFeatures) { + (panel as unknown as import('@/components/Panel').Panel).showLocked(lockedFeatures); + } else { + await replayPendingCalls(key, panel); + if (setup) setup(panel); + } const el = panel.getElement(); this.makeDraggable(el, key); diff --git a/src/components/AirlineIntelPanel.ts b/src/components/AirlineIntelPanel.ts index 0521ad678..ae5fe3bf7 100644 --- a/src/components/AirlineIntelPanel.ts +++ b/src/components/AirlineIntelPanel.ts @@ -131,7 +131,6 @@ export class AirlineIntelPanel extends Panel { } }); - this.addStyles(); void this.refresh(); // Auto-refresh every 5 min — refresh() loads ops + active tab @@ -316,9 +315,9 @@ export class AirlineIntelPanel extends Panel { return; } const items = this.newsData.map(n => ` -
+
${escapeHtml(n.title)} -
${escapeHtml(n.sourceName)} · ${n.publishedAt.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
+
${escapeHtml(n.sourceName)} · ${n.publishedAt.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
`).join(''); this.content.innerHTML = `
${items}
`; } @@ -372,34 +371,5 @@ export class AirlineIntelPanel extends Panel { } - // ---- Styles ---- - private addStyles(): void { - if (document.getElementById('airline-intel-styles')) return; - const style = document.createElement('style'); - style.id = 'airline-intel-styles'; - style.textContent = ` - .airline-intel-tabs { display:flex;gap:2px;padding:8px 10px 0;flex-wrap:wrap;border-bottom:1px solid var(--border); } - .airline-intel-tabs .tab-btn { background:transparent;border:none;border-bottom:2px solid transparent;color:var(--text-dim,#9ca3af);cursor:pointer;font-size:11px;padding:6px 10px;transition:all .15s ease;white-space:nowrap; } - .airline-intel-tabs .tab-btn:hover { color:var(--text); } - .airline-intel-tabs .tab-btn.active { color:var(--accent);border-bottom-color:var(--accent); } - .airline-intel-content { overflow-y:auto;max-height:320px;padding:8px; } - .ops-grid,.flights-list,.carriers-list,.tracking-list { display:flex;flex-direction:column;gap:4px; } - .ops-row,.flight-row,.carrier-row,.track-row { display:flex;gap:8px;align-items:center;font-size:12px;padding:4px;border-radius:4px;transition:background .15s; } - .ops-row:hover,.flight-row:hover,.carrier-row:hover,.track-row:hover { background:var(--hover-bg,rgba(255,255,255,.04)); } - .ops-iata,.flight-num,.carrier-name,.track-cs { font-weight:600;min-width:36px; } - .ops-name,.flight-route { flex:1;color:var(--text-secondary,#9ca3af);overflow:hidden;text-overflow:ellipsis;white-space:nowrap; } - .ops-closed { color:#ef4444;font-weight:700;font-size:11px; } - .ops-notam { color:#f59e0b;font-size:11px; } - .price-row { display:flex;gap:8px;align-items:center;font-size:12px;padding:6px;border-bottom:1px solid var(--border-color,#333); } - .price-carrier { min-width:80px;font-weight:600; } - .price-route { flex:1;color:var(--text-secondary,#9ca3af); } - .price-input { background:var(--input-bg,#1e2533);border:1px solid var(--border-color,#374151);border-radius:4px;color:var(--text-primary,#e5e7eb);padding:4px 6px;font-size:12px; } - .demo-badge { display:inline-block;font-size:10px;padding:2px 6px;background:rgba(245,158,11,.15);border:1px solid #f59e0b;border-radius:3px;color:#f59e0b;margin-bottom:6px; } - .tp-badge { display:inline-block;font-size:10px;padding:2px 6px;background:rgba(96,165,250,.12);border:1px solid #60a5fa;border-radius:3px;color:#60a5fa;margin-bottom:6px; } - .no-data { color:var(--text-secondary,#9ca3af);font-size:12px;text-align:center;padding:20px 0; } - .news-link { color:var(--text-primary,#e5e7eb);text-decoration:none;font-size:12px;line-height:1.4; } - .news-link:hover { color:var(--accent,#60a5fa); } - `; - document.head.appendChild(style); - } + /* Styles moved to panels.css (PERF-012) */ } diff --git a/src/components/AviationCommandBar.ts b/src/components/AviationCommandBar.ts index b16b422e0..8878b5007 100644 --- a/src/components/AviationCommandBar.ts +++ b/src/components/AviationCommandBar.ts @@ -264,17 +264,17 @@ export class AviationCommandBar { style.id = 'aviation-cmd-styles'; style.textContent = ` #aviation-cmd-overlay { position:fixed;inset:0;background:rgba(0,0,0,.6);z-index:9999;display:flex;align-items:flex-start;justify-content:center;padding-top:80px; } - #aviation-cmd-box { background:var(--panel-bg,#1a1f2e);border:1px solid var(--border-color,#374151);border-radius:10px;padding:16px;width:min(560px,92vw);box-shadow:0 24px 60px rgba(0,0,0,.7);max-height:80vh;overflow-y:auto; } - #aviation-cmd-header { display:flex;justify-content:space-between;align-items:center;margin-bottom:10px;font-size:14px;font-weight:600;color:var(--text-primary,#e5e7eb); } + #aviation-cmd-box { background:var(--surface,#141414);border:1px solid var(--border,#2a2a2a);border-radius:10px;padding:16px;width:min(560px,92vw);box-shadow:0 24px 60px rgba(0,0,0,.7);max-height:80vh;overflow-y:auto; } + #aviation-cmd-header { display:flex;justify-content:space-between;align-items:center;margin-bottom:10px;font-size:14px;font-weight:600;color:var(--text,#e8e8e8); } #aviation-cmd-close { background:none;border:none;color:#6b7280;cursor:pointer;font-size:18px;line-height:1; } - #aviation-cmd-input { width:100%;box-sizing:border-box;background:rgba(255,255,255,.05);border:1px solid var(--border-color,#374151);border-radius:6px;color:var(--text-primary,#e5e7eb);font-size:14px;padding:10px;outline:none; } + #aviation-cmd-input { width:100%;box-sizing:border-box;background:rgba(255,255,255,.05);border:1px solid var(--border,#2a2a2a);border-radius:6px;color:var(--text,#e8e8e8);font-size:14px;padding:10px;outline:none; } #aviation-cmd-input:focus { border-color:var(--accent,#60a5fa); } #aviation-cmd-result { margin-top:12px;font-size:13px; } .cmd-row { display:flex;gap:10px;align-items:center;padding:4px 0;font-size:13px; } .cmd-section { padding:8px 0; } .cmd-empty { color:#6b7280;font-size:12px;padding:8px 0; } .cmd-news-item { padding:4px 0; } - .cmd-news-item a { color:var(--text-primary,#e5e7eb);text-decoration:none;font-size:12px; } + .cmd-news-item a { color:var(--text,#e8e8e8);text-decoration:none;font-size:12px; } .cmd-news-item a:hover { color:var(--accent,#60a5fa); } #aviation-cmd-hint { font-size:11px;color:#4b5563;margin-top:10px;text-align:right; } #aviation-cmd-hint kbd { background:#374151;border-radius:2px;padding:1px 4px;font-family:monospace; } diff --git a/src/components/DeckGLMap.ts b/src/components/DeckGLMap.ts index f86f91d78..8d777ba69 100644 --- a/src/components/DeckGLMap.ts +++ b/src/components/DeckGLMap.ts @@ -88,6 +88,7 @@ import { import type { GulfInvestment } from '@/types'; import { resolveTradeRouteSegments, TRADE_ROUTES as TRADE_ROUTES_LIST, type TradeRouteSegment } from '@/config/trade-routes'; import { getLayersForVariant, resolveLayerLabel, type MapVariant } from '@/config/map-layer-definitions'; +import { getSecretState } from '@/services/runtime-config'; import { MapPopup, type PopupType } from './MapPopup'; import { updateHotspotEscalation, @@ -515,7 +516,7 @@ export class DeckGLMap { const primaryStyle = isHappyVariant ? (getCurrentTheme() === 'light' ? HAPPY_LIGHT_STYLE : HAPPY_DARK_STYLE) : getStyleForProvider(initialProvider, initialMapTheme); - if (!isHappyVariant && typeof primaryStyle === 'string' && !primaryStyle.includes('pmtiles') && initialProvider !== 'carto') { + if (!isHappyVariant && typeof primaryStyle === 'string' && !primaryStyle.includes('pmtiles')) { this.usedFallbackStyle = true; const attr = this.container.querySelector('.map-attribution'); if (attr) attr.innerHTML = '© OpenFreeMap © OpenStreetMap'; @@ -3537,10 +3538,12 @@ export class DeckGLMap { toggles.className = 'layer-toggles deckgl-layer-toggles'; const layerDefs = getLayersForVariant((SITE_VARIANT || 'full') as MapVariant, 'flat'); + const _wmKey = getSecretState('WORLDMONITOR_API_KEY').present; const layerConfig = layerDefs.map(def => ({ key: def.key, label: resolveLayerLabel(def, t), icon: def.icon, + premium: def.premium, })); toggles.innerHTML = ` @@ -3550,13 +3553,16 @@ export class DeckGLMap {
- ${layerConfig.map(({ key, label, icon }) => ` - `; + }).join('')}
`; diff --git a/src/components/DeductionPanel.ts b/src/components/DeductionPanel.ts index bc35e5e97..d59c2609d 100644 --- a/src/components/DeductionPanel.ts +++ b/src/components/DeductionPanel.ts @@ -64,25 +64,7 @@ export class DeductionPanel extends Panel { replaceChildren(this.content, container); - if (!document.getElementById('deduction-panel-styles')) { - const style = document.createElement('style'); - style.id = 'deduction-panel-styles'; - style.textContent = ` - .deduction-panel-content { display: flex; flex-direction: column; gap: 12px; padding: 8px; height: 100%; overflow-y: auto; } - .deduction-form { display: flex; flex-direction: column; gap: 8px; } - .deduction-input, .deduction-geo-input { width: 100%; padding: 8px; background: var(--bg-secondary, #2a2a2a); border: 1px solid var(--border-color, #444); color: var(--text-primary, #fff); border-radius: 4px; font-family: inherit; resize: vertical; } - .deduction-submit-btn { padding: 8px 16px; background: var(--accent-color, #3b82f6); color: white; border: none; border-radius: 4px; cursor: pointer; align-self: flex-end; font-weight: 500; } - .deduction-submit-btn:hover { background: var(--accent-hover, #2563eb); } - .deduction-submit-btn:disabled { opacity: 0.5; cursor: not-allowed; } - .deduction-result { flex: 1; margin-top: 8px; line-height: 1.5; font-size: 0.9em; color: var(--text-primary, #ddd); } - .deduction-result.loading { opacity: 0.7; font-style: italic; } - .deduction-result.error { color: var(--semantic-critical, #ef4444); } - .deduction-result h3 { margin-top: 12px; margin-bottom: 4px; font-size: 1.1em; color: var(--text-bright, #fff); } - .deduction-result ul { padding-left: 20px; margin-top: 4px; } - .deduction-result li { margin-bottom: 4px; } - `; - document.head.appendChild(style); - } + /* Styles moved to panels.css (PERF-012) */ this.contextHandler = ((e: CustomEvent) => { const { query, geoContext, autoSubmit } = e.detail; @@ -97,7 +79,7 @@ export class DeductionPanel extends Panel { this.show(); this.element.animate([ - { backgroundColor: 'var(--accent-hover, #2563eb)' }, + { backgroundColor: 'var(--overlay-heavy, rgba(255,255,255,.2))' }, { backgroundColor: 'transparent' } ], { duration: 800, easing: 'ease-out' }); diff --git a/src/components/GlobeMap.ts b/src/components/GlobeMap.ts index 1eddeaa49..4204b5fca 100644 --- a/src/components/GlobeMap.ts +++ b/src/components/GlobeMap.ts @@ -23,6 +23,7 @@ import { t } from '@/services/i18n'; import { SITE_VARIANT } from '@/config/variant'; import { getGlobeRenderScale, resolveGlobePixelRatio, resolvePerformanceProfile, subscribeGlobeRenderScaleChange, getGlobeTexture, GLOBE_TEXTURE_URLS, subscribeGlobeTextureChange, getGlobeVisualPreset, subscribeGlobeVisualPresetChange, type GlobeRenderScale, type GlobePerformanceProfile, type GlobeVisualPreset } from '@/services/globe-render-settings'; import { getLayersForVariant, resolveLayerLabel, type MapVariant } from '@/config/map-layer-definitions'; +import { getSecretState } from '@/services/runtime-config'; import { resolveTradeRouteSegments, type TradeRouteSegment } from '@/config/trade-routes'; import { GAMMA_IRRADIATORS } from '@/config/irradiators'; import { AI_DATA_CENTERS } from '@/config/ai-datacenters'; @@ -1109,15 +1110,16 @@ export class GlobeMap { private createLayerToggles(): void { const layerDefs = getLayersForVariant((SITE_VARIANT || 'full') as MapVariant, 'globe'); + const _wmKey = getSecretState('WORLDMONITOR_API_KEY').present; const layers = layerDefs.map(def => ({ key: def.key, label: resolveLayerLabel(def, t), icon: def.icon, + premium: def.premium, })); const el = document.createElement('div'); el.className = 'layer-toggles deckgl-layer-toggles'; - // Override deckgl-layer-toggles CSS which places at bottom; globe needs top-left el.style.bottom = 'auto'; el.style.top = '10px'; el.innerHTML = ` @@ -1126,12 +1128,16 @@ export class GlobeMap {
- ${layers.map(({ key, label, icon }) => ` - `; + }).join('')}
`; const authorBadge = document.createElement('div'); authorBadge.className = 'map-author-badge'; diff --git a/src/components/MarketPanel.ts b/src/components/MarketPanel.ts index 2a31e711c..d452a5987 100644 --- a/src/components/MarketPanel.ts +++ b/src/components/MarketPanel.ts @@ -107,7 +107,7 @@ export class MarketPanel extends Panel { public renderMarkets(data: MarketData[], rateLimited?: boolean): void { if (data.length === 0) { - this.showError(rateLimited ? t('common.rateLimitedMarket') : t('common.failedMarketData')); + this.showRetrying(rateLimited ? t('common.rateLimitedMarket') : t('common.failedMarketData')); return; } @@ -142,7 +142,7 @@ export class HeatmapPanel extends Panel { const validData = data.filter((d) => d.change !== null); if (validData.length === 0) { - this.showError(t('common.failedSectorData')); + this.showRetrying(t('common.failedSectorData')); return; } @@ -173,7 +173,7 @@ export class CommoditiesPanel extends Panel { const validData = data.filter((d) => d.price !== null); if (validData.length === 0) { - this.showError(t('common.failedCommodities')); + this.showRetrying(t('common.failedCommodities')); return; } @@ -204,7 +204,7 @@ export class CryptoPanel extends Panel { public renderCrypto(data: CryptoData[]): void { if (data.length === 0) { - this.showError(t('common.failedCryptoData')); + this.showRetrying(t('common.failedCryptoData')); return; } diff --git a/src/components/Panel.ts b/src/components/Panel.ts index 85c774529..387f30084 100644 --- a/src/components/Panel.ts +++ b/src/components/Panel.ts @@ -4,6 +4,7 @@ import { t } from '../services/i18n'; import { h, replaceChildren, safeHtml } from '../utils/dom-utils'; import { trackPanelResized } from '@/services/analytics'; import { getAiFlowSettings } from '@/services/ai-flow-settings'; +import { getSecretState } from '@/services/runtime-config'; export interface PanelOptions { id: string; @@ -12,6 +13,7 @@ export interface PanelOptions { className?: string; trackActivity?: boolean; infoTooltip?: string; + premium?: 'locked' | 'enhanced'; } const PANEL_SPANS_KEY = 'worldmonitor-panel-spans'; @@ -196,7 +198,9 @@ export class Panel { private contentDebounceTimer: ReturnType | null = null; private retryCallback: (() => void) | null = null; private retryCountdownTimer: ReturnType | null = null; + private retryAttempt = 0; private _fetching = false; + private _locked = false; constructor(options: PanelOptions) { this.panelId = options.id; @@ -244,6 +248,11 @@ export class Panel { headerLeft.appendChild(this.newBadgeEl); } + if (options.premium === 'enhanced' && !getSecretState('WORLDMONITOR_API_KEY').present) { + const proBadge = h('span', { className: 'panel-pro-badge' }, t('premium.pro')); + headerLeft.appendChild(proBadge); + } + this.header.appendChild(headerLeft); this.statusBadgeEl = document.createElement('span'); @@ -628,6 +637,7 @@ export class Panel { } public showLoading(message = t('common.loading')): void { + if (this._locked) return; this.clearRetryCountdown(); replaceChildren(this.content, h('div', { className: 'panel-loading' }, @@ -640,54 +650,105 @@ export class Panel { ); } - public showError(message = t('common.failedToLoad'), onRetry?: () => void): void { + public showError(message?: string, onRetry?: () => void, autoRetrySeconds?: number): void { + if (this._locked) return; this.clearRetryCountdown(); if (onRetry !== undefined) this.retryCallback = onRetry; - const children: (HTMLElement | string)[] = [ - h('div', { className: 'panel-error-icon' }, '\u2014'), - h('div', { className: 'panel-error-msg' }, message), - ]; - if (this.retryCallback) { - children.push( - h('button', { - type: 'button', - className: 'panel-error-retry-btn', - 'data-panel-retry': '', - }, t('common.retry')), - ); - } - replaceChildren(this.content, h('div', { className: 'panel-error-state' }, ...children)); - } - public showRetrying(message = t('common.retrying'), countdownSeconds?: number): void { - this.clearRetryCountdown(); - const textNode = document.createTextNode( - countdownSeconds ? `${message} (${countdownSeconds}s)` : message, + const radarEl = h('div', { className: 'panel-loading-radar panel-error-radar' }, + h('div', { className: 'panel-radar-sweep' }), + h('div', { className: 'panel-radar-dot error' }), ); - const textEl = h('div', { className: 'panel-loading-text retrying' }); - textEl.appendChild(textNode); - if (countdownSeconds && countdownSeconds > 0) { - let remaining = countdownSeconds; + const msgEl = h('div', { className: 'panel-error-msg' }, message || t('common.failedToLoad')); + + const children: (HTMLElement | string)[] = [radarEl, msgEl]; + + if (this.retryCallback) { + const backoffSeconds = autoRetrySeconds ?? Math.min(15 * Math.pow(2, this.retryAttempt), 180); + this.retryAttempt++; + let remaining = Math.round(backoffSeconds); + const countdownEl = h('div', { className: 'panel-error-countdown' }, + `${t('common.retrying')} (${remaining}s)`, + ); + children.push(countdownEl); this.retryCountdownTimer = setInterval(() => { remaining--; if (remaining <= 0) { this.clearRetryCountdown(); - textNode.textContent = message; + this.retryCallback?.(); return; } - textNode.textContent = `${message} (${remaining}s)`; + countdownEl.textContent = `${t('common.retrying')} (${remaining}s)`; + }, 1000); + } + replaceChildren(this.content, h('div', { className: 'panel-error-state' }, ...children)); + } + + public resetRetryBackoff(): void { + this.retryAttempt = 0; + } + + public showLocked(_features: string[] = []): void { + this._locked = true; + this.clearRetryCountdown(); + + for (let child = this.header.nextElementSibling; child && child !== this.content; child = child.nextElementSibling) { + (child as HTMLElement).style.display = 'none'; + } + this.element.classList.add('panel-is-locked'); + + const lockSvg = ``; + const iconEl = h('div', { className: 'panel-locked-icon' }); + iconEl.innerHTML = lockSvg; + + const lockedChildren: (HTMLElement | string)[] = [ + iconEl, + h('div', { className: 'panel-locked-desc' }, t('premium.lockedDesc')), + ]; + + const ctaBtn = h('button', { type: 'button', className: 'panel-locked-cta' }, t('premium.joinWaitlist')); + if (isDesktopRuntime()) { + ctaBtn.addEventListener('click', () => void invokeTauri('open_settings_window_command').catch(() => {})); + } else { + ctaBtn.addEventListener('click', () => window.open('https://worldmonitor.app/pro', '_blank')); + } + lockedChildren.push(ctaBtn); + + replaceChildren(this.content, h('div', { className: 'panel-locked-state' }, ...lockedChildren)); + } + + public showRetrying(message?: string, countdownSeconds?: number): void { + if (this._locked) return; + this.clearRetryCountdown(); + + const radarEl = h('div', { className: 'panel-loading-radar panel-error-radar' }, + h('div', { className: 'panel-radar-sweep' }), + h('div', { className: 'panel-radar-dot error' }), + ); + + const msgEl = h('div', { className: 'panel-error-msg' }, message || t('common.retrying')); + const children: (HTMLElement | string)[] = [radarEl, msgEl]; + + if (countdownSeconds && countdownSeconds > 0) { + let remaining = countdownSeconds; + const countdownEl = h('div', { className: 'panel-error-countdown' }, + `${t('common.retrying')} (${remaining}s)`, + ); + children.push(countdownEl); + this.retryCountdownTimer = setInterval(() => { + remaining--; + if (remaining <= 0) { + this.clearRetryCountdown(); + countdownEl.textContent = t('common.retrying'); + return; + } + countdownEl.textContent = `${t('common.retrying')} (${remaining}s)`; }, 1000); } replaceChildren(this.content, - h('div', { className: 'panel-loading' }, - h('div', { className: 'panel-loading-radar' }, - h('div', { className: 'panel-radar-sweep' }), - h('div', { className: 'panel-radar-dot' }), - ), - textEl, - ), + h('div', { className: 'panel-error-state' }, ...children), ); } @@ -748,7 +809,9 @@ export class Panel { } public setContent(html: string): void { + if (this._locked) return; this.clearRetryCountdown(); + this.retryAttempt = 0; if (this.pendingContentHtml === html || this.content.innerHTML === html) { return; } diff --git a/src/components/WorldClockPanel.ts b/src/components/WorldClockPanel.ts index 57108eb72..5cbf148da 100644 --- a/src/components/WorldClockPanel.ts +++ b/src/components/WorldClockPanel.ts @@ -126,52 +126,7 @@ function pad2(n: number): string { return n < 10 ? `0${n}` : `${n}`; } -const STYLE = ``; +/* Styles moved to panels.css (PERF-012) */ export class WorldClockPanel extends Panel { private tickInterval: ReturnType | null = null; @@ -234,7 +189,7 @@ export class WorldClockPanel extends Panel { } private renderSettings(): void { - let html = STYLE + '
'; + let html = '
'; for (const region of CITY_REGIONS) { html += `
${region.name}
`; for (const id of region.ids) { @@ -322,11 +277,11 @@ export class WorldClockPanel extends Panel { .filter((c): c is CityEntry => !!c); if (sorted.length === 0) { - this.setContent(STYLE + '
No cities selected. Click \u2699 to add cities.
'); + this.setContent('
No cities selected. Click \u2699 to add cities.
'); return; } - let html = STYLE + '
'; + let html = '
'; for (const city of sorted) { const { h, m, s, dayOfWeek } = getTimeInZone(city.timezone); const isDay = h >= 6 && h < 20; diff --git a/src/config/geo.ts b/src/config/geo.ts index 878d404b3..b042b4376 100644 --- a/src/config/geo.ts +++ b/src/config/geo.ts @@ -74,6 +74,29 @@ export const INTEL_HOTSPOTS: Hotspot[] = [ }, whyItMatters: 'Bab el-Mandeb chokepoint security; 12% of global trade at risk; Red Sea shipping rerouting', }, + { + id: 'pak_afghan', + name: 'Pakistan–Afghanistan Border', + subtext: 'Border Conflict / TTP', + lat: 31.8, + lon: 69.0, + location: 'Pakistan–Afghanistan border (KP, Balochistan)', + keywords: ['pakistan', 'afghanistan', 'ttp', 'taliban', 'torkham', 'chaman', 'waziristan', 'khyber', 'peshawar', 'border', 'cross-border', 'airstrike', 'pak-afghan'], + agencies: ['Pakistan Military', 'TTP', 'Afghan Taliban'], + description: 'Ongoing conflict along the Pak–Afghan border. Pakistan military operations against TTP; cross-border strikes and border closures. Tensions with Afghan Taliban over border security.', + status: 'Monitoring', + escalationScore: 4, + escalationTrend: 'escalating', + escalationIndicators: ['Border clashes and closures', 'Pakistan airstrikes in Afghanistan', 'TTP attacks in KP', 'Torkham/Chaman crossing tensions'], + history: { + lastMajorEvent: 'Cross-border strikes and border closures', + lastMajorEventDate: '2024', + precedentCount: 3, + precedentDescription: 'Recurring border crises, TTP resurgence post-2021, militant sanctuaries in Afghanistan', + cyclicalRisk: 'Militant infiltration; seasonal operations', + }, + whyItMatters: 'Nuclear-armed state at contested border; regional stability; displacement and humanitarian impact', + }, { id: 'dc', name: 'DC', @@ -669,6 +692,28 @@ export const CONFLICT_ZONES: ConflictZone[] = [ [126.0955, 37.7876], ], }, + { + id: 'pak_afghan', + name: 'Pakistan–Afghanistan Border Conflict', + coords: [ + [72.50, 35.70], + [69.40, 31.69], + [65.95, 29.33], + [64.90, 30.29], + [71.02, 36.55], + [72.50, 35.70], + ], + center: [69, 31.8], + intensity: 'medium', + parties: ['Pakistan (Military)', 'TTP', 'Afghan Taliban'], + casualties: 'Ongoing military and civilian casualties', + displaced: 'Displacement along border areas', + keywords: ['pakistan', 'afghanistan', 'ttp', 'taliban', 'torkham', 'chaman', 'waziristan', 'kpk', 'border', 'cross-border', 'airstrike'], + startDate: 'Feb 21, 2026', + location: 'Pakistan–Afghanistan border (KPK, Balochistan, Federally Administered Tribal Areas)', + description: 'Escalating tensions along the Pakistan–Afghanistan border. Pakistan has conducted cross-border strikes targeting TTP sanctuaries in Afghan territory, prompting border closures and diplomatic friction with the Taliban government. Long-running dispute over militant safe havens and border security.', + keyDevelopments: ['Pakistan cross-border strikes in Afghanistan', 'TTP attacks in KPK', 'Torkham/Chaman crossing tensions', 'Militant infiltration', 'Border closures'], + }, ]; // US Domestic bases (not in overseas dataset - these are CONUS bases) diff --git a/src/config/map-layer-definitions.ts b/src/config/map-layer-definitions.ts index e63b03c2f..5aeea1c59 100644 --- a/src/config/map-layer-definitions.ts +++ b/src/config/map-layer-definitions.ts @@ -9,6 +9,7 @@ export interface LayerDefinition { i18nSuffix: string; fallbackLabel: string; renderers: MapRenderer[]; + premium?: 'locked' | 'enhanced'; } const def = ( @@ -17,10 +18,11 @@ const def = ( i18nSuffix: string, fallbackLabel: string, renderers: MapRenderer[] = ['flat', 'globe'], -): LayerDefinition => ({ key, icon, i18nSuffix, fallbackLabel, renderers }); + premium?: 'locked' | 'enhanced', +): LayerDefinition => ({ key, icon, i18nSuffix, fallbackLabel, renderers, ...(premium && { premium }) }); export const LAYER_REGISTRY: Record = { - iranAttacks: def('iranAttacks', '🎯', 'iranAttacks', 'Iran Attacks'), + iranAttacks: def('iranAttacks', '🎯', 'iranAttacks', 'Iran Attacks', ['flat', 'globe'], 'locked'), hotspots: def('hotspots', '🎯', 'intelHotspots', 'Intel Hotspots'), conflicts: def('conflicts', '⚔', 'conflictZones', 'Conflict Zones'), @@ -47,8 +49,8 @@ export const LAYER_REGISTRY: Record = { waterways: def('waterways', '⚓', 'strategicWaterways', 'Strategic Waterways'), economic: def('economic', '💰', 'economicCenters', 'Economic Centers'), minerals: def('minerals', '💎', 'criticalMinerals', 'Critical Minerals'), - gpsJamming: def('gpsJamming', '📡', 'gpsJamming', 'GPS Jamming'), - ciiChoropleth: def('ciiChoropleth', '🌎', 'ciiChoropleth', 'CII Instability'), + gpsJamming: def('gpsJamming', '📡', 'gpsJamming', 'GPS Jamming', ['flat', 'globe'], 'locked'), + ciiChoropleth: def('ciiChoropleth', '🌎', 'ciiChoropleth', 'CII Instability', ['flat', 'globe'], 'enhanced'), dayNight: def('dayNight', '🌓', 'dayNight', 'Day/Night', ['flat']), sanctions: def('sanctions', '🚫', 'sanctions', 'Sanctions', []), startupHubs: def('startupHubs', '🚀', 'startupHubs', 'Startup Hubs'), diff --git a/src/config/panels.ts b/src/config/panels.ts index 93cfea40e..2d96f80f1 100644 --- a/src/config/panels.ts +++ b/src/config/panels.ts @@ -16,10 +16,10 @@ const FULL_PANELS: Record = { 'live-webcams': { name: 'Live Webcams', enabled: true, priority: 1 }, insights: { name: 'AI Insights', enabled: true, priority: 1 }, 'strategic-posture': { name: 'AI Strategic Posture', enabled: true, priority: 1 }, - cii: { name: 'Country Instability', enabled: true, priority: 1 }, - 'strategic-risk': { name: 'Strategic Risk Overview', enabled: true, priority: 1 }, + cii: { name: 'Country Instability', enabled: true, priority: 1, premium: 'enhanced' }, + 'strategic-risk': { name: 'Strategic Risk Overview', enabled: true, priority: 1, premium: 'enhanced' }, intel: { name: 'Intel Feed', enabled: true, priority: 1 }, - 'gdelt-intel': { name: 'Live Intelligence', enabled: true, priority: 1 }, + 'gdelt-intel': { name: 'Live Intelligence', enabled: true, priority: 1, premium: 'enhanced' }, cascade: { name: 'Infrastructure Cascade', enabled: true, priority: 1 }, politics: { name: 'World News', enabled: true, priority: 1 }, us: { name: 'United States', enabled: true, priority: 1 }, @@ -36,7 +36,7 @@ const FULL_PANELS: Record = { markets: { name: 'Markets', enabled: true, priority: 1 }, economic: { name: 'Economic Indicators', enabled: true, priority: 1 }, 'trade-policy': { name: 'Trade Policy', enabled: true, priority: 1 }, - 'supply-chain': { name: 'Supply Chain', enabled: true, priority: 1 }, + 'supply-chain': { name: 'Supply Chain', enabled: true, priority: 1, premium: 'enhanced' }, finance: { name: 'Financial', enabled: true, priority: 1 }, tech: { name: 'Technology', enabled: true, priority: 2 }, crypto: { name: 'Crypto', enabled: true, priority: 2 }, @@ -55,8 +55,8 @@ const FULL_PANELS: Record = { climate: { name: 'Climate Anomalies', enabled: true, priority: 2 }, 'population-exposure': { name: 'Population Exposure', enabled: true, priority: 2 }, 'security-advisories': { name: 'Security Advisories', enabled: true, priority: 2 }, - 'oref-sirens': { name: 'Israel Sirens', enabled: true, priority: 2 }, - 'telegram-intel': { name: 'Telegram Intel', enabled: true, priority: 2 }, + 'oref-sirens': { name: 'Israel Sirens', enabled: true, priority: 2, premium: 'locked' }, + 'telegram-intel': { name: 'Telegram Intel', enabled: true, priority: 2, premium: 'locked' }, 'airline-intel': { name: 'Airline Intelligence', enabled: true, priority: 2 }, 'world-clock': { name: 'World Clock', enabled: true, priority: 2 }, }; diff --git a/src/locales/en.json b/src/locales/en.json index 12980b805..dfaa873be 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -2289,8 +2289,9 @@ "updated": "Updated just now", "ago": "{{time}} ago", "retrying": "Retrying...", - "failedToLoad": "Failed to load data", + "failedToLoad": "Temporarily unavailable — retrying", "noDataShort": "No data", + "dataTemporarilyUnavailable": "Data temporarily unavailable", "upstreamUnavailable": "Upstream API unavailable — will retry automatically", "loadingUcdpEvents": "Loading armed conflict events", "loadingStablecoins": "Loading stablecoins...", @@ -2301,16 +2302,16 @@ "loadingGiving": "Loading global giving data", "loadingDisplacement": "Loading displacement data", "loadingClimateData": "Loading climate data", - "failedTechReadiness": "Failed to load tech readiness data", - "failedRiskOverview": "Failed to calculate risk overview", - "failedPredictions": "Failed to load predictions", - "failedCII": "Failed to calculate CII", - "failedDependencyGraph": "Failed to build dependency graph", - "failedIntelFeed": "Failed to load intelligence feed", - "failedMarketData": "Failed to load market data", - "failedSectorData": "Failed to load sector data", - "failedCommodities": "Failed to load commodities", - "failedCryptoData": "Failed to load crypto data", + "failedTechReadiness": "Tech readiness data temporarily unavailable", + "failedRiskOverview": "Risk overview temporarily unavailable", + "failedPredictions": "Predictions temporarily unavailable", + "failedCII": "CII data temporarily unavailable", + "failedDependencyGraph": "Dependency graph temporarily unavailable", + "failedIntelFeed": "Intelligence feed temporarily unavailable", + "failedMarketData": "Market data temporarily unavailable", + "failedSectorData": "Sector data temporarily unavailable", + "failedCommodities": "Commodities data temporarily unavailable", + "failedCryptoData": "Crypto data temporarily unavailable", "rateLimitedMarket": "Market data temporarily unavailable (rate limited) — retrying shortly", "failedClusterNews": "Failed to cluster news", "noNewsAvailable": "No news available", @@ -2358,5 +2359,16 @@ "mapThemeDesc": "Visual style of the map tiles. Options vary by provider.", "globePreset": "Visual Preset", "globePresetDesc": "Switch between classic and enhanced globe visuals to compare." + }, + "premium": { + "pro": "PRO", + "lockedDesc": "Requires a World Monitor license key", + "joinWaitlist": "Join Waitlist", + "features": { + "orefSirens1": "Real-time Israel missile & rocket alerts", + "orefSirens2": "Siren zone mapping with threat classification", + "telegramIntel1": "Curated Telegram OSINT channels", + "telegramIntel2": "Near-real-time conflict & geopolitical updates" + } } } diff --git a/src/services/country-geometry.ts b/src/services/country-geometry.ts index 1abf4f013..d24247b26 100644 --- a/src/services/country-geometry.ts +++ b/src/services/country-geometry.ts @@ -14,6 +14,9 @@ interface CountryHit { const COUNTRY_GEOJSON_URL = '/data/countries.geojson'; +/** Optional higher-resolution boundary overrides sourced from Natural Earth. */ +const COUNTRY_OVERRIDES_URL = '/data/country-boundary-overrides.geojson'; + const POLITICAL_OVERRIDES: Record = { 'CN-TW': 'TW' }; const NAME_ALIASES: Record = { @@ -176,7 +179,10 @@ async function ensureLoaded(): Promise { if (typeof fetch !== 'function') return; try { - const response = await fetch(COUNTRY_GEOJSON_URL); + const [response, overrideResp] = await Promise.all([ + fetch(COUNTRY_GEOJSON_URL), + fetch(COUNTRY_OVERRIDES_URL).catch(() => null), + ]); if (!response.ok) { throw new Error(`HTTP ${response.status}`); } @@ -220,6 +226,33 @@ async function ensureLoaded(): Promise { } } + // Apply optional higher-resolution boundary overrides (sourced from Natural Earth) + try { + if (overrideResp?.ok) { + const overrideData = (await overrideResp.json()) as FeatureCollection; + if (overrideData?.type === 'FeatureCollection' && Array.isArray(overrideData.features)) { + for (const overrideFeature of overrideData.features) { + const code = normalizeCode(overrideFeature.properties); + if (!code || !overrideFeature.geometry) continue; + const mainFeature = data.features.find((f) => normalizeCode(f.properties) === code); + if (!mainFeature) continue; + mainFeature.geometry = overrideFeature.geometry; + const polygons = normalizeGeometry(overrideFeature.geometry); + const bbox = computeBbox(polygons); + if (bbox && polygons.length > 0) { + const existing = countryIndex.get(code); + if (existing) { + existing.polygons = polygons; + existing.bbox = bbox; + } + } + } + } + } + } catch { + // Overrides are optional; ignore fetch/parse errors + } + buildCountryNameMatchers(); } catch (err) { console.warn('[country-geometry] Failed to load countries.geojson:', err); diff --git a/src/styles/main.css b/src/styles/main.css index e07946253..0be8aac91 100644 --- a/src/styles/main.css +++ b/src/styles/main.css @@ -273,6 +273,28 @@ canvas, flex-shrink: 0; } +/* -- Tauri desktop: separate title bar for traffic lights -- */ +.tauri-titlebar { + height: 28px; + background: var(--bg); + flex-shrink: 0; + -webkit-app-region: drag; + border-bottom: 1px solid var(--border-subtle); +} + +.tauri-titlebar + .header { + border-top: none; + -webkit-app-region: drag; +} + +.tauri-titlebar + .header button, +.tauri-titlebar + .header a, +.tauri-titlebar + .header input, +.tauri-titlebar + .header select, +.tauri-titlebar + .header .search-wrapper { + -webkit-app-region: no-drag; +} + .header-left { display: flex; align-items: center; @@ -905,6 +927,9 @@ canvas, .map-section .panel-header { flex-shrink: 0; gap: 6px; + padding: 8px 12px; + background: var(--surface); + border-bottom: 1px solid var(--border); } .map-section .map-container { @@ -5704,15 +5729,36 @@ a.prediction-link:hover { flex-direction: column; align-items: center; justify-content: center; - gap: 6px; + gap: 8px; + padding: 1.5rem 1rem; + min-height: 120px; padding: 24px 12px; min-height: 100px; } +.panel-error-radar { + transform: scale(0.65); + margin-bottom: -8px; + opacity: 0.7; +} + +.panel-error-radar .panel-radar-dot.error { + background: var(--semantic-warning, #ff8844); + box-shadow: 0 0 6px var(--semantic-warning, #ff8844); +} + +.panel-error-countdown { + font-size: 10px; + color: var(--text-dim); + opacity: 0.7; + margin-top: 2px; +} + .panel-error-icon { font-size: 18px; color: var(--text-dim); opacity: 0.5; + display: none; } .panel-error-msg { @@ -5723,6 +5769,18 @@ a.prediction-link:hover { line-height: 1.4; } +.panel-empty { + display: flex; + align-items: center; + justify-content: center; + padding: 24px 12px; + min-height: 100px; + font-size: 11px; + color: var(--text-dim); + text-align: center; + opacity: 0.6; +} + .panel-error-retry-btn { margin-top: 4px; padding: 4px 10px; @@ -9190,19 +9248,10 @@ a.prediction-link:hover { border-left: 2px solid var(--accent); } -.cluster-toggle { - display: block; +.cluster-more { padding: 4px 8px; - margin-top: 4px; - background: none; - border: none; - color: var(--accent); - cursor: pointer; - font-size: 0.85em; -} - -.cluster-toggle:hover { - text-decoration: underline; + color: var(--text-muted); + font-style: italic; } .popup-description.alert { @@ -9256,6 +9305,88 @@ a.prediction-link:hover { } } +/* -- Premium locked panel overlay -- */ +.panel-locked-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + padding: 1.2rem 1rem; + gap: 8px; + background: radial-gradient(ellipse at center, rgba(180, 130, 40, 0.06) 0%, transparent 70%); +} + +.panel-locked-icon { + color: #d4a843; + filter: drop-shadow(0 0 8px rgba(212, 168, 67, 0.3)); +} + +.panel-locked-desc { + font-size: 11px; + color: var(--text-dim, #888); +} + +.panel-locked-cta { + padding: 6px 18px; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.05em; + color: #111; + background: linear-gradient(135deg, #d4a843 0%, #b8902e 100%); + border: none; + border-radius: 4px; + cursor: pointer; + transition: filter 0.15s; +} + +.panel-locked-cta:hover { + filter: brightness(1.15); +} + +/* -- PRO badge (panel header) -- */ +.panel-pro-badge { + display: inline-flex; + align-items: center; + font-size: 8px; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + color: #d4a843; + border: 1px solid rgba(212, 168, 67, 0.4); + border-radius: 3px; + padding: 1px 4px; + margin-left: 6px; + vertical-align: middle; + line-height: 1; +} + +/* -- PRO badge (layer toggle) -- */ +.layer-pro-badge { + display: inline; + font-size: 7px; + font-weight: 700; + letter-spacing: 0.05em; + color: #d4a843; + border: 1px solid rgba(212, 168, 67, 0.35); + border-radius: 2px; + padding: 0 3px; + margin-left: 4px; + vertical-align: middle; +} + +/* -- Locked panel: hide count badge + extra chrome -- */ +.panel-is-locked .panel-count, +.panel-is-locked .panel-data-badge { + display: none !important; +} + +/* -- Locked layer toggle -- */ +.layer-toggle-locked { + opacity: 0.45; + pointer-events: none; +} + /* Panel header glow when has new items */ .panel.has-new .panel-header { background: linear-gradient(90deg, var(--overlay-medium) 0%, transparent 100%); @@ -14939,6 +15070,10 @@ body.has-critical-banner .panels-grid { pointer-events: none; } +body:has(.tauri-titlebar) .breaking-news-container { + top: 70px; +} + .breaking-alert { pointer-events: auto; padding: 8px 16px; @@ -17053,45 +17188,6 @@ body.has-breaking-alert .panels-grid { padding: 40px 24px; } -.cb-geo-error { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - gap: 12px; - padding: 40px 24px; - text-align: center; -} - -.cb-geo-error-actions { - display: flex; - gap: 8px; - margin-top: 4px; -} - -.cb-geo-retry-btn, -.cb-geo-close-btn { - padding: 6px 16px; - border: 1px solid var(--border); - border-radius: 6px; - background: transparent; - color: var(--text-primary); - font-size: 12px; - cursor: pointer; - transition: background 0.15s; -} - -.cb-geo-retry-btn:hover, -.cb-geo-close-btn:hover { - background: color-mix(in srgb, var(--text-faint) 15%, transparent); -} - -.cb-geo-retry-btn { - background: color-mix(in srgb, var(--accent) 15%, transparent); - border-color: var(--accent); - color: var(--accent); -} - .cb-empty { color: var(--text-faint); font-size: 12px; diff --git a/src/types/index.ts b/src/types/index.ts index 26ba7cb2c..cf7caee7e 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -498,6 +498,7 @@ export interface PanelConfig { name: string; enabled: boolean; priority?: number; + premium?: 'locked' | 'enhanced'; } export interface MapLayers {