From b130656cd1945c356e4a15fec02afe9a9d26bbd5 Mon Sep 17 00:00:00 2001 From: Shadowbroker <43977454+BigBodyCobain@users.noreply.github.com> Date: Thu, 20 Aug 2026 20:58:35 -0600 Subject: [PATCH] fix(search): locate assets from visible map layers --- frontend/src/components/FindLocateBar.tsx | 210 ++++++++++++++++++++-- 1 file changed, 200 insertions(+), 10 deletions(-) diff --git a/frontend/src/components/FindLocateBar.tsx b/frontend/src/components/FindLocateBar.tsx index deaebb1..cc0989a 100644 --- a/frontend/src/components/FindLocateBar.tsx +++ b/frontend/src/components/FindLocateBar.tsx @@ -1,10 +1,15 @@ 'use client'; -import React, { useState, useMemo, useRef, useEffect } from 'react'; +import React, { useDeferredValue, useState, useMemo, useRef, useEffect } from 'react'; import { Search, Crosshair, Plane, Shield, Star, Ship, X, Database } from 'lucide-react'; import { motion, AnimatePresence } from '@/lib/motion'; import { trackedOperators } from '../lib/trackedData'; import { useDataKeys } from '@/hooks/useDataStore'; +import { + LAYER_PREFERENCES_CHANGED_EVENT, + loadActiveLayers, +} from '@/lib/layerPreferences'; +import type { ActiveLayers } from '@/types/dashboard'; import { useTranslation } from '@/i18n'; interface FindLocateBarProps { @@ -24,14 +29,63 @@ interface SearchResult { extra?: string; } +const SEARCH_RESULT_LIMIT = 12; + +function isFiniteCoordinate(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value); +} + +function matchesQuery(query: string, ...values: unknown[]): boolean { + return values.some((value) => { + if (value === null || value === undefined) return false; + if (Array.isArray(value)) return value.some((item) => String(item).toLowerCase().includes(query)); + return String(value).toLowerCase().includes(query); + }); +} + const FindLocateBar = React.memo(function FindLocateBar({ onLocate, onFilter }: FindLocateBarProps) { const { t } = useTranslation(); - const data = useDataKeys(['commercial_flights', 'private_flights', 'private_jets', 'military_flights', 'tracked_flights', 'ships'] as const); + const data = useDataKeys([ + 'commercial_flights', + 'private_flights', + 'private_jets', + 'military_flights', + 'tracked_flights', + 'ships', + 'fishing_activity', + 'satellites', + 'trains', + 'military_bases', + 'kiwisdr', + ] as const); const [query, setQuery] = useState(''); + const deferredQuery = useDeferredValue(query.trim().toLowerCase()); const [isOpen, setIsOpen] = useState(false); + const [visibleLayers, setVisibleLayers] = useState(loadActiveLayers); const inputRef = useRef(null); const containerRef = useRef(null); + // Keep search visibility aligned with the DATA LAYERS panel without requiring + // a heavyweight page-level prop chain. Same-tab saves emit a custom event; + // the storage listener covers another tab changing the shared preferences. + useEffect(() => { + const onPreferenceChange = (event: Event) => { + const detail = (event as CustomEvent).detail; + setVisibleLayers(detail ?? loadActiveLayers()); + }; + const onStorage = (event: StorageEvent) => { + if (event.key === null || event.key.startsWith('sb_')) { + setVisibleLayers(loadActiveLayers()); + } + }; + window.addEventListener(LAYER_PREFERENCES_CHANGED_EVENT, onPreferenceChange); + window.addEventListener('storage', onStorage); + return () => { + window.removeEventListener(LAYER_PREFERENCES_CHANGED_EVENT, onPreferenceChange); + window.removeEventListener('storage', onStorage); + }; + }, []); + // Close dropdown when clicking outside useEffect(() => { const handler = (e: MouseEvent) => { @@ -43,7 +97,8 @@ const FindLocateBar = React.memo(function FindLocateBar({ onLocate, onFilter }: return () => document.removeEventListener('mousedown', handler); }, []); - // Build searchable index from all data + // Keep the long-standing flight/ship/database index cheap and ready. Heavy + // optional layers are matched lazily below only while the user is searching. const allEntities = useMemo(() => { const results: SearchResult[] = []; @@ -99,7 +154,9 @@ const FindLocateBar = React.memo(function FindLocateBar({ onLocate, onFilter }: const operator = f.alert_operator || 'Unknown Operator'; const category = f.alert_category || 'Tracked'; const type = f.alert_type || f.model || 'Unknown'; - const extras = [f.alert_operator, f.alert_tags, f.owner, f.name, f.callsign, f.registration].filter(Boolean).join(' '); + const extras = [f.alert_operator, f.alert_tags, f.owner, f.name, f.callsign, f.registration] + .filter(Boolean) + .join(' '); results.push({ id: `tracked-${uid}`, label: operator, @@ -144,17 +201,145 @@ const FindLocateBar = React.memo(function FindLocateBar({ onLocate, onFilter }: return results; }, [data]); - // Filter results based on query + // Filter results based on query. Large/optional datasets are scanned only + // for a real search (2+ chars), only when their layer is visible, and stop + // as soon as the 12-result UI cap is satisfied. useDeferredValue keeps the + // input responsive even with tens of thousands of fishing events. const filtered = useMemo(() => { - if (!query.trim()) return []; - const q = query.toLowerCase(); - return allEntities + if (!deferredQuery) return []; + const q = deferredQuery; + const results = allEntities .filter((e) => { const searchable = `${e.label} ${e.sublabel} ${e.id} ${e.extra || ''}`.toLowerCase(); return searchable.includes(q); }) - .slice(0, 12); - }, [query, allEntities]); + .slice(0, SEARCH_RESULT_LIMIT); + + if (q.length < 2 || results.length >= SEARCH_RESULT_LIMIT) return results; + + const push = ( + result: SearchResult, + searchableValues: unknown[], + ) => { + if (results.length >= SEARCH_RESULT_LIMIT) return; + if (!isFiniteCoordinate(result.lat) || !isFiniteCoordinate(result.lng)) return; + if (matchesQuery(q, ...searchableValues)) results.push(result); + }; + + if (visibleLayers.fishing_activity) { + for (const event of data?.fishing_activity || []) { + push( + { + id: `fishing-${event.id || event.vessel_id || event.vessel_ssvid || ''}`, + label: event.vessel_name || event.vessel_ssvid || 'UNKNOWN VESSEL', + sublabel: `${event.type || 'Fishing activity'} · ${event.vessel_flag || 'Unknown flag'}`, + category: 'FISHING', + categoryColor: 'text-teal-400', + lat: event.lat, + lng: event.lng, + entityType: 'fishing_activity', + }, + [ + event.vessel_name, + event.vessel_flag, + event.vessel_id, + event.vessel_ssvid, + event.type, + event.id, + ], + ); + if (results.length >= SEARCH_RESULT_LIMIT) break; + } + } + + if (visibleLayers.satellites && results.length < SEARCH_RESULT_LIMIT) { + for (const satellite of data?.satellites || []) { + push( + { + id: `satellite-${satellite.id}`, + label: satellite.name || String(satellite.id), + sublabel: `${satellite.mission || satellite.sat_type || 'Satellite'} · ${satellite.country || 'Unknown'}`, + category: 'SATELLITE', + categoryColor: 'text-violet-400', + lat: satellite.lat, + lng: satellite.lng, + entityType: 'satellite', + }, + [satellite.name, satellite.id, satellite.mission, satellite.sat_type, satellite.country], + ); + if (results.length >= SEARCH_RESULT_LIMIT) break; + } + } + + if (visibleLayers.trains && results.length < SEARCH_RESULT_LIMIT) { + for (const train of data?.trains || []) { + push( + { + id: `train-${train.id || train.number || train.name || ''}`, + label: train.name || train.number || train.id || 'TRAIN', + sublabel: `${train.operator || train.source_label || train.source || 'Rail'} · ${train.route || train.status || 'Unknown route'}`, + category: 'TRAIN', + categoryColor: 'text-emerald-400', + lat: train.lat, + lng: train.lng, + entityType: 'train', + }, + [ + train.id, + train.name, + train.number, + train.operator, + train.country, + train.route, + train.status, + train.source, + train.source_label, + ], + ); + if (results.length >= SEARCH_RESULT_LIMIT) break; + } + } + + if (visibleLayers.military_bases && results.length < SEARCH_RESULT_LIMIT) { + for (const base of data?.military_bases || []) { + push( + { + id: `military-base-${base.name || ''}-${base.country || ''}`, + label: base.name || 'MILITARY BASE', + sublabel: `${base.branch || base.operator || 'Military'} · ${base.country || 'Unknown'}`, + category: 'BASE', + categoryColor: 'text-red-400', + lat: base.lat, + lng: base.lng, + entityType: 'military_base', + }, + [base.name, base.country, base.operator, base.branch], + ); + if (results.length >= SEARCH_RESULT_LIMIT) break; + } + } + + if (visibleLayers.kiwisdr && results.length < SEARCH_RESULT_LIMIT) { + for (const receiver of data?.kiwisdr || []) { + push( + { + id: `kiwisdr-${receiver.name || ''}-${receiver.lat}-${receiver.lon}`, + label: receiver.name || receiver.location || 'KIWISDR', + sublabel: `${receiver.location || receiver.bands || 'KiwiSDR'} · ${receiver.users ?? 0}/${receiver.users_max ?? '?'} users`, + category: 'SDR', + categoryColor: 'text-green-400', + lat: receiver.lat, + lng: receiver.lon, + entityType: 'kiwisdr', + }, + [receiver.name, receiver.location, receiver.bands, receiver.antenna, receiver.url], + ); + if (results.length >= SEARCH_RESULT_LIMIT) break; + } + } + + return results; + }, [deferredQuery, allEntities, data, visibleLayers]); const handleSelect = (result: SearchResult) => { if (result.entityType === 'database_operator') { @@ -172,6 +357,11 @@ const FindLocateBar = React.memo(function FindLocateBar({ onLocate, onFilter }: MILITARY: , TRACKED: , MARITIME: , + FISHING: , + SATELLITE: , + TRAIN: , + BASE: , + SDR: , DATABASE: , };