/** * Presence Layer Component * * Renders location presence indicators on the canvas/map. * Shows other users with uncertainty circles based on trust-level precision. */ import React, { useMemo } from 'react'; import type { PresenceView } from './types'; import type { PresenceIndicatorData } from './useLocationPresence'; import { viewsToIndicators } from './useLocationPresence'; import { getRadiusForPrecision, TRUST_LEVEL_PRECISION } from './types'; // ============================================================================= // Types // ============================================================================= export interface PresenceLayerProps { /** Presence views to render */ views: PresenceView[]; /** Map projection function (lat/lng to screen coordinates) */ project: (lat: number, lng: number) => { x: number; y: number }; /** Current zoom level (for scaling indicators) */ zoom: number; /** Whether to show uncertainty circles */ showUncertainty?: boolean; /** Whether to show direction arrows */ showDirection?: boolean; /** Whether to show names */ showNames?: boolean; /** Click handler for presence indicators */ onIndicatorClick?: (indicator: PresenceIndicatorData) => void; /** Hover handler */ onIndicatorHover?: (indicator: PresenceIndicatorData | null) => void; /** Custom render function for indicators */ renderIndicator?: (indicator: PresenceIndicatorData, screenPos: { x: number; y: number }) => React.ReactNode; } // ============================================================================= // Component // ============================================================================= export function PresenceLayer({ views, project, zoom, showUncertainty = true, showDirection = true, showNames = true, onIndicatorClick, onIndicatorHover, renderIndicator, }: PresenceLayerProps) { // Convert views to indicator data const indicators = useMemo(() => viewsToIndicators(views), [views]); // Calculate screen positions const positioned = useMemo(() => { return indicators.map((indicator) => ({ indicator, screenPos: project(indicator.position.lat, indicator.position.lng), })); }, [indicators, project]); if (positioned.length === 0) { return null; } return (
{positioned.map(({ indicator, screenPos }) => { if (renderIndicator) { return (
{renderIndicator(indicator, screenPos)}
); } return ( ); })}
); } // ============================================================================= // Presence Indicator Component // ============================================================================= interface PresenceIndicatorProps { indicator: PresenceIndicatorData; screenPos: { x: number; y: number }; zoom: number; showUncertainty: boolean; showDirection: boolean; showName: boolean; onClick?: (indicator: PresenceIndicatorData) => void; onHover?: (indicator: PresenceIndicatorData | null) => void; } function PresenceIndicator({ indicator, screenPos, zoom, showUncertainty, showDirection, showName, onClick, onHover, }: PresenceIndicatorProps) { // Calculate uncertainty circle radius in pixels // This is approximate - would need proper map projection for accuracy const metersPerPixel = 156543.03392 * Math.cos((indicator.position.lat * Math.PI) / 180) / Math.pow(2, zoom); const uncertaintyPixels = indicator.uncertaintyRadius / metersPerPixel; // Clamp uncertainty circle size const clampedUncertainty = Math.min(Math.max(uncertaintyPixels, 20), 200); // Status-based opacity const opacity = indicator.status === 'online' ? 1 : indicator.status === 'away' ? 0.7 : 0.4; // Moving animation const isAnimated = indicator.isMoving; return (
onClick?.(indicator)} onMouseEnter={() => onHover?.(indicator)} onMouseLeave={() => onHover?.(null)} > {/* Uncertainty circle */} {showUncertainty && (
)} {/* Direction arrow */} {showDirection && indicator.heading !== undefined && indicator.isMoving && (
)} {/* Center dot */}
{/* Verified badge */} {indicator.isVerified && (
)}
{/* Name label */} {showName && (
{indicator.displayName}
)}
); } // ============================================================================= // Trust Badge Component // ============================================================================= interface TrustBadgeProps { level: PresenceIndicatorData['trustLevel']; } function TrustBadge({ level }: TrustBadgeProps) { const badges: Record = { intimate: { icon: '♥', color: '#ec4899' }, close: { icon: '★', color: '#f59e0b' }, friends: { icon: '●', color: '#22c55e' }, network: { icon: '◐', color: '#3b82f6' }, public: { icon: '○', color: '#6b7280' }, }; const badge = badges[level] ?? badges.public; return ( {badge.icon} ); } // ============================================================================= // Presence List Component // ============================================================================= export interface PresenceListProps { views: PresenceView[]; onUserClick?: (view: PresenceView) => void; onTrustLevelChange?: (pubKey: string, level: PresenceView['trustLevel']) => void; } export function PresenceList({ views, onUserClick, onTrustLevelChange }: PresenceListProps) { const sortedViews = useMemo(() => { return [...views].sort((a, b) => { // Online first, then by proximity if (a.status !== b.status) { const statusOrder = { online: 0, away: 1, busy: 2, invisible: 3, offline: 4 }; return statusOrder[a.status] - statusOrder[b.status]; } if (a.proximity && b.proximity) { const distOrder = { here: 0, nearby: 1, 'same-area': 2, 'same-city': 3, far: 4 }; return distOrder[a.proximity.category] - distOrder[b.proximity.category]; } return 0; }); }, [views]); if (sortedViews.length === 0) { return (
No other users nearby
); } return (
{sortedViews.map((view) => ( onUserClick?.(view)} onTrustLevelChange={onTrustLevelChange} /> ))}
); } interface PresenceListItemProps { view: PresenceView; onClick?: () => void; onTrustLevelChange?: (pubKey: string, level: PresenceView['trustLevel']) => void; } function PresenceListItem({ view, onClick, onTrustLevelChange }: PresenceListItemProps) { const proximityLabels = { here: 'Right here', nearby: 'Nearby', 'same-area': 'Same area', 'same-city': 'Same city', far: 'Far away', }; const statusColors = { online: '#22c55e', away: '#f59e0b', busy: '#ef4444', invisible: '#6b7280', offline: '#374151', }; return (
{/* Avatar */}
{view.user.displayName.charAt(0).toUpperCase()} {/* Status dot */}
{/* Info */}
{view.user.displayName}
{view.proximity ? proximityLabels[view.proximity.category] : 'Location unknown'} {view.location?.isMoving && ' • Moving'}
{/* Trust level selector */} {onTrustLevelChange && ( )}
); } // ============================================================================= // CSS Keyframes (inject once) // ============================================================================= const styleId = 'presence-layer-styles'; if (typeof document !== 'undefined' && !document.getElementById(styleId)) { const style = document.createElement('style'); style.id = styleId; style.textContent = ` @keyframes pulse { 0%, 100% { transform: translate(-50%, -50%) scale(1); opacity: 0.6; } 50% { transform: translate(-50%, -50%) scale(1.1); opacity: 0.4; } } `; document.head.appendChild(style); }