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:
parent
e635d0e7f6
commit
3c3a6639e4
|
|
@ -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,
|
||||||
|
};
|
||||||
|
|
@ -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%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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',
|
||||||
|
};
|
||||||
|
|
@ -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 (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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 };
|
||||||
Loading…
Reference in New Issue