/**
* 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);
}