mirror of
https://github.com/BigBodyCobain/Shadowbroker.git
synced 2026-08-24 19:32:42 +02:00
fix(search): locate assets from visible map layers
This commit is contained in:
@@ -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" />,
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user