live search 2/2

This commit is contained in:
lovebird 2026-03-26 23:46:42 +01:00
parent 46e25ccd48
commit 46184a1281
6 changed files with 255 additions and 145 deletions

View File

@ -41,7 +41,17 @@ export const CompetitorsGridView: React.FC<CompetitorsGridViewProps> = ({ compet
const [sortModel, setSortModel] = useState<GridSortModel>(() => paramsToSortModel(searchParams));
const [columnVisibilityModel, setColumnVisibilityModel] = useState<GridColumnVisibilityModel>(() => paramsToVisibilityModel(searchParams));
const filteredCompetitors = competitors;
// Deduplicate and filter rows that have no usable ID (livesearch streams may have placeId instead of place_id, or missing IDs)
const filteredCompetitors = React.useMemo(() => {
const seen = new Set<string>();
return competitors.filter(c => {
const id = c.place_id || (c as any).placeId || (c as any).id;
if (!id) return false;
if (seen.has(id)) return false;
seen.add(id);
return true;
});
}, [competitors]);
// Column Widths state
const [columnWidths, setColumnWidths] = useState<Record<string, number>>(() => {
try {
@ -220,7 +230,7 @@ export const CompetitorsGridView: React.FC<CompetitorsGridViewProps> = ({ compet
<DataGrid
rows={filteredCompetitors}
columns={orderedColumns}
getRowId={(row) => row.place_id}
getRowId={(row) => row.place_id || row.placeId || row.id}
loading={loading}
filterModel={filterModel}
onFilterModelChange={handleFilterModelChange}

View File

@ -603,9 +603,9 @@ export const CompetitorsMapView: React.FC<CompetitorsMapViewProps> = ({ competit
<SimulatorLayers
map={map.current}
isDarkStyle={mapStyleKey === 'dark'}
simulatorData={simulatorData}
simulatorPath={simulatorPath}
simulatorScanner={simulatorScanner || liveScanner}
simulatorData={liveAreas.length > 0 ? null : simulatorData}
simulatorPath={liveAreas.length > 0 ? null : simulatorPath}
simulatorScanner={liveScanner || simulatorScanner}
/>
<RegionLayers
map={map.current}

View File

@ -5,76 +5,61 @@ export function LiveSearchLayers({ map, liveAreas, liveRadii, liveNodes, isDarkS
useEffect(() => {
if (!map) return;
// Add source if not present
if (!map.getSource('live-areas')) {
map.addSource('live-areas', {
type: 'geojson',
data: { type: 'FeatureCollection', features: [] }
});
map.addLayer({
id: 'live-areas-fill',
type: 'fill',
source: 'live-areas',
paint: {
'fill-color': '#eab308', // yellow-500
'fill-opacity': 0.15
}
});
map.addLayer({
id: 'live-areas-line',
type: 'line',
source: 'live-areas',
paint: {
'line-color': '#eab308',
'line-width': 2,
'line-dasharray': [2, 2]
}
});
}
if (!map.getSource('live-radii')) {
map.addSource('live-radii', {
type: 'geojson',
data: { type: 'FeatureCollection', features: [] }
});
map.addLayer({
id: 'live-radii-fill',
type: 'fill',
source: 'live-radii',
paint: {
'fill-color': '#3b82f6', // blue-500
'fill-opacity': 0.1
}
});
map.addLayer({
id: 'live-radii-line',
type: 'line',
source: 'live-radii',
paint: {
'line-color': '#3b82f6',
'line-width': 2
}
});
}
if (!map.getSource('live-nodes')) {
map.addSource('live-nodes', {
type: 'geojson',
data: { type: 'FeatureCollection', features: [] }
});
map.addLayer({
id: 'live-nodes-circle',
type: 'circle',
source: 'live-nodes',
paint: {
'circle-radius': 4,
'circle-color': '#f43f5e', // rose-500
'circle-stroke-width': 1,
'circle-stroke-color': '#ffffff'
}
});
const initSources = () => {
if (!map.getSource('live-areas')) {
map.addSource('live-areas', {
type: 'geojson',
data: { type: 'FeatureCollection', features: [] }
});
map.addLayer({
id: 'live-areas-fill',
type: 'fill',
source: 'live-areas',
paint: {
'fill-color': '#eab308',
'fill-opacity': 0.15
}
});
map.addLayer({
id: 'live-areas-line',
type: 'line',
source: 'live-areas',
paint: {
'line-color': '#eab308',
'line-width': 2,
'line-dasharray': [2, 2]
}
});
}
if (!map.getSource('live-nodes')) {
map.addSource('live-nodes', {
type: 'geojson',
data: { type: 'FeatureCollection', features: [] }
});
map.addLayer({
id: 'live-nodes-circle',
type: 'circle',
source: 'live-nodes',
paint: {
'circle-radius': 4,
'circle-color': '#f43f5e',
'circle-stroke-width': 1,
'circle-stroke-color': '#ffffff'
}
});
}
};
if (map.isStyleLoaded()) {
initSources();
} else {
map.once('style.load', initSources);
}
return () => {
map.off('style.load', initSources);
};
}, [map]);
useEffect(() => {
@ -99,62 +84,32 @@ export function LiveSearchLayers({ map, liveAreas, liveRadii, liveNodes, isDarkS
});
}, [map, liveAreas]);
useEffect(() => {
if (!map || !map.getSource('live-radii')) return;
// Radii usually come as center + radius
// Let's create a rough circle polygon using basic math if there is no boundary
const features = liveRadii.map(r => {
const center = r.location?.location || r.center || r;
const radiusInKm = r.radius || r.radius_km;
if (center && radiusInKm) {
const lng = center.lng || center.lon;
if (lng === undefined || center.lat === undefined) return null;
// Return a circle polygon
const points = 64;
const coords = [];
const distanceX = radiusInKm / (111.320 * Math.cos((center.lat * Math.PI) / 180));
const distanceY = radiusInKm / 110.574;
for (let i = 0; i < points; i++) {
const theta = (i / points) * (2 * Math.PI);
const x = distanceX * Math.cos(theta);
const y = distanceY * Math.sin(theta);
coords.push([lng + x, center.lat + y]);
}
coords.push(coords[0]); // close polygon
return {
type: 'Feature',
properties: { name: r.name || 'Radius' },
geometry: {
type: 'Polygon',
coordinates: [coords]
}
};
}
return null;
}).filter(Boolean);
(map.getSource('live-radii') as maplibregl.GeoJSONSource).setData({
type: 'FeatureCollection',
features: features as any
});
}, [map, liveRadii]);
useEffect(() => {
if (!map || !map.getSource('live-nodes')) return;
const features = liveNodes.map(n => {
const loc = n.location || n;
if (loc && typeof loc.lat === 'number' && typeof loc.lng === 'number') {
// Resolve coordinates from multiple possible formats
let lat: number | undefined;
let lng: number | undefined;
if (n.gps_coordinates) {
lat = n.gps_coordinates.latitude;
lng = n.gps_coordinates.longitude;
} else if (n.location && typeof n.location.lat === 'number') {
lat = n.location.lat;
lng = n.location.lng || n.location.lon;
} else if (typeof n.lat === 'number') {
lat = n.lat;
lng = n.lng || n.lon;
}
if (lat !== undefined && lng !== undefined && isFinite(lat) && isFinite(lng)) {
return {
type: 'Feature',
properties: { name: n.name || 'Node' },
properties: { name: n.title || n.name || 'Node', type: n.type },
geometry: {
type: 'Point',
coordinates: [loc.lng, loc.lat]
coordinates: [lng, lat]
}
};
}

View File

@ -69,6 +69,16 @@ export function GridSearchResults({ jobId, competitors, excludedTypes, updateExc
<div className="flex items-center gap-3 p-1 rounded-lg border border-gray-200 dark:border-gray-700 shadow-sm bg-white dark:bg-gray-800">
<span className="text-xs text-gray-500 px-2">
{competitors.length} results
{(() => {
const emailCount = competitors.filter(c => {
const e = c as any;
const emails = e.emails || e.email;
return emails && (Array.isArray(emails) ? emails.length > 0 : !!emails);
}).length;
return emailCount > 0 ? (
<span className="ml-2 text-emerald-600 dark:text-emerald-400">📧 {emailCount}</span>
) : null;
})()}
</span>
<div className="h-4 w-px bg-gray-200 dark:bg-gray-700" />
<div className="flex">

View File

@ -2,6 +2,8 @@ import React, { createContext, useContext, useState, useEffect, ReactNode } from
import { getPlacesGridSearchStreamUrl } from '../client-gridsearch';
import { supabase } from '@/integrations/supabase/client';
export type StreamPhase = 'idle' | 'grid' | 'searching' | 'enriching' | 'complete' | 'failed';
export interface GridSearchStreamState {
streaming: boolean;
connected: boolean;
@ -12,11 +14,15 @@ export interface GridSearchStreamState {
liveNodes: any[];
stats: {
completedWaypoints: number;
totalWaypoints: number;
totalResults: number;
apiCalls: number;
totalLocationsEnriched: number;
errors: number;
phase: StreamPhase;
};
liveScanner: any;
jobResult: any;
}
const initialState: GridSearchStreamState = {
@ -29,26 +35,65 @@ const initialState: GridSearchStreamState = {
liveNodes: [],
stats: {
completedWaypoints: 0,
totalWaypoints: 0,
totalResults: 0,
apiCalls: 0,
totalLocationsEnriched: 0,
errors: 0,
phase: 'idle',
},
liveScanner: null
liveScanner: null,
jobResult: null,
};
// ── pure helpers ──
function buildScannerGeoJSON(data: any): any | null {
const loc = data.location?.location || data.center || data;
const lng = loc.lng || loc.lon;
if (!loc.lat || !lng) return null;
function extractCoords(data: any): { lat: number; lng: number } | null {
// waypoint-start: data.location.location or data.center
const loc = data.location?.location || data.center;
if (loc) {
const lng = loc.lng || loc.lon;
if (loc.lat && lng) return { lat: loc.lat, lng };
}
// node event: gps_coordinates from places table
if (data.gps_coordinates) {
return { lat: data.gps_coordinates.latitude, lng: data.gps_coordinates.longitude };
}
// fallback
if (typeof data.lat === 'number') {
return { lat: data.lat, lng: data.lng || data.lon };
}
return null;
}
function bearingBetween(lat1: number, lng1: number, lat2: number, lng2: number): number {
const toRad = (d: number) => (d * Math.PI) / 180;
const toDeg = (r: number) => (r * 180) / Math.PI;
const dLng = toRad(lng2 - lng1);
const y = Math.sin(dLng) * Math.cos(toRad(lat2));
const x = Math.cos(toRad(lat1)) * Math.sin(toRad(lat2)) -
Math.sin(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.cos(dLng);
return (toDeg(Math.atan2(y, x)) + 360) % 360;
}
function buildScannerGeoJSON(
lat: number, lng: number,
prevLat?: number, prevLng?: number,
mouthOpen = true
): any {
const bearing = (prevLat !== undefined && prevLng !== undefined)
? bearingBetween(prevLat, prevLng, lat, lng)
: 0;
return {
type: 'FeatureCollection',
features: [{
type: 'Feature',
properties: { icon_state: 'pacman-open', bearing: 0, radius: data.radius_km || 0.5 },
geometry: { type: 'Point', coordinates: [lng, loc.lat] }
properties: {
icon_state: mouthOpen ? 'pacman-open' : 'pacman-closed',
bearing,
},
geometry: { type: 'Point', coordinates: [lng, lat] }
}]
};
}
@ -81,16 +126,46 @@ function applyEventToState(
const stats = parsed.stats || data?.stats;
if (stats) next.stats = { ...next.stats, ...stats };
console.log(`SSE ${type}`, parsed);
switch (type) {
case 'grid-ready':
next.stats = { ...next.stats, totalWaypoints: data?.totalWaypoints || data?.waypoints?.length || 0, phase: 'searching' };
next.statusMessage = `Grid ready — ${next.stats.totalWaypoints} waypoints`;
break;
case 'area-start':
next.liveAreas = [...next.liveAreas, data];
next.statusMessage = `Searching area: ${data?.name || '...'}`;
break;
case 'area-finish':
next.statusMessage = `Finished area: ${data?.area_gid || '...'}`;
break;
case 'radius-start':
case 'waypoint-start':
next.liveRadii = [...next.liveRadii, data];
next.liveScanner = buildScannerGeoJSON(data) ?? next.liveScanner;
case 'waypoint-start': {
// No longer accumulate liveRadii (blue circles removed)
const wCoords = extractCoords(data);
if (wCoords) {
const prevPos = (next as any)._lastScannerPos;
const mouth = !((next as any)._pacmanOpen ?? true); // toggle
next.liveScanner = buildScannerGeoJSON(
wCoords.lat, wCoords.lng,
prevPos?.lat, prevPos?.lng,
mouth
);
(next as any)._lastScannerPos = wCoords;
(next as any)._pacmanOpen = mouth;
}
break;
}
case 'waypoint-finish':
next.stats = {
...next.stats,
completedWaypoints: next.stats.completedWaypoints + 1,
apiCalls: data?.apiCalls ?? next.stats.apiCalls,
};
break;
case 'node-start':
@ -101,10 +176,62 @@ function applyEventToState(
case 'node': {
const comp = normalizeLocation(type, data);
next.competitors = [...next.competitors, comp];
// Also add to liveNodes for map pins — replace any node-start placeholder
const pid = data?.place_id || data?.placeId;
if (pid) {
next.liveNodes = [
...next.liveNodes.filter((n: any) => (n.place_id || n.placeId) !== pid),
data
];
} else {
next.liveNodes = [...next.liveNodes, data];
}
// Move pacman to the found location
const nCoords = extractCoords(data);
if (nCoords) {
const prevPos = (next as any)._lastScannerPos;
const mouth = !((next as any)._pacmanOpen ?? true);
next.liveScanner = buildScannerGeoJSON(
nCoords.lat, nCoords.lng,
prevPos?.lat, prevPos?.lng,
mouth
);
(next as any)._lastScannerPos = nCoords;
(next as any)._pacmanOpen = mouth;
}
next.stats = { ...next.stats, totalResults: next.competitors.length };
if (type === 'node') next.stats = { ...next.stats, totalLocationsEnriched: next.stats.totalLocationsEnriched + 1 };
next.statusMessage = `Found: ${comp?.title || comp?.name || 'Location'}`;
break;
}
case 'enrich-start':
next.stats = { ...next.stats, phase: 'enriching' };
next.statusMessage = `Enriching ${data?.locationCount || ''} locations...`;
break;
case 'node-error':
case 'nodePage':
next.stats = { ...next.stats, errors: next.stats.errors + 1 };
break;
case 'job_result':
next.jobResult = data;
next.stats = {
...next.stats,
phase: 'complete',
totalResults: data?.totalResults ?? next.stats.totalResults,
apiCalls: data?.freshApiCalls ?? next.stats.apiCalls,
};
next.statusMessage = 'Search Complete';
break;
case 'failed':
next.streaming = false;
next.stats = { ...next.stats, phase: 'failed' };
next.statusMessage = data?.error || 'Job failed';
break;
case 'log':
break; // cpp logging forward ignored
}
@ -117,7 +244,8 @@ function applyEventToState(
const STREAM_EVENTS = [
'progress', 'area-start', 'area-finish', 'waypoint-start',
'waypoint-finish', 'radius-start', 'node-start', 'location',
'node', 'log', 'job_result', 'stats'
'node', 'node-error', 'nodePage', 'log', 'job_result', 'stats',
'grid-ready', 'enrich-start', 'failed'
] as const;
const GridSearchStreamContext = createContext<GridSearchStreamState>(initialState);
@ -150,7 +278,6 @@ export function GridSearchStreamProvider({
if (!isActive) return;
try {
const { type, parsed, data } = parseStreamEvent(eventType, e.data);
console.log('sse - uds stream', parsed);
setState(prev => applyEventToState(prev, type, parsed, data));
} catch (err) {
console.error('Failed parsing stream event', eventType, err);
@ -173,7 +300,16 @@ export function GridSearchStreamProvider({
es.addEventListener('complete', () => {
if (!isActive) return;
setState(prev => ({ ...prev, streaming: false, connected: false, statusMessage: 'Search Complete' }));
setState(prev => ({ ...prev, streaming: false, connected: false, statusMessage: 'Search Complete', stats: { ...prev.stats, phase: 'complete' } }));
es?.close();
});
es.addEventListener('failed', (e: MessageEvent) => {
if (!isActive) return;
try {
const parsed = JSON.parse(e.data);
setState(prev => ({ ...prev, streaming: false, connected: false, statusMessage: parsed?.error || 'Job failed', stats: { ...prev.stats, phase: 'failed' } }));
} catch { /* ignore */ }
es?.close();
});

View File

@ -40,6 +40,7 @@ export function JobViewer({ jobId }: { jobId: string }) {
const searchSettings = restoredState?.run?.request?.guided?.settings || null;
const [retrying, setRetrying] = useState(false);
const [reloadKey, setReloadKey] = useState(0);
// For CompetitorsGridView props
const [excludedTypes, setExcludedTypes] = useState<string[]>([]);
@ -54,16 +55,13 @@ export function JobViewer({ jobId }: { jobId: string }) {
const data = await fetchPlacesGridSearchById(jobId);
if (!active) return;
// Handle nested wrapper if present (sometimes { data: { ... } } or just the object)
// Backend always returns { request, result, areas, status }
const payload = data?.data ? data.data : data;
setJobData(payload);
if (payload && payload.result && Array.isArray(payload.result.enrichResults)) {
setCompetitors(payload.result.enrichResults);
} else if (Array.isArray(payload)) {
setCompetitors(payload);
} else if (payload && Array.isArray(payload.enrichResults)) {
setCompetitors(payload.enrichResults);
const enrichResults = payload?.result?.enrichResults;
if (Array.isArray(enrichResults)) {
setCompetitors(enrichResults);
} else {
setCompetitors([]);
}
@ -78,7 +76,7 @@ export function JobViewer({ jobId }: { jobId: string }) {
load();
return () => { active = false; };
}, [jobId]);
}, [jobId, reloadKey]);
const dummyUpdateExcluded = async (types: string[]) => setExcludedTypes(types);
@ -86,7 +84,8 @@ export function JobViewer({ jobId }: { jobId: string }) {
try {
setRetrying(true);
await retryPlacesGridSearchJob(jobId);
window.location.reload();
// Bust the dedup cache and re-trigger the useEffect instead of hard-reloading
setReloadKey(k => k + 1);
} catch (e: any) {
console.error(e);
setError(e.message || 'Failed to retry job');
@ -124,8 +123,8 @@ export function JobViewer({ jobId }: { jobId: string }) {
);
}
if (jobData?.status === 'failed' || jobData?.run?.status === 'failed') {
const errorMsg = jobData.result?.error || jobData?.run?.result?.error || 'Unknown error during job execution. The backend worker may have crashed or timed out.';
if (jobData?.status === 'failed') {
const errorMsg = jobData.result?.error || 'Unknown error during job execution. The backend worker may have crashed or timed out.';
return (
<div className="flex flex-col items-center justify-center p-6 flex-1 w-full box-border max-w-4xl mx-auto min-h-0">
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-xl w-full p-10 text-center space-y-6">