diff --git a/src/apps/AppHost.js b/src/apps/AppHost.js new file mode 100644 index 0000000..55b758a --- /dev/null +++ b/src/apps/AppHost.js @@ -0,0 +1,370 @@ +/** + * AppHost - Runtime manager for app instances + * + * Manages: + * - App instance lifecycle (mount/unmount) + * - Event bus for app communication + * - Bridge between apps and backend + * - Map context distribution + */ + +import { getApp } from './AppRegistry'; +import { EVENT_TYPES } from './types'; +import { loadAuth } from '../auth/authStorage'; + +// Generate unique IDs +function generateId() { + return 'ai_' + Math.random().toString(36).substr(2, 9) + Date.now().toString(36); +} + +function generateRequestId() { + return 'req_' + Math.random().toString(36).substr(2, 9) + Date.now().toString(36); +} + +/** + * Create an AppHost instance + * @returns {AppHost} + */ +export function createAppHost() { + /** @type {Map} */ + const instances = new Map(); + + /** @type {Map>} */ + const eventListeners = new Map(); + + /** @type {import('./types').MapContext | null} */ + let currentMapContext = null; + + /** @type {Set} */ + const mapContextListeners = new Set(); + + // API base URL + function apiBase() { + const raw = (import.meta?.env?.VITE_API_BASE_URL || '').toString().replace(/\/+$/, ''); + if (raw) return raw; + try { + if (typeof window !== 'undefined') { + const host = window.location.hostname || ''; + if (host.startsWith('www.')) { + return `https://${host.replace(/^www\./, '')}`; + } + } + } catch {} + return ''; + } + + /** + * Send action to backend + * @param {string} appInstanceId + * @param {string} action + * @param {Object} payload + * @returns {Promise<{requestId: string}>} + */ + async function sendAction(appInstanceId, action, payload) { + const instance = instances.get(appInstanceId); + if (!instance) { + throw new Error(`Unknown app instance: ${appInstanceId}`); + } + + const auth = loadAuth(); + const userId = auth?.user?.id || auth?.user?.sub || 'anonymous'; + const requestId = generateRequestId(); + + const envelope = { + v: 1, + requestId, + ts: Math.floor(Date.now() / 1000), + userId, + appId: instance.appId, + appInstanceId, + action, + payload, + context: { + clientId: getClientId(), + traceId: requestId, + }, + }; + + const url = `${apiBase()}/api/apps/${appInstanceId}/in`; + + try { + const res = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(auth?.access_token ? { Authorization: `Bearer ${auth.access_token}` } : {}), + }, + credentials: 'include', + body: JSON.stringify(envelope), + }); + + if (!res.ok) { + const text = await res.text().catch(() => ''); + throw new Error(`App action failed: ${res.status} ${text}`); + } + + return { requestId }; + } catch (err) { + console.error('AppHost sendAction error:', err); + throw err; + } + } + + /** + * Emit event to a specific app instance + * @param {string} appInstanceId + * @param {Object} event + */ + function emitToApp(appInstanceId, event) { + const listeners = eventListeners.get(appInstanceId); + if (listeners) { + for (const listener of listeners) { + try { + listener(event); + } catch (err) { + console.error('App event listener error:', err); + } + } + } + } + + /** + * Emit event to all apps + * @param {Object} event + */ + function emitToAllApps(event) { + for (const appInstanceId of instances.keys()) { + emitToApp(appInstanceId, event); + } + } + + /** + * Subscribe to events for an app instance + * @param {string} appInstanceId + * @param {function} handler + * @returns {function} Unsubscribe function + */ + function onAppEvent(appInstanceId, handler) { + if (!eventListeners.has(appInstanceId)) { + eventListeners.set(appInstanceId, new Set()); + } + eventListeners.get(appInstanceId).add(handler); + + return () => { + const listeners = eventListeners.get(appInstanceId); + if (listeners) { + listeners.delete(handler); + if (listeners.size === 0) { + eventListeners.delete(appInstanceId); + } + } + }; + } + + /** + * Update map context and notify apps + * @param {import('./types').MapContext} context + */ + function updateMapContext(context) { + const prevSectorId = currentMapContext?.sectorId; + currentMapContext = context; + + // Notify map context listeners + for (const listener of mapContextListeners) { + try { + listener(context); + } catch (err) { + console.error('Map context listener error:', err); + } + } + + // Emit to all apps if sector changed + if (context.sectorId !== prevSectorId) { + emitToAllApps({ + type: EVENT_TYPES.LOCATION_CHANGED, + payload: context, + }); + } + } + + /** + * Get current map context + * @returns {import('./types').MapContext | null} + */ + function getMapContext() { + return currentMapContext; + } + + /** + * Subscribe to map context changes + * @param {function} handler + * @returns {function} Unsubscribe function + */ + function onMapContextChange(handler) { + mapContextListeners.add(handler); + // Immediately call with current context if available + if (currentMapContext) { + try { + handler(currentMapContext); + } catch (err) { + console.error('Map context handler error:', err); + } + } + return () => { + mapContextListeners.delete(handler); + }; + } + + /** + * Create a bridge object for an app instance + * @param {string} appInstanceId + * @returns {import('./types').AppHostBridge} + */ + function createBridge(appInstanceId) { + return { + sendAction: (action, payload) => sendAction(appInstanceId, action, payload), + onEvent: (eventType, handler) => { + // Wrap handler to filter by event type + const wrappedHandler = (event) => { + if (!eventType || event.type === eventType) { + handler(event); + } + }; + return onAppEvent(appInstanceId, wrappedHandler); + }, + getMapContext, + onMapContextChange, + }; + } + + /** + * Install an app instance + * @param {string} appId + * @param {Object} [options] + * @returns {import('./types').AppInstance} + */ + function installApp(appId, options = {}) { + const definition = getApp(appId); + if (!definition) { + throw new Error(`Unknown app: ${appId}`); + } + + const appInstanceId = options.appInstanceId || generateId(); + const auth = loadAuth(); + + const instance = { + appInstanceId, + appId, + userId: auth?.user?.id || auth?.user?.sub || 'anonymous', + slot: options.slot || definition.defaultSlot, + order: options.order ?? definition.order ?? 0, + config: options.config || {}, + enabled: options.enabled !== false, + }; + + instances.set(appInstanceId, instance); + return instance; + } + + /** + * Uninstall an app instance + * @param {string} appInstanceId + */ + function uninstallApp(appInstanceId) { + instances.delete(appInstanceId); + eventListeners.delete(appInstanceId); + } + + /** + * Get all installed instances + * @returns {import('./types').AppInstance[]} + */ + function getInstalledApps() { + return Array.from(instances.values()); + } + + /** + * Get instances for a specific slot + * @param {string} slotId + * @returns {import('./types').AppInstance[]} + */ + function getAppsForSlot(slotId) { + return getInstalledApps() + .filter(inst => inst.slot === slotId && inst.enabled) + .sort((a, b) => a.order - b.order); + } + + /** + * Get an instance by ID + * @param {string} appInstanceId + * @returns {import('./types').AppInstance | undefined} + */ + function getInstance(appInstanceId) { + return instances.get(appInstanceId); + } + + /** + * Handle incoming WS message for apps + * @param {Object} message + */ + function handleWSMessage(message) { + // Check if this is an app event + if (message.type === 'app.event' && message.appInstanceId) { + emitToApp(message.appInstanceId, message.event || message); + } + } + + return { + // Instance management + installApp, + uninstallApp, + getInstalledApps, + getAppsForSlot, + getInstance, + + // Event bus + emitToApp, + emitToAllApps, + onAppEvent, + handleWSMessage, + + // Map context + updateMapContext, + getMapContext, + onMapContextChange, + + // Bridge factory + createBridge, + + // Direct action (for internal use) + sendAction, + }; +} + +// Client ID for tracking +let clientId = null; +function getClientId() { + if (!clientId) { + clientId = 'c_' + Math.random().toString(36).substr(2, 9); + } + return clientId; +} + +// Singleton instance +let defaultHost = null; + +/** + * Get the default AppHost instance + * @returns {ReturnType} + */ +export function getDefaultAppHost() { + if (!defaultHost) { + defaultHost = createAppHost(); + } + return defaultHost; +} + +export default { + createAppHost, + getDefaultAppHost, +}; diff --git a/src/apps/AppOverlay.css b/src/apps/AppOverlay.css new file mode 100644 index 0000000..6d6263b --- /dev/null +++ b/src/apps/AppOverlay.css @@ -0,0 +1,176 @@ +/** + * AppOverlay - Widget slot positioning and styling + */ + +.app-overlay { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + pointer-events: none; + z-index: 500; + overflow: hidden; +} + +/* Slot positioning */ +.app-slot { + position: absolute; + pointer-events: auto; + display: flex; + flex-direction: column; + gap: 8px; + max-width: calc(100% - 16px); +} + +.app-slot-top-left { + top: 78px; /* Below search bar */ + left: 13px; + align-items: flex-start; +} + +.app-slot-top-right { + top: 70px; + right: 8px; + align-items: flex-end; +} + +.app-slot-bottom-left { + bottom: 100px; /* Above bottom controls */ + left: 8px; + align-items: flex-start; +} + +.app-slot-bottom-right { + bottom: 100px; + right: 8px; + align-items: flex-end; +} + +.app-slot-bottom-sheet { + bottom: 0; + left: 0; + right: 0; + max-width: 100%; + align-items: stretch; +} + +/* Widget container - full card style */ +.widget-container { + background: rgba(15, 23, 42, 0.85); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + border: 1px solid rgba(148, 163, 184, 0.2); + border-radius: 12px; + overflow: hidden; + min-width: 200px; + max-width: 320px; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3); +} + +/* Compact widget - no extra styling */ +.widget-compact { + /* Widget handles its own styling */ +} + +.app-slot-bottom-sheet .widget-container { + max-width: 100%; + border-radius: 16px 16px 0 0; + border-bottom: none; +} + +/* Widget internal styling */ +.widget-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 12px; + border-bottom: 1px solid rgba(148, 163, 184, 0.15); + background: rgba(30, 41, 59, 0.5); +} + +.widget-header-title { + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; + font-weight: 600; + color: #e2e8f0; +} + +.widget-header-title i { + color: #60a5fa; + font-size: 14px; +} + +.widget-header-actions { + display: flex; + gap: 4px; +} + +.widget-header-btn { + background: transparent; + border: none; + color: #94a3b8; + padding: 4px 6px; + cursor: pointer; + border-radius: 4px; + font-size: 12px; +} + +.widget-header-btn:hover { + background: rgba(148, 163, 184, 0.1); + color: #e2e8f0; +} + +.widget-content { + padding: 12px; +} + +.widget-loading { + display: flex; + align-items: center; + justify-content: center; + padding: 24px; + color: #94a3b8; + font-size: 13px; + gap: 8px; +} + +.widget-loading i { + animation: spin 1s linear infinite; +} + +@keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + +.widget-error { + padding: 12px; + color: #f87171; + font-size: 13px; + text-align: center; +} + +/* Mobile adjustments */ +@media (max-width: 640px) { + .app-slot-top-left, + .app-slot-top-right { + top: 60px; + } + + .app-slot-bottom-left, + .app-slot-bottom-right { + bottom: 140px; + } + + .widget-container { + min-width: 160px; + max-width: 280px; + } + + .app-slot-bottom-sheet .widget-container { + max-width: 100%; + } +} diff --git a/src/apps/AppOverlay.jsx b/src/apps/AppOverlay.jsx new file mode 100644 index 0000000..a66c8f9 --- /dev/null +++ b/src/apps/AppOverlay.jsx @@ -0,0 +1,113 @@ +/** + * AppOverlay - Renders widget slots over the map + * + * This component creates the overlay layer with positioned slots + * for app widgets. Each slot can contain multiple widgets. + */ + +import React, { useEffect, useState, useCallback } from 'react'; +import WidgetSlot from './slots/WidgetSlot'; +import { SLOT_IDS } from './types'; +import { getDefaultAppHost } from './AppHost'; +import './AppOverlay.css'; + +/** + * @param {Object} props + * @param {ReturnType} [props.appHost] - AppHost instance + * @param {boolean} [props.visible=true] - Whether the overlay is visible + */ +export default function AppOverlay({ appHost: providedAppHost, visible = true }) { + const appHost = providedAppHost || getDefaultAppHost(); + const [slots, setSlots] = useState({}); + const [updateTrigger, setUpdateTrigger] = useState(0); + + // Refresh slot contents when apps change + const refreshSlots = useCallback(() => { + const newSlots = {}; + for (const slotId of Object.values(SLOT_IDS)) { + newSlots[slotId] = appHost.getAppsForSlot(slotId); + } + setSlots(newSlots); + }, [appHost]); + + useEffect(() => { + refreshSlots(); + }, [refreshSlots, updateTrigger]); + + // Expose refresh method for external triggering + useEffect(() => { + // Simple polling to detect new apps (can be replaced with event-based) + const interval = setInterval(() => { + setUpdateTrigger((t) => t + 1); + }, 2000); + return () => clearInterval(interval); + }, []); + + if (!visible) { + return null; + } + + const hasAnyWidgets = Object.values(slots).some((s) => s && s.length > 0); + if (!hasAnyWidgets) { + return null; + } + + return ( +
+ {/* Top Left */} + {slots[SLOT_IDS.TOP_LEFT]?.length > 0 && ( +
+ +
+ )} + + {/* Top Right */} + {slots[SLOT_IDS.TOP_RIGHT]?.length > 0 && ( +
+ +
+ )} + + {/* Bottom Left */} + {slots[SLOT_IDS.BOTTOM_LEFT]?.length > 0 && ( +
+ +
+ )} + + {/* Bottom Right */} + {slots[SLOT_IDS.BOTTOM_RIGHT]?.length > 0 && ( +
+ +
+ )} + + {/* Bottom Sheet */} + {slots[SLOT_IDS.BOTTOM_SHEET]?.length > 0 && ( +
+ +
+ )} +
+ ); +} diff --git a/src/apps/AppRegistry.js b/src/apps/AppRegistry.js new file mode 100644 index 0000000..8b16f7c --- /dev/null +++ b/src/apps/AppRegistry.js @@ -0,0 +1,83 @@ +/** + * AppRegistry - Central registry for app definitions + * + * Apps register themselves here with their mount functions and metadata. + * This is the "catalog" of available apps - not instances. + */ + +import { SLOT_IDS } from './types'; + +/** @type {Map} */ +const registry = new Map(); + +/** + * Register an app definition + * @param {import('./types').AppDefinition} definition + */ +export function registerApp(definition) { + if (!definition.appId) { + throw new Error('App definition must have appId'); + } + if (!definition.mountWidget) { + throw new Error('App definition must have mountWidget function'); + } + if (registry.has(definition.appId)) { + console.warn(`App ${definition.appId} already registered, overwriting`); + } + registry.set(definition.appId, { + order: 0, + defaultSlot: SLOT_IDS.TOP_RIGHT, + permissions: {}, + ...definition, + }); +} + +/** + * Get an app definition by ID + * @param {string} appId + * @returns {import('./types').AppDefinition | undefined} + */ +export function getApp(appId) { + return registry.get(appId); +} + +/** + * Get all registered apps + * @returns {import('./types').AppDefinition[]} + */ +export function getAllApps() { + return Array.from(registry.values()); +} + +/** + * Check if an app is registered + * @param {string} appId + * @returns {boolean} + */ +export function hasApp(appId) { + return registry.has(appId); +} + +/** + * Unregister an app (mainly for testing) + * @param {string} appId + */ +export function unregisterApp(appId) { + registry.delete(appId); +} + +/** + * Clear all registered apps (mainly for testing) + */ +export function clearRegistry() { + registry.clear(); +} + +export default { + registerApp, + getApp, + getAllApps, + hasApp, + unregisterApp, + clearRegistry, +}; diff --git a/src/apps/index.js b/src/apps/index.js new file mode 100644 index 0000000..550762b --- /dev/null +++ b/src/apps/index.js @@ -0,0 +1,47 @@ +/** + * Apps System - Main Entry Point + * + * Exports all apps system components and initializes built-in apps. + */ + +// Core exports +export { registerApp, getApp, getAllApps, hasApp } from './AppRegistry'; +export { createAppHost, getDefaultAppHost } from './AppHost'; +export { createMapContextBridge } from './providers/MapContextBridge'; +export { SLOT_IDS, EVENT_TYPES } from './types'; + +// Components +export { default as AppOverlay } from './AppOverlay'; +export { default as WidgetSlot } from './slots/WidgetSlot'; + +// Built-in apps +import { registerWeatherApp } from './weather'; + +/** + * Initialize all built-in apps + * Call this once at app startup + */ +export function initializeApps() { + registerWeatherApp(); +} + +/** + * Setup demo apps for testing + * Creates instances of built-in apps + * + * @param {ReturnType} appHost + */ +export function setupDemoApps(appHost) { + // Install weather widget + appHost.installApp('weather', { + appInstanceId: 'ai_weather_demo', + config: { + units: 'metric', + }, + }); +} + +export default { + initializeApps, + setupDemoApps, +}; diff --git a/src/apps/providers/MapContextBridge.js b/src/apps/providers/MapContextBridge.js new file mode 100644 index 0000000..677eb81 --- /dev/null +++ b/src/apps/providers/MapContextBridge.js @@ -0,0 +1,164 @@ +/** + * MapContextBridge - Connects MapLibre events to the AppHost + * + * Listens to map 'moveend' events, computes sector ID, + * and emits context.location.changed to apps with deduplication and throttling. + */ + +import { getDefaultAppHost } from '../AppHost'; + +/** + * Calculate a stable sector ID from map state + * Format: "{zoomBucket}:{lat2d}:{lng2d}" + * + * @param {number} zoom + * @param {number} lat + * @param {number} lng + * @returns {string} + */ +function computeSectorId(zoom, lat, lng) { + const zoomBucket = Math.floor(zoom); + const lat2d = lat.toFixed(2); + const lng2d = lng.toFixed(2); + return `${zoomBucket}:${lat2d}:${lng2d}`; +} + +/** + * Create a map context from MapLibre map state + * @param {Object} map - MapLibre map instance + * @returns {import('../types').MapContext} + */ +function createMapContext(map) { + const center = map.getCenter(); + const bounds = map.getBounds(); + const zoom = map.getZoom(); + + const lat = center.lat; + const lng = center.lng; + const sectorId = computeSectorId(zoom, lat, lng); + + return { + center: { lat, lng }, + bbox: { + west: bounds.getWest(), + south: bounds.getSouth(), + east: bounds.getEast(), + north: bounds.getNorth(), + }, + zoom, + sectorId, + }; +} + +/** + * Create a MapContextBridge instance + * + * @param {Object} map - MapLibre map instance + * @param {Object} [options] + * @param {number} [options.throttleMs=500] - Minimum time between emissions + * @param {ReturnType} [options.appHost] - AppHost instance + * @returns {Object} Bridge instance with cleanup method + */ +export function createMapContextBridge(map, options = {}) { + const { + throttleMs = 500, + appHost = getDefaultAppHost(), + } = options; + + let lastSectorId = null; + let lastEmitTime = 0; + let pendingTimeout = null; + let isDestroyed = false; + + /** + * Process map state and emit if sector changed + */ + function processMapState() { + if (isDestroyed || !map) return; + + const now = Date.now(); + const context = createMapContext(map); + + // Check if sector actually changed + if (context.sectorId === lastSectorId) { + return; + } + + // Throttle: if we emitted recently, schedule for later + const timeSinceLastEmit = now - lastEmitTime; + if (timeSinceLastEmit < throttleMs) { + if (pendingTimeout) { + clearTimeout(pendingTimeout); + } + pendingTimeout = setTimeout(() => { + pendingTimeout = null; + processMapState(); + }, throttleMs - timeSinceLastEmit); + return; + } + + // Emit the update + lastSectorId = context.sectorId; + lastEmitTime = now; + appHost.updateMapContext(context); + } + + /** + * Handle moveend event + */ + function onMoveEnd() { + processMapState(); + } + + /** + * Handle styledata event (map ready) + */ + function onStyleData() { + // Initial context when style loads + processMapState(); + } + + // Attach event listeners + map.on('moveend', onMoveEnd); + map.on('styledata', onStyleData); + + // Initial emit if map is ready + if (map.isStyleLoaded()) { + processMapState(); + } + + /** + * Cleanup and remove listeners + */ + function destroy() { + isDestroyed = true; + if (pendingTimeout) { + clearTimeout(pendingTimeout); + pendingTimeout = null; + } + map.off('moveend', onMoveEnd); + map.off('styledata', onStyleData); + } + + /** + * Force an immediate context update + */ + function forceUpdate() { + if (!isDestroyed && map) { + const context = createMapContext(map); + lastSectorId = context.sectorId; + lastEmitTime = Date.now(); + appHost.updateMapContext(context); + } + } + + return { + destroy, + forceUpdate, + getLastSectorId: () => lastSectorId, + }; +} + +export default { + createMapContextBridge, +}; diff --git a/src/apps/slots/WidgetSlot.jsx b/src/apps/slots/WidgetSlot.jsx new file mode 100644 index 0000000..1f225c5 --- /dev/null +++ b/src/apps/slots/WidgetSlot.jsx @@ -0,0 +1,52 @@ +/** + * WidgetSlot - Renders widgets for a specific slot position + */ + +import React from 'react'; +import { getApp } from '../AppRegistry'; + +/** + * @param {Object} props + * @param {string} props.slotId - The slot identifier + * @param {import('../types').AppInstance[]} props.instances - App instances for this slot + * @param {ReturnType} props.appHost - AppHost instance + * @param {string} [props.className] - Additional CSS class + */ +export default function WidgetSlot({ slotId, instances, appHost, className = '' }) { + if (!instances || instances.length === 0) { + return null; + } + + return ( +
+ {instances.map((instance) => { + const definition = getApp(instance.appId); + if (!definition) { + console.warn(`No definition found for app: ${instance.appId}`); + return null; + } + + const bridge = appHost.createBridge(instance.appInstanceId); + const Widget = definition.mountWidget; + + // Compact widgets don't get the container styling + const isCompact = definition.compact === true; + + return ( +
+ +
+ ); + })} +
+ ); +} diff --git a/src/apps/types.js b/src/apps/types.js new file mode 100644 index 0000000..6d4957b --- /dev/null +++ b/src/apps/types.js @@ -0,0 +1,106 @@ +/** + * Apps System Type Definitions + * + * These are JSDoc type definitions for the widget/apps system. + * Architecture is designed to be iframe-compatible in the future. + */ + +/** + * @typedef {'mapOverlay:topLeft' | 'mapOverlay:topRight' | 'mapOverlay:bottomLeft' | 'mapOverlay:bottomRight' | 'mapOverlay:bottomSheet'} SlotId + */ + +/** + * @typedef {Object} AppPermission + * @property {boolean} [location] - Can receive location updates + * @property {boolean} [user] - Can access user info + * @property {boolean} [network] - Can make network requests + */ + +/** + * @typedef {Object} AppDefinition + * @property {string} appId - Unique app identifier + * @property {string} name - Display name + * @property {string} [description] - App description + * @property {string} [icon] - Font Awesome icon class + * @property {SlotId} defaultSlot - Default slot for this app + * @property {number} [order] - Order within slot (lower = first) + * @property {AppPermission} permissions - Required permissions + * @property {function(AppWidgetProps): JSX.Element} mountWidget - Widget component factory + */ + +/** + * @typedef {Object} AppInstance + * @property {string} appInstanceId - Unique instance ID (ai_xxx) + * @property {string} appId - App definition ID + * @property {string} userId - Owner user ID + * @property {SlotId} slot - Current slot placement + * @property {number} order - Order in slot + * @property {Object} [config] - Instance-specific config + * @property {boolean} enabled - Is instance active + */ + +/** + * @typedef {Object} AppWidgetProps + * @property {string} appInstanceId - Instance ID + * @property {AppHostBridge} appHost - Bridge to host services + * @property {Object} [config] - Instance config + */ + +/** + * @typedef {Object} AppHostBridge + * @property {function(string, Object): void} sendAction - Send action to backend + * @property {function(string, function): function} onEvent - Subscribe to events, returns unsubscribe + * @property {function(): MapContext} getMapContext - Get current map context + * @property {function(string, function): function} onMapContextChange - Subscribe to map context changes + */ + +/** + * @typedef {Object} MapContext + * @property {Object} center - {lat: number, lng: number} + * @property {Object} bbox - {west: number, south: number, east: number, north: number} + * @property {number} zoom - Current zoom level + * @property {string} sectorId - Stable sector identifier for deduping + */ + +/** + * @typedef {Object} AppEvent + * @property {string} type - Event type (e.g., 'weather.updated') + * @property {Object} payload - Event payload + * @property {string} [requestId] - Correlation ID + */ + +/** + * @typedef {Object} AppActionEnvelope + * @property {number} v - Version (1) + * @property {string} requestId - UUID + * @property {number} ts - Unix timestamp + * @property {string} userId - User ID + * @property {string} appId - App ID + * @property {string} appInstanceId - Instance ID + * @property {string} action - Action name + * @property {Object} payload - Action payload + * @property {Object} context - Client context + */ + +/** + * @typedef {Object} AppEventEnvelope + * @property {number} v - Version (1) + * @property {number} ts - Unix timestamp + * @property {string} userId - User ID + * @property {string} appInstanceId - Instance ID + * @property {string} type - "app.event" + * @property {AppEvent} event - The actual event + */ + +export const SLOT_IDS = { + TOP_LEFT: 'mapOverlay:topLeft', + TOP_RIGHT: 'mapOverlay:topRight', + BOTTOM_LEFT: 'mapOverlay:bottomLeft', + BOTTOM_RIGHT: 'mapOverlay:bottomRight', + BOTTOM_SHEET: 'mapOverlay:bottomSheet', +}; + +export const EVENT_TYPES = { + LOCATION_CHANGED: 'context.location.changed', + APP_EVENT: 'app.event', +}; diff --git a/src/apps/weather/WeatherWidget.jsx b/src/apps/weather/WeatherWidget.jsx new file mode 100644 index 0000000..dcbd73a --- /dev/null +++ b/src/apps/weather/WeatherWidget.jsx @@ -0,0 +1,314 @@ +/** + * WeatherWidget - Compact weather pill (same style as old widget) + * + * Calls weather service on us02 via apps system (Pulsar) + */ + +import React, { useEffect, useState, useCallback, useRef } from 'react'; + +// Emoji icons for weather +const ICON_MAP = { + 'sun': '☀️', 'moon': '🌙', 'cloud-sun': '⛅', 'cloud': '☁️', + 'smog': '🌫️', 'cloud-rain': '🌧️', 'cloud-showers-heavy': '🌧️', + 'snowflake': '❄️', 'cloud-sun-rain': '🌦️', 'cloud-meatball': '🌨️', 'bolt': '⛈️' +}; + +// WMO codes to icon names +const WMO_TO_ICON = { + 0: 'sun', 1: 'sun', 2: 'cloud-sun', 3: 'cloud', + 45: 'smog', 48: 'smog', + 51: 'cloud-rain', 53: 'cloud-rain', 55: 'cloud-rain', + 61: 'cloud-rain', 63: 'cloud-showers-heavy', 65: 'cloud-showers-heavy', + 71: 'snowflake', 73: 'snowflake', 75: 'snowflake', 77: 'snowflake', + 80: 'cloud-sun-rain', 81: 'cloud-showers-heavy', 82: 'cloud-showers-heavy', + 85: 'cloud-meatball', 86: 'cloud-meatball', + 95: 'bolt', 96: 'bolt', 99: 'bolt', +}; + +// Day name helper - dateStr is "YYYY-MM-DD" +function getDayName(dateStr) { + // Parse as local date (not UTC) by using date parts + const [year, month, day] = dateStr.split('-').map(Number); + const date = new Date(year, month - 1, day); + const today = new Date(); + today.setHours(0, 0, 0, 0); + const tomorrow = new Date(today); + tomorrow.setDate(tomorrow.getDate() + 1); + const dateOnly = new Date(date); + dateOnly.setHours(0, 0, 0, 0); + if (dateOnly.getTime() === today.getTime()) return "Auj."; + if (dateOnly.getTime() === tomorrow.getTime()) return "Dem."; + return date.toLocaleDateString("fr-FR", { weekday: "short" }); +} + +export default function WeatherWidget({ appInstanceId, appHost, config = {} }) { + const [weather, setWeather] = useState(null); + const [daily, setDaily] = useState(null); + const [expanded, setExpanded] = useState(false); + const [lastSectorId, setLastSectorId] = useState(null); + const pendingRequestRef = useRef(null); + const lastCoordsRef = useRef({ lat: null, lon: null }); + const widgetRef = useRef(null); + const lastRequestTimeRef = useRef(0); + + // Fallback: fetch directly from Open-Meteo (defined first to avoid circular dep) + const fetchWeatherDirect = useCallback(async (lat, lon) => { + try { + lastCoordsRef.current = { lat, lon }; + const url = `https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lon}¤t=temperature_2m,weather_code,is_day,relative_humidity_2m,wind_speed_10m&daily=weather_code,temperature_2m_max,temperature_2m_min,precipitation_sum&timezone=auto`; + const res = await fetch(url); + if (!res.ok) return; + + const data = await res.json(); + const current = data.current; + if (!current) return; + + const wmoCode = current.weather_code || 0; + const isDay = current.is_day === 1; + let iconName = WMO_TO_ICON[wmoCode] || 'cloud'; + if (!isDay && (wmoCode === 0 || wmoCode === 1)) iconName = 'moon'; + + // Get city name + let city = `${lat.toFixed(1)}, ${lon.toFixed(1)}`; + try { + const geoRes = await fetch(`https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lon}&format=json&zoom=10`, { + headers: { 'User-Agent': 'Sociowire/1.0' } + }); + if (geoRes.ok) { + const geo = await geoRes.json(); + const addr = geo.address || {}; + city = addr.city || addr.town || addr.village || addr.municipality || city; + } + } catch {} + + setWeather({ + icon: ICON_MAP[iconName] || '🌡️', + city, + temp: Math.round(current.temperature_2m), + humidity: current.relative_humidity_2m, + wind: Math.round(current.wind_speed_10m), + isDay, + }); + + // Set daily forecast + if (data.daily) { + const dailyData = data.daily.time.map((date, i) => ({ + date, + code: data.daily.weather_code[i], + max: Math.round(data.daily.temperature_2m_max[i]), + min: Math.round(data.daily.temperature_2m_min[i]), + precip: data.daily.precipitation_sum[i], + })); + setDaily(dailyData); + } + } catch (err) { + console.warn('Direct weather fetch failed:', err); + } + }, []); + + // Send weather request via apps system + const requestWeather = useCallback(async (lat, lon) => { + const requestId = `weather_${Date.now()}`; + pendingRequestRef.current = requestId; + + try { + // Send action to apps-gateway -> Pulsar -> weather-service on us02 + await appHost.sendAction('weather.get', { lat, lon, requestId }); + } catch (err) { + console.warn('Weather request failed, using fallback:', err); + // Fallback: direct Open-Meteo call + fetchWeatherDirect(lat, lon); + } + }, [appHost, fetchWeatherDirect]); + + // Handle weather response from apps system (via WebSocket) + useEffect(() => { + // Listen for weather.data events + const unsubscribe = appHost.onEvent('weather.data', (event) => { + const data = event.payload || event; + if (data.city && data.current) { + const iconName = data.current.weather_icon || 'cloud'; + setWeather({ + icon: ICON_MAP[iconName] || '🌡️', + city: data.city, + temp: Math.round(data.current.temperature || 0), + isDay: data.current.is_day !== false, + }); + } + }); + return unsubscribe; + }, [appHost]); + + // Handle map context changes (throttled to 1 request per second) + const handleMapContextChange = useCallback((context) => { + if (!context || context.sectorId === lastSectorId) return; + + const now = Date.now(); + if (now - lastRequestTimeRef.current < 1000) return; // Throttle: 1 second min + + setLastSectorId(context.sectorId); + lastRequestTimeRef.current = now; + + const lat = context.center?.lat || 0; + const lon = context.center?.lng || context.center?.lon || 0; + + if (lat !== 0 || lon !== 0) { + requestWeather(lat, lon); + } + }, [lastSectorId, requestWeather]); + + // Subscribe to map context + useEffect(() => { + const unsubscribe = appHost.onMapContextChange(handleMapContextChange); + return unsubscribe; + }, [appHost, handleMapContextChange]); + + // Close on click outside + useEffect(() => { + if (!expanded) return; + + const handleClickOutside = (e) => { + if (widgetRef.current && !widgetRef.current.contains(e.target)) { + setExpanded(false); + } + }; + + // Use capture phase to catch clicks before they reach the map + document.addEventListener('click', handleClickOutside, true); + return () => document.removeEventListener('click', handleClickOutside, true); + }, [expanded]); + + // No weather yet - show nothing (like old widget) + if (!weather) return null; + + // Determine background based on weather + const isSunny = weather.icon === '☀️' || weather.icon === '⛅'; + const isNight = weather.icon === '🌙'; + const isRainy = weather.icon === '🌧️' || weather.icon === '🌦️' || weather.icon === '⛈️'; + const isSnow = weather.icon === '❄️' || weather.icon === '🌨️'; + + const bg = isNight + ? 'linear-gradient(135deg, rgba(30, 41, 59, 0.9), rgba(15, 23, 42, 0.9))' + : isSunny + ? 'linear-gradient(135deg, rgba(56, 189, 248, 0.85), rgba(14, 165, 233, 0.85))' + : isRainy + ? 'linear-gradient(135deg, rgba(71, 85, 105, 0.9), rgba(51, 65, 85, 0.9))' + : isSnow + ? 'linear-gradient(135deg, rgba(148, 163, 184, 0.9), rgba(203, 213, 225, 0.9))' + : 'linear-gradient(135deg, rgba(100, 116, 139, 0.85), rgba(71, 85, 105, 0.85))'; + + // Collapsed pill view + if (!expanded) { + return ( +
setExpanded(true)} + style={{ + display: 'inline-flex', + alignItems: 'center', + gap: '5px', + padding: '5px 12px', + borderRadius: '16px', + background: bg, + border: '1px solid rgba(255,255,255,0.25)', + color: isSnow ? '#1e293b' : '#fff', + fontSize: '13px', + fontWeight: '600', + cursor: 'pointer', + backdropFilter: 'blur(8px)', + boxShadow: '0 2px 8px rgba(0,0,0,0.2)', + }} + > + {weather.icon} + {weather.city}: + {weather.temp}° +
+ ); + } + + // Expanded view with 7-day forecast + return ( +
+ {/* Header with current weather */} +
setExpanded(false)} + style={{ + padding: '12px', + background: bg, + cursor: 'pointer', + display: 'flex', + alignItems: 'center', + gap: '12px', + }} + > + {weather.icon} +
+
+ {weather.temp}° +
+
+ {weather.city} +
+
+ +
+ + {/* Current details */} +
+ {weather.humidity != null && ( +
+ 💧 {weather.humidity}% +
+ )} + {weather.wind != null && ( +
+ 🌬️ {weather.wind} km/h +
+ )} +
+ + {/* 7-day forecast */} +
+
+ Prévisions 7 jours +
+ {daily && daily.slice(0, 7).map((d, i) => { + const dayIcon = ICON_MAP[WMO_TO_ICON[d.code] || 'cloud'] || '🌡️'; + return ( +
+ {getDayName(d.date)} + {dayIcon} + + {d.max}° + {d.min}° + {d.precip > 0 && ( + 💧{d.precip.toFixed(1)} + )} + {d.precip <= 0 && } +
+ ); + })} +
+
+ ); +} diff --git a/src/apps/weather/index.js b/src/apps/weather/index.js new file mode 100644 index 0000000..404e563 --- /dev/null +++ b/src/apps/weather/index.js @@ -0,0 +1,27 @@ +/** + * Weather App Registration + */ + +import { registerApp } from '../AppRegistry'; +import { SLOT_IDS } from '../types'; +import WeatherWidget from './WeatherWidget'; + +export function registerWeatherApp() { + registerApp({ + appId: 'weather', + name: 'Weather', + description: 'Shows current weather for the map location', + icon: 'fa-solid fa-cloud-sun', + defaultSlot: SLOT_IDS.TOP_LEFT, + order: 10, + compact: true, // Use minimal styling (pill) + permissions: { + location: true, + network: true, + }, + mountWidget: WeatherWidget, + }); +} + +export { WeatherWidget }; +export default { registerWeatherApp };