mirror of
https://github.com/BigBodyCobain/Shadowbroker.git
synced 2026-07-06 20:37:56 +02:00
release: prepare v0.9.7
This commit is contained in:
@@ -0,0 +1,569 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* AIIntelPinDetail — floating popup shown when the user clicks an AI Intel pin
|
||||
* on the map.
|
||||
*
|
||||
* Features:
|
||||
* - Shows label, category, coordinates, reverse-geocoded place
|
||||
* - Shows entity attachment info (if pin is tracking a moving object)
|
||||
* - Editable label / description
|
||||
* - Threaded comment system with reply support (user + agent)
|
||||
* - Follows the Threat-alert marker pattern: offset from target with a
|
||||
* dashed connecting line + arrow pointing at the pin.
|
||||
*/
|
||||
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Marker } from 'react-map-gl/maplibre';
|
||||
import { API_BASE } from '@/lib/api';
|
||||
import ConfirmDialog from '@/components/ui/ConfirmDialog';
|
||||
import {
|
||||
fetchAIIntelPin,
|
||||
updateAIIntelPin,
|
||||
addAIIntelPinComment,
|
||||
deleteAIIntelPinComment,
|
||||
} from '@/lib/aiIntelClient';
|
||||
import {
|
||||
PIN_CATEGORY_COLORS,
|
||||
PIN_CATEGORY_LABELS,
|
||||
type PinCategory,
|
||||
type AIIntelPin,
|
||||
type AIIntelPinComment,
|
||||
} from '@/types/aiIntel';
|
||||
|
||||
interface Props {
|
||||
pinId: string;
|
||||
onClose: () => void;
|
||||
onDeleted?: () => void;
|
||||
onUpdated?: () => void;
|
||||
}
|
||||
|
||||
interface ReverseGeocode {
|
||||
city?: string;
|
||||
state?: string;
|
||||
country?: string;
|
||||
display_name?: string;
|
||||
}
|
||||
|
||||
const POPUP_OFFSET = 160;
|
||||
|
||||
export const AIIntelPinDetail: React.FC<Props> = ({ pinId, onClose, onDeleted, onUpdated }) => {
|
||||
const [pin, setPin] = useState<AIIntelPin | null>(null);
|
||||
const [geo, setGeo] = useState<ReverseGeocode | null>(null);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [editLabel, setEditLabel] = useState('');
|
||||
const [editDescription, setEditDescription] = useState('');
|
||||
const [editCategory, setEditCategory] = useState<PinCategory>('custom');
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const [newComment, setNewComment] = useState('');
|
||||
const [replyTo, setReplyTo] = useState<string>('');
|
||||
const [commentAuthor, setCommentAuthor] = useState<'user' | 'agent'>('user');
|
||||
const [posting, setPosting] = useState(false);
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
|
||||
const commentInputRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
|
||||
// Initial pin fetch
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetchAIIntelPin(pinId);
|
||||
if (cancelled) return;
|
||||
setPin(res.pin);
|
||||
setEditLabel(res.pin.label);
|
||||
setEditDescription(res.pin.description || '');
|
||||
setEditCategory(res.pin.category);
|
||||
} catch (err) {
|
||||
console.error('Failed to load pin:', err);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [pinId]);
|
||||
|
||||
// Reverse geocode once we have coordinates
|
||||
useEffect(() => {
|
||||
if (!pin) return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const url = `${API_BASE}/api/geocode/reverse?lat=${pin.lat}&lng=${pin.lng}`;
|
||||
const resp = await fetch(url);
|
||||
if (!resp.ok) return;
|
||||
const data = await resp.json();
|
||||
if (cancelled) return;
|
||||
setGeo({
|
||||
city: data.city || data.town || data.village || data.hamlet || '',
|
||||
state: data.state || data.region || '',
|
||||
country: data.country || '',
|
||||
display_name: data.display_name || '',
|
||||
});
|
||||
} catch {
|
||||
/* ignore reverse geocode failures */
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [pin]);
|
||||
|
||||
const handleSaveEdit = useCallback(async () => {
|
||||
if (!pin || !editLabel.trim()) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await updateAIIntelPin(pin.id, {
|
||||
label: editLabel.trim(),
|
||||
description: editDescription.trim(),
|
||||
category: editCategory,
|
||||
});
|
||||
setPin(res.pin);
|
||||
setEditing(false);
|
||||
onUpdated?.();
|
||||
} catch (err) {
|
||||
console.error('Failed to update pin:', err);
|
||||
}
|
||||
setSaving(false);
|
||||
}, [pin, editLabel, editDescription, editCategory, onUpdated]);
|
||||
|
||||
const handlePostComment = useCallback(async () => {
|
||||
if (!pin || !newComment.trim()) return;
|
||||
setPosting(true);
|
||||
try {
|
||||
const res = await addAIIntelPinComment(pin.id, {
|
||||
text: newComment.trim(),
|
||||
author: commentAuthor,
|
||||
reply_to: replyTo,
|
||||
});
|
||||
setPin(res.pin);
|
||||
setNewComment('');
|
||||
setReplyTo('');
|
||||
onUpdated?.();
|
||||
} catch (err) {
|
||||
console.error('Failed to post comment:', err);
|
||||
}
|
||||
setPosting(false);
|
||||
}, [pin, newComment, commentAuthor, replyTo, onUpdated]);
|
||||
|
||||
const handleDeleteComment = useCallback(
|
||||
async (commentId: string) => {
|
||||
if (!pin) return;
|
||||
try {
|
||||
await deleteAIIntelPinComment(pin.id, commentId);
|
||||
// Refresh pin
|
||||
const refreshed = await fetchAIIntelPin(pin.id);
|
||||
setPin(refreshed.pin);
|
||||
onUpdated?.();
|
||||
} catch (err) {
|
||||
console.error('Failed to delete comment:', err);
|
||||
}
|
||||
},
|
||||
[pin, onUpdated],
|
||||
);
|
||||
|
||||
const executeDeletePin = useCallback(async () => {
|
||||
if (!pin) return;
|
||||
setShowDeleteConfirm(false);
|
||||
try {
|
||||
await fetch(`${API_BASE}/api/ai/pins/${pin.id}`, { method: 'DELETE' });
|
||||
onDeleted?.();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
console.error('Failed to delete pin:', err);
|
||||
}
|
||||
}, [pin, onDeleted, onClose]);
|
||||
|
||||
// Stop keyboard events from leaking to global hotkeys
|
||||
const stopKeys = useCallback((e: React.KeyboardEvent) => {
|
||||
e.stopPropagation();
|
||||
e.nativeEvent.stopImmediatePropagation();
|
||||
}, []);
|
||||
|
||||
if (!pin) return null;
|
||||
|
||||
const categoryColor = PIN_CATEGORY_COLORS[pin.category] || '#8b5cf6';
|
||||
const locationLine = [geo?.city, geo?.state, geo?.country].filter(Boolean).join(', ');
|
||||
|
||||
// Build reply map (comment_id → replies)
|
||||
const comments = pin.comments || [];
|
||||
const topLevel = comments.filter((c) => !c.reply_to);
|
||||
const replies: Record<string, AIIntelPinComment[]> = {};
|
||||
for (const c of comments) {
|
||||
if (c.reply_to) {
|
||||
(replies[c.reply_to] = replies[c.reply_to] || []).push(c);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Marker
|
||||
latitude={pin.lat}
|
||||
longitude={pin.lng}
|
||||
anchor="center"
|
||||
offset={[0, -POPUP_OFFSET]}
|
||||
style={{ zIndex: 9995 }}
|
||||
>
|
||||
<div
|
||||
className="relative"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onKeyDown={stopKeys}
|
||||
onKeyUp={stopKeys}
|
||||
>
|
||||
{/* Dashed connecting line */}
|
||||
<svg
|
||||
className="absolute pointer-events-none"
|
||||
style={{ left: '50%', top: '50%', width: 1, height: 1, overflow: 'visible', zIndex: -1 }}
|
||||
>
|
||||
<line
|
||||
x1={0}
|
||||
y1={0}
|
||||
x2={0}
|
||||
y2={POPUP_OFFSET}
|
||||
stroke={categoryColor}
|
||||
strokeWidth={1.5}
|
||||
strokeDasharray="4,3"
|
||||
className="opacity-80"
|
||||
/>
|
||||
<circle cx={0} cy={POPUP_OFFSET} r={4} fill={categoryColor} stroke="#0a0a14" strokeWidth={1.5} />
|
||||
</svg>
|
||||
|
||||
{/* Arrow pointing down */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: -6,
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
width: 0,
|
||||
height: 0,
|
||||
borderLeft: '6px solid transparent',
|
||||
borderRight: '6px solid transparent',
|
||||
borderTop: `6px solid ${categoryColor}`,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Dialog body */}
|
||||
<div
|
||||
className="bg-[#0a0a14] border-2 font-mono text-white"
|
||||
style={{
|
||||
borderColor: `${categoryColor}99`,
|
||||
minWidth: 320,
|
||||
maxWidth: 360,
|
||||
maxHeight: 460,
|
||||
overflowY: 'auto',
|
||||
transform: 'translateX(-50%)',
|
||||
marginLeft: '50%',
|
||||
boxShadow: `0 10px 30px rgba(0,0,0,0.7), 0 0 0 1px ${categoryColor}33`,
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
className="flex items-center justify-between px-3 py-2 border-b"
|
||||
style={{ borderColor: `${categoryColor}55`, background: `${categoryColor}18` }}
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span
|
||||
className="inline-block w-2 h-2 rounded-full flex-shrink-0"
|
||||
style={{ background: categoryColor }}
|
||||
/>
|
||||
<span className="text-[10px] uppercase tracking-widest truncate" style={{ color: categoryColor }}>
|
||||
{PIN_CATEGORY_LABELS[pin.category] || pin.category}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{!editing && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditing(true)}
|
||||
className="text-[10px] px-2 py-0.5 text-violet-300 hover:text-white border border-violet-500/30 hover:border-violet-500/60"
|
||||
>
|
||||
EDIT
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
className="text-[10px] px-2 py-0.5 text-red-400 hover:text-red-200 border border-red-500/30 hover:border-red-500/60"
|
||||
>
|
||||
DEL
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="text-gray-500 hover:text-white text-base leading-none px-1"
|
||||
aria-label="Close"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main body */}
|
||||
<div className="px-3 py-2 space-y-2">
|
||||
{editing ? (
|
||||
<>
|
||||
<input
|
||||
type="text"
|
||||
value={editLabel}
|
||||
onChange={(e) => setEditLabel(e.target.value)}
|
||||
placeholder="Label"
|
||||
onMouseDown={(e) => {
|
||||
e.stopPropagation();
|
||||
(e.currentTarget as HTMLInputElement).focus();
|
||||
}}
|
||||
onKeyDown={stopKeys}
|
||||
className="w-full px-2 py-1 text-[12px] font-mono bg-black/60 border border-violet-500/40 outline-none focus:border-violet-500"
|
||||
/>
|
||||
<select
|
||||
aria-label="Category"
|
||||
value={editCategory}
|
||||
onChange={(e) => setEditCategory(e.target.value as PinCategory)}
|
||||
className="w-full px-2 py-1 text-[11px] font-mono bg-black/60 border border-violet-500/40 outline-none focus:border-violet-500 border-l-4"
|
||||
style={{ borderLeftColor: PIN_CATEGORY_COLORS[editCategory] }}
|
||||
>
|
||||
{(Object.keys(PIN_CATEGORY_LABELS) as PinCategory[]).map((c) => (
|
||||
<option key={c} value={c} className="bg-[#0a0a14]">
|
||||
{PIN_CATEGORY_LABELS[c]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<textarea
|
||||
value={editDescription}
|
||||
onChange={(e) => setEditDescription(e.target.value)}
|
||||
placeholder="Notes"
|
||||
rows={3}
|
||||
onMouseDown={(e) => {
|
||||
e.stopPropagation();
|
||||
(e.currentTarget as HTMLTextAreaElement).focus();
|
||||
}}
|
||||
onKeyDown={stopKeys}
|
||||
className="w-full px-2 py-1 text-[11px] font-mono bg-black/60 border border-violet-500/30 outline-none focus:border-violet-500 resize-none"
|
||||
/>
|
||||
<div className="flex gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
disabled={saving || !editLabel.trim()}
|
||||
onClick={handleSaveEdit}
|
||||
className="flex-1 py-1 text-[11px] bg-violet-600/40 border border-violet-500/60 hover:bg-violet-600/60 disabled:opacity-40"
|
||||
>
|
||||
{saving ? '...' : 'SAVE'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setEditing(false);
|
||||
setEditLabel(pin.label);
|
||||
setEditDescription(pin.description || '');
|
||||
setEditCategory(pin.category);
|
||||
}}
|
||||
className="px-3 py-1 text-[11px] border border-gray-600/40 text-gray-400 hover:text-white"
|
||||
>
|
||||
CANCEL
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-[14px] font-bold leading-snug break-words">{pin.label}</div>
|
||||
{pin.description && (
|
||||
<div className="text-[11px] text-gray-300 whitespace-pre-wrap break-words leading-relaxed">
|
||||
{pin.description}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Location / entity metadata */}
|
||||
<div className="text-[10px] text-gray-400 space-y-0.5 pt-1 border-t border-white/5">
|
||||
{pin.entity_attachment ? (
|
||||
<div className="text-cyan-400">
|
||||
<span className="text-gray-500">TRACKING: </span>
|
||||
{pin.entity_attachment.entity_label || pin.entity_attachment.entity_id}
|
||||
<span className="text-cyan-600 ml-1">({pin.entity_attachment.entity_type})</span>
|
||||
</div>
|
||||
) : null}
|
||||
<div>
|
||||
<span className="text-gray-500">COORDS: </span>
|
||||
{pin.lat.toFixed(5)}, {pin.lng.toFixed(5)}
|
||||
</div>
|
||||
{locationLine && (
|
||||
<div>
|
||||
<span className="text-gray-500">PLACE: </span>
|
||||
{locationLine}
|
||||
</div>
|
||||
)}
|
||||
{pin.source && (
|
||||
<div>
|
||||
<span className="text-gray-500">SOURCE: </span>
|
||||
{pin.source}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Comments thread */}
|
||||
<div className="border-t border-white/10 px-3 py-2">
|
||||
<div className="text-[10px] uppercase tracking-widest text-violet-400 mb-1.5">
|
||||
Comments ({comments.length})
|
||||
</div>
|
||||
|
||||
{topLevel.length === 0 && (
|
||||
<div className="text-[10px] text-gray-600 italic mb-1.5">No comments yet.</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1.5 max-h-40 overflow-y-auto">
|
||||
{topLevel.map((c) => (
|
||||
<CommentBlock
|
||||
key={c.id}
|
||||
comment={c}
|
||||
replies={replies[c.id] || []}
|
||||
onReply={(id) => {
|
||||
setReplyTo(id);
|
||||
setTimeout(() => commentInputRef.current?.focus(), 30);
|
||||
}}
|
||||
onDelete={handleDeleteComment}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* New comment input */}
|
||||
<div className="mt-2 pt-2 border-t border-white/5 space-y-1.5">
|
||||
{replyTo && (
|
||||
<div className="text-[9px] text-violet-400 flex items-center justify-between">
|
||||
<span>Replying to comment…</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setReplyTo('')}
|
||||
className="text-gray-500 hover:text-white"
|
||||
>
|
||||
cancel
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<textarea
|
||||
ref={commentInputRef}
|
||||
value={newComment}
|
||||
onChange={(e) => setNewComment(e.target.value)}
|
||||
placeholder={replyTo ? 'Reply…' : 'Add a comment…'}
|
||||
rows={2}
|
||||
onMouseDown={(e) => {
|
||||
e.stopPropagation();
|
||||
(e.currentTarget as HTMLTextAreaElement).focus();
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
stopKeys(e);
|
||||
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
handlePostComment();
|
||||
}
|
||||
}}
|
||||
className="w-full px-2 py-1 text-[11px] font-mono bg-black/60 border border-violet-500/30 outline-none focus:border-violet-500 resize-none"
|
||||
/>
|
||||
<div className="flex items-center justify-between gap-1.5">
|
||||
<select
|
||||
aria-label="Comment as"
|
||||
value={commentAuthor}
|
||||
onChange={(e) => setCommentAuthor(e.target.value as 'user' | 'agent')}
|
||||
className="text-[10px] font-mono bg-black/60 border border-violet-500/30 px-1 py-0.5 outline-none"
|
||||
>
|
||||
<option value="user">as USER</option>
|
||||
<option value="agent">as AGENT</option>
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
disabled={posting || !newComment.trim()}
|
||||
onClick={handlePostComment}
|
||||
className="flex-1 py-1 text-[11px] bg-violet-600/40 border border-violet-500/60 hover:bg-violet-600/60 disabled:opacity-40"
|
||||
>
|
||||
{posting ? '...' : replyTo ? 'REPLY' : 'POST'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Marker>
|
||||
{showDeleteConfirm && (
|
||||
<ConfirmDialog
|
||||
open
|
||||
title="DELETE PIN"
|
||||
message={`Delete pin "${pin.label}"?\n\nThis cannot be undone.`}
|
||||
confirmLabel="DELETE"
|
||||
danger
|
||||
onConfirm={executeDeletePin}
|
||||
onCancel={() => setShowDeleteConfirm(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Comment block (recursive for replies)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface CommentBlockProps {
|
||||
comment: AIIntelPinComment;
|
||||
replies: AIIntelPinComment[];
|
||||
onReply: (commentId: string) => void;
|
||||
onDelete: (commentId: string) => void;
|
||||
}
|
||||
|
||||
const CommentBlock: React.FC<CommentBlockProps> = ({ comment, replies, onReply, onDelete }) => {
|
||||
const authorColor = comment.author === 'agent' ? '#22d3ee' : comment.author === 'openclaw' ? '#f59e0b' : '#a78bfa';
|
||||
const when = formatRelative(comment.created_at);
|
||||
|
||||
return (
|
||||
<div className="text-[11px] leading-snug">
|
||||
<div className="flex items-start gap-1.5">
|
||||
<span
|
||||
className="text-[9px] uppercase tracking-wider font-bold flex-shrink-0 mt-0.5"
|
||||
style={{ color: authorColor }}
|
||||
>
|
||||
{comment.author}
|
||||
</span>
|
||||
<span className="text-[9px] text-gray-600 flex-shrink-0 mt-0.5">{when}</span>
|
||||
<div className="flex-1 min-w-0 flex items-start justify-between gap-1">
|
||||
<div className="whitespace-pre-wrap break-words text-gray-200 flex-1">{comment.text}</div>
|
||||
<div className="flex gap-1 flex-shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onReply(comment.id)}
|
||||
className="text-[9px] text-gray-500 hover:text-violet-300"
|
||||
>
|
||||
reply
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDelete(comment.id)}
|
||||
className="text-[9px] text-gray-600 hover:text-red-400"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{replies.length > 0 && (
|
||||
<div className="ml-4 mt-1 pl-2 border-l border-violet-500/20 space-y-1">
|
||||
{replies.map((r) => (
|
||||
<CommentBlock key={r.id} comment={r} replies={[]} onReply={onReply} onDelete={onDelete} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function formatRelative(ts: number): string {
|
||||
const now = Date.now() / 1000;
|
||||
const diff = now - ts;
|
||||
if (diff < 60) return 'now';
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)}m`;
|
||||
if (diff < 86400) return `${Math.floor(diff / 3600)}h`;
|
||||
return `${Math.floor(diff / 86400)}d`;
|
||||
}
|
||||
|
||||
export default AIIntelPinDetail;
|
||||
@@ -0,0 +1,119 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import { Source, Layer, Marker } from 'react-map-gl/maplibre';
|
||||
import { API_BASE } from '@/lib/api';
|
||||
|
||||
interface Props {
|
||||
vesselLat: number;
|
||||
vesselLng: number;
|
||||
destination: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Geocodes a fishing vessel's AIS destination and draws a dashed cyan route line
|
||||
* from the vessel to the destination on the map.
|
||||
*/
|
||||
export default function FishingDestinationRoute({ vesselLat, vesselLng, destination }: Props) {
|
||||
const [destCoords, setDestCoords] = useState<[number, number] | null>(null);
|
||||
const [destLabel, setDestLabel] = useState('');
|
||||
const prevDest = useRef('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!destination) { setDestCoords(null); return; }
|
||||
const query = destination.trim();
|
||||
if (!query || query === prevDest.current) return;
|
||||
prevDest.current = query;
|
||||
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/geocode/search?q=${encodeURIComponent(query)}&limit=1`);
|
||||
if (!res.ok || cancelled) return;
|
||||
const json = await res.json();
|
||||
const results = json.results || json;
|
||||
if (Array.isArray(results) && results.length > 0 && !cancelled) {
|
||||
const r = results[0];
|
||||
setDestCoords([r.lng ?? r.lon, r.lat]);
|
||||
setDestLabel(r.label || r.display_name || query);
|
||||
} else {
|
||||
setDestCoords(null);
|
||||
}
|
||||
} catch {
|
||||
setDestCoords(null);
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [destination]);
|
||||
|
||||
if (!destCoords) return null;
|
||||
|
||||
const geojson: GeoJSON.FeatureCollection = {
|
||||
type: 'FeatureCollection',
|
||||
features: [
|
||||
{
|
||||
type: 'Feature',
|
||||
properties: { type: 'fishing-route' },
|
||||
geometry: {
|
||||
type: 'LineString',
|
||||
coordinates: [[vesselLng, vesselLat], destCoords],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'Feature',
|
||||
properties: { type: 'fishing-dest' },
|
||||
geometry: {
|
||||
type: 'Point',
|
||||
coordinates: destCoords,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Source id="fishing-dest-route" type="geojson" data={geojson}>
|
||||
<Layer
|
||||
id="fishing-dest-line"
|
||||
type="line"
|
||||
filter={['==', ['get', 'type'], 'fishing-route']}
|
||||
paint={{
|
||||
'line-color': '#0ea5e9',
|
||||
'line-width': 2,
|
||||
'line-opacity': 0.7,
|
||||
'line-dasharray': [6, 4],
|
||||
}}
|
||||
/>
|
||||
<Layer
|
||||
id="fishing-dest-point"
|
||||
type="circle"
|
||||
filter={['==', ['get', 'type'], 'fishing-dest']}
|
||||
paint={{
|
||||
'circle-radius': 6,
|
||||
'circle-color': 'rgba(14, 165, 233, 0.3)',
|
||||
'circle-stroke-width': 2,
|
||||
'circle-stroke-color': '#0ea5e9',
|
||||
}}
|
||||
/>
|
||||
<Layer
|
||||
id="fishing-dest-label"
|
||||
type="symbol"
|
||||
filter={['==', ['get', 'type'], 'fishing-dest']}
|
||||
layout={{
|
||||
'text-field': destLabel,
|
||||
'text-font': ['Noto Sans Bold'],
|
||||
'text-size': 11,
|
||||
'text-offset': [0, 1.4],
|
||||
'text-anchor': 'top',
|
||||
'text-allow-overlap': true,
|
||||
}}
|
||||
paint={{
|
||||
'text-color': '#0ea5e9',
|
||||
'text-halo-color': 'rgba(0,0,0,0.9)',
|
||||
'text-halo-width': 1.5,
|
||||
}}
|
||||
/>
|
||||
</Source>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -321,6 +321,13 @@ export function ThreatMarkers({
|
||||
const riskColor = getRiskColor(score);
|
||||
const alertKey = n.alertKey || `${n.title}|${n.coords?.[0]},${n.coords?.[1]}`;
|
||||
|
||||
// Color-blind accessible border pattern based on severity
|
||||
const threatBorderClass =
|
||||
score >= 9 ? 'threat-border-critical' :
|
||||
score >= 7 ? 'threat-border-high' :
|
||||
score >= 4 ? 'threat-border-medium' :
|
||||
'threat-border-low';
|
||||
|
||||
let isVisible = zoom >= 1;
|
||||
if (selectedEntity) {
|
||||
if (selectedEntity.type === 'news') {
|
||||
@@ -371,12 +378,12 @@ export function ThreatMarkers({
|
||||
)}
|
||||
|
||||
<div
|
||||
className="cursor-pointer transition-opacity duration-300 relative"
|
||||
className={`cursor-pointer transition-opacity duration-300 relative ${threatBorderClass}`}
|
||||
style={{
|
||||
opacity: isVisible ? 1.0 : 0.0,
|
||||
pointerEvents: isVisible ? 'auto' : 'none',
|
||||
backgroundColor: 'rgba(5, 5, 5, 0.96)',
|
||||
border: `2px solid ${riskColor}`,
|
||||
borderColor: riskColor,
|
||||
borderRadius: '4px',
|
||||
padding: '8px 20px 8px 12px',
|
||||
color: riskColor,
|
||||
@@ -384,7 +391,6 @@ export function ThreatMarkers({
|
||||
fontSize: '12px',
|
||||
fontWeight: 'bold',
|
||||
textAlign: 'center',
|
||||
boxShadow: `0 0 20px ${riskColor}80, 0 0 40px ${riskColor}30`,
|
||||
zIndex: 10,
|
||||
lineHeight: '1.3',
|
||||
minWidth: '200px',
|
||||
|
||||
@@ -68,7 +68,16 @@ type BuildRequest = {
|
||||
payload: DynamicMapLayersBuildPayload;
|
||||
};
|
||||
|
||||
type WorkerRequest = SyncRequest | BuildRequest;
|
||||
type SyncAndBuildRequest = {
|
||||
id: string;
|
||||
action: 'sync_and_build_dynamic_layers';
|
||||
payload: {
|
||||
data: DynamicMapLayersDataPayload;
|
||||
build: DynamicMapLayersBuildPayload;
|
||||
};
|
||||
};
|
||||
|
||||
type WorkerRequest = SyncRequest | BuildRequest | SyncAndBuildRequest;
|
||||
|
||||
type WorkerResponse = {
|
||||
id: string;
|
||||
@@ -164,6 +173,34 @@ function inView(lat: number, lng: number, bounds: BoundsTuple): boolean {
|
||||
return lng >= bounds[0] && lng <= bounds[2] && lat >= bounds[1] && lat <= bounds[3];
|
||||
}
|
||||
|
||||
function cleanLabel(value: unknown): string {
|
||||
if (typeof value !== 'string' && typeof value !== 'number') return '';
|
||||
return String(value).trim();
|
||||
}
|
||||
|
||||
function isRawIcaoLabel(label: string, icao24: unknown): boolean {
|
||||
const icao = cleanLabel(icao24).toLowerCase();
|
||||
return Boolean(icao && label.toLowerCase() === icao);
|
||||
}
|
||||
|
||||
function flightDisplayLabel(f: Flight): string {
|
||||
const candidates: unknown[] = [
|
||||
'alert_operator' in f ? f.alert_operator : '',
|
||||
'operator' in f ? f.operator : '',
|
||||
'owner' in f ? f.owner : '',
|
||||
'tracked_name' in f ? f.tracked_name : '',
|
||||
'name' in f ? f.name : '',
|
||||
f.callsign,
|
||||
f.registration,
|
||||
f.model,
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
const label = cleanLabel(candidate);
|
||||
if (label && !isRawIcaoLabel(label, f.icao24)) return label;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function interpFlightPosition(f: Flight, dtSeconds: number): [number, number] {
|
||||
if (!f.speed_knots || f.speed_knots <= 0 || dtSeconds <= 0) return [f.lng, f.lat];
|
||||
if (f.alt != null && f.alt <= 100) return [f.lng, f.lat];
|
||||
@@ -236,7 +273,7 @@ function buildFlightLayerGeoJSONWorker(
|
||||
properties: {
|
||||
id: f.icao24 || f.callsign || `${idPrefix}${i}`,
|
||||
type: typeLabel,
|
||||
callsign: f.callsign || f.icao24,
|
||||
callsign: flightDisplayLabel(f),
|
||||
rotation,
|
||||
iconId,
|
||||
},
|
||||
@@ -274,14 +311,7 @@ function buildTrackedFlightsGeoJSONWorker(
|
||||
: TRACKED_ICON_MAP[acType]?.[alertColor] ||
|
||||
TRACKED_ICON_MAP.airliner[alertColor] ||
|
||||
'svgAirlinerWhite';
|
||||
const displayName =
|
||||
('alert_operator' in f ? f.alert_operator : '') ||
|
||||
('operator' in f ? f.operator : '') ||
|
||||
('owner' in f ? f.owner : '') ||
|
||||
('name' in f ? f.name : '') ||
|
||||
f.callsign ||
|
||||
f.icao24 ||
|
||||
'UNKNOWN';
|
||||
const displayName = flightDisplayLabel(f);
|
||||
|
||||
features.push({
|
||||
type: 'Feature',
|
||||
@@ -570,6 +600,12 @@ self.onmessage = (event: MessageEvent<WorkerRequest>) => {
|
||||
postMessage({ id, ok: true, result: EMPTY_RESULT } satisfies WorkerResponse);
|
||||
return;
|
||||
}
|
||||
if (action === 'sync_and_build_dynamic_layers') {
|
||||
dynamicData = payload.data;
|
||||
const result = buildDynamicLayers(payload.build);
|
||||
postMessage({ id, ok: true, result } satisfies WorkerResponse);
|
||||
return;
|
||||
}
|
||||
if (action !== 'build_dynamic_layers') {
|
||||
postMessage({ id, ok: false, error: 'unsupported_action' } satisfies WorkerResponse);
|
||||
return;
|
||||
|
||||
@@ -2,12 +2,14 @@ import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
buildEarthquakesGeoJSON,
|
||||
buildFirmsGeoJSON,
|
||||
buildFishingActivityGeoJSON,
|
||||
buildShipsGeoJSON,
|
||||
buildCarriersGeoJSON,
|
||||
} from '@/components/map/geoJSONBuilders';
|
||||
import type {
|
||||
Earthquake,
|
||||
FireHotspot,
|
||||
FishingEvent,
|
||||
Ship,
|
||||
ActiveLayers,
|
||||
} from '@/types/dashboard';
|
||||
@@ -184,3 +186,29 @@ describe('buildCarriersGeoJSON', () => {
|
||||
expect(result.features[0].properties?.iconId).toBe('svgCarrier');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildFishingActivityGeoJSON', () => {
|
||||
it('reuses AIS ship icon styling when a fishing vessel matches a live ship', () => {
|
||||
const events: FishingEvent[] = [
|
||||
{
|
||||
id: 'fish-1',
|
||||
type: 'fishing',
|
||||
lat: 12,
|
||||
lng: 34,
|
||||
start: '2026-04-08T00:00:00Z',
|
||||
end: '2026-04-08T01:00:00Z',
|
||||
vessel_name: 'PACIFIC HARVEST',
|
||||
vessel_flag: 'US',
|
||||
duration_hrs: 1,
|
||||
},
|
||||
];
|
||||
const ships: Ship[] = [
|
||||
{ name: 'Pacific Harvest', lat: 12, lng: 34, type: 'cargo', mmsi: '123', heading: 87 } as Ship,
|
||||
];
|
||||
|
||||
const result = buildFishingActivityGeoJSON(events, ships)!;
|
||||
expect(result.features[0].properties?.iconId).toBe('svgShipRed');
|
||||
expect(result.features[0].properties?.shipCategory).toBe('cargo');
|
||||
expect(result.features[0].properties?.rotation).toBe(87);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -35,6 +35,11 @@ import type {
|
||||
SigintSignal,
|
||||
Train,
|
||||
CorrelationAlert,
|
||||
UAPSighting,
|
||||
WastewaterPlant,
|
||||
CrowdThreatItem,
|
||||
SarAnomaly,
|
||||
SarAoi,
|
||||
} from '@/types/dashboard';
|
||||
import { classifyAircraft } from '@/utils/aircraftClassification';
|
||||
import { MISSION_COLORS, MISSION_ICON_MAP } from '@/components/map/icons/SatelliteIcons';
|
||||
@@ -217,16 +222,35 @@ export function buildCorrelationsGeoJSON(alerts?: CorrelationAlert[]): FC {
|
||||
rf_anomaly: { high: 0.40, medium: 0.25, low: 0.15 },
|
||||
military_buildup: { high: 0.40, medium: 0.25, low: 0.15 },
|
||||
infra_cascade: { high: 0.45, medium: 0.30, low: 0.20 },
|
||||
contradiction: { high: 0.35, medium: 0.25, low: 0.15 },
|
||||
analysis_zone: { high: 0.35, medium: 0.22, low: 0.12 },
|
||||
};
|
||||
return {
|
||||
type: 'Feature' as const,
|
||||
properties: {
|
||||
id: i,
|
||||
id: a.id || `corr-${i}`,
|
||||
type: 'correlation',
|
||||
corr_type: a.type,
|
||||
severity: a.severity,
|
||||
score: a.score,
|
||||
drivers: (a.drivers || []).join(' + '),
|
||||
opacity: opacityMap[a.type]?.[a.severity] ?? 0.2,
|
||||
corr_index: i,
|
||||
// Contradiction extras
|
||||
...(a.type === 'contradiction' && {
|
||||
context: a.context || '',
|
||||
alternatives: (a.alternatives || []).join(' | '),
|
||||
location_name: a.location_name || '',
|
||||
}),
|
||||
// Analysis zone extras (OpenClaw-placed)
|
||||
...(a.type === 'analysis_zone' && {
|
||||
zone_id: a.id || '',
|
||||
zone_title: a.title || '',
|
||||
zone_body: a.body || '',
|
||||
zone_category: a.category || 'analysis',
|
||||
zone_source: a.source || 'openclaw',
|
||||
zone_deletable: true,
|
||||
}),
|
||||
},
|
||||
geometry: {
|
||||
type: 'Polygon' as const,
|
||||
@@ -906,6 +930,33 @@ export function buildShipsGeoJSON(
|
||||
|
||||
// ─── Carriers ───────────────────────────────────────────────────────────────
|
||||
|
||||
function normalizeShipName(value: string | undefined | null): string {
|
||||
return (value || '').trim().toUpperCase();
|
||||
}
|
||||
|
||||
function getShipIconId(ship: Pick<Ship, 'type' | 'yacht_alert'> | null | undefined): string {
|
||||
if (!ship) return 'svgShipBlue';
|
||||
const isTrackedYacht = !!ship.yacht_alert;
|
||||
const isMilitary = ship.type === 'carrier' || ship.type === 'military_vessel';
|
||||
const isCargo = ship.type === 'tanker' || ship.type === 'cargo';
|
||||
const isPassenger = ship.type === 'passenger';
|
||||
|
||||
if (isTrackedYacht) return 'svgShipPink';
|
||||
if (isCargo) return 'svgShipRed';
|
||||
if (ship.type === 'yacht' || isPassenger) return 'svgShipWhite';
|
||||
if (isMilitary) return 'svgShipAmber';
|
||||
return 'svgShipBlue';
|
||||
}
|
||||
|
||||
function getShipCategory(ship: Pick<Ship, 'type' | 'yacht_alert'> | null | undefined): string {
|
||||
if (!ship) return 'civilian';
|
||||
if (ship.yacht_alert || ship.type === 'yacht') return 'yacht';
|
||||
if (ship.type === 'tanker' || ship.type === 'cargo') return 'cargo';
|
||||
if (ship.type === 'passenger') return 'passenger';
|
||||
if (ship.type === 'carrier' || ship.type === 'military_vessel') return 'military';
|
||||
return 'civilian';
|
||||
}
|
||||
|
||||
// ─── SIGINT GeoJSON ──────────────────────────────────────────────────────────
|
||||
|
||||
function buildSigintFeature(sig: SigintSignal): GeoJSON.Feature | null {
|
||||
@@ -1196,24 +1247,38 @@ export function buildVolcanoesGeoJSON(volcanoes?: Volcano[]): FC {
|
||||
|
||||
// ─── Fishing Activity ───────────────────────────────────────────────────────
|
||||
|
||||
export function buildFishingActivityGeoJSON(events?: FishingEvent[]): FC {
|
||||
export function buildFishingActivityGeoJSON(events?: FishingEvent[], ships?: Ship[]): FC {
|
||||
if (!events?.length) return null;
|
||||
const shipsByName = new Map<string, Ship>();
|
||||
for (const ship of ships || []) {
|
||||
const normalizedName = normalizeShipName(ship.name);
|
||||
if (normalizedName && !shipsByName.has(normalizedName)) {
|
||||
shipsByName.set(normalizedName, ship);
|
||||
}
|
||||
}
|
||||
return {
|
||||
type: 'FeatureCollection' as const,
|
||||
features: events.map((e, i) => ({
|
||||
type: 'Feature' as const,
|
||||
properties: {
|
||||
id: e.id || `fish-${i}`,
|
||||
type: 'fishing_event',
|
||||
vessel_name: e.vessel_name,
|
||||
vessel_flag: e.vessel_flag,
|
||||
event_type: e.type,
|
||||
start: e.start,
|
||||
end: e.end,
|
||||
duration_hrs: e.duration_hrs,
|
||||
},
|
||||
geometry: { type: 'Point' as const, coordinates: [e.lng, e.lat] },
|
||||
})),
|
||||
features: events.map((e, i) => {
|
||||
const matchedShip = shipsByName.get(normalizeShipName(e.vessel_name));
|
||||
return {
|
||||
type: 'Feature' as const,
|
||||
properties: {
|
||||
id: e.id || `fish-${i}`,
|
||||
type: 'fishing_event',
|
||||
vessel_name: e.vessel_name,
|
||||
vessel_flag: e.vessel_flag,
|
||||
event_type: e.type,
|
||||
start: e.start,
|
||||
end: e.end,
|
||||
duration_hrs: e.duration_hrs,
|
||||
iconId: getShipIconId(matchedShip),
|
||||
shipCategory: getShipCategory(matchedShip),
|
||||
aisMatched: !!matchedShip,
|
||||
rotation: matchedShip?.heading || 0,
|
||||
},
|
||||
geometry: { type: 'Point' as const, coordinates: [e.lng, e.lat] },
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1307,3 +1372,406 @@ export function buildISSFootprintGeoJSON(
|
||||
features: [geoCircle(lng, lat, footprintKm)],
|
||||
};
|
||||
}
|
||||
|
||||
// ─── AI Intel Layer ──────────────────────────────────────────────────────────
|
||||
|
||||
export interface AIIntelPinData {
|
||||
id: string;
|
||||
layer_id?: string;
|
||||
lat: number;
|
||||
lng: number;
|
||||
label: string;
|
||||
category: string;
|
||||
color: string;
|
||||
description: string;
|
||||
source: string;
|
||||
source_url: string;
|
||||
confidence: number;
|
||||
created_at: string;
|
||||
entity_attachment?: {
|
||||
entity_type: string;
|
||||
entity_id: string;
|
||||
entity_label?: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
/** Resolve the live position of an entity-attached pin from telemetry data. */
|
||||
function resolveEntityPosition(
|
||||
attachment: NonNullable<AIIntelPinData['entity_attachment']>,
|
||||
data?: DashboardData | null,
|
||||
): { lat: number; lng: number } | null {
|
||||
if (!data) return null;
|
||||
const id = attachment.entity_id;
|
||||
const t = attachment.entity_type;
|
||||
|
||||
// Flight types — keyed by icao24
|
||||
if (t === 'flight' || t === 'commercial_flight') {
|
||||
const e = data.commercial_flights?.find((f) => f.icao24 === id);
|
||||
if (e) return { lat: e.lat, lng: e.lng };
|
||||
}
|
||||
if (t === 'private_flight' || t === 'private_ga') {
|
||||
const e = data.private_flights?.find((f) => f.icao24 === id);
|
||||
if (e) return { lat: e.lat, lng: e.lng };
|
||||
}
|
||||
if (t === 'private_jet') {
|
||||
const e = data.private_jets?.find((f) => f.icao24 === id);
|
||||
if (e) return { lat: e.lat, lng: e.lng };
|
||||
}
|
||||
if (t === 'military_flight') {
|
||||
const e = data.military_flights?.find((f) => f.icao24 === id);
|
||||
if (e) return { lat: e.lat, lng: e.lng };
|
||||
}
|
||||
if (t === 'tracked_flight') {
|
||||
const e = data.tracked_flights?.find((f) => f.icao24 === id);
|
||||
if (e) return { lat: e.lat, lng: e.lng };
|
||||
}
|
||||
if (t === 'uav') {
|
||||
const e = data.uavs?.find((u) => String(u.id) === id);
|
||||
if (e) return { lat: e.lat, lng: e.lng };
|
||||
}
|
||||
|
||||
// Ships — keyed by MMSI
|
||||
if (t === 'ship') {
|
||||
const e = data.ships?.find((s) => String(s.mmsi) === id);
|
||||
if (e) return { lat: e.lat, lng: e.lng };
|
||||
}
|
||||
|
||||
// Satellites — keyed by numeric ID
|
||||
if (t === 'satellite') {
|
||||
const e = data.satellites?.find((s) => String(s.id) === id);
|
||||
if (e) return { lat: e.lat, lng: e.lng };
|
||||
}
|
||||
|
||||
// Trains — keyed by id
|
||||
if (t === 'train') {
|
||||
const e = data.trains?.find((tr) => tr.id === id);
|
||||
if (e) return { lat: e.lat, lng: e.lng };
|
||||
}
|
||||
|
||||
// Fallback: search all flight arrays if generic "flight" didn't match
|
||||
if (t === 'flight') {
|
||||
for (const arr of [data.private_flights, data.private_jets, data.military_flights, data.tracked_flights, data.uavs] as Array<Array<{ icao24?: string; id?: string | number; lat: number; lng: number }> | undefined>) {
|
||||
const e = arr?.find((f) => (f.icao24 ?? String(f.id)) === id);
|
||||
if (e) return { lat: e.lat, lng: e.lng };
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function buildAIIntelGeoJSON(pins?: AIIntelPinData[], data?: DashboardData | null): FC {
|
||||
if (!pins?.length) return null;
|
||||
return {
|
||||
type: 'FeatureCollection' as const,
|
||||
features: pins
|
||||
.filter((pin) => pin.lat != null && pin.lng != null)
|
||||
.map((pin) => {
|
||||
// For entity-attached pins, resolve live position from telemetry
|
||||
let lat = pin.lat;
|
||||
let lng = pin.lng;
|
||||
let tracking = false;
|
||||
if (pin.entity_attachment?.entity_type && pin.entity_attachment?.entity_id) {
|
||||
const live = resolveEntityPosition(pin.entity_attachment, data);
|
||||
if (live) {
|
||||
lat = live.lat;
|
||||
lng = live.lng;
|
||||
tracking = true;
|
||||
}
|
||||
}
|
||||
return {
|
||||
type: 'Feature' as const,
|
||||
properties: {
|
||||
type: 'ai_intel_pin',
|
||||
id: pin.id,
|
||||
layer_id: pin.layer_id || '',
|
||||
name: pin.label,
|
||||
label: pin.label,
|
||||
category: pin.category,
|
||||
color: pin.color || '#3b82f6',
|
||||
description: pin.description,
|
||||
source: pin.source,
|
||||
source_url: pin.source_url,
|
||||
confidence: pin.confidence,
|
||||
created_at: pin.created_at,
|
||||
entity_type: pin.entity_attachment?.entity_type || '',
|
||||
entity_id: pin.entity_attachment?.entity_id || '',
|
||||
tracking,
|
||||
},
|
||||
geometry: {
|
||||
type: 'Point' as const,
|
||||
coordinates: [lng, lat],
|
||||
},
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
// ─── UAP Sightings ─────────────────────────────────────────────────────────
|
||||
|
||||
const UAP_SHAPE_COLORS: Record<string, string> = {
|
||||
triangle: '#ef4444', // Red
|
||||
orb: '#3b82f6', // Blue
|
||||
light: '#facc15', // Yellow
|
||||
disk: '#a855f7', // Purple
|
||||
cigar: '#f97316', // Orange
|
||||
'tic-tac': '#22d3ee', // Cyan
|
||||
fireball: '#dc2626', // Deep red
|
||||
formation: '#10b981', // Emerald
|
||||
diamond: '#e879f9', // Fuchsia
|
||||
rectangle: '#6366f1', // Indigo
|
||||
flash: '#fbbf24', // Amber
|
||||
changing: '#8b5cf6', // Violet
|
||||
unknown: '#9ca3af', // Grey
|
||||
};
|
||||
|
||||
// ─── CrowdThreat ──────────────────────────────────────────────────────────
|
||||
|
||||
export function buildCrowdThreatGeoJSON(threats?: CrowdThreatItem[], inView?: InViewFilter): FC {
|
||||
if (!threats?.length) return null;
|
||||
return {
|
||||
type: 'FeatureCollection' as const,
|
||||
features: threats
|
||||
.map((t) => {
|
||||
if (t.lat == null || t.lng == null) return null;
|
||||
if (inView && !inView(t.lat, t.lng)) return null;
|
||||
return {
|
||||
type: 'Feature' as const,
|
||||
properties: {
|
||||
id: `ct-${t.id}`,
|
||||
type: 'crowdthreat',
|
||||
title: t.title,
|
||||
summary: t.summary || '',
|
||||
category: t.category,
|
||||
category_colour: t.category_colour,
|
||||
subcategory: t.subcategory,
|
||||
threat_type: t.threat_type,
|
||||
address: t.address,
|
||||
city: t.city,
|
||||
country: t.country || '',
|
||||
timeago: t.timeago,
|
||||
occurred: t.occurred,
|
||||
occurred_iso: t.occurred_iso || '',
|
||||
verification: t.verification || '',
|
||||
severity: t.severity || '',
|
||||
source_url: t.source_url || '',
|
||||
votes: t.votes || 0,
|
||||
reporter: t.reporter || '',
|
||||
iconId: t.icon_id,
|
||||
name: t.title,
|
||||
},
|
||||
geometry: { type: 'Point' as const, coordinates: [t.lng, t.lat] },
|
||||
};
|
||||
})
|
||||
.filter(Boolean) as GeoJSON.Feature[],
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Wastewater colors by alert level ────────────────────────────────────
|
||||
const WW_COLORS = {
|
||||
alert: '#ff3333', // red — elevated pathogen detected
|
||||
active: '#00e5ff', // cyan — recent data, no alert
|
||||
stale: '#556677', // gray — plant exists but no recent data
|
||||
};
|
||||
|
||||
export function buildWastewaterGeoJSON(plants?: WastewaterPlant[]): FC {
|
||||
if (!plants?.length) return null;
|
||||
return {
|
||||
type: 'FeatureCollection' as const,
|
||||
features: plants
|
||||
.filter((p) => p.lat != null && p.lng != null)
|
||||
.map((p, i) => {
|
||||
const hasAlerts = p.alert_count > 0;
|
||||
const hasData = p.pathogens && p.pathogens.length > 0;
|
||||
const color = hasAlerts ? WW_COLORS.alert : hasData ? WW_COLORS.active : WW_COLORS.stale;
|
||||
const icon = hasAlerts ? 'ww-alert' : hasData ? 'ww-clean' : 'ww-stale';
|
||||
const alertPathogens = (p.pathogens || []).filter((pt) => pt.alert).map((pt) => pt.name);
|
||||
const allPathogens = (p.pathogens || []).map((pt) => pt.name);
|
||||
// Build a rich label: name + location + alert info
|
||||
const loc = [p.city, p.state].filter(Boolean).join(', ');
|
||||
const labelParts = [p.name || p.site_name || 'Treatment Plant'];
|
||||
if (loc) labelParts.push(loc);
|
||||
if (hasAlerts && alertPathogens.length > 0) {
|
||||
labelParts.push(`⚠ ${alertPathogens.join(', ')}`);
|
||||
}
|
||||
return {
|
||||
type: 'Feature' as const,
|
||||
properties: {
|
||||
id: p.id || `ww-${i}`,
|
||||
type: 'wastewater',
|
||||
name: p.name || p.site_name || 'Treatment Plant',
|
||||
label: labelParts.join('\n'),
|
||||
site_name: p.site_name,
|
||||
city: p.city,
|
||||
state: p.state,
|
||||
population: p.population,
|
||||
collection_date: p.collection_date,
|
||||
pathogen_count: (p.pathogens || []).length,
|
||||
alert_count: p.alert_count,
|
||||
alert_pathogens: alertPathogens.join(', '),
|
||||
detected_pathogens: allPathogens.join(', '),
|
||||
// Serialize pathogen details for fallback popup rendering
|
||||
pathogens_json: JSON.stringify(p.pathogens || []),
|
||||
color,
|
||||
icon,
|
||||
},
|
||||
geometry: {
|
||||
type: 'Point' as const,
|
||||
coordinates: [p.lng, p.lat],
|
||||
},
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildUapSightingsGeoJSON(sightings?: UAPSighting[]): FC {
|
||||
if (!sightings?.length) return null;
|
||||
return {
|
||||
type: 'FeatureCollection' as const,
|
||||
features: sightings
|
||||
.filter((s) => s.lat != null && s.lng != null)
|
||||
.map((s, i) => {
|
||||
// Build a rich label with all available info
|
||||
const location = [s.city, s.state].filter(Boolean).join(', ') || 'Unknown location';
|
||||
const dateStr = s.date_time || 'Date unknown';
|
||||
// Format: "City, ST — Date" for the map label
|
||||
const label = `${location}\n${dateStr}`;
|
||||
// Popup-friendly name with count if available
|
||||
const countMatch = s.summary?.match(/(\d+)\s*sighting/);
|
||||
const count = countMatch ? parseInt(countMatch[1], 10) : 1;
|
||||
const name = count > 1
|
||||
? `${count} sightings — ${location}`
|
||||
: `UAP Sighting — ${location}`;
|
||||
|
||||
return {
|
||||
type: 'Feature' as const,
|
||||
properties: {
|
||||
id: s.id || `uap-${i}`,
|
||||
type: 'uap_sighting',
|
||||
shape: s.shape || 'unknown',
|
||||
shape_raw: s.shape_raw || s.shape || 'Unknown',
|
||||
city: s.city,
|
||||
state: s.state,
|
||||
country: s.country,
|
||||
date_time: s.date_time,
|
||||
duration: s.duration,
|
||||
summary: s.summary,
|
||||
source: s.source || 'NUFORC',
|
||||
count,
|
||||
color: UAP_SHAPE_COLORS[s.shape] || UAP_SHAPE_COLORS.unknown,
|
||||
name,
|
||||
label,
|
||||
},
|
||||
geometry: {
|
||||
type: 'Point' as const,
|
||||
coordinates: [s.lng, s.lat],
|
||||
},
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
// ─── SAR (Synthetic Aperture Radar) ────────────────────────────────────────
|
||||
|
||||
/** Colors keyed by SAR anomaly `kind`. Matches sar_normalize._kind_to_pin_category
|
||||
* so the map and pin store agree on semantics. */
|
||||
const SAR_KIND_COLORS: Record<string, string> = {
|
||||
ground_deformation: '#f97316', // orange — subsidence, landslides
|
||||
surface_water_change: '#06b6d4', // cyan — flood/water extent
|
||||
flood_extent: '#06b6d4',
|
||||
vegetation_disturbance: '#22c55e', // green — deforestation, burn, blast
|
||||
damage_assessment: '#ef4444', // red — UNOSAT / EMS damage polygons
|
||||
coherence_change: '#a855f7', // purple — generic scatter change
|
||||
};
|
||||
|
||||
const SAR_DEFAULT_COLOR = '#eab308';
|
||||
|
||||
export function buildSarAnomaliesGeoJSON(anomalies?: SarAnomaly[]): FC {
|
||||
if (!anomalies?.length) return null;
|
||||
return {
|
||||
type: 'FeatureCollection' as const,
|
||||
features: anomalies
|
||||
.filter((a) => Number.isFinite(a.lat) && Number.isFinite(a.lon))
|
||||
.map((a) => ({
|
||||
type: 'Feature' as const,
|
||||
properties: {
|
||||
id: a.anomaly_id,
|
||||
type: 'sar_anomaly',
|
||||
kind: a.kind,
|
||||
name: a.title || `SAR ${a.kind}`,
|
||||
title: a.title || '',
|
||||
summary: a.summary || '',
|
||||
solver: a.solver || '',
|
||||
source_constellation: a.source_constellation || '',
|
||||
magnitude: a.magnitude ?? 0,
|
||||
magnitude_unit: a.magnitude_unit || '',
|
||||
confidence: a.confidence ?? 0,
|
||||
first_seen: a.first_seen ?? 0,
|
||||
last_seen: a.last_seen ?? 0,
|
||||
aoi_id: a.aoi_id || '',
|
||||
scene_count: a.scene_count ?? 0,
|
||||
category: a.category || 'watchlist',
|
||||
provenance_url: a.provenance_url || '',
|
||||
evidence_hash: a.evidence_hash || '',
|
||||
color: SAR_KIND_COLORS[a.kind] || SAR_DEFAULT_COLOR,
|
||||
},
|
||||
geometry: {
|
||||
type: 'Point' as const,
|
||||
coordinates: [a.lon, a.lat],
|
||||
},
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/** Draw AOIs as filled circles (approximated with a 64-vertex polygon). These
|
||||
* mark the operator's watchboxes — visible even before any anomalies arrive. */
|
||||
export function buildSarAoisGeoJSON(aois?: SarAoi[]): FC {
|
||||
if (!aois?.length) return null;
|
||||
const features: GeoJSON.Feature[] = [];
|
||||
for (const aoi of aois) {
|
||||
if (!Array.isArray(aoi.center) || aoi.center.length < 2) continue;
|
||||
const [lat, lon] = aoi.center;
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) continue;
|
||||
|
||||
// Use explicit polygon if provided, else build a 64-point circle.
|
||||
let ring: number[][];
|
||||
if (Array.isArray(aoi.polygon) && aoi.polygon.length >= 3) {
|
||||
ring = aoi.polygon.map((pt) => [pt[1], pt[0]]); // [lat,lon] → [lon,lat]
|
||||
// Ensure ring is closed
|
||||
const first = ring[0];
|
||||
const last = ring[ring.length - 1];
|
||||
if (first[0] !== last[0] || first[1] !== last[1]) ring.push([...first]);
|
||||
} else {
|
||||
const radiusKm = Math.max(1, aoi.radius_km || 25);
|
||||
const steps = 64;
|
||||
ring = [];
|
||||
const kmPerDegLat = 111.32;
|
||||
const kmPerDegLon = 111.32 * Math.cos((lat * Math.PI) / 180);
|
||||
for (let i = 0; i <= steps; i++) {
|
||||
const theta = (i / steps) * 2 * Math.PI;
|
||||
const dLat = (radiusKm * Math.sin(theta)) / kmPerDegLat;
|
||||
const dLon = (radiusKm * Math.cos(theta)) / Math.max(0.0001, kmPerDegLon);
|
||||
ring.push([lon + dLon, lat + dLat]);
|
||||
}
|
||||
}
|
||||
|
||||
features.push({
|
||||
type: 'Feature' as const,
|
||||
properties: {
|
||||
id: aoi.id,
|
||||
type: 'sar_aoi',
|
||||
name: aoi.name || aoi.id,
|
||||
description: aoi.description || '',
|
||||
category: aoi.category || 'watchlist',
|
||||
radius_km: aoi.radius_km || 0,
|
||||
center_lat: lat,
|
||||
center_lon: lon,
|
||||
},
|
||||
geometry: {
|
||||
type: 'Polygon' as const,
|
||||
coordinates: [ring],
|
||||
},
|
||||
});
|
||||
}
|
||||
if (features.length === 0) return null;
|
||||
return { type: 'FeatureCollection' as const, features };
|
||||
}
|
||||
|
||||
@@ -18,7 +18,16 @@ type BuildRequest = {
|
||||
payload: DynamicMapLayersBuildPayload;
|
||||
};
|
||||
|
||||
type WorkerRequest = SyncRequest | BuildRequest;
|
||||
type SyncAndBuildRequest = {
|
||||
id: string;
|
||||
action: 'sync_and_build_dynamic_layers';
|
||||
payload: {
|
||||
data: DynamicMapLayersDataPayload;
|
||||
build: DynamicMapLayersBuildPayload;
|
||||
};
|
||||
};
|
||||
|
||||
type WorkerRequest = SyncRequest | BuildRequest | SyncAndBuildRequest;
|
||||
|
||||
type WorkerResponse = {
|
||||
id: string;
|
||||
@@ -87,16 +96,26 @@ export function useDynamicMapLayersWorker(
|
||||
const [syncVersion, setSyncVersion] = useState(0);
|
||||
const syncVersionRef = useRef(0);
|
||||
const requestVersionRef = useRef(0);
|
||||
const hasSyncedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const id = `mapw_sync_${Date.now()}_${reqCounter++}`;
|
||||
const id = `mapw_sync_build_${Date.now()}_${reqCounter++}`;
|
||||
const currentSyncVersion = ++syncVersionRef.current;
|
||||
const requestVersion = ++requestVersionRef.current;
|
||||
|
||||
callWorker({ id, action: 'sync_dynamic_layers', payload: dataPayload })
|
||||
.then(() => {
|
||||
callWorker({
|
||||
id,
|
||||
action: 'sync_and_build_dynamic_layers',
|
||||
payload: { data: dataPayload, build: buildPayload },
|
||||
})
|
||||
.then((next) => {
|
||||
if (!cancelled) {
|
||||
hasSyncedRef.current = true;
|
||||
setSyncVersion(currentSyncVersion);
|
||||
if (requestVersion === requestVersionRef.current) {
|
||||
setResult(next);
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
@@ -111,6 +130,7 @@ export function useDynamicMapLayersWorker(
|
||||
}, dataDeps);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasSyncedRef.current) return;
|
||||
let cancelled = false;
|
||||
const requestVersion = ++requestVersionRef.current;
|
||||
const id = `mapw_build_${Date.now()}_${reqCounter++}`;
|
||||
|
||||
@@ -45,6 +45,9 @@ const EMPTY_RESULT: StaticMapLayersResult = {
|
||||
volcanoesGeoJSON: null,
|
||||
fishingGeoJSON: null,
|
||||
trainsGeoJSON: null,
|
||||
uapSightingsGeoJSON: null,
|
||||
wastewaterGeoJSON: null,
|
||||
crowdthreatGeoJSON: null,
|
||||
};
|
||||
|
||||
let worker: Worker | null = null;
|
||||
|
||||
@@ -512,6 +512,83 @@ export const svgWeatherGeneric = weatherSvg(
|
||||
`<circle cx="16" cy="28" r="1.8" fill="#f59e0b"/>`,
|
||||
);
|
||||
|
||||
// ─── CrowdThreat Icons ───────────────────────────────────────────────────────
|
||||
// Filled circle markers with inner symbol, matching CrowdThreat category colours.
|
||||
|
||||
function ctSvg(fill: string, inner: string): string {
|
||||
return `data:image/svg+xml;utf8,${encodeURIComponent(
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">` +
|
||||
`<circle cx="12" cy="12" r="10" fill="${fill}" stroke="#0a0a0a" stroke-width="1.5"/>` +
|
||||
`<circle cx="12" cy="12" r="10" fill="none" stroke="${fill}" stroke-width="0.5" stroke-opacity="0.4"/>` +
|
||||
inner +
|
||||
`</svg>`
|
||||
)}`;
|
||||
}
|
||||
|
||||
// Security & Conflict — red, crosshair
|
||||
export const svgCtSecurity = ctSvg('#ef4444',
|
||||
`<circle cx="12" cy="12" r="3.5" fill="none" stroke="#fff" stroke-width="1.4"/>` +
|
||||
`<line x1="12" y1="5" x2="12" y2="8" stroke="#fff" stroke-width="1.2" stroke-linecap="round"/>` +
|
||||
`<line x1="12" y1="16" x2="12" y2="19" stroke="#fff" stroke-width="1.2" stroke-linecap="round"/>` +
|
||||
`<line x1="5" y1="12" x2="8" y2="12" stroke="#fff" stroke-width="1.2" stroke-linecap="round"/>` +
|
||||
`<line x1="16" y1="12" x2="19" y2="12" stroke="#fff" stroke-width="1.2" stroke-linecap="round"/>`
|
||||
);
|
||||
|
||||
// Crime & Safety — blue, shield
|
||||
export const svgCtCrime = ctSvg('#3b82f6',
|
||||
`<path d="M12 5.5L7.5 7.5V11.5C7.5 14.5 9.5 17 12 18C14.5 17 16.5 14.5 16.5 11.5V7.5L12 5.5Z" fill="none" stroke="#fff" stroke-width="1.3" stroke-linejoin="round"/>`
|
||||
);
|
||||
|
||||
// Aviation — green, plane
|
||||
export const svgCtAviation = ctSvg('#22c55e',
|
||||
`<path d="M16.5 13v-1l-4-2.5V6.25c0-.42-.34-.75-.75-.75s-.75.34-.75.75V9.5L7 12v1l4.25-1.25V15L10 16v.75L11.75 16 13.5 16.75V16L12.25 15V11.75L16.5 13z" fill="#fff"/>`
|
||||
);
|
||||
|
||||
// Maritime — teal, anchor
|
||||
export const svgCtMaritime = ctSvg('#14b8a6',
|
||||
`<circle cx="12" cy="8" r="1.5" fill="none" stroke="#fff" stroke-width="1.2"/>` +
|
||||
`<line x1="12" y1="9.5" x2="12" y2="17" stroke="#fff" stroke-width="1.2" stroke-linecap="round"/>` +
|
||||
`<path d="M8 15C8 17.2 9.8 19 12 19C14.2 19 16 17.2 16 15" fill="none" stroke="#fff" stroke-width="1.2" stroke-linecap="round"/>` +
|
||||
`<line x1="10" y1="12" x2="14" y2="12" stroke="#fff" stroke-width="1.2" stroke-linecap="round"/>`
|
||||
);
|
||||
|
||||
// Industrial & Infrastructure — orange, bolt
|
||||
export const svgCtInfrastructure = ctSvg('#f97316',
|
||||
`<path d="M13 5.5L8 13h4l-.5 5.5L17 11h-4l.5-5.5z" fill="#fff" stroke="none"/>`
|
||||
);
|
||||
|
||||
// Special Threats — purple, warning triangle
|
||||
export const svgCtSpecial = ctSvg('#a855f7',
|
||||
`<path d="M12 6L6.5 17h11L12 6z" fill="none" stroke="#fff" stroke-width="1.3" stroke-linejoin="round"/>` +
|
||||
`<line x1="12" y1="10" x2="12" y2="13.5" stroke="#fff" stroke-width="1.3" stroke-linecap="round"/>` +
|
||||
`<circle cx="12" cy="15.5" r="0.8" fill="#fff"/>`
|
||||
);
|
||||
|
||||
// Social & Political — pink, people
|
||||
export const svgCtSocial = ctSvg('#ec4899',
|
||||
`<circle cx="10" cy="9" r="2" fill="#fff"/>` +
|
||||
`<circle cx="14.5" cy="9" r="2" fill="#fff"/>` +
|
||||
`<path d="M6 16.5C6 14 7.8 13 10 13C11 13 11.8 13.3 12.2 13.7" fill="none" stroke="#fff" stroke-width="1.2"/>` +
|
||||
`<path d="M10.8 13.7C11.2 13.3 12 13 13 13C15.2 13 17 14 17 16.5" fill="none" stroke="#fff" stroke-width="1.2"/>`
|
||||
);
|
||||
|
||||
// Other — gray, question mark
|
||||
export const svgCtOther = ctSvg('#6b7280',
|
||||
`<text x="12" y="16" text-anchor="middle" fill="#fff" font-size="11" font-weight="bold" font-family="sans-serif">?</text>`
|
||||
);
|
||||
|
||||
/** All CrowdThreat icon specs for preloading. */
|
||||
export const CT_ICON_SPECS: { id: string; svg: string }[] = [
|
||||
{ id: 'ct-security', svg: svgCtSecurity },
|
||||
{ id: 'ct-crime', svg: svgCtCrime },
|
||||
{ id: 'ct-aviation', svg: svgCtAviation },
|
||||
{ id: 'ct-maritime', svg: svgCtMaritime },
|
||||
{ id: 'ct-infrastructure', svg: svgCtInfrastructure },
|
||||
{ id: 'ct-special', svg: svgCtSpecial },
|
||||
{ id: 'ct-social', svg: svgCtSocial },
|
||||
{ id: 'ct-other', svg: svgCtOther },
|
||||
];
|
||||
|
||||
/** Map event name keywords → weather icon ID */
|
||||
export function weatherIconId(event: string): string {
|
||||
const e = event.toLowerCase();
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
// UAP (UFO) and Wastewater SVG icon builders for MapLibre symbol layers
|
||||
|
||||
/**
|
||||
* Purple UFO silhouette — classic saucer shape with dome and glow.
|
||||
* 36×36 viewport for a "healthy sized" icon on the map.
|
||||
*/
|
||||
export const makeUfoSvg = (): string => {
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 36 36">
|
||||
<!-- outer glow ring -->
|
||||
<ellipse cx="18" cy="22" rx="16" ry="7" fill="none" stroke="#c084fc" stroke-width="1" opacity="0.4"/>
|
||||
<!-- saucer body -->
|
||||
<ellipse cx="18" cy="22" rx="14" ry="5.5" fill="#7c3aed" stroke="#a855f7" stroke-width="1"/>
|
||||
<!-- dome -->
|
||||
<ellipse cx="18" cy="18" rx="7" ry="6" fill="#8b5cf6" stroke="#c084fc" stroke-width="0.8"/>
|
||||
<!-- dome highlight -->
|
||||
<ellipse cx="16" cy="16" rx="3" ry="2.5" fill="#c4b5fd" opacity="0.35"/>
|
||||
<!-- saucer lights -->
|
||||
<circle cx="7" cy="22" r="1.2" fill="#e9d5ff" opacity="0.9"/>
|
||||
<circle cx="13" cy="24" r="1.2" fill="#e9d5ff" opacity="0.9"/>
|
||||
<circle cx="18" cy="25" r="1.2" fill="#e9d5ff" opacity="0.9"/>
|
||||
<circle cx="23" cy="24" r="1.2" fill="#e9d5ff" opacity="0.9"/>
|
||||
<circle cx="29" cy="22" r="1.2" fill="#e9d5ff" opacity="0.9"/>
|
||||
<!-- bottom beam hint -->
|
||||
<line x1="15" y1="27" x2="13" y2="33" stroke="#c084fc" stroke-width="0.6" opacity="0.25"/>
|
||||
<line x1="18" y1="27" x2="18" y2="34" stroke="#c084fc" stroke-width="0.6" opacity="0.3"/>
|
||||
<line x1="21" y1="27" x2="23" y2="33" stroke="#c084fc" stroke-width="0.6" opacity="0.25"/>
|
||||
</svg>`;
|
||||
return 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg);
|
||||
};
|
||||
|
||||
/**
|
||||
* Larger UFO for cluster icons — 80×80, bold and unmissable at continental zoom.
|
||||
*/
|
||||
export const makeUfoClusterSvg = (): string => {
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 80 80">
|
||||
<!-- glow rings -->
|
||||
<circle cx="40" cy="40" r="38" fill="#7c3aed" opacity="0.2"/>
|
||||
<circle cx="40" cy="40" r="32" fill="#7c3aed" opacity="0.15"/>
|
||||
<!-- outer glow ring -->
|
||||
<ellipse cx="40" cy="46" rx="32" ry="13" fill="none" stroke="#c084fc" stroke-width="1.8" opacity="0.6"/>
|
||||
<!-- saucer body -->
|
||||
<ellipse cx="40" cy="46" rx="28" ry="10" fill="#7c3aed" stroke="#a855f7" stroke-width="1.8"/>
|
||||
<!-- dome -->
|
||||
<ellipse cx="40" cy="38" rx="14" ry="12" fill="#8b5cf6" stroke="#c084fc" stroke-width="1.2"/>
|
||||
<!-- dome highlight -->
|
||||
<ellipse cx="35" cy="34" rx="5" ry="4" fill="#c4b5fd" opacity="0.3"/>
|
||||
<!-- saucer lights -->
|
||||
<circle cx="16" cy="46" r="2.2" fill="#e9d5ff" opacity="0.95"/>
|
||||
<circle cx="26" cy="50" r="2.2" fill="#e9d5ff" opacity="0.95"/>
|
||||
<circle cx="40" cy="52" r="2.2" fill="#e9d5ff" opacity="0.95"/>
|
||||
<circle cx="54" cy="50" r="2.2" fill="#e9d5ff" opacity="0.95"/>
|
||||
<circle cx="64" cy="46" r="2.2" fill="#e9d5ff" opacity="0.95"/>
|
||||
<!-- bottom beam -->
|
||||
<line x1="34" y1="56" x2="30" y2="70" stroke="#c084fc" stroke-width="1.2" opacity="0.35"/>
|
||||
<line x1="40" y1="56" x2="40" y2="72" stroke="#c084fc" stroke-width="1.2" opacity="0.4"/>
|
||||
<line x1="46" y1="56" x2="50" y2="70" stroke="#c084fc" stroke-width="1.2" opacity="0.35"/>
|
||||
</svg>`;
|
||||
return 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg);
|
||||
};
|
||||
|
||||
/**
|
||||
* Water droplet icon for wastewater plants.
|
||||
* @param fill — fill colour (#00e5ff for clean, #ff2222 for alert)
|
||||
* @param stroke — optional stroke override
|
||||
*/
|
||||
export const makeWaterDropSvg = (fill: string, stroke?: string): string => {
|
||||
const s = stroke || fill;
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="24" viewBox="0 0 24 34">
|
||||
<!-- drop body -->
|
||||
<path d="M12,2 Q12,2 4,16 A10,10 0 0,0 20,16 Q12,2 12,2 Z"
|
||||
fill="${fill}" stroke="${s}" stroke-width="1.2" stroke-linejoin="round"/>
|
||||
<!-- inner highlight -->
|
||||
<path d="M12,5 Q12,5 6,16 A8,8 0 0,0 18,16 Q12,5 12,5 Z"
|
||||
fill="${fill}" opacity="0.5" stroke="none"/>
|
||||
<!-- shine -->
|
||||
<ellipse cx="9" cy="18" rx="2.5" ry="3.5" fill="white" opacity="0.18" transform="rotate(-15,9,18)"/>
|
||||
</svg>`;
|
||||
return 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg);
|
||||
};
|
||||
|
||||
/**
|
||||
* Larger water droplet for cluster icons — 64×80, bold at continental zoom.
|
||||
* @param fill — fill colour
|
||||
*/
|
||||
export const makeWaterDropClusterSvg = (fill: string): string => {
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="28" viewBox="0 0 64 80">
|
||||
<!-- glow -->
|
||||
<ellipse cx="32" cy="46" rx="28" ry="30" fill="${fill}" opacity="0.18"/>
|
||||
<!-- drop body -->
|
||||
<path d="M32,6 Q32,6 10,42 A24,24 0 0,0 54,42 Q32,6 32,6 Z"
|
||||
fill="${fill}" stroke="${fill}" stroke-width="2" stroke-linejoin="round"/>
|
||||
<!-- inner highlight -->
|
||||
<path d="M32,14 Q32,14 15,42 A19,19 0 0,0 49,42 Q32,14 32,14 Z"
|
||||
fill="${fill}" opacity="0.45" stroke="none"/>
|
||||
<!-- shine -->
|
||||
<ellipse cx="24" cy="46" rx="6" ry="9" fill="white" opacity="0.18" transform="rotate(-15,24,46)"/>
|
||||
</svg>`;
|
||||
return 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg);
|
||||
};
|
||||
|
||||
// Keep old exports as aliases for backward compat with geoJSONBuilders icon names
|
||||
export const makeWastewaterSvg = makeWaterDropSvg;
|
||||
@@ -16,12 +16,15 @@ export const makeSatSvg = (color: string) => {
|
||||
export const MISSION_COLORS: Record<string, string> = {
|
||||
military_recon: '#ff3333',
|
||||
military_sar: '#ff3333',
|
||||
military_comms: '#ff6644',
|
||||
sar: '#00e5ff',
|
||||
sigint: '#ffffff',
|
||||
navigation: '#4488ff',
|
||||
early_warning: '#ff00ff',
|
||||
commercial_imaging: '#44ff44',
|
||||
space_station: '#ffdd00',
|
||||
starlink: '#8899bb',
|
||||
constellation: '#7799cc',
|
||||
};
|
||||
|
||||
/** Special ISS icon — larger with built-in golden dashed halo ring */
|
||||
@@ -54,10 +57,13 @@ export const makeTrainSvg = (color: string) => {
|
||||
export const MISSION_ICON_MAP: Record<string, string> = {
|
||||
military_recon: 'sat-mil',
|
||||
military_sar: 'sat-mil',
|
||||
military_comms: 'sat-mil',
|
||||
sar: 'sat-sar',
|
||||
sigint: 'sat-sigint',
|
||||
navigation: 'sat-nav',
|
||||
early_warning: 'sat-ew',
|
||||
commercial_imaging: 'sat-com',
|
||||
space_station: 'sat-station',
|
||||
starlink: 'sat-com',
|
||||
constellation: 'sat-com',
|
||||
};
|
||||
|
||||
@@ -142,22 +142,22 @@ export function SigintSendForm({
|
||||
|
||||
return (
|
||||
<div className="mt-2 pt-1.5 border-t border-[var(--border-primary)]/30">
|
||||
<div className="text-[8px] text-[#666] tracking-widest mb-1">{label}</div>
|
||||
<div className="text-[11px] text-[#666] tracking-widest mb-1">{label}</div>
|
||||
{isMesh && (
|
||||
<div className="mb-1.5 rounded border border-amber-500/30 bg-amber-950/20 px-2 py-1.5">
|
||||
<div className="text-[8px] text-amber-300 tracking-widest">
|
||||
<div className="text-[11px] text-amber-300 tracking-widest">
|
||||
PUBLIC MESH NOTICE
|
||||
</div>
|
||||
<div className="text-[8px] text-amber-200/80 mt-0.5 leading-relaxed">
|
||||
<div className="text-[11px] text-amber-200/80 mt-0.5 leading-relaxed">
|
||||
These Meshtastic messages are public/degraded, not private. They may be intercepted,
|
||||
relayed, logged, or fail to deliver.
|
||||
</div>
|
||||
{publicMeshAddress && (
|
||||
<div className="text-[8px] text-amber-100/70 mt-1 font-mono">
|
||||
<div className="text-[11px] text-amber-100/70 mt-1 font-mono">
|
||||
YOUR PUBLIC MESH ADDRESS: {publicMeshAddress.toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
<label className="mt-1 flex items-start gap-1.5 text-[8px] text-amber-100/80 cursor-pointer">
|
||||
<label className="mt-1 flex items-start gap-1.5 text-[11px] text-amber-100/80 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={warningAck}
|
||||
@@ -176,7 +176,7 @@ export function SigintSendForm({
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSend()}
|
||||
placeholder={placeholder}
|
||||
maxLength={200}
|
||||
className={`flex-1 bg-[#0a0e1a] border border-[var(--border-primary)] rounded px-2 py-1 text-[10px] text-white font-mono placeholder:text-[#444] focus:outline-none ${
|
||||
className={`flex-1 bg-[#0a0e1a] border border-[var(--border-primary)] rounded px-2 py-1 text-[13px] text-white font-mono placeholder:text-[#444] focus:outline-none ${
|
||||
isMesh ? 'focus:border-green-500/50' : 'focus:border-cyan-500/50'
|
||||
}`}
|
||||
/>
|
||||
@@ -200,11 +200,11 @@ export function SigintSendForm({
|
||||
</button>
|
||||
</div>
|
||||
{status === 'sent' && (
|
||||
<div className="text-[8px] text-green-400 mt-0.5">Routed via {detail}</div>
|
||||
<div className="text-[11px] text-green-400 mt-0.5">Routed via {detail}</div>
|
||||
)}
|
||||
{status === 'error' && <div className="text-[8px] text-red-400 mt-0.5">{detail}</div>}
|
||||
{status === 'error' && <div className="text-[11px] text-red-400 mt-0.5">{detail}</div>}
|
||||
{status === 'sending' && (
|
||||
<div className="text-[8px] text-cyan-400 mt-0.5 animate-pulse">Routing...</div>
|
||||
<div className="text-[11px] text-cyan-400 mt-0.5 animate-pulse">Routing...</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -283,21 +283,21 @@ export function MeshtasticChannelFeed({ region, channel }: { region: string; cha
|
||||
const sortedChannels = Object.entries(regionChannels).sort((a, b) => b[1] - a[1]);
|
||||
|
||||
if (loading)
|
||||
return <div className="text-[8px] text-cyan-400/50 animate-pulse mt-1">Loading...</div>;
|
||||
return <div className="text-[11px] text-cyan-400/50 animate-pulse mt-1">Loading...</div>;
|
||||
|
||||
return (
|
||||
<div className="mt-1.5 pt-1 border-t border-green-500/20">
|
||||
{/* Channel population — which channels are active in this region */}
|
||||
{sortedChannels.length > 0 && (
|
||||
<div className="mb-1.5">
|
||||
<div className="text-[8px] text-green-400/60 tracking-widest mb-0.5">
|
||||
<div className="text-[11px] text-green-400/60 tracking-widest mb-0.5">
|
||||
ACTIVE CHANNELS — {region}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{sortedChannels.map(([ch, count]) => (
|
||||
<span
|
||||
key={ch}
|
||||
className={`font-mono text-[8px] px-1.5 py-0.5 rounded border ${
|
||||
className={`font-mono text-[11px] px-1.5 py-0.5 rounded border ${
|
||||
ch === channel
|
||||
? 'bg-green-900/50 text-green-300 border-green-500/40'
|
||||
: 'bg-slate-800/50 text-slate-400 border-slate-600/30'
|
||||
@@ -308,7 +308,7 @@ export function MeshtasticChannelFeed({ region, channel }: { region: string; cha
|
||||
))}
|
||||
</div>
|
||||
{(channelStats?.total_nodes ?? 0) > 0 && (
|
||||
<div className="text-[8px] text-[#555] mt-0.5">
|
||||
<div className="text-[11px] text-[#555] mt-0.5">
|
||||
{channelStats?.total_live} live + {channelStats?.total_api?.toLocaleString()} map nodes
|
||||
globally
|
||||
</div>
|
||||
@@ -319,7 +319,7 @@ export function MeshtasticChannelFeed({ region, channel }: { region: string; cha
|
||||
{/* Message feed */}
|
||||
{messages.length > 0 ? (
|
||||
<>
|
||||
<div className="text-[8px] text-green-400/60 tracking-widest mb-1">
|
||||
<div className="text-[11px] text-green-400/60 tracking-widest mb-1">
|
||||
MESSAGES — {channel} ({region})
|
||||
</div>
|
||||
<div className="max-h-[140px] overflow-y-auto space-y-0.5 scrollbar-thin">
|
||||
@@ -335,7 +335,7 @@ export function MeshtasticChannelFeed({ region, channel }: { region: string; cha
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={`text-[9px] font-mono py-0.5 px-1 rounded hover:bg-green-950/20 ${
|
||||
className={`text-[12px] font-mono py-0.5 px-1 rounded hover:bg-green-950/20 ${
|
||||
directedToYou ? 'bg-amber-950/20 border border-amber-500/20' : ''
|
||||
}`}
|
||||
>
|
||||
@@ -364,7 +364,7 @@ export function MeshtasticChannelFeed({ region, channel }: { region: string; cha
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-[8px] text-[#555]">No recent messages on {channel}</div>
|
||||
<div className="text-[11px] text-[#555]">No recent messages on {channel}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* AI Intel pin icons — teardrop SVG data URIs per category.
|
||||
*
|
||||
* These are registered with MapLibre via `map.addImage()` during init and
|
||||
* referenced from the ai-intel-pin-layer via the `icon-image` layout prop.
|
||||
*/
|
||||
|
||||
import { PIN_CATEGORY_COLORS, type PinCategory } from '@/types/aiIntel';
|
||||
|
||||
/** Classic teardrop pin shape with a white dot in the head. */
|
||||
function buildPinSvg(color: string): string {
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="40" height="54" viewBox="0 0 40 54">
|
||||
<defs>
|
||||
<filter id="s" x="-30%" y="-30%" width="160%" height="160%">
|
||||
<feDropShadow dx="0" dy="1" stdDeviation="1.5" flood-color="#000" flood-opacity="0.55"/>
|
||||
</filter>
|
||||
</defs>
|
||||
<path filter="url(#s)" d="M20 2 C10 2 2 10 2 20 C2 32 20 52 20 52 C20 52 38 32 38 20 C38 10 30 2 20 2 Z"
|
||||
fill="${color}" stroke="#0a0a14" stroke-width="2"/>
|
||||
<circle cx="20" cy="20" r="6.5" fill="#ffffff" stroke="#0a0a14" stroke-width="1.25"/>
|
||||
</svg>`;
|
||||
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
|
||||
}
|
||||
|
||||
/** Map image-id used in the layer's icon-image expression. */
|
||||
export const pinIconId = (category: PinCategory): string => `ai-pin-${category}`;
|
||||
|
||||
/** Generate every category's pin icon as a [id, dataURI] pair. */
|
||||
export function getAllPinIcons(): Array<[string, string]> {
|
||||
return (Object.keys(PIN_CATEGORY_COLORS) as PinCategory[]).map((cat) => [
|
||||
pinIconId(cat),
|
||||
buildPinSvg(PIN_CATEGORY_COLORS[cat]),
|
||||
]);
|
||||
}
|
||||
@@ -18,6 +18,9 @@ import {
|
||||
buildTrainsGeoJSON,
|
||||
buildVIIRSChangeNodesGeoJSON,
|
||||
buildVolcanoesGeoJSON,
|
||||
buildUapSightingsGeoJSON,
|
||||
buildWastewaterGeoJSON,
|
||||
buildCrowdThreatGeoJSON,
|
||||
} from '@/components/map/geoJSONBuilders';
|
||||
import type {
|
||||
AirQualityStation,
|
||||
@@ -34,9 +37,13 @@ import type {
|
||||
PowerPlant,
|
||||
SatNOGSStation,
|
||||
Scanner,
|
||||
Ship,
|
||||
Train,
|
||||
UAPSighting,
|
||||
WastewaterPlant,
|
||||
VIIRSChangeNode,
|
||||
Volcano,
|
||||
CrowdThreatItem,
|
||||
} from '@/types/dashboard';
|
||||
|
||||
type BoundsTuple = [number, number, number, number];
|
||||
@@ -59,7 +66,11 @@ export type StaticMapLayersDataPayload = {
|
||||
airQuality?: AirQualityStation[];
|
||||
volcanoes?: Volcano[];
|
||||
fishingActivity?: FishingEvent[];
|
||||
ships?: Ship[];
|
||||
trains?: Train[];
|
||||
uapSightings?: UAPSighting[];
|
||||
wastewater?: WastewaterPlant[];
|
||||
crowdthreat?: CrowdThreatItem[];
|
||||
};
|
||||
|
||||
export type StaticMapLayersBuildPayload = {
|
||||
@@ -81,6 +92,9 @@ export type StaticMapLayersBuildPayload = {
|
||||
volcanoes: boolean;
|
||||
fishing_activity: boolean;
|
||||
trains: boolean;
|
||||
uap_sightings: boolean;
|
||||
wastewater: boolean;
|
||||
crowdthreat: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -102,6 +116,9 @@ export type StaticMapLayersResult = {
|
||||
volcanoesGeoJSON: FC;
|
||||
fishingGeoJSON: FC;
|
||||
trainsGeoJSON: FC;
|
||||
uapSightingsGeoJSON: FC;
|
||||
wastewaterGeoJSON: FC;
|
||||
crowdthreatGeoJSON: FC;
|
||||
};
|
||||
|
||||
type SyncRequest = {
|
||||
@@ -168,9 +185,12 @@ function buildStaticLayers(payload: StaticMapLayersBuildPayload): StaticMapLayer
|
||||
airQualityGeoJSON: payload.activeLayers.air_quality ? buildAirQualityGeoJSON(staticData.airQuality) : null,
|
||||
volcanoesGeoJSON: payload.activeLayers.volcanoes ? buildVolcanoesGeoJSON(staticData.volcanoes) : null,
|
||||
fishingGeoJSON: payload.activeLayers.fishing_activity
|
||||
? buildFishingActivityGeoJSON(staticData.fishingActivity)
|
||||
? buildFishingActivityGeoJSON(staticData.fishingActivity, staticData.ships)
|
||||
: null,
|
||||
trainsGeoJSON: payload.activeLayers.trains ? buildTrainsGeoJSON(staticData.trains) : null,
|
||||
uapSightingsGeoJSON: payload.activeLayers.uap_sightings ? buildUapSightingsGeoJSON(staticData.uapSightings) : null,
|
||||
wastewaterGeoJSON: payload.activeLayers.wastewater ? buildWastewaterGeoJSON(staticData.wastewater) : null,
|
||||
crowdthreatGeoJSON: payload.activeLayers.crowdthreat ? buildCrowdThreatGeoJSON(staticData.crowdthreat, inView) : null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user