feat: Add weather widget with apps system

- Weather widget in top-left slot with compact pill display
- Click to expand 7-day forecast
- Uses apps system via Pulsar with Open-Meteo fallback
- Click outside to close expanded view
- Throttled updates (1/sec) when moving map

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Your Name 2026-01-21 10:18:34 +00:00
parent e635d0e7f6
commit 3c3a6639e4
10 changed files with 1452 additions and 0 deletions

370
src/apps/AppHost.js Normal file
View File

@ -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<string, import('./types').AppInstance>} */
const instances = new Map();
/** @type {Map<string, Set<function>>} */
const eventListeners = new Map();
/** @type {import('./types').MapContext | null} */
let currentMapContext = null;
/** @type {Set<function>} */
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<typeof createAppHost>}
*/
export function getDefaultAppHost() {
if (!defaultHost) {
defaultHost = createAppHost();
}
return defaultHost;
}
export default {
createAppHost,
getDefaultAppHost,
};

176
src/apps/AppOverlay.css Normal file
View File

@ -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%;
}
}

113
src/apps/AppOverlay.jsx Normal file
View File

@ -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<typeof import('./AppHost').createAppHost>} [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 (
<div className="app-overlay">
{/* Top Left */}
{slots[SLOT_IDS.TOP_LEFT]?.length > 0 && (
<div className="app-slot app-slot-top-left">
<WidgetSlot
slotId={SLOT_IDS.TOP_LEFT}
instances={slots[SLOT_IDS.TOP_LEFT]}
appHost={appHost}
/>
</div>
)}
{/* Top Right */}
{slots[SLOT_IDS.TOP_RIGHT]?.length > 0 && (
<div className="app-slot app-slot-top-right">
<WidgetSlot
slotId={SLOT_IDS.TOP_RIGHT}
instances={slots[SLOT_IDS.TOP_RIGHT]}
appHost={appHost}
/>
</div>
)}
{/* Bottom Left */}
{slots[SLOT_IDS.BOTTOM_LEFT]?.length > 0 && (
<div className="app-slot app-slot-bottom-left">
<WidgetSlot
slotId={SLOT_IDS.BOTTOM_LEFT}
instances={slots[SLOT_IDS.BOTTOM_LEFT]}
appHost={appHost}
/>
</div>
)}
{/* Bottom Right */}
{slots[SLOT_IDS.BOTTOM_RIGHT]?.length > 0 && (
<div className="app-slot app-slot-bottom-right">
<WidgetSlot
slotId={SLOT_IDS.BOTTOM_RIGHT}
instances={slots[SLOT_IDS.BOTTOM_RIGHT]}
appHost={appHost}
/>
</div>
)}
{/* Bottom Sheet */}
{slots[SLOT_IDS.BOTTOM_SHEET]?.length > 0 && (
<div className="app-slot app-slot-bottom-sheet">
<WidgetSlot
slotId={SLOT_IDS.BOTTOM_SHEET}
instances={slots[SLOT_IDS.BOTTOM_SHEET]}
appHost={appHost}
/>
</div>
)}
</div>
);
}

83
src/apps/AppRegistry.js Normal file
View File

@ -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<string, import('./types').AppDefinition>} */
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,
};

47
src/apps/index.js Normal file
View File

@ -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<typeof import('./AppHost').createAppHost>} appHost
*/
export function setupDemoApps(appHost) {
// Install weather widget
appHost.installApp('weather', {
appInstanceId: 'ai_weather_demo',
config: {
units: 'metric',
},
});
}
export default {
initializeApps,
setupDemoApps,
};

View File

@ -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<typeof import('../AppHost').createAppHost>} [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,
};

View File

@ -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<typeof import('../AppHost').createAppHost>} 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 (
<div className={`widget-slot widget-slot-${slotId.replace(':', '-')} ${className}`}>
{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 (
<div
key={instance.appInstanceId}
className={isCompact ? 'widget-compact' : 'widget-container'}
data-app-id={instance.appId}
data-instance-id={instance.appInstanceId}
>
<Widget
appInstanceId={instance.appInstanceId}
appHost={bridge}
config={instance.config}
/>
</div>
);
})}
</div>
);
}

