mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-05-13 18:46:21 +02:00
fix(economic): guard against undefined BIS and spending data (#1162)
* feat: premium panel gating, code cleanup, and backend simplifications
Recovered stranded changes from fix/desktop-premium-error-unification.
Premium gating:
- Add premium field ('locked'|'enhanced') to PanelConfig and LayerDefinition
- Panel.showLocked() with lock icon, CTA button, and _locked guard
- PRO badge for enhanced panels when no WM API key
- Exponential backoff auto-retry on showError() (15s→30s→60s→180s cap)
- Gate oref-sirens and telegram-intel panels behind WM API key
- Lock gpsJamming and iranAttacks layer toggles, badge ciiChoropleth
- Add tauri-titlebar drag region for custom titlebar
Code cleanup:
- Extract inline CSS from AirlineIntelPanel, WorldClockPanel to panels.css
- Remove unused showGeoError() from CountryBriefPage
- Remove dead geocodeFailed/retryBtn/closeBtn locale keys (20 files)
- Clean up var names and inline styles across 6 components
Backend:
- Remove seed-meta throttle from redis.ts (unnecessary complexity)
- Risk scores: call handler functions directly instead of raw Redis reads
- Update OpenRouter model to gpt-oss-safeguard-20b:nitro
- Add direct UCDP API fetching with version probing
Config:
- Remove titleBarStyle: Overlay from tauri.conf.json
- Add build:pro and build-sidecar-handlers to build:desktop
- Remove DXB/RUH from default aviation watchlist
- Simplify reverse-geocode (remove AbortController wrapper)
* fix: cast handler requests to any for API tsconfig compat
* fix: revert stale changes that conflict with merged PRs
Reverts files to main versions where old branch changes would
overwrite intentional fixes from PRs #1134, #1138, #1144, #1154:
- news/_shared.ts: keep gemini-2.5-flash model (not stale gpt-oss)
- redis.ts: keep seed-meta throttle from PR #1138
- reverse-geocode.ts: keep AbortController timeout from PR #1134
- CountryBriefPage.ts: keep showGeoError() from PR #1134
- country-intel.ts: keep showGeoError usage from PR #1134
- get-risk-scores.ts: revert non-existent imports
- watchlist.ts: keep DXB/RUH airports from PR #1144
- locales: restore geocodeFailed/retryBtn/closeBtn keys
* fix: neutralize language, parallel override loading, fetch timeout
- Rename conflict zone from "War" to "Border Conflict", intensity high→medium
- Rewrite description to factual language (no "open war" claim)
- Load country boundary overrides in parallel with main GeoJSON
- Neutralize comments/docs: reference Natural Earth source, remove political terms
- Add 60s timeout to Natural Earth fetch script (~24MB download)
- Add trailing newline to GeoJSON override file
* fix: restore caller messages in Panel errors and vessel expansion in popups
- Move UCDP direct-fetch cooldown after successful fetch to avoid
suppressing all data for 10 minutes on a single failure
- Use caller-provided messages in showError/showRetrying instead of
discarding them; respect autoRetrySeconds parameter
- Restore cluster-toggle click handler and expandable vessel list
in military cluster popups
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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\"",
|
||||
|
||||
1
public/data/country-boundary-overrides.geojson
Normal file
1
public/data/country-boundary-overrides.geojson
Normal file
File diff suppressed because one or more lines are too long
57
scripts/fetch-pakistan-boundary-override.mjs
Normal file
57
scripts/fetch-pakistan-boundary-override.mjs
Normal file
@@ -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);
|
||||
});
|
||||
@@ -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<number, UcdpViolenceType> = {
|
||||
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<unknown> {
|
||||
const headers: Record<string, string> = { 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<UcdpViolenceEvent[]> {
|
||||
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<string, unknown>;
|
||||
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<ListUcdpEventsResponse> {
|
||||
// 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 };
|
||||
}
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
"windows": [
|
||||
{
|
||||
"title": "World Monitor",
|
||||
"titleBarStyle": "Overlay",
|
||||
"width": 1440,
|
||||
"height": 900,
|
||||
"minWidth": 1200,
|
||||
|
||||
@@ -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 ? '<div class="tauri-titlebar" data-tauri-drag-region></div>' : ''}
|
||||
<div class="header">
|
||||
<div class="header-left">
|
||||
<button class="hamburger-btn" id="hamburgerBtn" aria-label="Menu">
|
||||
@@ -289,7 +291,7 @@ export class PanelLayoutManager implements AppModule {
|
||||
<span class="panel-title">${SITE_VARIANT === 'tech' ? t('panels.techMap') : SITE_VARIANT === 'happy' ? 'Good News Map' : t('panels.map')}</span>
|
||||
</div>
|
||||
<span class="header-clock" id="headerClock" translate="no"></span>
|
||||
<div style="display:flex;align-items:center;gap:2px">
|
||||
<div class="map-header-actions">
|
||||
<div class="map-dimension-toggle" id="mapDimensionToggle">
|
||||
<button class="map-dim-btn${loadFromStorage<string>(STORAGE_KEYS.mapMode, 'flat') === 'globe' ? '' : ' active'}" data-mode="flat" title="2D Map">2D</button>
|
||||
<button class="map-dim-btn${loadFromStorage<string>(STORAGE_KEYS.mapMode, 'flat') === 'globe' ? ' active' : ''}" data-mode="globe" title="3D Globe">3D</button>
|
||||
@@ -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<T>,
|
||||
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);
|
||||
|
||||
|
||||
@@ -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 => `
|
||||
<div class="news-item" style="padding:8px 0;border-bottom:1px solid var(--border-color,#333)">
|
||||
<div class="news-item" style="padding:8px 0;border-bottom:1px solid var(--border,#2a2a2a)">
|
||||
<a href="${sanitizeUrl(n.url)}" target="_blank" rel="noopener" class="news-link">${escapeHtml(n.title)}</a>
|
||||
<div class="news-meta" style="font-size:11px;color:var(--text-secondary,#999);margin-top:2px">${escapeHtml(n.sourceName)} · ${n.publishedAt.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</div>
|
||||
<div class="news-meta" style="font-size:11px;color:var(--text-dim,#888);margin-top:2px">${escapeHtml(n.sourceName)} · ${n.publishedAt.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</div>
|
||||
</div>`).join('');
|
||||
this.content.innerHTML = `<div class="news-list" style="padding:0 4px">${items}</div>`;
|
||||
}
|
||||
@@ -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) */
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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 = '© <a href="https://openfreemap.org" target="_blank" rel="noopener">OpenFreeMap</a> © <a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">OpenStreetMap</a>';
|
||||
@@ -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 {
|
||||
<button class="toggle-collapse">▼</button>
|
||||
</div>
|
||||
<div class="toggle-list" style="max-height: 32vh; overflow-y: auto; scrollbar-width: thin;">
|
||||
${layerConfig.map(({ key, label, icon }) => `
|
||||
<label class="layer-toggle" data-layer="${key}">
|
||||
<input type="checkbox" ${this.state.layers[key as keyof MapLayers] ? 'checked' : ''}>
|
||||
${layerConfig.map(({ key, label, icon, premium }) => {
|
||||
const isLocked = premium === 'locked' && !_wmKey;
|
||||
const isEnhanced = premium === 'enhanced' && !_wmKey;
|
||||
return `
|
||||
<label class="layer-toggle${isLocked ? ' layer-toggle-locked' : ''}" data-layer="${key}">
|
||||
<input type="checkbox" ${this.state.layers[key as keyof MapLayers] ? 'checked' : ''}${isLocked ? ' disabled' : ''}>
|
||||
<span class="toggle-icon">${icon}</span>
|
||||
<span class="toggle-label">${label}</span>
|
||||
</label>
|
||||
`).join('')}
|
||||
<span class="toggle-label">${label}${isLocked ? ' \uD83D\uDD12' : ''}${isEnhanced ? ' <span class="layer-pro-badge">PRO</span>' : ''}</span>
|
||||
</label>`;
|
||||
}).join('')}
|
||||
</div>
|
||||
`;
|
||||
|
||||
|
||||
@@ -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<DeductContextDetail>) => {
|
||||
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' });
|
||||
|
||||
|
||||
@@ -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 {
|
||||
<button class="toggle-collapse">▼</button>
|
||||
</div>
|
||||
<div class="toggle-list" style="max-height:32vh;overflow-y:auto;scrollbar-width:thin;">
|
||||
${layers.map(({ key, label, icon }) => `
|
||||
<label class="layer-toggle" data-layer="${key}">
|
||||
<input type="checkbox" ${this.layers[key] ? 'checked' : ''}>
|
||||
${layers.map(({ key, label, icon, premium }) => {
|
||||
const isLocked = premium === 'locked' && !_wmKey;
|
||||
const isEnhanced = premium === 'enhanced' && !_wmKey;
|
||||
return `
|
||||
<label class="layer-toggle${isLocked ? ' layer-toggle-locked' : ''}" data-layer="${key}">
|
||||
<input type="checkbox" ${this.layers[key] ? 'checked' : ''}${isLocked ? ' disabled' : ''}>
|
||||
<span class="toggle-icon">${icon}</span>
|
||||
<span class="toggle-label">${label}</span>
|
||||
</label>`).join('')}
|
||||
<span class="toggle-label">${label}${isLocked ? ' \uD83D\uDD12' : ''}${isEnhanced ? ' <span class="layer-pro-badge">PRO</span>' : ''}</span>
|
||||
</label>`;
|
||||
}).join('')}
|
||||
</div>`;
|
||||
const authorBadge = document.createElement('div');
|
||||
authorBadge.className = 'map-author-badge';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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<typeof setTimeout> | null = null;
|
||||
private retryCallback: (() => void) | null = null;
|
||||
private retryCountdownTimer: ReturnType<typeof setInterval> | 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 = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0110 0v4"/></svg>`;
|
||||
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<void>('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;
|
||||
}
|
||||
|
||||
@@ -126,52 +126,7 @@ function pad2(n: number): string {
|
||||
return n < 10 ? `0${n}` : `${n}`;
|
||||
}
|
||||
|
||||
const STYLE = `<style>
|
||||
.wc-container{display:flex;flex-direction:column}
|
||||
.wc-row{display:grid;grid-template-columns:auto 1fr auto;align-items:center;padding:7px 10px;border-bottom:1px solid var(--border-subtle,#1a1a1a);transition:background .15s;gap:0}
|
||||
.wc-row:last-child{border-bottom:none}
|
||||
.wc-row:hover{background:var(--surface-hover,#1e1e1e)}
|
||||
.wc-row.wc-home{border-left:2px solid #44ff88;padding-left:8px;background:rgba(68,255,136,.03)}
|
||||
.wc-row.wc-night .wc-time{opacity:.65}
|
||||
.wc-info{display:flex;flex-direction:column;gap:3px;min-width:0}
|
||||
.wc-name{font-size:12px;font-weight:700;color:var(--text,#e8e8e8);letter-spacing:.3px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.wc-home-tag{font-size:9px;color:#44ff88;margin-left:4px;font-weight:400;opacity:.7}
|
||||
.wc-detail{display:flex;align-items:center;gap:6px}
|
||||
.wc-exchange{font-size:10px;color:var(--text-dim,#888);letter-spacing:.5px;text-transform:uppercase}
|
||||
.wc-status{display:inline-flex;align-items:center;gap:4px;font-size:9px;font-weight:600;letter-spacing:.5px}
|
||||
.wc-dot{width:5px;height:5px;border-radius:50%;flex-shrink:0}
|
||||
.wc-dot.open{background:#44ff88;box-shadow:0 0 6px rgba(68,255,136,.6);animation:wc-pulse 2s ease-in-out infinite}
|
||||
.wc-dot.closed{background:var(--text-muted,#666)}
|
||||
.wc-status.open{color:#44ff88}
|
||||
.wc-status.closed{color:var(--text-muted,#666)}
|
||||
.wc-clock{display:flex;flex-direction:column;align-items:flex-end;gap:3px;flex-shrink:0}
|
||||
.wc-time{font-family:var(--font-mono);font-size:18px;font-weight:700;color:var(--text,#e8e8e8);letter-spacing:1.5px;line-height:1;font-variant-numeric:tabular-nums}
|
||||
.wc-tz{font-size:9px;color:var(--text-dim,#888);display:flex;align-items:center;gap:6px}
|
||||
.wc-bar-wrap{width:36px;height:3px;background:var(--border,#2a2a2a);border-radius:2px;overflow:hidden}
|
||||
.wc-bar{height:100%;border-radius:2px;transition:width 1s linear}
|
||||
.wc-bar.day{background:linear-gradient(90deg,#44ff88,#88ff44)}
|
||||
.wc-bar.night{background:linear-gradient(90deg,#445,#556)}
|
||||
@keyframes wc-pulse{0%,100%{opacity:1}50%{opacity:.4}}
|
||||
.wc-settings-btn{background:none;border:1px solid transparent;color:var(--text-dim,#888);cursor:pointer;font-size:13px;padding:2px 6px;border-radius:4px;display:flex;align-items:center;justify-content:center;transition:all .15s;line-height:1}
|
||||
.wc-settings-btn:hover{background:var(--overlay-light,rgba(255,255,255,.05));color:var(--text,#e8e8e8);border-color:var(--border,#2a2a2a)}
|
||||
.wc-settings-btn.wc-active{background:rgba(68,255,136,.1);color:#44ff88;border-color:rgba(68,255,136,.3)}
|
||||
.wc-settings-view{padding:2px 0}
|
||||
.wc-region-header{font-size:9px;font-weight:600;color:var(--text-dim,#888);text-transform:uppercase;letter-spacing:1px;padding:8px 10px 4px;border-bottom:1px solid var(--border-subtle,#1a1a1a)}
|
||||
.wc-region-header:first-child{padding-top:4px}
|
||||
.wc-region-grid{display:grid;grid-template-columns:1fr 1fr;gap:0}
|
||||
.wc-city-option{display:flex;align-items:center;gap:6px;padding:5px 10px;cursor:pointer;transition:background .1s;font-size:11px}
|
||||
.wc-city-option:hover{background:var(--surface-hover,#1e1e1e)}
|
||||
.wc-city-option input[type=checkbox]{accent-color:#44ff88;width:12px;height:12px;flex-shrink:0;cursor:pointer}
|
||||
.wc-opt-name{font-weight:600;color:var(--text,#e8e8e8);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.wc-opt-label{font-size:9px;color:var(--text-muted,#666);margin-left:auto;flex-shrink:0}
|
||||
.wc-empty{padding:20px 10px;text-align:center;color:var(--text-dim,#888);font-size:11px}
|
||||
.wc-drag-handle{cursor:grab;color:var(--text-muted,#555);font-size:11px;padding:0 6px 0 2px;user-select:none;opacity:.35;transition:opacity .15s;display:flex;align-items:center}
|
||||
.wc-row:hover .wc-drag-handle{opacity:.6}
|
||||
.wc-drag-handle:hover{opacity:1!important;color:var(--text,#e8e8e8);cursor:grab}
|
||||
.wc-row.wc-dragging{opacity:.3}
|
||||
.wc-row.wc-drag-over-above{box-shadow:inset 0 2px 0 #44ff88}
|
||||
.wc-row.wc-drag-over-below{box-shadow:inset 0 -2px 0 #44ff88}
|
||||
</style>`;
|
||||
/* Styles moved to panels.css (PERF-012) */
|
||||
|
||||
export class WorldClockPanel extends Panel {
|
||||
private tickInterval: ReturnType<typeof setInterval> | null = null;
|
||||
@@ -234,7 +189,7 @@ export class WorldClockPanel extends Panel {
|
||||
}
|
||||
|
||||
private renderSettings(): void {
|
||||
let html = STYLE + '<div class="wc-settings-view">';
|
||||
let html = '<div class="wc-settings-view">';
|
||||
for (const region of CITY_REGIONS) {
|
||||
html += `<div class="wc-region-header">${region.name}</div><div class="wc-region-grid">`;
|
||||
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 + '<div class="wc-empty">No cities selected. Click \u2699 to add cities.</div>');
|
||||
this.setContent('<div class="wc-empty">No cities selected. Click \u2699 to add cities.</div>');
|
||||
return;
|
||||
}
|
||||
|
||||
let html = STYLE + '<div class="wc-container" translate="no">';
|
||||
let html = '<div class="wc-container" translate="no">';
|
||||
for (const city of sorted) {
|
||||
const { h, m, s, dayOfWeek } = getTimeInZone(city.timezone);
|
||||
const isDay = h >= 6 && h < 20;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<keyof MapLayers, LayerDefinition> = {
|
||||
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<keyof MapLayers, LayerDefinition> = {
|
||||
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'),
|
||||
|
||||
@@ -16,10 +16,10 @@ const FULL_PANELS: Record<string, PanelConfig> = {
|
||||
'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<string, PanelConfig> = {
|
||||
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<string, PanelConfig> = {
|
||||
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 },
|
||||
};
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string, string> = { 'CN-TW': 'TW' };
|
||||
|
||||
const NAME_ALIASES: Record<string, string> = {
|
||||
@@ -176,7 +179,10 @@ async function ensureLoaded(): Promise<void> {
|
||||
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<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// Apply optional higher-resolution boundary overrides (sourced from Natural Earth)
|
||||
try {
|
||||
if (overrideResp?.ok) {
|
||||
const overrideData = (await overrideResp.json()) as FeatureCollection<Geometry>;
|
||||
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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -498,6 +498,7 @@ export interface PanelConfig {
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
priority?: number;
|
||||
premium?: 'locked' | 'enhanced';
|
||||
}
|
||||
|
||||
export interface MapLayers {
|
||||
|
||||
Reference in New Issue
Block a user