fix(search): locate assets from visible map layers

This commit is contained in:
Shadowbroker
2026-08-20 20:58:35 -06:00
parent f9ae967580
commit b130656cd1
+200 -10
View File
@@ -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<ActiveLayers>(loadActiveLayers);
const inputRef = useRef<HTMLInputElement>(null);
const containerRef = useRef<HTMLDivElement>(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<ActiveLayers>).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: <Shield size={10} className="text-yellow-400" />,
TRACKED: <Star size={10} className="text-pink-400" />,
MARITIME: <Ship size={10} className="text-blue-400" />,
FISHING: <Ship size={10} className="text-teal-400" />,
SATELLITE: <Star size={10} className="text-violet-400" />,
TRAIN: <Database size={10} className="text-emerald-400" />,
BASE: <Shield size={10} className="text-red-400" />,
SDR: <Crosshair size={10} className="text-green-400" />,
DATABASE: <Database size={10} className="text-purple-400" />,
};