106
src/apps/types.js Normal file
View File

@ -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',
};

View File

@ -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}&current=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 (
<div
ref={widgetRef}
onClick={() => 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)',
}}
>
<span style={{ fontSize: '16px' }}>{weather.icon}</span>
<span>{weather.city}:</span>
<span style={{ fontWeight: '700' }}>{weather.temp}°</span>
</div>
);
}
// Expanded view with 7-day forecast
return (
<div
ref={widgetRef}
style={{
background: 'rgba(15, 23, 42, 0.95)',
backdropFilter: 'blur(12px)',
borderRadius: '12px',
border: '1px solid rgba(148, 163, 184, 0.2)',
boxShadow: '0 4px 20px rgba(0,0,0,0.4)',
color: '#e2e8f0',
width: '280px',
overflow: 'hidden',
}}
>
{/* Header with current weather */}
<div
onClick={() => setExpanded(false)}
style={{
padding: '12px',
background: bg,
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: '12px',
}}
>
<span style={{ fontSize: '32px' }}>{weather.icon}</span>
<div style={{ flex: 1 }}>
<div style={{ fontSize: '24px', fontWeight: '700', color: isSnow ? '#1e293b' : '#fff' }}>
{weather.temp}°
</div>
<div style={{ fontSize: '13px', color: isSnow ? '#334155' : 'rgba(255,255,255,0.8)' }}>
{weather.city}
</div>
</div>
<i className="fa-solid fa-chevron-up" style={{ color: isSnow ? '#1e293b' : '#fff', opacity: 0.6 }} />
</div>
{/* Current details */}
<div style={{ padding: '10px 12px', display: 'flex', gap: '16px', borderBottom: '1px solid rgba(148,163,184,0.15)' }}>
{weather.humidity != null && (
<div style={{ display: 'flex', alignItems: 'center', gap: '4px', fontSize: '12px', color: '#94a3b8' }}>
<span>💧</span> {weather.humidity}%
</div>
)}
{weather.wind != null && (
<div style={{ display: 'flex', alignItems: 'center', gap: '4px', fontSize: '12px', color: '#94a3b8' }}>
<span>🌬</span> {weather.wind} km/h
</div>
)}
</div>
{/* 7-day forecast */}
<div style={{ padding: '8px 0' }}>
<div style={{ padding: '0 12px 6px', fontSize: '11px', color: '#64748b', fontWeight: '600', textTransform: 'uppercase' }}>
Prévisions 7 jours
</div>
{daily && daily.slice(0, 7).map((d, i) => {
const dayIcon = ICON_MAP[WMO_TO_ICON[d.code] || 'cloud'] || '🌡️';
return (
<div
key={i}
style={{
display: 'flex',
alignItems: 'center',
padding: '6px 12px',
gap: '8px',
}}
>
<span style={{ width: '40px', fontSize: '12px', color: '#94a3b8' }}>{getDayName(d.date)}</span>
<span style={{ fontSize: '16px' }}>{dayIcon}</span>
<span style={{ flex: 1 }} />
<span style={{ fontSize: '13px', fontWeight: '600', width: '30px', textAlign: 'right' }}>{d.max}°</span>
<span style={{ fontSize: '12px', color: '#64748b', width: '30px', textAlign: 'right' }}>{d.min}°</span>
{d.precip > 0 && (
<span style={{ fontSize: '11px', color: '#60a5fa', width: '45px', textAlign: 'right' }}>💧{d.precip.toFixed(1)}</span>
)}
{d.precip <= 0 && <span style={{ width: '45px' }} />}
</div>
);
})}
</div>
</div>
);
}

27
src/apps/weather/index.js Normal file
View File

@ -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 };