diff --git a/api/webcam/v1/[rpc].ts b/api/webcam/v1/[rpc].ts new file mode 100644 index 000000000..861b54dbd --- /dev/null +++ b/api/webcam/v1/[rpc].ts @@ -0,0 +1,9 @@ +export const config = { runtime: 'edge' }; + +import { createDomainGateway, serverOptions } from '../../../server/gateway'; +import { createWebcamServiceRoutes } from '../../../src/generated/server/worldmonitor/webcam/v1/service_server'; +import { webcamHandler } from '../../../server/worldmonitor/webcam/v1/handler'; + +export default createDomainGateway( + createWebcamServiceRoutes(webcamHandler, serverOptions), +); diff --git a/docker/nginx-security-headers.conf b/docker/nginx-security-headers.conf index 921b109f7..18c76ad87 100644 --- a/docker/nginx-security-headers.conf +++ b/docker/nginx-security-headers.conf @@ -4,4 +4,4 @@ add_header X-Content-Type-Options "nosniff" always; add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always; add_header Permissions-Policy "camera=(), microphone=(), geolocation=(self), accelerometer=(), autoplay=(self \"https://www.youtube.com\" \"https://www.youtube-nocookie.com\"), bluetooth=(), display-capture=(), encrypted-media=(self \"https://www.youtube.com\" \"https://www.youtube-nocookie.com\"), gyroscope=(), hid=(), idle-detection=(), magnetometer=(), midi=(), payment=(), picture-in-picture=(self \"https://www.youtube.com\" \"https://www.youtube-nocookie.com\" \"https://challenges.cloudflare.com\"), screen-wake-lock=(), serial=(), usb=(), xr-spatial-tracking=()" always; -add_header Content-Security-Policy "default-src 'self'; connect-src 'self' https: wss: blob: data:; img-src 'self' data: blob: https:; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval' https://www.youtube.com https://static.cloudflareinsights.com https://vercel.live https://challenges.cloudflare.com; worker-src 'self' blob:; font-src 'self' data: https:; media-src 'self' data: blob: https:; frame-src 'self' https://worldmonitor.app https://tech.worldmonitor.app https://finance.worldmonitor.app https://happy.worldmonitor.app https://www.youtube.com https://www.youtube-nocookie.com https://challenges.cloudflare.com; frame-ancestors 'self' https://www.worldmonitor.app https://tech.worldmonitor.app https://finance.worldmonitor.app https://happy.worldmonitor.app https://worldmonitor.app; base-uri 'self'; object-src 'none'; form-action 'self'" always; +add_header Content-Security-Policy "default-src 'self'; connect-src 'self' https: wss: blob: data:; img-src 'self' data: blob: https:; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval' https://www.youtube.com https://static.cloudflareinsights.com https://vercel.live https://challenges.cloudflare.com; worker-src 'self' blob:; font-src 'self' data: https:; media-src 'self' data: blob: https:; frame-src 'self' https://worldmonitor.app https://tech.worldmonitor.app https://finance.worldmonitor.app https://happy.worldmonitor.app https://www.youtube.com https://www.youtube-nocookie.com https://webcams.windy.com https://challenges.cloudflare.com; frame-ancestors 'self' https://www.worldmonitor.app https://tech.worldmonitor.app https://finance.worldmonitor.app https://happy.worldmonitor.app https://worldmonitor.app; base-uri 'self'; object-src 'none'; form-action 'self'" always; diff --git a/docs/docs.json b/docs/docs.json index 466fe040b..18e054a15 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -91,7 +91,8 @@ "maritime-intelligence", "natural-disasters", "infrastructure-cascade", - "maps-and-geocoding" + "maps-and-geocoding", + "webcam-layer" ] }, { diff --git a/docs/webcam-layer.mdx b/docs/webcam-layer.mdx new file mode 100644 index 000000000..2d2e8a213 --- /dev/null +++ b/docs/webcam-layer.mdx @@ -0,0 +1,184 @@ +--- +title: "Webcam Layer" +description: "Global webcam coverage with timelapse playback, pinned webcam panel, and multi-source integration roadmap." +--- +The Webcam layer provides global visual intelligence by overlaying webcam locations on the map, with interactive tooltips showing preview images and a pinned webcam panel for persistent monitoring of key locations. + +## Data Source: Windy Webcams API v3 + +The primary data source is the [Windy Webcams API](https://api.windy.com/webcams/api/v3/docs), which provides approximately 65,000 camera locations worldwide. + +| Attribute | Value | +|-----------|-------| +| **Provider** | Windy Webcams API v3 | +| **Coverage** | ~65,000 cameras globally | +| **Update frequency** | Cameras capture images periodically (every 5-15 min) | +| **Seeder** | `scripts/seed-webcams.mjs` — regional bounding-box fetch with adaptive quadrant splitting | +| **API key** | Required (`WINDY_API_KEY`); free tier available at [api.windy.com](https://api.windy.com) | +| **Attribution** | Required on free tier | + +### What the API provides + +**Seed-time fields** (bulk fetch with `include=location,categories`): + +| Field | Description | +|-------|-------------| +| `webcamId` | Unique camera identifier | +| `title` | Camera name/description | +| `location.latitude` / `location.longitude` | Geographic coordinates | +| `location.country` | Country name | +| `location.region` | Region/state | +| `categories` | Camera category (traffic, landscape, city, etc.) | +| `status` | Camera status (active/inactive) | + +**On-demand fields** (per-camera fetch with `include=images,urls`): + +| Field | Description | +|-------|-------------| +| `images.current.preview` | Latest captured still image URL | +| `images.current.thumbnail` | Smaller thumbnail URL | +| `urls.player` | Embeddable timelapse player URL | +| `lastUpdatedOn` | Timestamp of last image capture | + +### Free tier limitations + +- Image token URLs expire after 10 minutes +- Bounding-box queries capped at 10,000 results per request (seeder uses adaptive quadrant splitting to work around this) +- Rate limits apply (seeder uses sequential regional fetches) + +### API key configuration + +The webcam layer requires a `WINDY_API_KEY` environment variable. Get a free key at [api.windy.com](https://api.windy.com). + +| Environment | Where to set | Used by | +|-------------|-------------|---------| +| **Vercel** (production) | Project Settings > Environment Variables | `get-webcam-image.ts` (on-demand image/player URL fetches) | +| **Railway** (cron seeder) | Service Variables | `seed-webcams.mjs` (bulk metadata fetch) | +| **Tauri sidecar** (desktop) | Keychain via Settings > API Keys | On-demand image fetches via sidecar | +| **Local dev** | `.env` file | Both seeder and dev server | + +Without the key: +- **Seeder** exits gracefully with "WINDY_API_KEY not set, skipping webcam seed" +- **Image handler** returns `{ error: 'unavailable' }` and tooltips show "Preview unavailable" +- **Map layer** still renders markers from cached geo data (if previously seeded), but image previews are unavailable + +## Architecture + +``` +Seed (periodic): + Windy API → seed-webcams.mjs → Redis (geo index + metadata hash) + +Runtime: + Browser map viewport → listWebcams RPC → Redis geo search → clustered response + User clicks marker → getWebcamImage RPC → Windy API (cached 5 min) → tooltip with preview + User pins webcam → localStorage → PinnedWebcamsPanel (2x2 iframe grid) +``` + +### Server-side clustering + +The `listWebcams` handler performs server-side spatial clustering based on zoom level. At low zoom, nearby cameras are grouped into cluster markers showing a count. At higher zoom, individual markers appear. This keeps the map performant even when thousands of cameras are in view. + +### Caching + +Three cache layers work together to minimize latency and external API calls: + +| Layer | Scope | TTL | Key | +|-------|-------|-----|-----| +| **Redis — geo + metadata** | Seeded camera index | 24 hours | `webcam:cameras:geo:{version}`, `webcam:cameras:meta:{version}` | +| **Redis — viewport responses** | Clustered results per map view | 24 hours | `webcam:resp:{version}:{zoom}:{quantizedBbox}` | +| **Redis — image lookups** | Per-webcam image/player URLs | 5 minutes | `webcam:image:{webcamId}` | +| **Client — image cache** | In-memory Map in browser | 9 minutes | webcamId | +| **Client — pinned store** | localStorage (permanent) | None (user-managed) | `wm-pinned-webcams` | + +### Expected latency behavior + +On first interaction after a container start, there is a noticeable delay as caches are cold: + +1. **Map viewport change** → `listWebcams` RPC → server performs Redis geo search, builds clustered response, caches it. Subsequent identical viewports return instantly from Redis cache (24h TTL). +2. **First click on a webcam marker** → `getWebcamImage` RPC → server calls Windy API (network round-trip to external service), caches the response for 5 minutes server-side. The client also caches for 9 minutes — so re-clicking the same webcam within 9 minutes is instant with no server call. +3. **Pinning a webcam** → the player iframe loads from Windy's CDN (another external round-trip for the embed page). This is not cached by us — the browser handles iframe caching. + +Once caches are warm, the only external calls are for webcams not viewed in the last 5-9 minutes. + +### Redis data is ephemeral + +Redis data does not survive container rebuilds. After rebuilding the stack, the seeder (`scripts/seed-webcams.mjs`) must re-run to repopulate the geo index and metadata. Without seeded data, the webcam layer will show no markers. Viewport response caches and image caches will rebuild organically as users interact with the map. + +### No automatic re-seeding (known gap) + +**This is a known limitation that needs to be addressed in a follow-up PR.** + +The seeder writes geo and metadata keys to Redis with a 24-hour TTL, but nothing triggers a re-seed when those keys expire. After 24 hours without a manual re-seed, the webcam layer silently goes blank. + +Current ways to re-seed: +- Run `scripts/seed-webcams.mjs` from the host +- Run `scripts/run-seeders.sh` (runs all seeders including webcams) +- **Railway cron** (recommended): schedule `seed-webcams.mjs` as a Railway cron service every 12-18 hours to stay ahead of the 24-hour TTL expiry + +## Pinned Webcams Panel + +Users can pin webcams from map tooltips to a persistent side panel. The panel displays up to 4 webcams simultaneously in a 2x2 grid of embedded Windy player iframes. + +### Features + +- **2x2 iframe grid**: Four active webcam players visible at once +- **Toggle on/off**: Webcams can be toggled between active (showing in grid) and inactive (in list only) +- **Overflow list**: When more than 4 webcams are pinned, a scrollable list appears below the grid for managing all pins +- **Pin from any renderer**: Pin buttons appear in webcam tooltips across all three map renderers (SVG, Globe, DeckGL) +- **Persistence**: Pinned webcams survive page reloads via localStorage +- **Custom events**: Panel updates reactively when pins change from any source + +### Storage + +Pinned webcam data is stored in `localStorage` under the key `wm-pinned-webcams`. Each entry contains: + +``` +webcamId, title, lat, lng, category, country, playerUrl, active (boolean), pinnedAt (timestamp) +``` + +Maximum 4 webcams can be active (showing in grid) at any time. The total number of pinned webcams is not limited. + +## Current Limitations + +### Not live video + +**This is the most important limitation to understand.** The Windy player does not show live video streams. Most webcams in the Windy network capture still images at periodic intervals (every 5-15 minutes). The embedded player compiles these stills into a timelapse, typically showing the last 24-72 hours of captures. + +This means: +- The "player" is a timelapse of recent snapshots, not a live feed +- There is no way to filter for live-streaming cameras via the Windy API +- Real-time situational awareness is limited to the latest captured image (visible in the tooltip preview) + +### No live video API exists at free tier + +Free sources of actual live video webcam feeds with structured APIs do not currently exist. Live video requires streaming infrastructure (RTSP/HLS/WebRTC) which is expensive to operate. Known live sources are either paid, partner-only, or have no API: + +| Source | Status | +|--------|--------| +| YouTube Live | Already integrated (Live YouTube panel) but not location-indexed | +| EarthCam | Partner-only, no public API | +| SkylineWebcams | No API | +| TrafficLand | 25K cameras with HLS but requires business coordination | +| Insecam | Legal liability (unsecured cameras), not viable | + +### Other limitations + +- **Free tier attribution**: Windy requires attribution when using free API tier +- **Image token expiry**: Preview image URLs from Windy expire after ~10 minutes; re-fetching is needed for stale tooltips +- **Seed coverage**: The seeder caps at 10K cameras per regional bounding box; adaptive quadrant splitting mitigates this but very dense regions may still miss cameras +- **No status filtering**: Inactive/offline cameras may appear on the map with broken previews +- **iframe sandbox**: Player iframes use `allow-scripts allow-same-origin allow-popups` sandbox policy + +## Future Phases + +### Phase 2: US DOT 511 State APIs + +Tens of thousands of traffic cameras across 20+ US states. Free with developer key per state. These are also periodic still images (refresh every 30-60 seconds), not live video, but update more frequently than Windy cameras. + +Target states: NY (511ny.org), CA (511.org), GA (511ga.org), AZ (az511.com), UT. + +Challenge: Each state has a slightly different schema requiring normalizing adapters. + +### Phase 3: OpenWebcamDB + +Supplementary source with ~2,052 curated cameras. Free tier limited to 50 requests/day. Clean REST API but small dataset. Requires aggressive caching strategy. diff --git a/index.html b/index.html index 07f47bfb1..349d5c940 100644 --- a/index.html +++ b/index.html @@ -3,7 +3,7 @@ - + diff --git a/proto/worldmonitor/webcam/v1/get_webcam_image.proto b/proto/worldmonitor/webcam/v1/get_webcam_image.proto new file mode 100644 index 000000000..008136020 --- /dev/null +++ b/proto/worldmonitor/webcam/v1/get_webcam_image.proto @@ -0,0 +1,15 @@ +syntax = "proto3"; +package worldmonitor.webcam.v1; + +message GetWebcamImageRequest { + string webcam_id = 1; +} + +message GetWebcamImageResponse { + string thumbnail_url = 1; + string player_url = 2; + string title = 3; + string windy_url = 4; + int64 last_updated = 5; + string error = 6; +} diff --git a/proto/worldmonitor/webcam/v1/list_webcams.proto b/proto/worldmonitor/webcam/v1/list_webcams.proto new file mode 100644 index 000000000..1fd54a771 --- /dev/null +++ b/proto/worldmonitor/webcam/v1/list_webcams.proto @@ -0,0 +1,32 @@ +syntax = "proto3"; +package worldmonitor.webcam.v1; + +message ListWebcamsRequest { + int32 zoom = 1; + double bound_w = 2; + double bound_s = 3; + double bound_e = 4; + double bound_n = 5; +} + +message WebcamEntry { + string webcam_id = 1; + string title = 2; + double lat = 3; + double lng = 4; + string category = 5; + string country = 6; +} + +message WebcamCluster { + double lat = 1; + double lng = 2; + int32 count = 3; + repeated string categories = 4; +} + +message ListWebcamsResponse { + repeated WebcamEntry webcams = 1; + repeated WebcamCluster clusters = 2; + int32 total_in_view = 3; +} diff --git a/proto/worldmonitor/webcam/v1/service.proto b/proto/worldmonitor/webcam/v1/service.proto new file mode 100644 index 000000000..35180fed1 --- /dev/null +++ b/proto/worldmonitor/webcam/v1/service.proto @@ -0,0 +1,14 @@ +syntax = "proto3"; +package worldmonitor.webcam.v1; + +import "worldmonitor/webcam/v1/list_webcams.proto"; +import "worldmonitor/webcam/v1/get_webcam_image.proto"; + +service WebcamService { + rpc ListWebcams(ListWebcamsRequest) returns (ListWebcamsResponse) { + option (sebuf.http.rule) = { get: "/api/webcam/v1/list-webcams" }; + } + rpc GetWebcamImage(GetWebcamImageRequest) returns (GetWebcamImageResponse) { + option (sebuf.http.rule) = { get: "/api/webcam/v1/get-webcam-image" }; + } +} diff --git a/scripts/seed-webcams.mjs b/scripts/seed-webcams.mjs new file mode 100644 index 000000000..d8e6ff21a --- /dev/null +++ b/scripts/seed-webcams.mjs @@ -0,0 +1,219 @@ +#!/usr/bin/env node +/** + * Seed webcam camera metadata from Windy Webcams API v3. + * Writes versioned geo+meta keys to Redis for spatial queries. + * + * Usage: node scripts/seed-webcams.mjs + * Env: WINDY_API_KEY, UPSTASH_REDIS_REST_URL, UPSTASH_REDIS_REST_TOKEN + */ + +const WINDY_API_KEY = process.env.WINDY_API_KEY; +if (!WINDY_API_KEY) { + console.log('WINDY_API_KEY not set — skipping webcam seed'); + process.exit(0); +} + +const REDIS_URL = process.env.UPSTASH_REDIS_REST_URL; +const REDIS_TOKEN = process.env.UPSTASH_REDIS_REST_TOKEN; +if (!REDIS_URL || !REDIS_TOKEN) { + console.error('Redis credentials not set'); + process.exit(1); +} + +const PREFIX = process.env.KEY_PREFIX || ''; +const WINDY_BASE = 'https://api.windy.com/webcams/api/v3/webcams'; +const PAGE_LIMIT = 50; +const BATCH_SIZE = 500; +const GEO_TTL = 86400; +const MAX_OFFSET = 10000; + +// Regional bounding boxes: [S, W, N, E] +const REGIONS = [ + { name: 'Europe West', bounds: [35, -15, 72, 15] }, + { name: 'Europe East', bounds: [35, 15, 72, 45] }, + { name: 'Middle East + N.Africa', bounds: [10, 25, 45, 65] }, + { name: 'Asia East', bounds: [10, 65, 55, 145] }, + { name: 'Asia SE + Oceania', bounds: [-50, 95, 10, 180] }, + { name: 'Americas North', bounds: [15, -170, 72, -50] }, + { name: 'Americas South', bounds: [-60, -90, 15, -30] }, + { name: 'Africa Sub-Saharan', bounds: [-40, -20, 10, 55] }, +]; + +async function pipelineRequest(commands) { + const resp = await fetch(`${REDIS_URL}/pipeline`, { + method: 'POST', + headers: { + Authorization: `Bearer ${REDIS_TOKEN}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(commands), + }); + if (!resp.ok) throw new Error(`Redis pipeline failed: ${resp.status}`); + return resp.json(); +} + +async function fetchRegion(bounds, regionName) { + const [S, W, N, E] = bounds; + const cameras = []; + let offset = 0; + + while (offset < MAX_OFFSET) { + const url = new URL(WINDY_BASE); + url.searchParams.set('cameraBoundingBox', `${S},${W},${N},${E}`); + url.searchParams.set('include', 'location,categories'); + url.searchParams.set('limit', String(PAGE_LIMIT)); + url.searchParams.set('offset', String(offset)); + + const resp = await fetch(url, { + headers: { 'x-windy-api-key': WINDY_API_KEY }, + }); + + if (!resp.ok) { + console.warn(` [${regionName}] API error at offset ${offset}: ${resp.status}`); + break; + } + + const data = await resp.json(); + const webcams = data.webcams || []; + if (webcams.length === 0) break; + + for (const wc of webcams) { + const loc = wc.location || {}; + const cats = (wc.categories || []).map(c => c.id || c).filter(Boolean); + cameras.push({ + webcamId: String(wc.webcamId || wc.id), + title: wc.title || '', + lat: loc.latitude ?? 0, + lng: loc.longitude ?? 0, + category: cats[0] || 'other', + country: loc.country || '', + region: loc.region || '', + status: wc.status || 'active', + }); + } + + offset += webcams.length; + if (webcams.length < PAGE_LIMIT) break; + } + + // Adaptive split: if we hit the 10K cap, split into quadrants + if (offset >= MAX_OFFSET - 50 && cameras.length >= MAX_OFFSET - 50) { + console.log(` [${regionName}] Hit 10K cap, splitting into quadrants...`); + const midLat = (S + N) / 2; + const midLon = (W + E) / 2; + const quadrants = [ + [[S, W, midLat, midLon], `${regionName} SW`], + [[S, midLon, midLat, E], `${regionName} SE`], + [[midLat, W, N, midLon], `${regionName} NW`], + [[midLat, midLon, N, E], `${regionName} NE`], + ]; + cameras.length = 0; + for (const [qBounds, qName] of quadrants) { + const qCameras = await fetchRegion(qBounds, qName); + cameras.push(...qCameras); + } + } + + return cameras; +} + +async function seedGeo(geoKey, cameras) { + for (let i = 0; i < cameras.length; i += BATCH_SIZE) { + const batch = cameras.slice(i, i + BATCH_SIZE); + const args = []; + for (const c of batch) { + args.push(String(c.lng), String(c.lat), c.webcamId); + } + await pipelineRequest([['GEOADD', geoKey, ...args]]); + } +} + +async function seedMeta(metaKey, cameras) { + for (let i = 0; i < cameras.length; i += BATCH_SIZE) { + const batch = cameras.slice(i, i + BATCH_SIZE); + const args = []; + for (const c of batch) { + const { webcamId, ...meta } = c; + args.push(webcamId, JSON.stringify(meta)); + } + await pipelineRequest([['HSET', metaKey, ...args]]); + } +} + +async function main() { + console.log('seed-webcams: starting...'); + + const allCameras = []; + for (const { name, bounds } of REGIONS) { + console.log(` Fetching ${name}...`); + const cameras = await fetchRegion(bounds, name); + console.log(` ${name}: ${cameras.length} cameras`); + allCameras.push(...cameras); + } + + // Deduplicate by webcamId + const seen = new Set(); + const unique = []; + for (const c of allCameras) { + if (!seen.has(c.webcamId)) { + seen.add(c.webcamId); + unique.push(c); + } + } + console.log(` Total unique: ${unique.length}`); + + if (unique.length === 0) { + console.log('seed-webcams: no cameras found, skipping'); + return; + } + + // Versioned write + const version = Date.now(); + const geoKey = `${PREFIX}webcam:cameras:geo:${version}`; + const metaKey = `${PREFIX}webcam:cameras:meta:${version}`; + const activeKey = `${PREFIX}webcam:cameras:active`; + + console.log(` Writing geo index (${unique.length} entries)...`); + await seedGeo(geoKey, unique); + + console.log(` Writing metadata...`); + await seedMeta(metaKey, unique); + + // Set TTL on data keys + await pipelineRequest([ + ['EXPIRE', geoKey, String(GEO_TTL)], + ['EXPIRE', metaKey, String(GEO_TTL)], + ]); + + // Atomic pointer swap + const oldVersion = await pipelineRequest([['GET', activeKey]]); + await pipelineRequest([['SET', activeKey, String(version)]]); + // Set TTL on active pointer AFTER the SET — 30h outlives the 24h data keys + await pipelineRequest([ + ['EXPIRE', activeKey, String(GEO_TTL + 21600)], // 30h — outlives data keys + ]); + console.log(` Activated version ${version}`); + + // Clean up old version + const prev = oldVersion?.[0]?.result; + if (prev && String(prev) !== String(version)) { + await pipelineRequest([ + ['DEL', `${PREFIX}webcam:cameras:geo:${prev}`], + ['DEL', `${PREFIX}webcam:cameras:meta:${prev}`], + ]); + console.log(` Cleaned up old version ${prev}`); + } + + const seedMetaKey = `${PREFIX}seed-meta:webcam:cameras:geo`; + const seedMeta = JSON.stringify({ fetchedAt: Date.now(), recordCount: unique.length }); + await pipelineRequest([['SET', seedMetaKey, seedMeta, 'EX', '604800']]); + + console.log(`seed-webcams: done (${unique.length} cameras seeded)`); +} + +main().then(() => { + process.exit(0); +}).catch(err => { + console.error('seed-webcams: fatal error:', err.message); + process.exit(1); +}); diff --git a/server/worldmonitor/webcam/v1/get-webcam-image.ts b/server/worldmonitor/webcam/v1/get-webcam-image.ts new file mode 100644 index 000000000..ee20adf2f --- /dev/null +++ b/server/worldmonitor/webcam/v1/get-webcam-image.ts @@ -0,0 +1,49 @@ +import type { GetWebcamImageRequest, GetWebcamImageResponse, ServerContext } from '../../../../src/generated/server/worldmonitor/webcam/v1/service_server'; +import { cachedFetchJson } from '../../../_shared/redis'; + +const WINDY_BASE = 'https://api.windy.com/webcams/api/v3/webcams'; +const CACHE_TTL = 300; + +const WEBCAM_ID_RE = /^[\w-]+$/; + +export async function getWebcamImage(_ctx: ServerContext, req: GetWebcamImageRequest): Promise { + const { webcamId } = req; + const windyUrl = `https://www.windy.com/webcams/${encodeURIComponent(webcamId || '')}`; + + if (!webcamId || !WEBCAM_ID_RE.test(webcamId)) { + return { thumbnailUrl: '', playerUrl: '', title: '', windyUrl, lastUpdated: 0, error: 'missing webcam_id' }; + } + + const apiKey = process.env.WINDY_API_KEY; + if (!apiKey) { + return { thumbnailUrl: '', playerUrl: '', title: '', windyUrl, lastUpdated: 0, error: 'unavailable' }; + } + + const result = await cachedFetchJson( + `webcam:image:${webcamId}`, + CACHE_TTL, + async () => { + const resp = await fetch(`${WINDY_BASE}/${encodeURIComponent(webcamId)}?include=images,urls`, { + headers: { 'x-windy-api-key': apiKey }, + signal: AbortSignal.timeout(5000), + }); + if (!resp.ok) return null; + + const data = await resp.json(); + const wc = data.webcams?.[0] ?? data; + const images = wc.images || wc.image || {}; + const urls = wc.urls || {}; + + return { + thumbnailUrl: images.current?.preview || images.current?.thumbnail || '', + playerUrl: urls.player || '', + title: wc.title || '', + windyUrl, + lastUpdated: wc.lastUpdatedOn ? new Date(wc.lastUpdatedOn).getTime() : 0, + error: '', + }; + }, + ); + + return result ?? { thumbnailUrl: '', playerUrl: '', title: '', windyUrl, lastUpdated: 0, error: 'unavailable' }; +} diff --git a/server/worldmonitor/webcam/v1/handler.ts b/server/worldmonitor/webcam/v1/handler.ts new file mode 100644 index 000000000..5d98d108e --- /dev/null +++ b/server/worldmonitor/webcam/v1/handler.ts @@ -0,0 +1,8 @@ +import type { WebcamServiceHandler } from '../../../../src/generated/server/worldmonitor/webcam/v1/service_server'; +import { listWebcams } from './list-webcams'; +import { getWebcamImage } from './get-webcam-image'; + +export const webcamHandler: WebcamServiceHandler = { + listWebcams, + getWebcamImage, +}; diff --git a/server/worldmonitor/webcam/v1/list-webcams.ts b/server/worldmonitor/webcam/v1/list-webcams.ts new file mode 100644 index 000000000..e3bcb8243 --- /dev/null +++ b/server/worldmonitor/webcam/v1/list-webcams.ts @@ -0,0 +1,165 @@ +import type { ListWebcamsRequest, ListWebcamsResponse, WebcamEntry, WebcamCluster, ServerContext } from '../../../../src/generated/server/worldmonitor/webcam/v1/service_server'; +import { geoSearchByBox, getHashFieldsBatch, getCachedJson, setCachedJson } from '../../../_shared/redis'; + +const MAX_RESULTS = 2000; +const RESPONSE_CACHE_TTL = 3600; // 1 hour + +function getClusterCellSize(zoom: number): number { + if (zoom < 3) return 8; + if (zoom <= 4) return 5; + if (zoom <= 6) return 2; + if (zoom <= 8) return 0.5; + return 0; // no clustering +} + +function clusterWebcams( + webcams: Array<{ webcamId: string; title: string; lat: number; lng: number; category: string; country: string }>, + cellSize: number, +): { singles: WebcamEntry[]; clusters: WebcamCluster[] } { + if (cellSize <= 0) { + return { + singles: webcams.map(w => ({ + webcamId: w.webcamId, title: w.title, + lat: w.lat, lng: w.lng, + category: w.category, country: w.country, + })), + clusters: [], + }; + } + + const buckets = new Map(); + for (const w of webcams) { + const key = `${Math.floor(w.lat / cellSize)}:${Math.floor(w.lng / cellSize)}`; + let bucket = buckets.get(key); + if (!bucket) { bucket = []; buckets.set(key, bucket); } + bucket.push(w); + } + + const singles: WebcamEntry[] = []; + const clusters: WebcamCluster[] = []; + + for (const bucket of buckets.values()) { + if (bucket.length === 1) { + const w = bucket[0]!; + singles.push({ + webcamId: w.webcamId, title: w.title, + lat: w.lat, lng: w.lng, + category: w.category, country: w.country, + }); + } else { + // Circular mean for longitude (antimeridian-safe) + const toRad = Math.PI / 180; + const toDeg = 180 / Math.PI; + let sinSum = 0, cosSum = 0, latSum = 0; + const catSet = new Set(); + for (const w of bucket) { + latSum += w.lat; + sinSum += Math.sin(w.lng * toRad); + cosSum += Math.cos(w.lng * toRad); + catSet.add(w.category); + } + clusters.push({ + lat: latSum / bucket.length, + lng: Math.atan2(sinSum, cosSum) * toDeg, + count: bucket.length, + categories: [...catSet], + }); + } + } + + return { singles, clusters }; +} + +export async function listWebcams(_ctx: ServerContext, req: ListWebcamsRequest): Promise { + const { zoom = 3 } = req; + + // Quantize bounds so the GEOSEARCH matches the cache key semantics. + // Every viewport that maps to the same quantized key gets the same superset query. + const qW = Math.floor(req.boundW ?? -180); + const qS = Math.floor(req.boundS ?? -90); + const qE = Math.ceil(req.boundE ?? 180); + const qN = Math.ceil(req.boundN ?? 90); + + // Read active version + const versionResult = await getCachedJson('webcam:cameras:active'); + const version = versionResult != null ? String(versionResult) : null; + if (!version) { + return { webcams: [], clusters: [], totalInView: 0 }; + } + + // Check response cache (quantized bbox + zoom + version) + const cacheKey = `webcam:resp:${version}:${zoom}:${qW}:${qS}:${qE}:${qN}`; + const cached = await getCachedJson(cacheKey) as ListWebcamsResponse | null; + if (cached) return cached; + + const geoKey = `webcam:cameras:geo:${version}`; + const metaKey = `webcam:cameras:meta:${version}`; + + // Compute center and dimensions for GEOSEARCH using quantized bounds + const centerLat = (qN + qS) / 2; + const heightKm = Math.abs(qN - qS) * 111.32; + + // Antimeridian: if W > E, split into two queries + let ids: string[]; + if (qW > qE) { + const centerLon1 = (qW + 180) / 2; + const centerLon2 = (-180 + qE) / 2; + const width1 = (180 - qW) * 111.32 * Math.cos(centerLat * Math.PI / 180); + const width2 = (qE + 180) * 111.32 * Math.cos(centerLat * Math.PI / 180); + const [ids1, ids2] = await Promise.all([ + geoSearchByBox(geoKey, centerLon1, centerLat, width1, heightKm, MAX_RESULTS, true), + geoSearchByBox(geoKey, centerLon2, centerLat, width2, heightKm, MAX_RESULTS, true), + ]); + ids = [...ids1, ...ids2]; + } else { + const centerLon = (qW + qE) / 2; + const widthKm = equirectangularWidthKm(qS, qN, qW, qE); + ids = await geoSearchByBox(geoKey, centerLon, centerLat, widthKm, heightKm, MAX_RESULTS, true); + } + + if (ids.length === 0) { + const empty: ListWebcamsResponse = { webcams: [], clusters: [], totalInView: 0 }; + await setCachedJson(cacheKey, empty, RESPONSE_CACHE_TTL); + return empty; + } + + // Fetch metadata + const metaMap = await getHashFieldsBatch(metaKey, ids, true); + const webcams: Array<{ webcamId: string; title: string; lat: number; lng: number; category: string; country: string }> = []; + + for (const id of ids) { + const raw = metaMap.get(id); + if (!raw) continue; + try { + const meta = JSON.parse(raw); + webcams.push({ + webcamId: id, + title: meta.title || '', + lat: meta.lat || 0, + lng: meta.lng || 0, + category: meta.category || 'other', + country: meta.country || '', + }); + } catch { /* skip malformed */ } + } + + const cellSize = getClusterCellSize(zoom); + const { singles, clusters } = clusterWebcams(webcams, cellSize); + + const result: ListWebcamsResponse = { + webcams: singles, + clusters, + totalInView: webcams.length, + }; + + setCachedJson(cacheKey, result, RESPONSE_CACHE_TTL).catch(err => { + console.warn('[webcam] response cache write failed:', err); + }); + + return result; +} + +function equirectangularWidthKm(s: number, n: number, w: number, e: number): number { + const midLat = ((s + n) / 2) * Math.PI / 180; + return Math.abs(e - w) * 111.32 * Math.cos(midLat); +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 3febc0161..64a1ebeb2 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -29,7 +29,7 @@ } ], "security": { - "csp": "default-src 'self'; connect-src 'self' https: http://localhost:5173 http://127.0.0.1:* ws: wss: blob: data:; img-src 'self' data: blob: https:; style-src 'self' 'unsafe-inline'; script-src 'self' 'wasm-unsafe-eval' https://www.youtube.com; worker-src 'self' blob:; font-src 'self' data: https:; media-src 'self' data: blob: https: http://127.0.0.1:* http://localhost:*; frame-src 'self' http://127.0.0.1:* http://localhost:* https://worldmonitor.app https://tech.worldmonitor.app https://finance.worldmonitor.app https://commodity.worldmonitor.app https://happy.worldmonitor.app https://www.youtube.com https://www.youtube-nocookie.com;" + "csp": "default-src 'self'; connect-src 'self' https: http://localhost:5173 http://127.0.0.1:* ws: wss: blob: data:; img-src 'self' data: blob: https:; style-src 'self' 'unsafe-inline'; script-src 'self' 'wasm-unsafe-eval' https://www.youtube.com; worker-src 'self' blob:; font-src 'self' data: https:; media-src 'self' data: blob: https: http://127.0.0.1:* http://localhost:*; frame-src 'self' http://127.0.0.1:* http://localhost:* https://worldmonitor.app https://tech.worldmonitor.app https://finance.worldmonitor.app https://commodity.worldmonitor.app https://happy.worldmonitor.app https://www.youtube.com https://www.youtube-nocookie.com https://webcams.windy.com;" } }, "bundle": { diff --git a/src/app/data-loader.ts b/src/app/data-loader.ts index a67a60d93..8c43f4152 100644 --- a/src/app/data-loader.ts +++ b/src/app/data-loader.ts @@ -466,6 +466,7 @@ export class DataLoaderManager implements AppModule { if (SITE_VARIANT !== 'happy' && !isDesktopRuntime()) tasks.push({ name: 'iranAttacks', task: runGuarded('iranAttacks', () => this.loadIranEvents()) }); if (SITE_VARIANT !== 'happy' && (this.ctx.mapLayers.techEvents || SITE_VARIANT === 'tech')) tasks.push({ name: 'techEvents', task: runGuarded('techEvents', () => this.loadTechEvents()) }); if (SITE_VARIANT !== 'happy' && this.ctx.mapLayers.satellites && this.ctx.map?.isGlobeMode?.()) tasks.push({ name: 'satellites', task: runGuarded('satellites', () => this.loadSatellites()) }); + if (SITE_VARIANT !== 'happy' && this.ctx.mapLayers.webcams) tasks.push({ name: 'webcams', task: runGuarded('webcams', () => this.loadWebcams()) }); if (SITE_VARIANT !== 'happy') { tasks.push({ name: 'techReadiness', task: runGuarded('techReadiness', () => (this.ctx.panels['tech-readiness'] as TechReadinessPanel)?.refresh()) }); @@ -565,6 +566,9 @@ export class DataLoaderManager implements AppModule { this.loadImageryFootprints(); break; } + case 'webcams': + await this.loadWebcams(); + break; case 'ucdpEvents': case 'displacement': case 'climate': @@ -2009,6 +2013,51 @@ export class DataLoaderManager implements AppModule { } } + private lastWebcamBbox: { w: number; s: number; e: number; n: number; zoom: number } | null = null; + private lastWebcamFetchAt = 0; + + async loadWebcams(): Promise { + if (!this.ctx.map) return; + try { + const map = this.ctx.map; + const zoom = map.getState().zoom ?? 3; + + if (zoom < 2) return; + + const now = Date.now(); + if (now - this.lastWebcamFetchAt < 1000) return; + + const bboxStr = map.getBbox(); + const parts = bboxStr ? bboxStr.split(',').map(Number) : [-180, -90, 180, 90]; + const w = parts[0] ?? -180; + const s = parts[1] ?? -90; + const e = parts[2] ?? 180; + const n = parts[3] ?? 90; + + if (this.lastWebcamBbox && this.lastWebcamBbox.zoom === zoom) { + const prev = this.lastWebcamBbox; + const overlapW = Math.max(0, Math.min(prev.e, e) - Math.max(prev.w, w)); + const overlapH = Math.max(0, Math.min(prev.n, n) - Math.max(prev.s, s)); + const overlapArea = overlapW * overlapH; + const currentArea = Math.max(0.001, (e - w) * (n - s)); + if (overlapArea / currentArea > 0.8) return; + } + + this.lastWebcamFetchAt = now; + this.lastWebcamBbox = { w, s, e, n, zoom }; + + const { fetchWebcams } = await import('@/services/webcams'); + const result = await fetchWebcams(zoom, { w, s, e, n }); + + const allMarkers = [...result.webcams, ...result.clusters]; + map.setWebcams(allMarkers); + map.setLayerReady('webcams', allMarkers.length > 0); + } catch (err) { + console.warn('[data-loader] webcams failed:', err); + this.ctx.map?.setLayerReady('webcams', false); + } + } + async loadFlightDelays(): Promise { try { const delays = await fetchFlightDelays(); diff --git a/src/app/event-handlers.ts b/src/app/event-handlers.ts index 79b7e63f8..f91dd118b 100644 --- a/src/app/event-handlers.ts +++ b/src/app/event-handlers.ts @@ -27,6 +27,7 @@ import { LAYER_TO_SOURCE, FEEDS, INTEL_SOURCES, + DEFAULT_PANELS, } from '@/config'; import { VARIANT_META } from '@/config/variant-meta'; import { @@ -50,6 +51,7 @@ import { dataFreshness } from '@/services/data-freshness'; import { mlWorker } from '@/services/ml-worker'; import { UnifiedSettings } from '@/components/UnifiedSettings'; import { t } from '@/services/i18n'; +import { TvModeController } from '@/services/tv-mode'; export interface EventHandlerCallbacks { updateSearchIndex: () => void; @@ -98,6 +100,12 @@ export class EventHandlerManager implements AppModule { try { history.replaceState(null, '', shareUrl); } catch { } }, 250); + private readonly debouncedWebcamReload = debounce(() => { + if (this.ctx.mapLayers?.webcams) { + this.callbacks.loadDataForLayer('webcams'); + } + }, 350); + constructor(ctx: AppContext, callbacks: EventHandlerCallbacks) { this.ctx = ctx; this.callbacks = callbacks; @@ -106,6 +114,7 @@ export class EventHandlerManager implements AppModule { init(): void { this.setupEventListeners(); this.setupIdleDetection(); + this.setupTvMode(); } private performUndo(): void { @@ -126,9 +135,51 @@ export class EventHandlerManager implements AppModule { } } + private setupTvMode(): void { + if (SITE_VARIANT !== 'happy') return; + + const tvBtn = document.getElementById('tvModeBtn'); + const tvExitBtn = document.getElementById('tvExitBtn'); + if (tvBtn) { + tvBtn.addEventListener('click', () => this.toggleTvMode()); + } + if (tvExitBtn) { + tvExitBtn.addEventListener('click', () => this.toggleTvMode()); + } + // Keyboard shortcut: Shift+T + this.boundTvKeydownHandler = (e: KeyboardEvent) => { + if (e.shiftKey && e.key === 'T' && !e.ctrlKey && !e.metaKey && !e.altKey) { + const active = document.activeElement; + if (active?.tagName !== 'INPUT' && active?.tagName !== 'TEXTAREA') { + e.preventDefault(); + this.toggleTvMode(); + } + } + }; + document.addEventListener('keydown', this.boundTvKeydownHandler); + } + + private toggleTvMode(): void { + const panelKeys = Object.keys(DEFAULT_PANELS).filter( + key => this.ctx.panelSettings[key]?.enabled !== false + ); + if (!this.ctx.tvMode) { + this.ctx.tvMode = new TvModeController({ + panelKeys, + onPanelChange: () => { + document.getElementById('tvModeBtn')?.classList.toggle('active', this.ctx.tvMode?.active ?? false); + } + }); + } else { + this.ctx.tvMode.updatePanelKeys(panelKeys); + } + this.ctx.tvMode.toggle(); + document.getElementById('tvModeBtn')?.classList.toggle('active', this.ctx.tvMode.active); + } destroy(): void { this.debouncedUrlSync.cancel(); + this.debouncedWebcamReload.cancel(); if (this.boundFullscreenHandler) { document.removeEventListener('fullscreenchange', this.boundFullscreenHandler); this.boundFullscreenHandler = null; @@ -537,6 +588,7 @@ export class EventHandlerManager implements AppModule { regionSelect.value = state.view; } } + this.debouncedWebcamReload(); }); this.debouncedUrlSync(); } diff --git a/src/app/panel-layout.ts b/src/app/panel-layout.ts index cc4b51a75..d83b7dd65 100644 --- a/src/app/panel-layout.ts +++ b/src/app/panel-layout.ts @@ -17,6 +17,7 @@ import { GdeltIntelPanel, LiveNewsPanel, LiveWebcamsPanel, + PinnedWebcamsPanel, CIIPanel, CascadePanel, StrategicRiskPanel, @@ -217,6 +218,7 @@ export class PanelLayoutManager implements AppModule { ${this.ctx.isDesktopApp ? '' : ``} ${this.ctx.isDesktopApp ? '' : ``} + ${SITE_VARIANT === 'happy' ? `` : ''} @@ -316,6 +318,7 @@ export class PanelLayoutManager implements AppModule {
+ ${SITE_VARIANT === 'happy' ? '' : ''}
@@ -737,6 +740,10 @@ export class PanelLayoutManager implements AppModule { this.ctx.panels['live-webcams'] = new LiveWebcamsPanel(); } + if (this.shouldCreatePanel('pinned-webcams')) { + this.ctx.panels['pinned-webcams'] = new PinnedWebcamsPanel(); + } + this.createPanel('events', () => new TechEventsPanel('events', () => this.ctx.allNews)); this.createPanel('service-status', () => new ServiceStatusPanel()); @@ -929,13 +936,9 @@ export class PanelLayoutManager implements AppModule { addPanelBlock.addEventListener('click', () => { this.ctx.unifiedSettings?.open('panels'); }); + panelsGrid.appendChild(addPanelBlock); const bottomGrid = document.getElementById('mapBottomGrid'); - if (SITE_VARIANT === 'happy' && bottomGrid) { - bottomGrid.appendChild(addPanelBlock); - } else { - panelsGrid.appendChild(addPanelBlock); - } if (bottomGrid) { bottomOrder.forEach(key => { const panel = this.ctx.panels[key]; diff --git a/src/components/DeckGLMap.ts b/src/components/DeckGLMap.ts index 72a795540..246bdebb4 100644 --- a/src/components/DeckGLMap.ts +++ b/src/components/DeckGLMap.ts @@ -111,6 +111,9 @@ import { getCountriesGeoJson, getCountryAtCoordinates, getCountryBbox } from '@/ import type { FeatureCollection, Geometry } from 'geojson'; import { isAllowedPreviewUrl } from '@/utils/imagery-preview'; +import { pinWebcam, isPinned } from '@/services/webcams/pinned-store'; +import type { WebcamEntry, WebcamCluster } from '@/generated/client/worldmonitor/webcam/v1/service_client'; +import { fetchWebcamImage } from '@/services/webcams'; export type TimeRange = '1h' | '6h' | '24h' | '48h' | '7d' | 'all'; export type DeckMapView = 'global' | 'america' | 'mena' | 'eu' | 'asia' | 'latam' | 'africa' | 'oceania'; @@ -341,6 +344,7 @@ export class DeckGLMap { private happinessSource = ''; private speciesRecoveryZones: Array = []; private renewableInstallations: RenewableInstallation[] = []; + private webcamData: Array = []; private countriesGeoJsonData: FeatureCollection | null = null; private conflictZoneGeoJson: GeoJSON.FeatureCollection | null = null; @@ -1502,6 +1506,19 @@ export class DeckGLMap { layers.push(this.createImageryFootprintLayer()); } + // Webcam layer (server-side clustered markers) + if (mapLayers.webcams && this.webcamData.length > 0) { + layers.push(new ScatterplotLayer({ + id: 'webcam-layer', + data: this.webcamData, + getPosition: (d) => [d.lng, d.lat], + getRadius: (d) => ('count' in d ? Math.min(8 + d.count * 0.5, 24) : 6), + getFillColor: (d) => ('count' in d ? [0, 212, 255, 180] : [255, 215, 0, 200]) as [number, number, number, number], + radiusUnits: 'pixels', + pickable: true, + })); + } + // News geo-locations (always shown if data exists) if (this.newsLocations.length > 0) { layers.push(...this.createNewsLocationsLayer()); @@ -3457,6 +3474,12 @@ export class DeckGLMap { imgHtml += ''; return { html: imgHtml }; } + case 'webcam-layer': { + const label = 'count' in obj + ? `${obj.count} webcams` + : (obj.title || obj.name || 'Webcam'); + return { html: `
${text(label)}
` }; + } default: return null; } @@ -3636,6 +3659,11 @@ export class DeckGLMap { return; } + if (layerId === 'webcam-layer' && !('count' in info.object)) { + this.showWebcamClickPopup(info.object as WebcamEntry, info.x, info.y); + return; + } + // Map layer IDs to popup types const layerToPopupType: Record = { 'conflict-zones-layer': 'conflict', @@ -3720,6 +3748,72 @@ export class DeckGLMap { }); } + private async showWebcamClickPopup(webcam: WebcamEntry, x: number, y: number): Promise { + // Remove any existing popup + this.container.querySelector('.deckgl-webcam-popup')?.remove(); + + const popup = document.createElement('div'); + popup.className = 'deckgl-webcam-popup'; + popup.style.position = 'absolute'; + popup.style.left = x + 'px'; + popup.style.top = y + 'px'; + popup.style.zIndex = '1000'; + + const titleEl = document.createElement('div'); + titleEl.className = 'deckgl-webcam-popup-title'; + titleEl.textContent = webcam.title || webcam.webcamId || ''; + popup.appendChild(titleEl); + + const locationEl = document.createElement('div'); + locationEl.className = 'deckgl-webcam-popup-location'; + locationEl.textContent = webcam.country || ''; + popup.appendChild(locationEl); + + const id = webcam.webcamId; + + // Fetch playerUrl for when user pins + const imageData = await fetchWebcamImage(id).catch(() => null); + + const pinBtn = document.createElement('button'); + pinBtn.className = 'webcam-pin-btn'; + if (isPinned(id)) { + pinBtn.classList.add('webcam-pin-btn--pinned'); + pinBtn.textContent = '\u{1F4CC} Pinned'; + pinBtn.disabled = true; + } else { + pinBtn.textContent = '\u{1F4CC} Pin'; + pinBtn.addEventListener('click', (e) => { + e.stopPropagation(); + pinWebcam({ + webcamId: id, + title: webcam.title || imageData?.title || '', + lat: webcam.lat, + lng: webcam.lng, + category: webcam.category || 'other', + country: webcam.country || '', + playerUrl: imageData?.playerUrl || '', + }); + pinBtn.classList.add('webcam-pin-btn--pinned'); + pinBtn.textContent = '\u{1F4CC} Pinned'; + pinBtn.disabled = true; + }); + } + popup.appendChild(pinBtn); + + const cleanup = () => { + popup.remove(); + document.removeEventListener('click', closeHandler); + clearTimeout(autoDismiss); + }; + const closeHandler = (e: MouseEvent) => { + if (!popup.contains(e.target as Node)) cleanup(); + }; + const autoDismiss = setTimeout(cleanup, 8000); + setTimeout(() => document.addEventListener('click', closeHandler), 0); + + this.container.appendChild(popup); + } + // Utility methods private hexToRgba(hex: string, alpha: number): [number, number, number, number] { const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); @@ -4682,6 +4776,11 @@ export class DeckGLMap { this.render(); } + public setWebcams(markers: Array): void { + this.webcamData = markers; + this.render(); + } + public setGpsJamming(hexes: GpsJamHex[]): void { this.gpsJammingHexes = hexes; this.render(); diff --git a/src/components/GlobeMap.ts b/src/components/GlobeMap.ts index 2ed5c59c0..376058f4a 100644 --- a/src/components/GlobeMap.ts +++ b/src/components/GlobeMap.ts @@ -35,11 +35,6 @@ import type { MapLayers, Hotspot, MilitaryFlight, MilitaryVessel, MilitaryVessel import type { Earthquake } from '@/services/earthquakes'; import type { AirportDelayAlert } from '@/services/aviation'; import { MapPopup } from './MapPopup'; -import type { PositiveGeoEvent } from '@/services/positive-events-geo'; -import type { KindnessPoint } from '@/services/kindness-data'; -import type { HappinessData } from '@/services/happiness-data'; -import type { SpeciesRecovery } from '@/services/conservation-data'; -import type { RenewableInstallation } from '@/services/renewable-installations'; import type { MapContainerState, MapView, TimeRange } from './MapContainer'; import type { CountryClickPayload } from './DeckGLMap'; import type { WeatherAlert } from '@/services/weather'; @@ -50,6 +45,9 @@ import type { GpsJamHex } from '@/services/gps-interference'; import type { SatellitePosition } from '@/services/satellites'; import type { ImageryScene } from '@/generated/server/worldmonitor/imagery/v1/service_server'; import { isAllowedPreviewUrl } from '@/utils/imagery-preview'; +import { getCategoryStyle } from '@/services/webcams'; +import { pinWebcam, isPinned } from '@/services/webcams/pinned-store'; +import type { WebcamEntry, WebcamCluster } from '@/generated/client/worldmonitor/webcam/v1/service_client'; const SAT_COUNTRY_COLORS: Record = { CN: '#ff2020', RU: '#ff8800', US: '#4488ff', EU: '#44cc44', KR: '#aa66ff', IN: '#ff66aa', TR: '#ff4466', OTHER: '#ccccff' }; const SAT_TYPE_EMOJI: Record = { sar: '\u{1F4E1}', optical: '\u{1F4F7}', military: '\u{1F396}', sigint: '\u{1F4FB}' }; @@ -338,31 +336,17 @@ interface ImagerySceneMarker extends BaseMarker { mode: string; previewUrl: string; } -interface PositiveEventMarker extends BaseMarker { - _kind: 'positiveEvent'; - name: string; +interface WebcamMarkerData extends BaseMarker { + _kind: 'webcam'; + webcamId: string; + title: string; category: string; + country: string; +} +interface WebcamClusterData extends BaseMarker { + _kind: 'webcam-cluster'; count: number; -} -interface KindnessMarker extends BaseMarker { - _kind: 'kindness'; - name: string; - description: string; - intensity: number; - type: 'baseline' | 'real'; -} -interface SpeciesRecoveryMarker extends BaseMarker { - _kind: 'speciesRecovery'; - id: string; - commonName: string; - recoveryStatus: string; -} -interface RenewableInstallationMarker extends BaseMarker { - _kind: 'renewableInstallation'; - id: string; - name: string; - type: string; - capacityMW: number; + categories: string[]; } interface GlobePath { id: string; @@ -376,7 +360,7 @@ interface GlobePath { interface GlobePolygon { coords: number[][][]; name: string; - _kind: 'cii' | 'conflict' | 'imageryFootprint' | 'forecastCone' | 'happiness'; + _kind: 'cii' | 'conflict' | 'imageryFootprint' | 'forecastCone'; level?: string; score?: number; @@ -399,7 +383,7 @@ type GlobeMarker = | EarthquakeMarker | EconomicMarker | DatacenterMarker | WaterwayMarker | MineralMarker | FlightDelayMarker | NotamRingMarker | CableAdvisoryMarker | RepairShipMarker | AisDisruptionMarker | NewsLocationMarker | FlashMarker | SatelliteMarker | SatFootprintMarker | ImagerySceneMarker - | PositiveEventMarker | KindnessMarker | SpeciesRecoveryMarker | RenewableInstallationMarker; + | WebcamMarkerData | WebcamClusterData; interface GlobeControlsLike { autoRotate: boolean; @@ -488,15 +472,9 @@ export class GlobeMap { private stormConePolygons: GlobePolygon[] = []; private satelliteFootprintMarkers: SatFootprintMarker[] = []; private imagerySceneMarkers: ImagerySceneMarker[] = []; + private webcamMarkers: (WebcamMarkerData | WebcamClusterData)[] = []; + private webcamMarkerMode: string = localStorage.getItem('wm-webcam-marker-mode') || 'icon'; private imageryFootprintPolygons: GlobePolygon[] = []; - private positiveEventMarkers: GlobeMarker[] = []; - private kindnessMarkers: GlobeMarker[] = []; - private speciesRecoveryMarkers: GlobeMarker[] = []; - private renewableInstallationMarkers: GlobeMarker[] = []; - private happinessScores: Map = new Map(); - private happinessYear = 0; - private happinessSource = ''; - private hasFocusedOnHappy = false; private lastImageryCenter: { lat: number; lon: number } | null = null; private imageryFetchTimer: ReturnType | null = null; private imageryFetchVersion = 0; @@ -548,11 +526,7 @@ export class GlobeMap { this.currentView = initialState.view; this.container.classList.add('globe-mode'); - this.container.style.position = 'relative'; - this.container.style.width = '100%'; - this.container.style.height = '100%'; - this.container.style.minHeight = '100%'; - this.container.style.background = '#000'; + this.container.style.cssText = 'width:100%;height:100%;background:#000;position:relative;'; this.initGlobe().catch(err => { console.error('[GlobeMap] Init failed:', err); @@ -636,8 +610,6 @@ export class GlobeMap { if (glCanvas) { (glCanvas as HTMLElement).style.cssText = 'position:absolute;top:0;left:0;width:100% !important;height:100% !important;'; - } else { - console.warn('[GlobeMap] initGlobe no canvas found (will retry on resize).'); } // Globe attribution (texture + OpenStreetMap data) @@ -814,15 +786,6 @@ export class GlobeMap { .polygonCapColor((d: GlobePolygon) => { if (d._kind === 'cii') return GlobeMap.CII_GLOBE_COLORS[d.level!] ?? 'rgba(0,0,0,0)'; if (d._kind === 'conflict') return GlobeMap.CONFLICT_CAP[d.intensity!] ?? GlobeMap.CONFLICT_CAP.low; - if (d._kind === 'happiness') { - const score = (d as any).score as number | undefined; - if (score == null) return 'rgba(0,0,0,0)'; - const t = score / 10; - const r = Math.round(40 + (1 - t) * 180); - const g = Math.round(180 + t * 60); - const b = Math.round(40 + (1 - t) * 100); - return `rgba(${r},${g},${b},0.35)`; - } if (d._kind === 'imageryFootprint') return 'rgba(0,0,0,0)'; if (d._kind === 'forecastCone') return 'rgba(255,140,60,0.2)'; return 'rgba(255,60,60,0.15)'; @@ -830,7 +793,6 @@ export class GlobeMap { .polygonSideColor((d: GlobePolygon) => { if (d._kind === 'cii') return 'rgba(0,0,0,0)'; if (d._kind === 'conflict') return GlobeMap.CONFLICT_SIDE[d.intensity!] ?? GlobeMap.CONFLICT_SIDE.low; - if (d._kind === 'happiness') return 'rgba(70, 170, 70, 0.25)'; if (d._kind === 'imageryFootprint') return 'rgba(0,0,0,0)'; if (d._kind === 'forecastCone') return 'rgba(255,140,60,0.1)'; return 'rgba(255,60,60,0.08)'; @@ -838,7 +800,6 @@ export class GlobeMap { .polygonStrokeColor((d: GlobePolygon) => { if (d._kind === 'cii') return 'rgba(80,80,80,0.3)'; if (d._kind === 'conflict') return GlobeMap.CONFLICT_STROKE[d.intensity!] ?? GlobeMap.CONFLICT_STROKE.low; - if (d._kind === 'happiness') return 'rgba(70,170,70,0.4)'; if (d._kind === 'imageryFootprint') return '#00b4ff'; if (d._kind === 'forecastCone') return 'rgba(255,140,60,0.5)'; return '#ff4444'; @@ -856,11 +817,6 @@ export class GlobeMap { if (d.casualties) label += `
Casualties: ${escapeHtml(d.casualties)}`; return label; } - if (d._kind === 'happiness') { - const year = this.happinessYear || 'unknown'; - const source = this.happinessSource ? `Source: ${escapeHtml(this.happinessSource)}` : 'Source unknown'; - return `${escapeHtml(d.name)}
Happiness score: ${d.score?.toFixed(1) ?? 'N/A'}
Year: ${year}
${source}`; - } if (d._kind === 'imageryFootprint') { let label = `🛰 ${escapeHtml(d.satellite ?? '')}`; if (d.datetime) label += `
${escapeHtml(d.datetime)}`; @@ -1161,21 +1117,6 @@ export class GlobeMap { const sc = d.severity === 'high' ? '#ff2020' : d.severity === 'elevated' ? '#ff8800' : '#44aaff'; el.innerHTML = GlobeMap.wrapHit(`
`); el.title = d.name; - } else if (d._kind === 'positiveEvent') { - const color = d.count > 8 ? '#44ff88' : '#88ff44'; - el.innerHTML = GlobeMap.wrapHit(`
`); - el.title = `${d.name} (${d.category})`; - } else if (d._kind === 'kindness') { - const color = d.type === 'real' ? '#38d6ff' : '#88c8ff'; - el.innerHTML = GlobeMap.wrapHit(`
💖
`); - el.title = `${d.name}`; - } else if (d._kind === 'speciesRecovery') { - el.innerHTML = GlobeMap.wrapHit(`
🌿
`); - el.title = `${d.commonName} (${d.recoveryStatus || 'recovered'})`; - } else if (d._kind === 'renewableInstallation') { - const icon = d.type === 'wind' ? '🌀' : d.type === 'solar' ? '☀️' : d.type === 'hydro' ? '💧' : '🌋'; - el.innerHTML = GlobeMap.wrapHit(`
${icon}
`); - el.title = `${d.name} · ${d.type} ${d.capacityMW}MW`; } else if (d._kind === 'satellite') { const c = SAT_COUNTRY_COLORS[(d as SatelliteMarker).country] || '#ccccff'; el.innerHTML = `
`; @@ -1188,6 +1129,14 @@ export class GlobeMap { } else if (d._kind === 'imageryScene') { el.innerHTML = GlobeMap.wrapHit(`
🛰
`); el.title = `${d.satellite} ${d.datetime}`; + } else if (d._kind === 'webcam') { + const style = getCategoryStyle(d.category); + const emoji = this.webcamMarkerMode === 'emoji' ? style.emoji : '\u{1F4F7}'; + el.innerHTML = GlobeMap.wrapHit(`${emoji}`); + el.title = d.title; + } else if (d._kind === 'webcam-cluster') { + el.innerHTML = GlobeMap.wrapHit(`${d.count}`); + el.title = `${d.count} webcams`; } else if (d._kind === 'flash') { el.style.pointerEvents = 'none'; el.innerHTML = ` @@ -1245,6 +1194,11 @@ export class GlobeMap { } } + if (d._kind === 'webcam-cluster' && this.globe) { + const pov = this.globe.pointOfView(); + // Fly to cluster and zoom in (reduce altitude by 60%) + this.globe.pointOfView({ lat: d._lat, lng: d._lng, altitude: pov.altitude * 0.4 }, 800); + } this.showMarkerTooltip(d, anchor); } @@ -1474,10 +1428,103 @@ export class GlobeMap { const safeHref = escapeHtml(new URL(d.previewUrl!).href); html += `
`; } + } else if (d._kind === 'webcam') { + html = ''; + } else if (d._kind === 'webcam-cluster') { + html = ''; } el.innerHTML = `
${closeBtn}${html}
`; if (d._kind === 'satellite') el.style.maxWidth = '300px'; el.querySelector('button')?.addEventListener('click', () => this.hideTooltip()); + + if (d._kind === 'webcam') { + const wrapper = el.firstElementChild!; + const titleSpan = document.createElement('span'); + titleSpan.style.cssText = 'color:#00d4ff;font-weight:bold;'; + titleSpan.textContent = `\u{1F4F7} ${d.title.slice(0, 50)}`; + wrapper.appendChild(titleSpan); + + const metaSpan = document.createElement('span'); + metaSpan.style.cssText = 'display:block;opacity:.7;font-size:11px;'; + metaSpan.textContent = `${d.country} \u00B7 ${d.category}`; + wrapper.appendChild(metaSpan); + + const previewDiv = document.createElement('div'); + previewDiv.style.marginTop = '4px'; + const loadingSpan = document.createElement('span'); + loadingSpan.style.cssText = 'opacity:.5;font-size:11px;'; + loadingSpan.textContent = 'Loading preview...'; + previewDiv.appendChild(loadingSpan); + wrapper.appendChild(previewDiv); + + const link = document.createElement('a'); + link.href = `https://www.windy.com/webcams/${encodeURIComponent(d.webcamId)}`; + link.target = '_blank'; + link.rel = 'noopener'; + link.style.cssText = 'display:block;color:#00d4ff;font-size:11px;text-decoration:none;'; + link.textContent = 'Open on Windy \u2197'; + wrapper.appendChild(link); + + const attribution = document.createElement('div'); + attribution.style.cssText = 'opacity:.4;font-size:9px;margin-top:4px;'; + attribution.textContent = 'Powered by Windy'; + wrapper.appendChild(attribution); + + import('@/services/webcams').then(({ fetchWebcamImage }) => { + fetchWebcamImage(d.webcamId).then(img => { + if (!el.isConnected) return; + previewDiv.replaceChildren(); + if (img.thumbnailUrl) { + const imgEl = document.createElement('img'); + imgEl.src = img.thumbnailUrl; + imgEl.style.cssText = 'width:200px;border-radius:4px;margin-bottom:4px;'; + imgEl.loading = 'lazy'; + previewDiv.appendChild(imgEl); + } else { + const span = document.createElement('span'); + span.style.cssText = 'opacity:.5;font-size:11px;'; + span.textContent = 'Preview unavailable'; + previewDiv.appendChild(span); + } + const pinBtn = document.createElement('button'); + pinBtn.className = 'webcam-pin-btn'; + pinBtn.style.cssText = 'display:block;margin-top:4px;'; + if (isPinned(d.webcamId)) { + pinBtn.classList.add('webcam-pin-btn--pinned'); + pinBtn.textContent = '\u{1F4CC} Pinned'; + pinBtn.disabled = true; + } else { + pinBtn.textContent = '\u{1F4CC} Pin'; + pinBtn.addEventListener('click', (e) => { + e.stopPropagation(); + pinWebcam({ + webcamId: d.webcamId, + title: d.title || img.title || '', + lat: d._lat, + lng: d._lng, + category: d.category || 'other', + country: d.country || '', + playerUrl: img.playerUrl || '', + }); + pinBtn.classList.add('webcam-pin-btn--pinned'); + pinBtn.textContent = '\u{1F4CC} Pinned'; + pinBtn.disabled = true; + }); + } + wrapper.appendChild(pinBtn); + }); + }); + } else if (d._kind === 'webcam-cluster') { + const wrapper = el.firstElementChild!; + const header = document.createElement('span'); + header.style.cssText = 'color:#00d4ff;font-weight:bold;'; + header.textContent = `\u{1F4F7} ${d.count} webcams`; + wrapper.appendChild(header); + const loadingSpan = document.createElement('span'); + loadingSpan.style.cssText = 'display:block;opacity:.5;font-size:10px;'; + loadingSpan.textContent = 'Loading list...'; + wrapper.appendChild(loadingSpan); + } el.addEventListener('mouseenter', () => { if (this.tooltipHideTimer) { clearTimeout(this.tooltipHideTimer); this.tooltipHideTimer = null; } }); @@ -1503,8 +1550,79 @@ export class GlobeMap { this.tooltipEl = el; if (this.tooltipHideTimer) clearTimeout(this.tooltipHideTimer); - const hideDelay = d._kind === 'satellite' ? 6000 : 3500; + const hideDelay = d._kind === 'satellite' ? 6000 : d._kind === 'webcam' ? 8000 : d._kind === 'webcam-cluster' ? 12000 : 3500; this.tooltipHideTimer = setTimeout(() => this.hideTooltip(), hideDelay); + + if (d._kind === 'webcam-cluster') { + const tooltipEl = el; + const alt = this.globe?.pointOfView()?.altitude ?? 2.0; + const approxZoom = alt >= 2.0 ? 2 : alt >= 1.0 ? 4 : alt >= 0.5 ? 6 : 8; + import('@/services/webcams').then(({ fetchWebcams, getClusterCellSize }) => { + const margin = Math.max(0.5, getClusterCellSize(approxZoom)); + fetchWebcams(10, { + w: d._lng - margin, s: d._lat - margin, + e: d._lng + margin, n: d._lat + margin, + }).then(result => { + if (!tooltipEl.isConnected) return; + const webcams = result.webcams.slice(0, 20); + + const wrapper = document.createElement('div'); + wrapper.style.cssText = 'padding-right:16px;position:relative;'; + + const closeBtn2 = document.createElement('button'); + closeBtn2.style.cssText = 'position:absolute;top:4px;right:4px;background:none;border:none;color:#888;cursor:pointer;font-size:14px;line-height:1;padding:2px 4px;'; + closeBtn2.setAttribute('aria-label', 'Close'); + closeBtn2.textContent = '\u00D7'; + closeBtn2.addEventListener('click', () => this.hideTooltip()); + wrapper.appendChild(closeBtn2); + + const headerSpan = document.createElement('span'); + headerSpan.style.cssText = 'color:#00d4ff;font-weight:bold;'; + headerSpan.textContent = `\u{1F4F7} ${webcams.length} webcams`; + wrapper.appendChild(headerSpan); + + const listDiv = document.createElement('div'); + listDiv.style.cssText = 'max-height:180px;overflow-y:auto;margin-top:4px;'; + + for (const webcam of webcams) { + const item = document.createElement('div'); + item.style.cssText = 'padding:2px 0;cursor:pointer;color:#aaa;border-bottom:1px solid rgba(255,255,255,0.08);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;'; + + const nameSpan = document.createElement('span'); + nameSpan.textContent = webcam.title || webcam.category || 'Webcam'; + item.appendChild(nameSpan); + + if (webcam.country) { + const countrySpan = document.createElement('span'); + countrySpan.style.cssText = 'float:right;opacity:0.4;font-size:10px;margin-left:6px;'; + countrySpan.textContent = webcam.country; + item.appendChild(countrySpan); + } + + item.addEventListener('mouseenter', () => { item.style.color = '#00d4ff'; }); + item.addEventListener('mouseleave', () => { item.style.color = '#aaa'; }); + item.addEventListener('click', (e) => { + e.stopPropagation(); + const cr = this.container.getBoundingClientRect(); + const me = e as MouseEvent; + const phantom = document.createElement('div'); + phantom.style.cssText = `position:absolute;left:${me.clientX - cr.left}px;top:${me.clientY - cr.top}px;width:1px;height:1px;pointer-events:none;`; + this.container.appendChild(phantom); + this.showMarkerTooltip({ + _kind: 'webcam', _lat: webcam.lat, _lng: webcam.lng, + webcamId: webcam.webcamId, title: webcam.title, + category: webcam.category, country: webcam.country, + } as GlobeMarker, phantom); + phantom.remove(); + }); + listDiv.appendChild(item); + } + + wrapper.appendChild(listDiv); + tooltipEl.replaceChildren(wrapper); + }); + }); + } } private hideTooltip(): void { @@ -1598,9 +1716,44 @@ export class GlobeMap { this.flushLayerChannels(layer); this.onLayerChangeCb?.(layer, checked, 'user'); this.enforceLayerLimit(); + // Show/hide webcam marker-mode sub-row when webcam layer is toggled + if (layer === 'webcams') { + const modeRow = el.querySelector('.webcam-mode-row') as HTMLElement | null; + if (modeRow) modeRow.style.display = checked ? '' : 'none'; + } } }); }); + + // ── Webcam marker-mode sub-toggle ──────────────────────────────────────── + const webcamToggleEl = el.querySelector('.layer-toggle[data-layer="webcams"]') as HTMLElement | null; + if (webcamToggleEl) { + const modeRow = document.createElement('div'); + modeRow.className = 'webcam-mode-row'; + modeRow.style.cssText = 'display:none;padding:2px 6px 4px 24px;font-size:10px;color:#aaa;'; + const currentMode = (): string => localStorage.getItem('wm-webcam-marker-mode') || 'icon'; + const renderModeLabel = (): string => currentMode() === 'emoji' ? '📷 icon mode' : '😀 emoji mode'; + const modeBtn = document.createElement('button'); + modeBtn.style.cssText = 'background:rgba(0,212,255,0.1);border:1px solid rgba(0,212,255,0.3);color:#00d4ff;font-size:10px;padding:1px 6px;border-radius:3px;cursor:pointer;margin-left:2px;'; + modeBtn.title = 'Toggle webcam marker style'; + modeBtn.innerHTML = renderModeLabel(); + modeBtn.addEventListener('click', (e) => { + e.stopPropagation(); + const next = currentMode() === 'icon' ? 'emoji' : 'icon'; + localStorage.setItem('wm-webcam-marker-mode', next); + this.webcamMarkerMode = next; + modeBtn.innerHTML = renderModeLabel(); + this.flushMarkers(); + }); + const modeLabel = document.createElement('span'); + modeLabel.textContent = 'Marker: '; + modeRow.appendChild(modeLabel); + modeRow.appendChild(modeBtn); + webcamToggleEl.insertAdjacentElement('afterend', modeRow); + // Show immediately if webcam layer is already enabled + if (this.layers.webcams) modeRow.style.display = ''; + } + this.enforceLayerLimit(); bindLayerSearch(el); @@ -1691,23 +1844,18 @@ export class GlobeMap { markers.push(...this.satelliteFootprintMarkers); markers.push(...this.imagerySceneMarkers); } - if (this.layers.positiveEvents) markers.push(...this.positiveEventMarkers); - if (this.layers.kindness) markers.push(...this.kindnessMarkers); - if (this.layers.speciesRecovery) markers.push(...this.speciesRecoveryMarkers); - if (this.layers.renewableInstallations) markers.push(...this.renewableInstallationMarkers); if (this.layers.techEvents) markers.push(...this.techMarkers); if (this.layers.cables) { markers.push(...this.cableAdvisoryMarkers); markers.push(...this.repairShipMarkers); } + if (this.layers.webcams) markers.push(...this.webcamMarkers); markers.push(...this.newsLocationMarkers); markers.push(...this.flashMarkers); try { this.globe.htmlElementsData(markers); - } catch (err) { - if (import.meta.env.DEV) console.warn('[GlobeMap] flush error', err); - } + } catch (err) { if (import.meta.env.DEV) console.warn('[GlobeMap] flush error', err); } } private flushArcs(): void { @@ -1800,22 +1948,6 @@ export class GlobeMap { } } - if (this.layers.happiness && this.countriesGeoData && this.happinessScores.size > 0) { - for (const feat of this.countriesGeoData.features) { - const code = feat.properties?.['ISO3166-1-Alpha-2'] as string | undefined; - if (!code) continue; - const score = this.happinessScores.get(code); - if (score == null) continue; - const geom = feat.geometry; - if (!geom) continue; - const rings = geom.type === 'Polygon' ? [geom.coordinates] : geom.type === 'MultiPolygon' ? geom.coordinates : []; - const name = (feat.properties?.name as string) ?? code; - for (const ring of rings) { - polys.push({ coords: ring, name, _kind: 'happiness', score }); - } - } - } - if (this.layers.satellites) { polys.push(...this.imageryFootprintPolygons); } @@ -2141,14 +2273,14 @@ export class GlobeMap { private static readonly LAYER_CHANNELS: Map = new Map([ ['ciiChoropleth', { markers: false, arcs: false, paths: false, polygons: true }], - ['happiness', { markers: false, arcs: false, paths: false, polygons: true }], ['tradeRoutes', { markers: false, arcs: true, paths: false, polygons: false }], ['pipelines', { markers: false, arcs: false, paths: true, polygons: false }], ['conflicts', { markers: true, arcs: false, paths: false, polygons: true }], ['cables', { markers: true, arcs: false, paths: true, polygons: false }], - ['satellites', { markers: true, arcs: false, paths: true, polygons: true }], + ['satellites', { markers: true, arcs: false, paths: true, polygons: true }], - ['natural', { markers: true, arcs: false, paths: true, polygons: true }], + ['natural', { markers: true, arcs: false, paths: true, polygons: true }], + ['webcams', { markers: true, arcs: false, paths: false, polygons: false }], ]); private flushLayerChannels(layer: keyof MapLayers): void { @@ -2266,6 +2398,19 @@ export class GlobeMap { return pov ? { lat: pov.lat, lon: pov.lng } : null; } + public getBbox(): string | null { + if (!this.globe) return null; + const pov = this.globe.pointOfView(); + if (!pov) return null; + const alt = pov.altitude ?? 2.0; + const R = Math.min(90, Math.max(5, alt * 30)); + const south = Math.max(-90, pov.lat - R); + const north = Math.min(90, pov.lat + R); + const west = Math.max(-180, pov.lng - R); + const east = Math.min(180, pov.lng + R); + return `${west.toFixed(4)},${south.toFixed(4)},${east.toFixed(4)},${north.toFixed(4)}`; + } + // ─── Resize ──────────────────────────────────────────────────────────────── public resize(): void { @@ -2600,122 +2745,11 @@ export class GlobeMap { })); this.flushMarkers(); } - public setPositiveEvents(events: PositiveGeoEvent[]): void { - this.positiveEventMarkers = (events ?? []).map(e => ({ - _kind: 'positiveEvent' as const, - _lat: e.lat, - _lng: e.lon, - name: e.name, - category: e.category, - count: e.count, - })); - this.flushMarkers(); - this.focusOnHappyData(); - } - - public setKindnessData(points: KindnessPoint[]): void { - this.kindnessMarkers = (points ?? []).map(p => ({ - _kind: 'kindness' as const, - _lat: p.lat, - _lng: p.lon, - name: p.name, - description: p.description, - intensity: p.intensity, - type: p.type, - })); - this.flushMarkers(); - this.focusOnHappyData(); - } - - public setHappinessScores(data: HappinessData): void { - this.happinessScores = new Map(Object.entries(data.scores || {})); - this.happinessYear = data.year; - this.happinessSource = data.source; - this.flushPolygons(); - this.focusOnHappyData(); - } - - private focusOnHappyData(): void { - if (!this.globe || !this.initialized || this.destroyed || this.webglLost) return; - if (this.hasFocusedOnHappy) return; - - const pointSources: Array<{ lat: number; lng: number }> = [ - ...this.positiveEventMarkers, - ...this.kindnessMarkers, - ...this.speciesRecoveryMarkers, - ...this.renewableInstallationMarkers, - ] - .filter(m => m._lat != null && m._lng != null) - .map(m => ({ lat: m._lat, lng: m._lng })); - - let minLat = Number.POSITIVE_INFINITY; - let maxLat = Number.NEGATIVE_INFINITY; - let minLng = Number.POSITIVE_INFINITY; - let maxLng = Number.NEGATIVE_INFINITY; - - for (const p of pointSources) { - minLat = Math.min(minLat, p.lat); - maxLat = Math.max(maxLat, p.lat); - minLng = Math.min(minLng, p.lng); - maxLng = Math.max(maxLng, p.lng); - } - - if (this.layers.happiness && this.happinessScores.size > 0 && this.countriesGeoData) { - for (const feat of this.countriesGeoData.features) { - const code = feat.properties?.['ISO3166-1-Alpha-2'] as string | undefined; - if (!code || !this.happinessScores.has(code)) continue; - const bbox = getCountryBbox(code); - if (!bbox) continue; - const [cMinLng, cMinLat, cMaxLng, cMaxLat] = bbox; - minLat = Math.min(minLat, cMinLat); - maxLat = Math.max(maxLat, cMaxLat); - minLng = Math.min(minLng, cMinLng); - maxLng = Math.max(maxLng, cMaxLng); - } - } - - if (!Number.isFinite(minLat) || !Number.isFinite(minLng)) return; - - const centerLat = (minLat + maxLat) / 2; - const centerLng = (minLng + maxLng) / 2; - const latSpan = Math.max(0.5, maxLat - minLat); - const lngSpan = Math.max(0.5, maxLng - minLng); - const span = Math.max(latSpan, lngSpan); - const altitude = Math.min(2.5, Math.max(0.8, span * 0.03 + 0.8)); - - this.globe.pointOfView({ lat: centerLat, lng: centerLng, altitude }, 1200); - this.hasFocusedOnHappy = true; - } - - public setSpeciesRecoveryZones(zones: SpeciesRecovery[]): void { - this.speciesRecoveryMarkers = (zones ?? []) - .filter(z => z.recoveryZone && z.recoveryZone.lat != null && z.recoveryZone.lon != null) - .map(z => ({ - _kind: 'speciesRecovery' as const, - _lat: z.recoveryZone!.lat, - _lng: z.recoveryZone!.lon, - id: z.id, - commonName: z.commonName, - recoveryStatus: z.recoveryStatus, - } as any)); - this.flushMarkers(); - this.focusOnHappyData(); - } - - public setRenewableInstallations(installations: RenewableInstallation[]): void { - this.renewableInstallationMarkers = (installations ?? []).map(i => ({ - _kind: 'renewableInstallation' as const, - _lat: i.lat, - _lng: i.lon, - id: i.id, - name: i.name, - type: i.type, - capacityMW: i.capacityMW, - })); - this.flushMarkers(); - this.focusOnHappyData(); - } - + public setPositiveEvents(_events: any[]): void {} + public setKindnessData(_points: any[]): void {} + public setHappinessScores(_data: any): void {} + public setSpeciesRecoveryZones(_zones: any[]): void {} + public setRenewableInstallations(_installations: any[]): void {} public setCyberThreats(threats: CyberThreat[]): void { this.cyberMarkers = (threats ?? []).filter(t => t.lat != null && t.lon != null).map(t => ({ _kind: 'cyber' as const, @@ -2752,6 +2786,15 @@ export class GlobeMap { })); this.flushMarkers(); } + public setWebcams(markers: Array): void { + this.webcamMarkers = markers.map(m => { + if ('count' in m) { + return { _kind: 'webcam-cluster' as const, _lat: m.lat, _lng: m.lng, count: m.count, categories: m.categories || [] }; + } + return { _kind: 'webcam' as const, _lat: m.lat, _lng: m.lng, webcamId: m.webcamId, title: m.title, category: m.category || 'other', country: m.country || '' }; + }); + this.flushMarkers(); + } public setUcdpEvents(events: UcdpGeoEvent[]): void { this.ucdpMarkers = (events ?? []).filter(e => e.latitude != null && e.longitude != null).map(e => ({ _kind: 'ucdp' as const, diff --git a/src/components/LiveWebcamsPanel.ts b/src/components/LiveWebcamsPanel.ts index f2c95cdb5..3ac7919ae 100644 --- a/src/components/LiveWebcamsPanel.ts +++ b/src/components/LiveWebcamsPanel.ts @@ -124,7 +124,6 @@ export class LiveWebcamsPanel extends Panel { private boundEmbedMessageHandler: (e: MessageEvent) => void; constructor() { - // allow users to close the live webcams panel super({ id: 'live-webcams', title: t('panels.liveWebcams'), className: 'panel-wide', closable: true }); this.insertLiveCountBadge(WEBCAM_FEEDS.length); diff --git a/src/components/Map.ts b/src/components/Map.ts index 680fd3c48..df047f7e5 100644 --- a/src/components/Map.ts +++ b/src/components/Map.ts @@ -44,6 +44,8 @@ import { CENTRAL_BANKS, COMMODITY_HUBS, } from '@/config'; +import { pinWebcam, isPinned } from '@/services/webcams/pinned-store'; +import type { WebcamEntry, WebcamCluster } from '@/generated/client/worldmonitor/webcam/v1/service_client'; import { tokenizeForMatch, matchKeyword, findMatchingKeywords } from '@/utils/keyword-match'; import { MapPopup } from './MapPopup'; import { @@ -141,6 +143,7 @@ export class MapComponent { private techActivities: TechHubActivity[] = []; private geoActivities: GeoHubActivity[] = []; private iranEvents: IranEvent[] = []; + private webcamData: Array = []; private news: NewsItem[] = []; private onTechHubClick?: (hub: TechHubActivity) => void; private onGeoHubClick?: (hub: GeoHubActivity) => void; @@ -2786,6 +2789,217 @@ export class MapComponent { this.overlays.appendChild(dot); }); } + + // Webcam markers (colored circles, gated by zoom >= 2) + if (this.state.layers.webcams && this.webcamData.length > 0 && this.state.zoom >= 2) { + const CATEGORY_COLORS: Record = { + traffic: '#ffd700', city: '#00d4ff', landscape: '#45b7d1', + nature: '#96ceb4', beach: '#f4a460', water: '#4169e1', other: '#888888', + }; + this.webcamData.forEach((cam) => { + const pos = projection([cam.lng, cam.lat]); + if (!pos || !Number.isFinite(pos[0]) || !Number.isFinite(pos[1])) return; + const isCluster = 'count' in cam; + const radius = isCluster ? Math.min(4 + Math.sqrt((cam as WebcamCluster).count), 12) : 3; + const size = radius * 2; + const color = isCluster ? '#00d4ff' : (CATEGORY_COLORS[(cam as WebcamEntry).category] ?? '#888888'); + const dot = document.createElement('div'); + dot.className = 'webcam-dot'; + dot.style.left = `${pos[0]}px`; + dot.style.top = `${pos[1]}px`; + dot.style.width = `${size}px`; + dot.style.height = `${size}px`; + dot.style.position = 'absolute'; + dot.style.borderRadius = '50%'; + dot.style.backgroundColor = color; + dot.style.opacity = '0.75'; + dot.style.cursor = 'pointer'; + dot.title = isCluster ? `${(cam as WebcamCluster).count} webcams` : ((cam as WebcamEntry).title || 'Webcam'); + dot.addEventListener('click', (e) => { + e.stopPropagation(); + if (isCluster) { + this.showWebcamClusterPopup(cam as WebcamCluster, e.clientX, e.clientY); + } else { + this.showWebcamTooltip(cam as WebcamEntry, e.clientX, e.clientY); + } + }); + this.overlays.appendChild(dot); + }); + } + } + + private makeWebcamTooltipShell(): { tooltip: HTMLDivElement; closeBtn: HTMLButtonElement } { + this.container.querySelector('.webcam-tooltip')?.remove(); + const tooltip = document.createElement('div'); + tooltip.className = 'webcam-tooltip'; + tooltip.style.cssText = [ + 'position:absolute', + 'background:rgba(10,12,16,0.95)', + 'border:1px solid rgba(60,120,60,0.6)', + 'padding:8px 12px', + 'border-radius:3px', + 'font-size:11px', + 'font-family:monospace', + 'color:#d4d4d4', + 'max-width:240px', + 'z-index:1000', + 'pointer-events:auto', + 'line-height:1.5', + ].join(';'); + const closeBtn = document.createElement('button'); + closeBtn.style.cssText = 'position:absolute;top:4px;right:4px;background:none;border:none;color:#888;cursor:pointer;font-size:14px;line-height:1;padding:2px 4px;'; + closeBtn.setAttribute('aria-label', 'Close'); + closeBtn.textContent = '×'; + closeBtn.addEventListener('click', () => tooltip.remove()); + tooltip.appendChild(closeBtn); + return { tooltip, closeBtn }; + } + + private placeWebcamTooltip(tooltip: HTMLElement, clientX: number, clientY: number): void { + const rect = this.container.getBoundingClientRect(); + this.container.appendChild(tooltip); + const x = Math.min(clientX - rect.left + 10, rect.width - 260); + const y = Math.max(clientY - rect.top - 20, 4); + tooltip.style.left = `${x}px`; + tooltip.style.top = `${y}px`; + let hideTimer: ReturnType | null = setTimeout(() => tooltip.remove(), 8000); + tooltip.addEventListener('mouseenter', () => { if (hideTimer) { clearTimeout(hideTimer); hideTimer = null; } }); + tooltip.addEventListener('mouseleave', () => { hideTimer = setTimeout(() => tooltip.remove(), 2000); }); + } + + private showWebcamTooltip(cam: WebcamEntry, clientX: number, clientY: number): void { + const { tooltip } = this.makeWebcamTooltipShell(); + + const title = document.createElement('div'); + title.style.cssText = 'font-weight:bold;color:#00d4ff;padding-right:18px;'; + title.textContent = `\u{1F4F7} ${cam.title || cam.category || 'Webcam'}`; + tooltip.appendChild(title); + + const meta = document.createElement('div'); + meta.style.cssText = 'opacity:0.7;font-size:10px;margin-top:2px;'; + meta.textContent = [cam.country, cam.category].filter(Boolean).join(' \u00B7 '); + if (meta.textContent) tooltip.appendChild(meta); + + const previewDiv = document.createElement('div'); + previewDiv.style.marginTop = '6px'; + const loadingSpan = document.createElement('span'); + loadingSpan.style.cssText = 'opacity:0.5;font-size:10px;'; + loadingSpan.textContent = 'Loading preview...'; + previewDiv.appendChild(loadingSpan); + tooltip.appendChild(previewDiv); + + if (cam.webcamId) { + const link = document.createElement('a'); + link.href = `https://www.windy.com/webcams/${cam.webcamId}`; + link.target = '_blank'; + link.rel = 'noopener'; + link.style.cssText = 'display:block;margin-top:4px;color:#00d4ff;font-size:11px;text-decoration:none;'; + link.textContent = 'Open on Windy \u2197'; + tooltip.appendChild(link); + } + + this.placeWebcamTooltip(tooltip, clientX, clientY); + + if (cam.webcamId) { + import('@/services/webcams').then(({ fetchWebcamImage }) => { + fetchWebcamImage(cam.webcamId).then(img => { + if (!tooltip.isConnected) return; + previewDiv.replaceChildren(); + if (img.thumbnailUrl) { + const imgEl = document.createElement('img'); + imgEl.src = img.thumbnailUrl; + imgEl.style.cssText = 'width:200px;border-radius:4px;margin-bottom:4px;'; + imgEl.loading = 'lazy'; + previewDiv.appendChild(imgEl); + } else { + const span = document.createElement('span'); + span.style.cssText = 'opacity:0.5;font-size:10px;'; + span.textContent = 'Preview unavailable'; + previewDiv.appendChild(span); + } + + const pinBtn = document.createElement('button'); + pinBtn.className = 'webcam-pin-btn'; + const wcId = cam.webcamId; + if (isPinned(wcId)) { + pinBtn.classList.add('webcam-pin-btn--pinned'); + pinBtn.textContent = '\u{1F4CC} Pinned'; + pinBtn.disabled = true; + } else { + pinBtn.textContent = '\u{1F4CC} Pin'; + pinBtn.addEventListener('click', (e) => { + e.stopPropagation(); + pinWebcam({ + webcamId: wcId, + title: cam.title || img?.title || '', + lat: cam.lat, + lng: cam.lng, + category: cam.category || 'other', + country: cam.country || '', + playerUrl: img?.playerUrl || '', + }); + pinBtn.classList.add('webcam-pin-btn--pinned'); + pinBtn.textContent = '\u{1F4CC} Pinned'; + pinBtn.disabled = true; + }); + } + tooltip.appendChild(pinBtn); + }); + }); + } else { + previewDiv.remove(); + } + } + + private showWebcamClusterPopup(cam: WebcamCluster, clientX: number, clientY: number): void { + const { tooltip } = this.makeWebcamTooltipShell(); + + const header = document.createElement('div'); + header.style.cssText = 'font-weight:bold;color:#00d4ff;padding-right:18px;'; + header.textContent = `\u{1F4F7} ${cam.count} webcams — loading...`; + tooltip.appendChild(header); + + this.placeWebcamTooltip(tooltip, clientX, clientY); + + const currentZoom = this.state.zoom ?? 3; + import('@/services/webcams').then(({ fetchWebcams, getClusterCellSize }) => { + const margin = Math.max(0.5, getClusterCellSize(currentZoom)); + fetchWebcams(10, { + w: cam.lng - margin, s: cam.lat - margin, + e: cam.lng + margin, n: cam.lat + margin, + }).then(result => { + if (!tooltip.isConnected) return; + const webcams = result.webcams.slice(0, 20); + header.textContent = `\u{1F4F7} ${webcams.length} webcams`; + + const list = document.createElement('div'); + list.style.cssText = 'max-height:200px;overflow-y:auto;margin-top:6px;'; + for (const webcam of webcams) { + const item = document.createElement('div'); + item.style.cssText = 'padding:3px 2px;cursor:pointer;color:#aaa;border-bottom:1px solid rgba(255,255,255,0.08);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;'; + const nameSpan = document.createElement('span'); + nameSpan.textContent = webcam.title || webcam.category || 'Webcam'; + item.appendChild(nameSpan); + if (webcam.country) { + const cc = document.createElement('span'); + cc.style.cssText = 'float:right;opacity:0.4;font-size:10px;margin-left:6px;'; + cc.textContent = webcam.country; + item.appendChild(cc); + } + item.addEventListener('mouseenter', () => { item.style.color = '#00d4ff'; }); + item.addEventListener('mouseleave', () => { item.style.color = '#aaa'; }); + item.addEventListener('click', (e) => { + e.stopPropagation(); + this.showWebcamTooltip(webcam, e.clientX, e.clientY); + }); + list.appendChild(item); + } + tooltip.appendChild(list); + }).catch(() => { + if (!tooltip.isConnected) return; + header.textContent = '\u{1F4F7} Failed to load webcam list'; + }); + }); } private renderWaterways(projection: d3.GeoProjection): void { @@ -3693,6 +3907,11 @@ export class MapComponent { this.render(); } + public setWebcams(markers: Array): void { + this.webcamData = markers; + this.render(); + } + public setTechEvents(events: TechEventMarker[]): void { this.techEvents = events; this.render(); diff --git a/src/components/MapContainer.ts b/src/components/MapContainer.ts index 42f2a4338..a9c8c9b5f 100644 --- a/src/components/MapContainer.ts +++ b/src/components/MapContainer.ts @@ -42,6 +42,7 @@ import type { GpsJamHex } from '@/services/gps-interference'; import type { SatellitePosition } from '@/services/satellites'; import type { IranEvent } from '@/services/conflict'; import type { ImageryScene } from '@/generated/server/worldmonitor/imagery/v1/service_server'; +import type { WebcamEntry, WebcamCluster } from '@/generated/client/worldmonitor/webcam/v1/service_client'; export type TimeRange = '1h' | '6h' | '24h' | '48h' | '7d' | 'all'; export type MapView = 'global' | 'america' | 'mena' | 'eu' | 'asia' | 'latam' | 'africa' | 'oceania'; @@ -133,6 +134,7 @@ export class MapContainer { private cachedEscalationFlights: MilitaryFlight[] | null = null; private cachedEscalationVessels: MilitaryVessel[] | null = null; private cachedImageryScenes: ImageryScene[] | null = null; + private cachedWebcams: Array | null = null; constructor(container: HTMLElement, initialState: MapContainerState, preferGlobe = false) { this.container = container; @@ -295,6 +297,11 @@ export class MapContainer { if (this.cachedHotspotActivity) this.updateHotspotActivity(this.cachedHotspotActivity); if (this.cachedEscalationFlights && this.cachedEscalationVessels) this.updateMilitaryForEscalation(this.cachedEscalationFlights, this.cachedEscalationVessels); if (this.cachedImageryScenes) this.setImageryScenes(this.cachedImageryScenes); + if (this.cachedWebcams) { + if (this.useGlobe) this.globeMap?.setWebcams(this.cachedWebcams); + else if (this.useDeckGL) this.deckGLMap?.setWebcams(this.cachedWebcams); + else this.svgMap?.setWebcams(this.cachedWebcams); + } } public isGlobeMode(): boolean { @@ -400,6 +407,13 @@ export class MapContainer { if (this.useDeckGL) { this.deckGLMap?.setImageryScenes(scenes); } } + public setWebcams(markers: Array): void { + this.cachedWebcams = markers; + if (this.useGlobe) { this.globeMap?.setWebcams(markers); return; } + if (this.useDeckGL) { this.deckGLMap?.setWebcams(markers); } + else { this.svgMap?.setWebcams(markers); } + } + public setWeatherAlerts(alerts: WeatherAlert[]): void { this.cachedWeatherAlerts = alerts; if (this.useGlobe) { this.globeMap?.setWeatherAlerts(alerts); return; } @@ -694,16 +708,7 @@ export class MapContainer { public getBbox(): string | null { if (this.useDeckGL) return this.deckGLMap?.getBbox() ?? null; - if (this.useGlobe) { - const center = this.globeMap?.getCenter(); - if (!center) return null; - const R = 5; - const south = Math.max(-90, center.lat - R); - const north = Math.min(90, center.lat + R); - const west = Math.max(-180, center.lon - R); - const east = Math.min(180, center.lon + R); - return `${west.toFixed(4)},${south.toFixed(4)},${east.toFixed(4)},${north.toFixed(4)}`; - } + if (this.useGlobe) return this.globeMap?.getBbox() ?? null; return null; } diff --git a/src/components/PinnedWebcamsPanel.ts b/src/components/PinnedWebcamsPanel.ts new file mode 100644 index 000000000..5c5b491f0 --- /dev/null +++ b/src/components/PinnedWebcamsPanel.ts @@ -0,0 +1,148 @@ +import { Panel } from './Panel'; +import { t } from '../services/i18n'; +import { + getPinnedWebcams, + getActiveWebcams, + unpinWebcam, + toggleWebcam, + onPinnedChange, +} from '../services/webcams/pinned-store'; + +const MAX_SLOTS = 4; +const PLAYER_FALLBACK = 'https://webcams.windy.com/webcams/public/embed/player'; + +function buildPlayerUrl(webcamId: string, playerUrl?: string): string { + if (playerUrl) return playerUrl; + return `${PLAYER_FALLBACK}/${encodeURIComponent(webcamId)}/day`; +} + +export class PinnedWebcamsPanel extends Panel { + private unsubscribe: (() => void) | null = null; + + constructor() { + super({ id: 'pinned-webcams', title: t('panels.pinnedWebcams'), className: 'panel-wide', closable: false }); + this.unsubscribe = onPinnedChange(() => this.render()); + this.render(); + } + + private render(): void { + while (this.content.firstChild) this.content.removeChild(this.content.firstChild); + this.content.className = 'panel-content pinned-webcams-content'; + + const active = getActiveWebcams(); + const allPinned = getPinnedWebcams(); + + const grid = document.createElement('div'); + grid.className = 'pinned-webcams-grid'; + + for (let i = 0; i < MAX_SLOTS; i++) { + const slot = document.createElement('div'); + slot.className = 'pinned-webcam-slot'; + + const cam = active[i]; + if (cam) { + const iframe = document.createElement('iframe'); + iframe.className = 'pinned-webcam-iframe'; + iframe.src = buildPlayerUrl(cam.webcamId, cam.playerUrl); + iframe.setAttribute('sandbox', 'allow-scripts allow-same-origin allow-popups'); + iframe.setAttribute('frameborder', '0'); + iframe.title = cam.title || cam.webcamId; + iframe.allow = 'autoplay; encrypted-media'; + iframe.allowFullscreen = true; + iframe.setAttribute('loading', 'lazy'); + slot.appendChild(iframe); + + const labelBar = document.createElement('div'); + labelBar.className = 'pinned-webcam-label'; + + const titleSpan = document.createElement('span'); + titleSpan.className = 'pinned-webcam-title'; + titleSpan.textContent = cam.title || cam.webcamId; + labelBar.appendChild(titleSpan); + + const toggleBtn = document.createElement('button'); + toggleBtn.className = 'pinned-webcam-toggle'; + toggleBtn.title = 'Hide stream'; + toggleBtn.textContent = '\u23F8'; + toggleBtn.addEventListener('click', () => toggleWebcam(cam.webcamId)); + labelBar.appendChild(toggleBtn); + + const unpinBtn = document.createElement('button'); + unpinBtn.className = 'pinned-webcam-unpin'; + unpinBtn.title = 'Unpin'; + unpinBtn.textContent = '\u2716'; + unpinBtn.addEventListener('click', () => unpinWebcam(cam.webcamId)); + labelBar.appendChild(unpinBtn); + + slot.appendChild(labelBar); + } else { + slot.classList.add('pinned-webcam-slot--empty'); + const placeholder = document.createElement('div'); + placeholder.className = 'pinned-webcam-placeholder'; + placeholder.textContent = t('components.pinnedWebcams.pinFromMap') || 'Pin a webcam from the map'; + slot.appendChild(placeholder); + } + + grid.appendChild(slot); + } + + this.content.appendChild(grid); + + if (allPinned.length > MAX_SLOTS) { + const listSection = document.createElement('div'); + listSection.className = 'pinned-webcams-list'; + + const listHeader = document.createElement('div'); + listHeader.className = 'pinned-webcams-list-header'; + listHeader.textContent = `Pinned (${allPinned.length})`; + listSection.appendChild(listHeader); + + allPinned.forEach(cam => { + const row = document.createElement('div'); + row.className = 'pinned-webcam-row'; + if (cam.active) row.classList.add('pinned-webcam-row--active'); + + const name = document.createElement('span'); + name.className = 'pinned-webcam-row-name'; + name.textContent = cam.title || cam.webcamId; + row.appendChild(name); + + const country = document.createElement('span'); + country.className = 'pinned-webcam-row-country'; + country.textContent = cam.country; + row.appendChild(country); + + const toggleBtn = document.createElement('button'); + toggleBtn.className = 'pinned-webcam-row-toggle'; + toggleBtn.textContent = cam.active ? 'ON' : 'OFF'; + toggleBtn.addEventListener('click', () => toggleWebcam(cam.webcamId)); + row.appendChild(toggleBtn); + + const removeBtn = document.createElement('button'); + removeBtn.className = 'pinned-webcam-row-remove'; + removeBtn.textContent = '\u2716'; + removeBtn.title = 'Unpin'; + removeBtn.addEventListener('click', () => unpinWebcam(cam.webcamId)); + row.appendChild(removeBtn); + + listSection.appendChild(row); + }); + + this.content.appendChild(listSection); + } + } + + public refresh(): void { + this.render(); + } + + public destroy(): void { + this.unsubscribe?.(); + this.unsubscribe = null; + this.content.querySelectorAll('iframe').forEach(f => { + f.src = 'about:blank'; + f.remove(); + }); + super.destroy(); + } +} diff --git a/src/components/index.ts b/src/components/index.ts index 78ed9ee82..e72855036 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -21,6 +21,7 @@ export * from './LlmStatusIndicator'; export * from './GdeltIntelPanel'; export * from './LiveNewsPanel'; export * from './LiveWebcamsPanel'; +export * from './PinnedWebcamsPanel'; export * from './CIIPanel'; export * from './CascadePanel'; export * from './StrategicRiskPanel'; diff --git a/src/config/map-layer-definitions.ts b/src/config/map-layer-definitions.ts index 8d1fd7d10..e156b518a 100644 --- a/src/config/map-layer-definitions.ts +++ b/src/config/map-layer-definitions.ts @@ -76,6 +76,7 @@ export const LAYER_REGISTRY: Record = { miningSites: def('miningSites', '🔭', 'miningSites', 'Mining Sites'), processingPlants: def('processingPlants', '🏭', 'processingPlants', 'Processing Plants'), commodityPorts: def('commodityPorts', '⛵', 'commodityPorts', 'Commodity Ports'), + webcams: def('webcams', '📷', 'webcams', 'Live Webcams'), }; const VARIANT_LAYER_ORDER: Record> = { @@ -87,7 +88,7 @@ const VARIANT_LAYER_ORDER: Record> = { 'ucdpEvents', 'displacement', 'climate', 'weather', 'outages', 'cyberThreats', 'natural', 'fires', 'waterways', 'economic', 'minerals', 'gpsJamming', - 'satellites', 'ciiChoropleth', 'dayNight', + 'satellites', 'ciiChoropleth', 'dayNight', 'webcams', ], tech: [ 'startupHubs', 'techHQs', 'accelerators', 'cloudRegions', @@ -207,6 +208,9 @@ export const LAYER_SYNONYMS: Record> = { sanction: ['sanctions'], night: ['dayNight'], sun: ['dayNight'], + webcam: ['webcams'], + camera: ['webcams'], + livecam: ['webcams'], }; export function resolveLayerLabel(def: LayerDefinition, tFn?: (key: string) => string): string { diff --git a/src/config/panels.ts b/src/config/panels.ts index 89f348e00..23c3b2adf 100644 --- a/src/config/panels.ts +++ b/src/config/panels.ts @@ -13,7 +13,8 @@ const _desktop = isDesktopRuntime(); const FULL_PANELS: Record = { map: { name: 'Global Map', enabled: true, priority: 1 }, 'live-news': { name: 'Live News', enabled: true, priority: 1 }, - 'live-webcams': { name: 'Live Webcams', enabled: true, priority: 1 }, + 'live-youtube': { name: 'Live YouTube', enabled: true, priority: 1 }, + 'pinned-webcams': { name: 'Pinned Webcams', enabled: true, priority: 2 }, 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, ...(_desktop && { premium: 'enhanced' as const }) }, @@ -123,6 +124,7 @@ const FULL_MAP_LAYERS: MapLayers = { miningSites: false, processingPlants: false, commodityPorts: false, + webcams: false, }; const FULL_MOBILE_MAP_LAYERS: MapLayers = { @@ -182,6 +184,7 @@ const FULL_MOBILE_MAP_LAYERS: MapLayers = { miningSites: false, processingPlants: false, commodityPorts: false, + webcams: false, }; // ============================================ @@ -190,7 +193,8 @@ const FULL_MOBILE_MAP_LAYERS: MapLayers = { const TECH_PANELS: Record = { map: { name: 'Global Tech Map', enabled: true, priority: 1 }, 'live-news': { name: 'Tech Headlines', enabled: true, priority: 1 }, - 'live-webcams': { name: 'Live Webcams', enabled: true, priority: 2 }, + 'live-youtube': { name: 'Live YouTube', enabled: true, priority: 2 }, + 'pinned-webcams': { name: 'Pinned Webcams', enabled: true, priority: 2 }, insights: { name: 'AI Insights', enabled: true, priority: 1 }, ai: { name: 'AI/ML News', enabled: true, priority: 1 }, tech: { name: 'Technology', enabled: true, priority: 1 }, @@ -283,6 +287,7 @@ const TECH_MAP_LAYERS: MapLayers = { miningSites: false, processingPlants: false, commodityPorts: false, + webcams: false, }; const TECH_MOBILE_MAP_LAYERS: MapLayers = { @@ -342,6 +347,7 @@ const TECH_MOBILE_MAP_LAYERS: MapLayers = { miningSites: false, processingPlants: false, commodityPorts: false, + webcams: false, }; // ============================================ @@ -350,7 +356,8 @@ const TECH_MOBILE_MAP_LAYERS: MapLayers = { const FINANCE_PANELS: Record = { map: { name: 'Global Markets Map', enabled: true, priority: 1 }, 'live-news': { name: 'Market Headlines', enabled: true, priority: 1 }, - 'live-webcams': { name: 'Live Webcams', enabled: true, priority: 2 }, + 'live-youtube': { name: 'Live YouTube', enabled: true, priority: 2 }, + 'pinned-webcams': { name: 'Pinned Webcams', enabled: true, priority: 2 }, insights: { name: 'AI Market Insights', enabled: true, priority: 1 }, markets: { name: 'Live Markets', enabled: true, priority: 1 }, 'stock-analysis': { name: 'Premium Stock Analysis', enabled: true, priority: 1, premium: 'locked' }, @@ -444,6 +451,7 @@ const FINANCE_MAP_LAYERS: MapLayers = { miningSites: false, processingPlants: false, commodityPorts: false, + webcams: false, }; const FINANCE_MOBILE_MAP_LAYERS: MapLayers = { @@ -503,6 +511,7 @@ const FINANCE_MOBILE_MAP_LAYERS: MapLayers = { miningSites: false, processingPlants: false, commodityPorts: false, + webcams: false, }; // ============================================ @@ -578,6 +587,7 @@ const HAPPY_MAP_LAYERS: MapLayers = { miningSites: false, processingPlants: false, commodityPorts: false, + webcams: false, }; const HAPPY_MOBILE_MAP_LAYERS: MapLayers = { @@ -637,6 +647,7 @@ const HAPPY_MOBILE_MAP_LAYERS: MapLayers = { miningSites: false, processingPlants: false, commodityPorts: false, + webcams: false, }; // ============================================ @@ -726,6 +737,7 @@ const COMMODITY_MAP_LAYERS: MapLayers = { miningSites: true, processingPlants: true, commodityPorts: true, + webcams: false, }; const COMMODITY_MOBILE_MAP_LAYERS: MapLayers = { @@ -785,6 +797,7 @@ const COMMODITY_MOBILE_MAP_LAYERS: MapLayers = { miningSites: true, processingPlants: false, commodityPorts: true, + webcams: false, }; // ============================================ @@ -845,7 +858,7 @@ export const PANEL_CATEGORY_MAP: Record; +} + +export interface WebcamServiceCallOptions { + headers?: Record; + signal?: AbortSignal; +} + +export class WebcamServiceClient { + private baseURL: string; + private fetchFn: typeof fetch; + private defaultHeaders: Record; + + constructor(baseURL: string, options?: WebcamServiceClientOptions) { + this.baseURL = baseURL.replace(/\/+$/, ""); + this.fetchFn = options?.fetch ?? globalThis.fetch; + this.defaultHeaders = { ...options?.defaultHeaders }; + } + + async listWebcams(req: ListWebcamsRequest, options?: WebcamServiceCallOptions): Promise { + let path = "/api/webcam/v1/list-webcams"; + const params = new URLSearchParams(); + if (req.zoom != null && req.zoom !== 0) params.set("zoom", String(req.zoom)); + if (req.boundW != null && req.boundW !== 0) params.set("bound_w", String(req.boundW)); + if (req.boundS != null && req.boundS !== 0) params.set("bound_s", String(req.boundS)); + if (req.boundE != null && req.boundE !== 0) params.set("bound_e", String(req.boundE)); + if (req.boundN != null && req.boundN !== 0) params.set("bound_n", String(req.boundN)); + const url = this.baseURL + path + (params.toString() ? "?" + params.toString() : ""); + + const headers: Record = { + "Content-Type": "application/json", + ...this.defaultHeaders, + ...options?.headers, + }; + + const resp = await this.fetchFn(url, { + method: "GET", + headers, + signal: options?.signal, + }); + + if (!resp.ok) { + return this.handleError(resp); + } + + return await resp.json() as ListWebcamsResponse; + } + + async getWebcamImage(req: GetWebcamImageRequest, options?: WebcamServiceCallOptions): Promise { + let path = "/api/webcam/v1/get-webcam-image"; + const params = new URLSearchParams(); + if (req.webcamId != null && req.webcamId !== "") params.set("webcam_id", String(req.webcamId)); + const url = this.baseURL + path + (params.toString() ? "?" + params.toString() : ""); + + const headers: Record = { + "Content-Type": "application/json", + ...this.defaultHeaders, + ...options?.headers, + }; + + const resp = await this.fetchFn(url, { + method: "GET", + headers, + signal: options?.signal, + }); + + if (!resp.ok) { + return this.handleError(resp); + } + + return await resp.json() as GetWebcamImageResponse; + } + + private async handleError(resp: Response): Promise { + const body = await resp.text(); + if (resp.status === 400) { + try { + const parsed = JSON.parse(body); + if (parsed.violations) { + throw new ValidationError(parsed.violations); + } + } catch (e) { + if (e instanceof ValidationError) throw e; + } + } + throw new ApiError(resp.status, `Request failed with status ${resp.status}`, body); + } +} diff --git a/src/generated/server/worldmonitor/webcam/v1/service_server.ts b/src/generated/server/worldmonitor/webcam/v1/service_server.ts new file mode 100644 index 000000000..6afc26b32 --- /dev/null +++ b/src/generated/server/worldmonitor/webcam/v1/service_server.ts @@ -0,0 +1,200 @@ +// @ts-nocheck +// Code generated by protoc-gen-ts-server. DO NOT EDIT. +// source: worldmonitor/webcam/v1/service.proto + +export interface ListWebcamsRequest { + zoom: number; + boundW: number; + boundS: number; + boundE: number; + boundN: number; +} + +export interface WebcamEntry { + webcamId: string; + title: string; + lat: number; + lng: number; + category: string; + country: string; +} + +export interface WebcamCluster { + lat: number; + lng: number; + count: number; + categories: string[]; +} + +export interface ListWebcamsResponse { + webcams: WebcamEntry[]; + clusters: WebcamCluster[]; + totalInView: number; +} + +export interface GetWebcamImageRequest { + webcamId: string; +} + +export interface GetWebcamImageResponse { + thumbnailUrl: string; + playerUrl: string; + title: string; + windyUrl: string; + lastUpdated: number; + error: string; +} + +export interface FieldViolation { + field: string; + description: string; +} + +export class ValidationError extends Error { + violations: FieldViolation[]; + + constructor(violations: FieldViolation[]) { + super("Validation failed"); + this.name = "ValidationError"; + this.violations = violations; + } +} + +export class ApiError extends Error { + statusCode: number; + body: string; + + constructor(statusCode: number, message: string, body: string) { + super(message); + this.name = "ApiError"; + this.statusCode = statusCode; + this.body = body; + } +} + +export interface ServerContext { + request: Request; + pathParams: Record; + headers: Record; +} + +export interface ServerOptions { + onError?: (error: unknown, req: Request) => Response | Promise; + validateRequest?: (methodName: string, body: unknown) => FieldViolation[] | undefined; +} + +export interface RouteDescriptor { + method: string; + path: string; + handler: (req: Request) => Promise; +} + +export interface WebcamServiceHandler { + listWebcams(ctx: ServerContext, req: ListWebcamsRequest): Promise; + getWebcamImage(ctx: ServerContext, req: GetWebcamImageRequest): Promise; +} + +export function createWebcamServiceRoutes( + handler: WebcamServiceHandler, + options?: ServerOptions, +): RouteDescriptor[] { + return [ + { + method: "GET", + path: "/api/webcam/v1/list-webcams", + handler: async (req: Request): Promise => { + try { + const pathParams: Record = {}; + const url = new URL(req.url, "http://localhost"); + const params = url.searchParams; + const body: ListWebcamsRequest = { + zoom: Number(params.get("zoom") ?? "3"), + boundW: Number(params.get("bound_w") ?? "-180"), + boundS: Number(params.get("bound_s") ?? "-90"), + boundE: Number(params.get("bound_e") ?? "180"), + boundN: Number(params.get("bound_n") ?? "90"), + }; + if (options?.validateRequest) { + const bodyViolations = options.validateRequest("listWebcams", body); + if (bodyViolations) { + throw new ValidationError(bodyViolations); + } + } + + const ctx: ServerContext = { + request: req, + pathParams, + headers: Object.fromEntries(req.headers.entries()), + }; + + const result = await handler.listWebcams(ctx, body); + return new Response(JSON.stringify(result as ListWebcamsResponse), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } catch (err: unknown) { + if (err instanceof ValidationError) { + return new Response(JSON.stringify({ violations: err.violations }), { + status: 400, + headers: { "Content-Type": "application/json" }, + }); + } + if (options?.onError) { + return options.onError(err, req); + } + const message = err instanceof Error ? err.message : String(err); + return new Response(JSON.stringify({ message }), { + status: 500, + headers: { "Content-Type": "application/json" }, + }); + } + }, + }, + { + method: "GET", + path: "/api/webcam/v1/get-webcam-image", + handler: async (req: Request): Promise => { + try { + const pathParams: Record = {}; + const url = new URL(req.url, "http://localhost"); + const body: GetWebcamImageRequest = { + webcamId: url.searchParams.get("webcam_id") ?? "", + }; + if (options?.validateRequest) { + const bodyViolations = options.validateRequest("getWebcamImage", body); + if (bodyViolations) { + throw new ValidationError(bodyViolations); + } + } + + const ctx: ServerContext = { + request: req, + pathParams, + headers: Object.fromEntries(req.headers.entries()), + }; + + const result = await handler.getWebcamImage(ctx, body); + return new Response(JSON.stringify(result as GetWebcamImageResponse), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } catch (err: unknown) { + if (err instanceof ValidationError) { + return new Response(JSON.stringify({ violations: err.violations }), { + status: 400, + headers: { "Content-Type": "application/json" }, + }); + } + if (options?.onError) { + return options.onError(err, req); + } + const message = err instanceof Error ? err.message : String(err); + return new Response(JSON.stringify({ message }), { + status: 500, + headers: { "Content-Type": "application/json" }, + }); + } + }, + }, + ]; +} diff --git a/src/locales/ar.json b/src/locales/ar.json index 85207974b..5e7c2142e 100644 --- a/src/locales/ar.json +++ b/src/locales/ar.json @@ -270,7 +270,8 @@ "techReadiness": "مؤشر الجاهزية التقنية", "gccInvestments": "استثمارات دول الخليج", "geoHubs": "مراكز جيوسياسية", - "liveWebcams": "كاميرات مباشرة", + "liveYouTube": "كاميرات مباشرة", + "pinnedWebcams": "Pinned Webcams", "securityAdvisories": "تنبيهات أمنية", "orefSirens": "Israel Sirens", "telegramIntel": "استخبارات Telegram", diff --git a/src/locales/bg.json b/src/locales/bg.json index 187f9668a..f278f9a56 100644 --- a/src/locales/bg.json +++ b/src/locales/bg.json @@ -275,7 +275,8 @@ "techReadiness": "Индекс на технологическа готовност", "gccInvestments": "GCC инвестиции", "geoHubs": "Геополитични центрове", - "liveWebcams": "Живи уеб камери", + "liveYouTube": "Живи уеб камери", + "pinnedWebcams": "Pinned Webcams", "gulfEconomies": "Икономики на залива", "gulfIndices": "Индекси на залива", "gulfCurrencies": "Валути на залива", diff --git a/src/locales/cs.json b/src/locales/cs.json index 5192c067d..ab99d19ae 100644 --- a/src/locales/cs.json +++ b/src/locales/cs.json @@ -275,7 +275,8 @@ "techReadiness": "Index tech. připravenosti", "gccInvestments": "Investice GCC", "geoHubs": "Geopolitická centra", - "liveWebcams": "Živé webkamery", + "liveYouTube": "Živé webkamery", + "pinnedWebcams": "Pinned Webcams", "gulfEconomies": "Ekonomiky Perského zálivu", "gulfIndices": "Indexy Perského zálivu", "gulfCurrencies": "Měny Perského zálivu", diff --git a/src/locales/de.json b/src/locales/de.json index 20069818b..f73691454 100644 --- a/src/locales/de.json +++ b/src/locales/de.json @@ -272,7 +272,8 @@ "techHubs": "Heiße Tech-Hubs", "gccInvestments": "GCC-Investitionen", "geoHubs": "Geopolitical Hotspots", - "liveWebcams": "Live-Webcams", + "liveYouTube": "Live-Webcams", + "pinnedWebcams": "Pinned Webcams", "securityAdvisories": "Sicherheitshinweise", "orefSirens": "Israel Sirens", "telegramIntel": "Telegram-Nachrichten", diff --git a/src/locales/el.json b/src/locales/el.json index fd2611737..0107dd3a0 100644 --- a/src/locales/el.json +++ b/src/locales/el.json @@ -275,7 +275,8 @@ "techReadiness": "Δείκτης Τεχνολογικής Ετοιμότητας", "gccInvestments": "Επενδύσεις GCC", "geoHubs": "Γεωπολιτικά Κέντρα", - "liveWebcams": "Ζωντανές Κάμερες", + "liveYouTube": "Ζωντανές Κάμερες", + "pinnedWebcams": "Pinned Webcams", "gulfEconomies": "Οικονομίες Κόλπου", "gulfIndices": "Δείκτες Κόλπου", "gulfCurrencies": "Νομίσματα Κόλπου", diff --git a/src/locales/en.json b/src/locales/en.json index 000c1cc19..5696bdabe 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -282,7 +282,8 @@ "techReadiness": "Tech Readiness Index", "gccInvestments": "GCC Investments", "geoHubs": "Geopolitical Hubs", - "liveWebcams": "Live Webcams", + "liveYouTube": "Live YouTube", + "pinnedWebcams": "Pinned Webcams", "gulfEconomies": "Gulf Economies", "gulfIndices": "Gulf Indices", "gulfCurrencies": "Gulf Currencies", @@ -691,6 +692,9 @@ "space": "SPACE" } }, + "pinnedWebcams": { + "pinFromMap": "Pin a webcam from the map" + }, "positiveNewsFeed": { "noStories": "No stories in this category yet" }, diff --git a/src/locales/es.json b/src/locales/es.json index e00131331..d0b384739 100644 --- a/src/locales/es.json +++ b/src/locales/es.json @@ -272,7 +272,8 @@ "techHubs": "Centros tecnológicos de moda", "gccInvestments": "Inversiones del CCG", "geoHubs": "Geopolitical Hotspots", - "liveWebcams": "Cámaras en Vivo", + "liveYouTube": "Cámaras en Vivo", + "pinnedWebcams": "Pinned Webcams", "securityAdvisories": "Alertas de Seguridad", "orefSirens": "Israel Sirens", "telegramIntel": "Inteligencia Telegram", diff --git a/src/locales/fr.json b/src/locales/fr.json index c4c5f124b..e617203b9 100644 --- a/src/locales/fr.json +++ b/src/locales/fr.json @@ -270,7 +270,8 @@ "techHubs": "Pôles Technologiques", "gccInvestments": "Investissements du CCG", "geoHubs": "Centres géopolitiques", - "liveWebcams": "Webcams en Direct", + "liveYouTube": "Webcams en Direct", + "pinnedWebcams": "Pinned Webcams", "securityAdvisories": "Avis de Sécurité", "orefSirens": "Israel Sirens", "telegramIntel": "Renseignement Telegram", diff --git a/src/locales/it.json b/src/locales/it.json index 4dd286ca3..f7c6b2a85 100644 --- a/src/locales/it.json +++ b/src/locales/it.json @@ -272,7 +272,8 @@ "techHubs": "Hub tecnologici caldi", "gccInvestments": "Investimenti GCC", "geoHubs": "Hotspot geopolitici", - "liveWebcams": "Webcam in Diretta", + "liveYouTube": "Webcam in Diretta", + "pinnedWebcams": "Pinned Webcams", "securityAdvisories": "Avvisi di Sicurezza", "orefSirens": "Israel Sirens", "telegramIntel": "Intelligence Telegram", diff --git a/src/locales/ja.json b/src/locales/ja.json index 3b18f5b1b..864dfea17 100644 --- a/src/locales/ja.json +++ b/src/locales/ja.json @@ -275,7 +275,8 @@ "techReadiness": "テック準備度指数", "gccInvestments": "GCC投資", "geoHubs": "地政学ハブ", - "liveWebcams": "ライブカメラ", + "liveYouTube": "ライブカメラ", + "pinnedWebcams": "Pinned Webcams", "gulfEconomies": "湾岸経済", "gulfIndices": "湾岸指数", "gulfCurrencies": "湾岸通貨", diff --git a/src/locales/ko.json b/src/locales/ko.json index 57231eeca..28a57cff0 100644 --- a/src/locales/ko.json +++ b/src/locales/ko.json @@ -275,7 +275,8 @@ "techReadiness": "기술 준비 지수", "gccInvestments": "GCC 투자", "geoHubs": "지정학 허브", - "liveWebcams": "실시간 웹캠", + "liveYouTube": "실시간 웹캠", + "pinnedWebcams": "Pinned Webcams", "gulfEconomies": "걸프 경제", "gulfIndices": "걸프 지수", "gulfCurrencies": "걸프 통화", diff --git a/src/locales/nl.json b/src/locales/nl.json index b9a5a0091..4153ffdc0 100644 --- a/src/locales/nl.json +++ b/src/locales/nl.json @@ -63,7 +63,8 @@ "geoHubs": "Geopolitical Hotspots", "polymarket": "Voorspellingen", "climate": "Klimaatafwijkingen", - "liveWebcams": "Live Webcams", + "liveYouTube": "Live Webcams", + "pinnedWebcams": "Pinned Webcams", "securityAdvisories": "Veiligheidsadviezen", "orefSirens": "Israel Sirens", "telegramIntel": "Telegram Inlichtingen", diff --git a/src/locales/pl.json b/src/locales/pl.json index 496d70357..7985298ca 100644 --- a/src/locales/pl.json +++ b/src/locales/pl.json @@ -270,7 +270,8 @@ "techHubs": "Gorące centra technologiczne", "gccInvestments": "Inwestycje GCC", "geoHubs": "Geopolitical Hotspots", - "liveWebcams": "Kamery na Żywo", + "liveYouTube": "Kamery na Żywo", + "pinnedWebcams": "Pinned Webcams", "securityAdvisories": "Ostrzeżenia Bezpieczeństwa", "orefSirens": "Israel Sirens", "telegramIntel": "Wywiad Telegram", diff --git a/src/locales/pt.json b/src/locales/pt.json index 551803264..135c3b9fb 100644 --- a/src/locales/pt.json +++ b/src/locales/pt.json @@ -63,7 +63,8 @@ "geoHubs": "Geopolitical Hotspots", "polymarket": "Previsões", "climate": "Anomalias Climáticas", - "liveWebcams": "Câmeras ao Vivo", + "liveYouTube": "Câmeras ao Vivo", + "pinnedWebcams": "Pinned Webcams", "securityAdvisories": "Alertas de Segurança", "orefSirens": "Israel Sirens", "telegramIntel": "Inteligência Telegram", diff --git a/src/locales/ro.json b/src/locales/ro.json index d0037ce54..d70604f5e 100644 --- a/src/locales/ro.json +++ b/src/locales/ro.json @@ -275,7 +275,8 @@ "techReadiness": "Indicele de pregătire tehnică", "gccInvestments": "GCC Investments", "geoHubs": "Noduri geopolitice", - "liveWebcams": "Camere Live", + "liveYouTube": "Camere Live", + "pinnedWebcams": "Pinned Webcams", "gulfEconomies": "Economiile Golfului", "gulfIndices": "Indicii Golfului", "gulfCurrencies": "Monede din Golf", diff --git a/src/locales/ru.json b/src/locales/ru.json index d1178dc38..ccc118bbc 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -275,7 +275,8 @@ "techReadiness": "Индекс технологической готовности", "gccInvestments": "Инвестиции стран Залива", "geoHubs": "Геополитические хабы", - "liveWebcams": "Веб-камеры", + "liveYouTube": "Веб-камеры", + "pinnedWebcams": "Pinned Webcams", "gulfEconomies": "Экономика Залива", "gulfIndices": "Индексы Залива", "gulfCurrencies": "Валюты Залива", diff --git a/src/locales/sv.json b/src/locales/sv.json index ebac9c98e..add1d91c7 100644 --- a/src/locales/sv.json +++ b/src/locales/sv.json @@ -63,7 +63,8 @@ "geoHubs": "Geopolitical Hotspots", "polymarket": "Förutsägelser", "climate": "Klimatanomalier", - "liveWebcams": "Webbkameror", + "liveYouTube": "Webbkameror", + "pinnedWebcams": "Pinned Webcams", "securityAdvisories": "Säkerhetsvarningar", "orefSirens": "Israel Sirens", "telegramIntel": "Telegram Underrättelser", diff --git a/src/locales/th.json b/src/locales/th.json index 5040f59ed..bf1854f1d 100644 --- a/src/locales/th.json +++ b/src/locales/th.json @@ -275,7 +275,8 @@ "techReadiness": "ดัชนีความพร้อมด้านเทคโนโลยี", "gccInvestments": "การลงทุน GCC", "geoHubs": "ศูนย์กลางภูมิรัฐศาสตร์", - "liveWebcams": "เว็บแคมสด", + "liveYouTube": "เว็บแคมสด", + "pinnedWebcams": "Pinned Webcams", "gulfEconomies": "เศรษฐกิจอ่าว", "gulfIndices": "ดัชนีอ่าว", "gulfCurrencies": "สกุลเงินอ่าว", diff --git a/src/locales/tr.json b/src/locales/tr.json index 757246d2b..16919ca29 100644 --- a/src/locales/tr.json +++ b/src/locales/tr.json @@ -275,7 +275,8 @@ "techReadiness": "Teknoloji Hazirlik Endeksi", "gccInvestments": "GCC Yatirimlari", "geoHubs": "Jeopolitik Merkezler", - "liveWebcams": "Canli Web Kameralari", + "liveYouTube": "Canli Web Kameralari", + "pinnedWebcams": "Pinned Webcams", "gulfEconomies": "Körfez Ekonomileri", "gulfIndices": "Körfez Endeksleri", "gulfCurrencies": "Körfez Para Birimleri", diff --git a/src/locales/vi.json b/src/locales/vi.json index 910b354e7..b54bad711 100644 --- a/src/locales/vi.json +++ b/src/locales/vi.json @@ -275,7 +275,8 @@ "techReadiness": "Chỉ số Sẵn sàng Công nghệ", "gccInvestments": "Đầu tư GCC", "geoHubs": "Trung tâm Địa chính trị", - "liveWebcams": "Webcam Trực tiếp", + "liveYouTube": "Webcam Trực tiếp", + "pinnedWebcams": "Pinned Webcams", "gulfEconomies": "Kinh tế vùng Vịnh", "gulfIndices": "Chỉ số vùng Vịnh", "gulfCurrencies": "Tiền tệ vùng Vịnh", diff --git a/src/locales/zh.json b/src/locales/zh.json index 99a1bc83b..bbf0d7eca 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -275,7 +275,8 @@ "techReadiness": "科技就绪指数", "gccInvestments": "GCC投资", "geoHubs": "地缘政治枢纽", - "liveWebcams": "实时摄像头", + "liveYouTube": "实时摄像头", + "pinnedWebcams": "Pinned Webcams", "gulfEconomies": "海湾经济", "gulfIndices": "海湾指数", "gulfCurrencies": "海湾货币", diff --git a/src/services/webcams/index.ts b/src/services/webcams/index.ts new file mode 100644 index 000000000..d9b39f9af --- /dev/null +++ b/src/services/webcams/index.ts @@ -0,0 +1,87 @@ +import { getRpcBaseUrl } from '@/services/rpc-client'; +import { + WebcamServiceClient, + type WebcamEntry, + type WebcamCluster, + type ListWebcamsResponse, + type GetWebcamImageResponse, +} from '@/generated/client/worldmonitor/webcam/v1/service_client'; + +const client = new WebcamServiceClient(getRpcBaseUrl(), { + fetch: (...args) => globalThis.fetch(...args), +}); + +const emptyResponse: ListWebcamsResponse = { webcams: [], clusters: [], totalInView: 0 }; + +// Client-side image cache (9 min, under Windy's 10-min token expiry) +const IMAGE_CACHE_MS = 9 * 60 * 1000; +const IMAGE_CACHE_MAX = 200; +const imageCacheMap = new Map(); + +export async function fetchWebcams( + zoom: number, + bounds: { w: number; s: number; e: number; n: number }, +): Promise { + try { + return await client.listWebcams({ + zoom, + boundW: bounds.w, + boundS: bounds.s, + boundE: bounds.e, + boundN: bounds.n, + }); + } catch (err) { + console.warn('[webcams] fetch failed:', err); + return emptyResponse; + } +} + +export async function fetchWebcamImage(webcamId: string): Promise { + // Check client cache + const cached = imageCacheMap.get(webcamId); + if (cached && cached.expires > Date.now()) return cached.data; + + try { + const result = await client.getWebcamImage({ webcamId }); + if (!result.error) { + if (imageCacheMap.size >= IMAGE_CACHE_MAX) { + const oldest = imageCacheMap.keys().next().value; + if (oldest) imageCacheMap.delete(oldest); + } + imageCacheMap.set(webcamId, { data: result, expires: Date.now() + IMAGE_CACHE_MS }); + } + return result; + } catch (err) { + console.warn('[webcams] image fetch failed:', err); + return { + thumbnailUrl: '', playerUrl: '', title: '', + windyUrl: `https://www.windy.com/webcams/${webcamId}`, + lastUpdated: 0, error: 'unavailable', + }; + } +} + +// Category mapping for marker rendering +export const WEBCAM_CATEGORIES: Record = { + traffic: { color: '#ffd700', emoji: '\u{1F697}' }, // 🚗 + city: { color: '#00d4ff', emoji: '\u{1F3D9}\uFE0F' }, // 🏙️ + landscape: { color: '#45b7d1', emoji: '\u{1F3D4}\uFE0F' }, // 🏔️ + nature: { color: '#96ceb4', emoji: '\u{1F33F}' }, // 🌿 + beach: { color: '#f4a460', emoji: '\u{1F3D6}\uFE0F' }, // 🏖️ + water: { color: '#4169e1', emoji: '\u{1F30A}' }, // 🌊 + other: { color: '#888888', emoji: '\u{1F4F7}' }, // 📷 +}; + +export function getClusterCellSize(zoom: number): number { + if (zoom < 3) return 8; + if (zoom <= 4) return 5; + if (zoom <= 6) return 2; + if (zoom <= 8) return 0.5; + return 0.5; +} + +export function getCategoryStyle(category: string) { + return WEBCAM_CATEGORIES[category] ?? WEBCAM_CATEGORIES.other!; +} + +export type { WebcamEntry, WebcamCluster, GetWebcamImageResponse }; diff --git a/src/services/webcams/pinned-store.ts b/src/services/webcams/pinned-store.ts new file mode 100644 index 000000000..692f0e82a --- /dev/null +++ b/src/services/webcams/pinned-store.ts @@ -0,0 +1,107 @@ +const STORAGE_KEY = 'wm-pinned-webcams'; +const CHANGE_EVENT = 'wm-pinned-webcams-changed'; +const MAX_ACTIVE = 4; + +export interface PinnedWebcam { + webcamId: string; + title: string; + lat: number; + lng: number; + category: string; + country: string; + playerUrl: string; + active: boolean; + pinnedAt: number; +} + +let _cachedList: PinnedWebcam[] | null = null; +let _cacheFrame: number | null = null; + +function load(): PinnedWebcam[] { + if (_cachedList !== null) return _cachedList; + try { + const raw = localStorage.getItem(STORAGE_KEY); + _cachedList = raw ? (JSON.parse(raw) as PinnedWebcam[]) : []; + } catch { + _cachedList = []; + } + if (_cacheFrame === null) { + _cacheFrame = requestAnimationFrame(() => { _cachedList = null; _cacheFrame = null; }); + } + return _cachedList; +} + +function showToast(msg: string): void { + const el = document.createElement('div'); + el.className = 'wm-toast'; + el.textContent = msg; + document.body.appendChild(el); + setTimeout(() => el.remove(), 3000); +} + +function save(webcams: PinnedWebcam[]): void { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(webcams)); + } catch (err) { + console.warn('[pinned-webcams] localStorage save failed:', err); + showToast('Could not save pinned webcams — storage full'); + } + _cachedList = null; + window.dispatchEvent(new CustomEvent(CHANGE_EVENT)); +} + +export function getPinnedWebcams(): PinnedWebcam[] { + return load(); +} + +export function getActiveWebcams(): PinnedWebcam[] { + return load() + .filter(w => w.active) + .sort((a, b) => a.pinnedAt - b.pinnedAt) + .slice(0, MAX_ACTIVE); +} + +export function isPinned(webcamId: string): boolean { + return load().some(w => w.webcamId === webcamId); +} + +export function pinWebcam(webcam: Omit): void { + const list = load(); + if (list.some(w => w.webcamId === webcam.webcamId)) return; + const activeCount = list.filter(w => w.active).length; + list.push({ + ...webcam, + active: activeCount < MAX_ACTIVE, + pinnedAt: Date.now(), + }); + save(list); +} + +export function unpinWebcam(webcamId: string): void { + const list = load().filter(w => w.webcamId !== webcamId); + save(list); +} + +export function toggleWebcam(webcamId: string): void { + const list = load(); + const target = list.find(w => w.webcamId === webcamId); + if (!target) return; + if (!target.active) { + const activeList = list + .filter(w => w.active) + .sort((a, b) => a.pinnedAt - b.pinnedAt); + if (activeList.length >= MAX_ACTIVE && activeList[0]) { + activeList[0].active = false; + } + target.active = true; + } else { + target.active = false; + } + save(list); +} + +export function onPinnedChange(handler: () => void): () => void { + const wrapped = () => handler(); + window.addEventListener(CHANGE_EVENT, wrapped); + return () => window.removeEventListener(CHANGE_EVENT, wrapped); +} diff --git a/src/styles/main.css b/src/styles/main.css index 71cb13460..c4fd6a242 100644 --- a/src/styles/main.css +++ b/src/styles/main.css @@ -2626,8 +2626,8 @@ body.live-news-fullscreen-active .panels-grid > *:not(.live-news-fullscreen) { display: block; } -/* Live Webcams Panel */ -.panel[data-panel="live-webcams"] .panel-content { +/* Live YouTube Panel */ +.panel[data-panel="live-youtube"] .panel-content { padding: 0; flex: 1; display: flex; @@ -2943,6 +2943,219 @@ body.live-news-fullscreen-active .panels-grid > *:not(.live-news-fullscreen) { flex: 0 0 auto; } } + +/* ── Pinned Webcams Panel ── */ +.pinned-webcams-grid { + display: grid; + grid-template-columns: 1fr 1fr; + grid-template-rows: 1fr 1fr; + gap: 4px; + aspect-ratio: 16 / 9; + width: 100%; +} + +.pinned-webcam-slot { + position: relative; + background: var(--bg-secondary, #1a1a2e); + border-radius: 4px; + overflow: hidden; +} + +.pinned-webcam-slot--empty { + display: flex; + align-items: center; + justify-content: center; +} + +.pinned-webcam-iframe { + width: 100%; + height: 100%; + border: none; + display: block; +} + +.pinned-webcam-placeholder { + color: var(--text-muted, #666); + font-size: 0.75rem; + text-align: center; + padding: 8px; +} + +.pinned-webcam-label { + position: absolute; + bottom: 0; + left: 0; + right: 0; + display: flex; + align-items: center; + gap: 4px; + padding: 2px 6px; + background: rgba(0, 0, 0, 0.7); + font-size: 0.65rem; +} + +.pinned-webcam-title { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: #eee; +} + +.pinned-webcam-toggle, +.pinned-webcam-unpin { + background: none; + border: none; + color: #aaa; + cursor: pointer; + font-size: 0.7rem; + padding: 0 2px; + line-height: 1; +} + +.pinned-webcam-toggle:hover, +.pinned-webcam-unpin:hover { + color: #fff; +} + +/* Pinned list below grid */ +.pinned-webcams-list { + margin-top: 8px; + max-height: 120px; + overflow-y: auto; +} + +.pinned-webcams-list-header { + font-size: 0.7rem; + color: var(--text-muted, #888); + padding: 4px 8px; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.pinned-webcam-row { + display: flex; + align-items: center; + gap: 6px; + padding: 3px 8px; + font-size: 0.75rem; +} + +.pinned-webcam-row--active { + background: rgba(0, 212, 255, 0.08); +} + +.pinned-webcam-row-name { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.pinned-webcam-row-country { + color: var(--text-muted, #888); + font-size: 0.65rem; +} + +.pinned-webcam-row-toggle { + background: none; + border: 1px solid var(--border-color, #333); + color: var(--text-secondary, #aaa); + border-radius: 3px; + padding: 1px 6px; + font-size: 0.65rem; + cursor: pointer; +} + +.pinned-webcam-row--active .pinned-webcam-row-toggle { + border-color: var(--accent, #00d4ff); + color: var(--accent, #00d4ff); +} + +.pinned-webcam-row-remove { + background: none; + border: none; + color: var(--text-muted, #666); + cursor: pointer; + font-size: 0.7rem; + padding: 0 2px; +} + +.pinned-webcam-row-remove:hover { + color: var(--error, #ff4444); +} + +/* Pin button in map tooltips */ +.webcam-pin-btn { + background: none; + border: 1px solid var(--border-color, #444); + color: var(--text-secondary, #ccc); + border-radius: 3px; + padding: 2px 8px; + font-size: 0.75rem; + cursor: pointer; + margin-top: 4px; + display: inline-flex; + align-items: center; + gap: 4px; +} + +.webcam-pin-btn:hover { + border-color: var(--accent, #00d4ff); + color: var(--accent, #00d4ff); +} + +/* Toast notification (localStorage full, etc.) */ +.wm-toast { + position: fixed; + bottom: 16px; + left: 50%; + transform: translateX(-50%); + background: var(--bg-secondary, #1a1a2e); + border: 1px solid var(--error, #ff4444); + color: var(--text-primary, #eee); + padding: 8px 16px; + border-radius: 6px; + font-size: 0.8rem; + z-index: 10000; + pointer-events: none; + animation: wm-toast-fade 3s ease-in-out; +} + +@keyframes wm-toast-fade { + 0%, 80% { opacity: 1; } + 100% { opacity: 0; } +} + +.webcam-pin-btn--pinned { + opacity: 0.6; + cursor: default; + border-color: var(--accent, #00d4ff); + color: var(--accent, #00d4ff); +} + +/* DeckGL webcam click popup */ +.deckgl-webcam-popup { + background: var(--bg-secondary, #1a1a2e); + border: 1px solid var(--border-color, #333); + border-radius: 6px; + padding: 8px 12px; + min-width: 140px; + pointer-events: auto; +} + +.deckgl-webcam-popup-title { + font-size: 0.8rem; + font-weight: 600; + margin-bottom: 2px; +} + +.deckgl-webcam-popup-location { + font-size: 0.7rem; + color: var(--text-muted, #888); + margin-bottom: 6px; +} + /* News Items */ .item { padding: 8px 0; diff --git a/src/types/index.ts b/src/types/index.ts index 5c922ceed..d35e09aa4 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -564,6 +564,7 @@ export interface MapLayers { miningSites: boolean; processingPlants: boolean; commodityPorts: boolean; + webcams: boolean; } export interface AIDataCenter { diff --git a/vercel.json b/vercel.json index 5f32550b4..61e3649b8 100644 --- a/vercel.json +++ b/vercel.json @@ -31,7 +31,7 @@ { "key": "Strict-Transport-Security", "value": "max-age=63072000; includeSubDomains; preload" }, { "key": "Referrer-Policy", "value": "strict-origin-when-cross-origin" }, { "key": "Permissions-Policy", "value": "camera=(), microphone=(), geolocation=(self), accelerometer=(), autoplay=(self \"https://www.youtube.com\" \"https://www.youtube-nocookie.com\"), bluetooth=(), display-capture=(), encrypted-media=(self \"https://www.youtube.com\" \"https://www.youtube-nocookie.com\"), gyroscope=(), hid=(), idle-detection=(), magnetometer=(), midi=(), payment=(), picture-in-picture=(self \"https://www.youtube.com\" \"https://www.youtube-nocookie.com\" \"https://challenges.cloudflare.com\"), screen-wake-lock=(), serial=(), usb=(), xr-spatial-tracking=()" }, - { "key": "Content-Security-Policy", "value": "default-src 'self'; connect-src 'self' https: wss: blob: data:; img-src 'self' data: blob: https:; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval' https://www.youtube.com https://static.cloudflareinsights.com https://vercel.live https://challenges.cloudflare.com; worker-src 'self' blob:; font-src 'self' data: https:; media-src 'self' data: blob: https:; frame-src 'self' https://worldmonitor.app https://tech.worldmonitor.app https://finance.worldmonitor.app https://commodity.worldmonitor.app https://happy.worldmonitor.app https://www.youtube.com https://www.youtube-nocookie.com https://challenges.cloudflare.com; frame-ancestors 'self' https://www.worldmonitor.app https://tech.worldmonitor.app https://finance.worldmonitor.app https://commodity.worldmonitor.app https://happy.worldmonitor.app https://worldmonitor.app; base-uri 'self'; object-src 'none'; form-action 'self'" } + { "key": "Content-Security-Policy", "value": "default-src 'self'; connect-src 'self' https: wss: blob: data:; img-src 'self' data: blob: https:; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval' https://www.youtube.com https://static.cloudflareinsights.com https://vercel.live https://challenges.cloudflare.com; worker-src 'self' blob:; font-src 'self' data: https:; media-src 'self' data: blob: https:; frame-src 'self' https://worldmonitor.app https://tech.worldmonitor.app https://finance.worldmonitor.app https://commodity.worldmonitor.app https://happy.worldmonitor.app https://www.youtube.com https://www.youtube-nocookie.com https://webcams.windy.com https://challenges.cloudflare.com; frame-ancestors 'self' https://www.worldmonitor.app https://tech.worldmonitor.app https://finance.worldmonitor.app https://commodity.worldmonitor.app https://happy.worldmonitor.app https://worldmonitor.app; base-uri 'self'; object-src 'none'; form-action 'self'" } ] }, {