release: prepare v0.9.7

This commit is contained in:
BigBodyCobain
2026-05-01 22:55:04 -06:00
parent ea457f27da
commit 28b3bd5ebf
670 changed files with 187060 additions and 14006 deletions
@@ -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>
</>
);
}
+9 -3
View File
@@ -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);
});
});
+484 -16
View File
@@ -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>
);
+34
View File
@@ -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,
};
}