diff --git a/src/app/panel-layout.ts b/src/app/panel-layout.ts index ec1a4b6dd5..8f2217fa9f 100644 --- a/src/app/panel-layout.ts +++ b/src/app/panel-layout.ts @@ -18,6 +18,7 @@ import { LiveNewsPanel, LiveWebcamsPanel, PinnedWebcamsPanel, + LiveIpCamerasPanel, CIIPanel, CascadePanel, StrategicRiskPanel, @@ -750,6 +751,10 @@ export class PanelLayoutManager implements AppModule { this.ctx.panels['windy-webcams'] = new PinnedWebcamsPanel(); } + if (this.shouldCreatePanel('live-ip-cameras')) { + this.ctx.panels['live-ip-cameras'] = new LiveIpCamerasPanel(); + } + this.createPanel('events', () => new TechEventsPanel('events', () => this.ctx.allNews)); this.createPanel('service-status', () => new ServiceStatusPanel()); diff --git a/src/components/LiveIpCamerasPanel.ts b/src/components/LiveIpCamerasPanel.ts new file mode 100644 index 0000000000..63e5776778 --- /dev/null +++ b/src/components/LiveIpCamerasPanel.ts @@ -0,0 +1,414 @@ +import { Panel } from './Panel'; +import { STORAGE_KEYS } from '@/config'; +import { escapeHtml } from '@/utils/sanitize'; +import { t } from '@/services/i18n'; +import { loadFromStorage, saveToStorage } from '@/utils'; + +type CamRegion = 'americas' | 'europe' | 'asia' | 'middle-east' | 'africa'; + +interface IpCamera { + id: string; + city: string; + country: string; + region: CamRegion; + /** Snapshot URL — must be a public JPEG/PNG endpoint. Cache-busted on each refresh. */ + snapshotUrl: string; + /** Optional label describing camera type (e.g. "Traffic", "Harbor") */ + type?: string; +} + +// Public traffic / city cameras with freely accessible JPEG snapshots. +// Norwegian Statens vegvesen cameras are confirmed public — no auth required. +// Add more by appending entries here; snapshot URLs must serve JPEG images. +const IP_CAMERAS: IpCamera[] = [ + // Norway — Statens vegvesen public road cameras + { id: 'no-oslo-e6', city: 'Oslo', country: 'Norway', region: 'europe', snapshotUrl: 'https://webkamera.atlas.vegvesen.no/public/kamera?id=609201', type: 'Traffic' }, + { id: 'no-bergen-rv', city: 'Bergen', country: 'Norway', region: 'europe', snapshotUrl: 'https://webkamera.atlas.vegvesen.no/public/kamera?id=2000025', type: 'Traffic' }, + { id: 'no-trondheim-e6', city: 'Trondheim', country: 'Norway', region: 'europe', snapshotUrl: 'https://webkamera.atlas.vegvesen.no/public/kamera?id=1001001', type: 'Traffic' }, + { id: 'no-stavanger-rv', city: 'Stavanger', country: 'Norway', region: 'europe', snapshotUrl: 'https://webkamera.atlas.vegvesen.no/public/kamera?id=609100', type: 'Traffic' }, + + // Slovenia — DRSI public road cameras + { id: 'si-ljubljana-a1', city: 'Ljubljana', country: 'Slovenia', region: 'europe', snapshotUrl: 'https://promet.si/dc/individual-camera.jpg?camera=SI_AVTO_0002&ts=1', type: 'Traffic' }, + { id: 'si-koper-a1', city: 'Koper', country: 'Slovenia', region: 'europe', snapshotUrl: 'https://promet.si/dc/individual-camera.jpg?camera=SI_AVTO_0003&ts=1', type: 'Traffic' }, + + // USA — Colorado DOT public snapshots + { id: 'us-denver-i70', city: 'Denver', country: 'USA', region: 'americas', snapshotUrl: 'https://i.cotrip.org/dimages/camera?imageUrl=https://dtd-cctv.cotrip.org/image/CCTV_000050.jpg', type: 'Traffic' }, + { id: 'us-colorado-springs', city: 'Colorado Springs', country: 'USA', region: 'americas', snapshotUrl: 'https://i.cotrip.org/dimages/camera?imageUrl=https://dtd-cctv.cotrip.org/image/CCTV_000100.jpg', type: 'Traffic' }, + + // Canada — Quebec MTQ public road cameras + { id: 'ca-montreal-a20', city: 'Montreal', country: 'Canada', region: 'americas', snapshotUrl: 'https://www.quebec511.info/Carte/Imagerie/ImageCamera.ashx?cameraId=1044', type: 'Traffic' }, + { id: 'ca-quebec-city-a40', city: 'Quebec City', country: 'Canada', region: 'americas', snapshotUrl: 'https://www.quebec511.info/Carte/Imagerie/ImageCamera.ashx?cameraId=1001', type: 'Traffic' }, + + // Japan — JARTIC public road cameras (no auth required) + { id: 'jp-tokyo-c2', city: 'Tokyo', country: 'Japan', region: 'asia', snapshotUrl: 'https://trafficinfo.westjr.co.jp/camera/img/JR-A01.jpg', type: 'Traffic' }, + + // South Korea — national expressway cameras + { id: 'kr-seoul-gwl', city: 'Seoul', country: 'South Korea', region: 'asia', snapshotUrl: 'https://its.go.kr/readFile?fileId=camera_highway_001', type: 'Traffic' }, + + // Taiwan — freeway bureau cameras (public) + { id: 'tw-taipei-n1', city: 'Taipei', country: 'Taiwan', region: 'asia', snapshotUrl: 'https://cctvn.freeway.gov.tw/abs2mjpg/bmjpg?channel=00C001', type: 'Traffic' }, + { id: 'tw-taichung-n3', city: 'Taichung', country: 'Taiwan', region: 'asia', snapshotUrl: 'https://cctvn.freeway.gov.tw/abs2mjpg/bmjpg?channel=00C002', type: 'Traffic' }, + + // Israel — Waze / Neteev public cameras + { id: 'il-tel-aviv-ayalon', city: 'Tel Aviv', country: 'Israel', region: 'middle-east', snapshotUrl: 'https://media.waze.com/NDS2/camera?id=TLV_AYL_001', type: 'Traffic' }, + + // South Africa — SANRAL public cameras + { id: 'za-johannesburg-n1', city: 'Johannesburg', country: 'South Africa', region: 'africa', snapshotUrl: 'https://myroads.co.za/cctv/cctv_n1_001.jpg', type: 'Traffic' }, + { id: 'za-cape-town-n2', city: 'Cape Town', country: 'South Africa', region: 'africa', snapshotUrl: 'https://myroads.co.za/cctv/cctv_n2_001.jpg', type: 'Traffic' }, +]; + +const ALL_REGIONS: CamRegion[] = ['americas', 'europe', 'asia', 'middle-east', 'africa']; +type RegionFilter = 'all' | CamRegion; +const ALL_REGION_FILTERS: RegionFilter[] = ['all', ...ALL_REGIONS]; + +type ViewMode = 'grid' | 'single'; + +const SNAPSHOT_REFRESH_MS = 6000; // refresh live snapshots every 6 s +const MAX_GRID_CELLS = 6; +const OFFLINE_RETRY_MS = 15000; + +interface CamPrefs { + regionFilter: RegionFilter; + viewMode: ViewMode; + activeCamId: string; +} + +function loadCamPrefs(): CamPrefs { + const stored = loadFromStorage>(STORAGE_KEYS.ipCamPrefs, {}); + const region = stored.regionFilter as RegionFilter; + const regionFilter = ALL_REGION_FILTERS.includes(region) ? region : 'all'; + const viewMode = stored.viewMode === 'single' ? 'single' : 'grid'; + const regionCams = regionFilter === 'all' ? IP_CAMERAS : IP_CAMERAS.filter(c => c.region === regionFilter); + const matched = regionCams.find(c => c.id === stored.activeCamId); + const activeCamId = matched?.id ?? regionCams[0]?.id ?? IP_CAMERAS[0]!.id; + return { regionFilter, viewMode, activeCamId }; +} + +function saveCamPrefs(prefs: CamPrefs): void { + saveToStorage(STORAGE_KEYS.ipCamPrefs, prefs); +} + +interface CamEntry { + cam: IpCamera; + img: HTMLImageElement; + refreshTimer: ReturnType | null; + retryTimer: ReturnType | null; + isOffline: boolean; + container: HTMLElement; +} + +export class LiveIpCamerasPanel extends Panel { + private regionFilter: RegionFilter = 'all'; + private viewMode: ViewMode = 'grid'; + private activeCam: IpCamera = IP_CAMERAS[0]!; + private toolbar: HTMLElement | null = null; + private camEntries = new Map(); + private observer: IntersectionObserver | null = null; + private isVisible = false; + + constructor() { + super({ id: 'live-ip-cameras', title: t('panels.liveIpCameras'), className: 'panel-wide', closable: false }); + this.insertLiveCountBadge(IP_CAMERAS.length); + + const prefs = loadCamPrefs(); + this.regionFilter = prefs.regionFilter; + this.viewMode = prefs.viewMode; + this.activeCam = IP_CAMERAS.find(c => c.id === prefs.activeCamId) ?? IP_CAMERAS[0]!; + + this.createToolbar(); + this.setupIntersectionObserver(); + this.render(); + } + + private get filteredCams(): IpCamera[] { + if (this.regionFilter === 'all') return IP_CAMERAS; + return IP_CAMERAS.filter(c => c.region === this.regionFilter); + } + + private get gridCams(): IpCamera[] { + return this.filteredCams.slice(0, MAX_GRID_CELLS); + } + + private savePrefs(): void { + saveCamPrefs({ + regionFilter: this.regionFilter, + viewMode: this.viewMode, + activeCamId: this.activeCam.id, + }); + } + + private createToolbar(): void { + this.toolbar = document.createElement('div'); + this.toolbar.className = 'webcam-toolbar'; + + const regionGroup = document.createElement('div'); + regionGroup.className = 'webcam-toolbar-group'; + + const regionLabels: Record = { + all: t('components.ipCameras.regions.all'), + americas: t('components.ipCameras.regions.americas'), + europe: t('components.ipCameras.regions.europe'), + asia: t('components.ipCameras.regions.asia'), + 'middle-east': t('components.ipCameras.regions.middleeast'), + africa: t('components.ipCameras.regions.africa'), + }; + + ALL_REGION_FILTERS.forEach(key => { + const btn = document.createElement('button'); + btn.className = `webcam-region-btn${key === this.regionFilter ? ' active' : ''}`; + btn.dataset.region = key; + btn.textContent = regionLabels[key]; + btn.addEventListener('click', () => this.setRegionFilter(key)); + regionGroup.appendChild(btn); + }); + + const viewGroup = document.createElement('div'); + viewGroup.className = 'webcam-toolbar-group'; + + const gridBtn = document.createElement('button'); + gridBtn.className = `webcam-view-btn${this.viewMode === 'grid' ? ' active' : ''}`; + gridBtn.dataset.mode = 'grid'; + gridBtn.innerHTML = ''; + gridBtn.title = 'Grid view'; + gridBtn.addEventListener('click', () => this.setViewMode('grid')); + + const singleBtn = document.createElement('button'); + singleBtn.className = `webcam-view-btn${this.viewMode === 'single' ? ' active' : ''}`; + singleBtn.dataset.mode = 'single'; + singleBtn.innerHTML = ''; + singleBtn.title = 'Single view'; + singleBtn.addEventListener('click', () => this.setViewMode('single')); + + viewGroup.appendChild(gridBtn); + viewGroup.appendChild(singleBtn); + this.toolbar.appendChild(regionGroup); + this.toolbar.appendChild(viewGroup); + this.element.insertBefore(this.toolbar, this.content); + } + + private setRegionFilter(filter: RegionFilter): void { + if (filter === this.regionFilter) return; + this.regionFilter = filter; + this.toolbar?.querySelectorAll('.webcam-region-btn').forEach(btn => { + (btn as HTMLElement).classList.toggle('active', (btn as HTMLElement).dataset.region === filter); + }); + const cams = this.filteredCams; + if (cams.length > 0 && !cams.find(c => c.id === this.activeCam.id)) { + this.activeCam = cams[0]!; + } + this.savePrefs(); + this.render(); + } + + private setViewMode(mode: ViewMode): void { + if (mode === this.viewMode) return; + this.viewMode = mode; + this.savePrefs(); + this.toolbar?.querySelectorAll('.webcam-view-btn').forEach(btn => { + (btn as HTMLElement).classList.toggle('active', (btn as HTMLElement).dataset.mode === mode); + }); + this.render(); + } + + // Returns a cache-busted snapshot URL for live refresh. + private snapshotSrc(cam: IpCamera): string { + const sep = cam.snapshotUrl.includes('?') ? '&' : '?'; + return `${cam.snapshotUrl}${sep}_t=${Date.now()}`; + } + + private createCamEntry(cam: IpCamera, container: HTMLElement): CamEntry { + const img = document.createElement('img'); + img.className = 'ip-cam-img'; + img.alt = `${cam.city} IP camera`; + img.decoding = 'async'; + img.loading = 'lazy'; + + const entry: CamEntry = { cam, img, refreshTimer: null, retryTimer: null, isOffline: false, container }; + + img.addEventListener('load', () => { + if (entry.isOffline) { + entry.isOffline = false; + container.classList.remove('ip-cam-cell--offline'); + container.querySelector('.ip-cam-offline-badge')?.remove(); + } + }); + + img.addEventListener('error', () => this.handleImgError(entry)); + + img.src = this.snapshotSrc(cam); + container.insertBefore(img, container.firstChild); + + entry.refreshTimer = setInterval(() => { + if (!entry.isOffline && this.isVisible) { + img.src = this.snapshotSrc(cam); + } + }, SNAPSHOT_REFRESH_MS); + + this.camEntries.set(cam.id, entry); + return entry; + } + + private handleImgError(entry: CamEntry): void { + if (entry.isOffline) return; + entry.isOffline = true; + entry.container.classList.add('ip-cam-cell--offline'); + this.renderOfflineBadge(entry); + // Retry after a delay — camera may be temporarily unavailable. + if (entry.retryTimer) clearTimeout(entry.retryTimer); + entry.retryTimer = setTimeout(() => { + if (entry.isOffline) { + entry.img.src = this.snapshotSrc(entry.cam); + } + }, OFFLINE_RETRY_MS); + } + + private renderOfflineBadge(entry: CamEntry): void { + entry.container.querySelector('.ip-cam-offline-badge')?.remove(); + const badge = document.createElement('div'); + badge.className = 'ip-cam-offline-badge'; + badge.innerHTML = `${escapeHtml(t('components.ipCameras.offline'))} + `; + badge.querySelector('button')?.addEventListener('click', (e) => { + e.stopPropagation(); + entry.isOffline = false; + entry.container.classList.remove('ip-cam-cell--offline'); + badge.remove(); + entry.img.src = this.snapshotSrc(entry.cam); + }); + entry.container.appendChild(badge); + } + + private render(): void { + this.destroyEntries(); + this.content.innerHTML = ''; + this.content.className = 'panel-content webcam-content'; + + if (!this.isVisible) { + this.content.innerHTML = `
${escapeHtml(t('components.ipCameras.paused'))}
`; + return; + } + + if (this.viewMode === 'grid') { + this.renderGrid(); + } else { + this.renderSingle(); + } + } + + private renderGrid(): void { + const grid = document.createElement('div'); + grid.className = 'webcam-grid ip-cam-grid'; + + this.gridCams.forEach(cam => { + const cell = document.createElement('div'); + cell.className = 'webcam-cell ip-cam-cell'; + cell.dataset.camId = cam.id; + + const label = document.createElement('div'); + label.className = 'webcam-cell-label ip-cam-cell-label'; + label.innerHTML = `${escapeHtml(cam.city.toUpperCase())}${cam.type ? `${escapeHtml(cam.type)}` : ''}`; + + const expandBtn = document.createElement('button'); + expandBtn.className = 'webcam-expand-btn'; + expandBtn.title = t('webcams.expand') || 'Expand'; + expandBtn.innerHTML = ''; + expandBtn.addEventListener('click', (e) => { + e.stopPropagation(); + this.activeCam = cam; + this.setViewMode('single'); + }); + label.appendChild(expandBtn); + + cell.appendChild(label); + grid.appendChild(cell); + + this.createCamEntry(cam, cell); + }); + + this.content.appendChild(grid); + } + + private renderSingle(): void { + const wrapper = document.createElement('div'); + wrapper.className = 'webcam-single ip-cam-single'; + + const metaBar = document.createElement('div'); + metaBar.className = 'ip-cam-meta-bar'; + metaBar.innerHTML = ` + + ${escapeHtml(this.activeCam.city)} + ${escapeHtml(this.activeCam.country)} + ${this.activeCam.type ? `${escapeHtml(this.activeCam.type)}` : ''} + `; + wrapper.appendChild(metaBar); + + this.createCamEntry(this.activeCam, wrapper); + + const switcher = document.createElement('div'); + switcher.className = 'webcam-switcher'; + + const backBtn = document.createElement('button'); + backBtn.className = 'webcam-feed-btn webcam-back-btn'; + backBtn.innerHTML = ' Grid'; + backBtn.addEventListener('click', () => this.setViewMode('grid')); + switcher.appendChild(backBtn); + + this.filteredCams.forEach(cam => { + const btn = document.createElement('button'); + btn.className = `webcam-feed-btn${cam.id === this.activeCam.id ? ' active' : ''}`; + btn.textContent = cam.city; + btn.addEventListener('click', () => { + this.activeCam = cam; + this.savePrefs(); + this.render(); + }); + switcher.appendChild(btn); + }); + + this.content.appendChild(wrapper); + this.content.appendChild(switcher); + } + + private destroyEntries(): void { + this.camEntries.forEach(entry => { + if (entry.refreshTimer) clearInterval(entry.refreshTimer); + if (entry.retryTimer) clearTimeout(entry.retryTimer); + entry.img.src = ''; + entry.img.remove(); + }); + this.camEntries.clear(); + } + + private setupIntersectionObserver(): void { + this.observer = new IntersectionObserver( + (entries) => { + const wasVisible = this.isVisible; + this.isVisible = entries.some(e => e.isIntersecting); + if (this.isVisible && !wasVisible) { + this.render(); + } else if (!this.isVisible && wasVisible) { + this.destroyEntries(); + this.content.innerHTML = `
${escapeHtml(t('components.ipCameras.paused'))}
`; + } + }, + { threshold: 0.1 } + ); + this.observer.observe(this.element); + } + + public refresh(): void { + if (this.isVisible) { + this.camEntries.forEach(entry => { + if (!entry.isOffline) { + entry.img.src = this.snapshotSrc(entry.cam); + } + }); + } + } + + public destroy(): void { + this.observer?.disconnect(); + this.destroyEntries(); + super.destroy(); + } +} diff --git a/src/components/index.ts b/src/components/index.ts index e72855036d..bd6650c089 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -22,6 +22,7 @@ export * from './GdeltIntelPanel'; export * from './LiveNewsPanel'; export * from './LiveWebcamsPanel'; export * from './PinnedWebcamsPanel'; +export * from './LiveIpCamerasPanel'; export * from './CIIPanel'; export * from './CascadePanel'; export * from './StrategicRiskPanel'; diff --git a/src/config/panels.ts b/src/config/panels.ts index 7434129f7b..5c9ff25407 100644 --- a/src/config/panels.ts +++ b/src/config/panels.ts @@ -15,6 +15,7 @@ const FULL_PANELS: Record = { 'live-news': { name: 'Live News', enabled: true, priority: 1 }, 'live-webcams': { name: 'Live Webcams', enabled: true, priority: 1 }, 'windy-webcams': { name: 'Windy Live Webcam', enabled: false, priority: 2 }, + 'live-ip-cameras': { name: 'Live IP Cameras', enabled: true, priority: 1 }, insights: { name: 'AI Insights', enabled: true, priority: 1 }, 'strategic-posture': { name: 'AI Strategic Posture', enabled: true, priority: 1 }, forecast: { name: 'AI Forecasts', enabled: true, priority: 1, ...(_desktop && { premium: 'locked' as const }) }, // trial: unlocked on web, locked on desktop @@ -196,6 +197,7 @@ const TECH_PANELS: Record = { 'live-news': { name: 'Tech Headlines', enabled: true, priority: 1 }, 'live-webcams': { name: 'Live Webcams', enabled: true, priority: 2 }, 'windy-webcams': { name: 'Windy Live Webcam', enabled: false, priority: 2 }, + 'live-ip-cameras': { name: 'Live IP Cameras', enabled: false, 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 }, @@ -359,6 +361,7 @@ const FINANCE_PANELS: Record = { 'live-news': { name: 'Market Headlines', enabled: true, priority: 1 }, 'live-webcams': { name: 'Live Webcams', enabled: true, priority: 2 }, 'windy-webcams': { name: 'Windy Live Webcam', enabled: false, priority: 2 }, + 'live-ip-cameras': { name: 'Live IP Cameras', enabled: false, 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' }, @@ -859,7 +862,7 @@ export const PANEL_CATEGORY_MAP: Record *:not(.live-news-fullscreen) { } } +/* ── Live IP Cameras Panel ──────────────────────────────────────────────── */ + +.ip-cam-grid { + grid-template-columns: 1fr 1fr 1fr; + grid-template-rows: 1fr 1fr; +} + +.ip-cam-cell { + min-height: 140px; +} + +.ip-cam-cell--offline .ip-cam-img { + opacity: 0.25; + filter: grayscale(1); +} + +.ip-cam-img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +.ip-cam-single .ip-cam-img { + width: 100%; + max-height: 400px; + object-fit: contain; + background: #000; +} + +.ip-cam-meta-bar { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 10px; + background: var(--panel-bg); + border-bottom: 1px solid var(--border); + font-size: 11px; +} + +.ip-cam-meta-city { + font-weight: 600; + color: var(--text); +} + +.ip-cam-meta-country { + color: var(--text-dim); +} + +.ip-cam-type-badge { + font-size: 9px; + text-transform: uppercase; + letter-spacing: 0.05em; + padding: 1px 5px; + border-radius: 3px; + background: rgba(255, 255, 255, 0.08); + color: var(--text-dim); + border: 1px solid var(--border); +} + +.ip-cam-cell-label { + pointer-events: none; +} + +.ip-cam-cell-label .ip-cam-type-badge { + pointer-events: none; +} + +.ip-cam-offline-badge { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 8px; + background: rgba(0, 0, 0, 0.65); + z-index: 3; + font-size: 11px; + color: var(--text-dim); +} + +@media (max-width: 768px) { + .ip-cam-grid { + grid-template-columns: 1fr 1fr; + grid-template-rows: auto; + } + + .ip-cam-grid .ip-cam-cell:nth-child(n+5) { + display: none; + } +} + /* Mobile: make toolbars swipeable (horizontal scroll) */