map & uds bugs
This commit is contained in:
parent
b15592fa11
commit
ac7d888f18
@ -294,13 +294,14 @@ const MarkdownRenderer = React.memo(({ content, className = "", variables, baseU
|
||||
return <h4 id={id} {...props}>{children}</h4>;
|
||||
},
|
||||
p: ({ node, children, ...props }) => {
|
||||
// Check if the paragraph contains an image
|
||||
// @ts-ignore
|
||||
const hasImage = node?.children?.some((child: any) =>
|
||||
child.type === 'element' && child.tagName === 'img'
|
||||
);
|
||||
// Deep check if any child is an image to avoid <p> nesting issues
|
||||
const hasImage = (n: any): boolean => {
|
||||
if (n.type === 'element' && n.tagName === 'img') return true;
|
||||
if (n.children) return n.children.some(hasImage);
|
||||
return false;
|
||||
};
|
||||
|
||||
if (hasImage) {
|
||||
if (hasImage(node)) {
|
||||
return <div {...props}>{children}</div>;
|
||||
}
|
||||
return <p {...props}>{children}</p>;
|
||||
@ -335,7 +336,20 @@ const MarkdownRenderer = React.memo(({ content, className = "", variables, baseU
|
||||
if (ids.length > 0) {
|
||||
return (
|
||||
<Suspense fallback={<div className="animate-pulse h-48 bg-muted rounded" />}>
|
||||
<GalleryWidget pictureIds={ids} thumbnailLayout="grid" imageFit="cover" />
|
||||
<GalleryWidget
|
||||
pictureIds={ids}
|
||||
thumbnailLayout="grid"
|
||||
imageFit="cover"
|
||||
thumbnailsPosition="bottom"
|
||||
thumbnailsOrientation="horizontal"
|
||||
zoomEnabled={false}
|
||||
showVersions={false}
|
||||
autoPlayVideos={false}
|
||||
showTitle={false}
|
||||
showDescription={false}
|
||||
thumbnailsClassName=""
|
||||
variables={{}}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
@ -51,7 +51,7 @@ const ResponsiveImage: React.FC<ResponsiveImageProps> = React.memo(({
|
||||
// Lazy load logic
|
||||
const [isInView, setIsInView] = useState(props.loading === 'eager');
|
||||
const [imgLoaded, setImgLoaded] = useState(false);
|
||||
const ref = React.useRef<HTMLDivElement>(null);
|
||||
const ref = React.useRef<HTMLSpanElement>(null);
|
||||
const imgRef = React.useRef<HTMLImageElement>(null);
|
||||
|
||||
|
||||
@ -118,7 +118,7 @@ const ResponsiveImage: React.FC<ResponsiveImageProps> = React.memo(({
|
||||
if (!isInView || isLoadingOrPending) {
|
||||
// Use className for wrapper if provided, otherwise generic
|
||||
// We attach the ref here to detect when this placeholder comes into view
|
||||
return <div ref={ref} className={`animate-pulse dark:bg-gray-800 bg-gray-200 w-full h-full ${className || ''}`} />;
|
||||
return <span ref={ref} className={`block animate-pulse dark:bg-gray-800 bg-gray-200 w-full h-full ${className || ''}`} />;
|
||||
}
|
||||
|
||||
if (error || !data) {
|
||||
@ -126,11 +126,11 @@ const ResponsiveImage: React.FC<ResponsiveImageProps> = React.memo(({
|
||||
if (typeof src === 'string') {
|
||||
return <img src={src} alt={alt} className={imgClassName || className} {...props} />;
|
||||
}
|
||||
return <div className="text-red-500 text-xs">Failed to load image</div>;
|
||||
return <span className="block text-red-500 text-xs">Failed to load image</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`relative w-full h-full overflow-hidden ${className || ''}`}>
|
||||
<span className={`relative block w-full h-full overflow-hidden ${className || ''}`}>
|
||||
<picture>
|
||||
{(data.sources || []).map((source, index) => (
|
||||
<source key={index} srcSet={source.srcset} type={source.type} sizes={sizes} />
|
||||
@ -158,11 +158,11 @@ const ResponsiveImage: React.FC<ResponsiveImageProps> = React.memo(({
|
||||
/>
|
||||
</picture>
|
||||
{!imgLoaded && (
|
||||
<div className="absolute inset-0 flex items-center justify-center dark:bg-gray-800 bg-gray-100/50 z-10 pointer-events-none">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-2 border-primary border-t-transparent"></div>
|
||||
</div>
|
||||
<span className="absolute inset-0 flex items-center justify-center dark:bg-gray-800 bg-gray-100/50 z-10 pointer-events-none">
|
||||
<span className="animate-spin rounded-full h-8 w-8 border-2 border-primary border-t-transparent"></span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</span>
|
||||
);
|
||||
}, (prev, next) => {
|
||||
// Only compare props that affect visual output — ignore callbacks
|
||||
|
||||
@ -296,6 +296,12 @@ const TopNavigation = () => {
|
||||
<T>Chat</T>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to={`/products/gridsearch`} className="flex items-center">
|
||||
<Home className="mr-2 h-4 w-4" />
|
||||
<T>GridSearch</T>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
<DropdownMenuItem asChild>
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { type GridPreset, getPresetVisibilityModel } from './useGridColumns';
|
||||
import {
|
||||
DataGrid,
|
||||
useGridApiRef,
|
||||
@ -31,13 +32,17 @@ import {
|
||||
} from './gridUtils';
|
||||
import { useGridColumns } from './useGridColumns';
|
||||
import { GripVertical } from 'lucide-react';
|
||||
import { LocationDetailView } from './LocationDetail';
|
||||
|
||||
interface CompetitorsGridViewProps {
|
||||
competitors: CompetitorFull[];
|
||||
loading: boolean;
|
||||
settings: CompetitorSettings;
|
||||
updateExcludedTypes: (types: string[]) => Promise<void>;
|
||||
selectedPlaceId?: string | null;
|
||||
onSelectPlace?: (id: string | null, behavior?: 'select' | 'open' | 'toggle') => void;
|
||||
isOwner?: boolean;
|
||||
isPublic?: boolean;
|
||||
preset?: GridPreset;
|
||||
}
|
||||
|
||||
const CustomToolbar = ({ selectedCount }: { selectedCount: number }) => {
|
||||
@ -61,7 +66,17 @@ const CustomToolbar = ({ selectedCount }: { selectedCount: number }) => {
|
||||
);
|
||||
};
|
||||
|
||||
export const CompetitorsGridView: React.FC<CompetitorsGridViewProps> = ({ competitors, loading, settings, updateExcludedTypes }) => {
|
||||
export const CompetitorsGridView: React.FC<CompetitorsGridViewProps> = ({
|
||||
competitors,
|
||||
loading,
|
||||
settings,
|
||||
updateExcludedTypes,
|
||||
selectedPlaceId,
|
||||
onSelectPlace,
|
||||
isOwner = false,
|
||||
isPublic = false,
|
||||
preset = 'full'
|
||||
}) => {
|
||||
const muiTheme = useMuiTheme();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
@ -69,9 +84,14 @@ export const CompetitorsGridView: React.FC<CompetitorsGridViewProps> = ({ compet
|
||||
const [filterModel, setFilterModel] = useState<GridFilterModel>(() => {
|
||||
const fromUrl = paramsToFilterModel(searchParams);
|
||||
if (fromUrl.items.length === 0 && !searchParams.has('nofilter')) {
|
||||
return {
|
||||
items: [{ field: 'email', operator: 'isNotEmpty' }]
|
||||
};
|
||||
// Only apply default "valid leads" filter if we are NOT in a public stripped view
|
||||
const shouldHideEmptyEmails = !isPublic || isOwner;
|
||||
|
||||
if (shouldHideEmptyEmails) {
|
||||
return {
|
||||
items: [{ field: 'email', operator: 'isNotEmpty' }]
|
||||
};
|
||||
}
|
||||
}
|
||||
return fromUrl;
|
||||
});
|
||||
@ -124,32 +144,29 @@ export const CompetitorsGridView: React.FC<CompetitorsGridViewProps> = ({ compet
|
||||
const [highlightedRowId, setHighlightedRowId] = useState<string | null>(null);
|
||||
const [anchorRowId, setAnchorRowId] = useState<string | null>(null);
|
||||
|
||||
// Sidebar panel state
|
||||
const [showLocationDetail, setShowLocationDetail] = useState(false);
|
||||
const [sidebarWidth, setSidebarWidth] = useState(400);
|
||||
// Sync local highlighted state with global selectedPlaceId
|
||||
useEffect(() => {
|
||||
if (selectedPlaceId !== highlightedRowId) {
|
||||
setHighlightedRowId(selectedPlaceId || null);
|
||||
}
|
||||
}, [selectedPlaceId]);
|
||||
|
||||
const isResizingRef = useRef(false);
|
||||
const startResizing = useCallback(() => {
|
||||
isResizingRef.current = true;
|
||||
const onMove = (e: MouseEvent) => {
|
||||
if (!isResizingRef.current) return;
|
||||
const newWidth = window.innerWidth - e.clientX;
|
||||
if (newWidth > 300 && newWidth < 800) setSidebarWidth(newWidth);
|
||||
};
|
||||
const onUp = () => {
|
||||
isResizingRef.current = false;
|
||||
window.removeEventListener('mousemove', onMove);
|
||||
window.removeEventListener('mouseup', onUp);
|
||||
};
|
||||
window.addEventListener('mousemove', onMove);
|
||||
window.addEventListener('mouseup', onUp);
|
||||
}, []);
|
||||
// Removed local sidebar panel state (moved to GridSearchResults)
|
||||
|
||||
// Get Columns Definition
|
||||
const columns = useGridColumns({
|
||||
settings,
|
||||
updateExcludedTypes
|
||||
});
|
||||
|
||||
// Sync Visibility Model when preset changes
|
||||
useEffect(() => {
|
||||
const presetModel = getPresetVisibilityModel(preset);
|
||||
setColumnVisibilityModel(prev => ({
|
||||
...prev,
|
||||
...presetModel
|
||||
}));
|
||||
}, [preset]);
|
||||
|
||||
// Update URL when filter model changes
|
||||
const handleFilterModelChange = (newFilterModel: GridFilterModel) => {
|
||||
@ -338,8 +355,7 @@ export const CompetitorsGridView: React.FC<CompetitorsGridViewProps> = ({ compet
|
||||
handled = true;
|
||||
} else if (e.key === 'Enter') {
|
||||
handled = true;
|
||||
setShowLocationDetail(prev => !prev);
|
||||
|
||||
onSelectPlace?.(highlightedRowId, 'toggle');
|
||||
} else if (e.key === ' ') {
|
||||
handled = true;
|
||||
const currentId = String(rowIds[currentIdx]);
|
||||
@ -364,6 +380,7 @@ export const CompetitorsGridView: React.FC<CompetitorsGridViewProps> = ({ compet
|
||||
if (nextIdx !== currentIdx || !highlightedRowId) {
|
||||
const nextId = String(rowIds[nextIdx]);
|
||||
setHighlightedRowId(nextId);
|
||||
onSelectPlace?.(nextId, 'select');
|
||||
|
||||
if (e.shiftKey) {
|
||||
const anchorIdx = anchorRowId ? rowIds.indexOf(anchorRowId) : currentIdx;
|
||||
@ -396,9 +413,9 @@ export const CompetitorsGridView: React.FC<CompetitorsGridViewProps> = ({ compet
|
||||
}, [highlightedRowId, anchorRowId, apiRef]);
|
||||
|
||||
const activeDetailCompetitor = React.useMemo(() => {
|
||||
if (!showLocationDetail || !highlightedRowId) return null;
|
||||
if (!selectedPlaceId || !highlightedRowId) return null;
|
||||
return competitors.find(c => String(c.place_id || (c as any).placeId || (c as any).id) === highlightedRowId);
|
||||
}, [showLocationDetail, highlightedRowId, competitors]);
|
||||
}, [selectedPlaceId, highlightedRowId, competitors]);
|
||||
|
||||
return (
|
||||
<div
|
||||
@ -441,8 +458,10 @@ export const CompetitorsGridView: React.FC<CompetitorsGridViewProps> = ({ compet
|
||||
return String(params.id) === highlightedRowId ? 'row-custom-highlighted' : '';
|
||||
}}
|
||||
onRowClick={(params) => {
|
||||
setHighlightedRowId(String(params.id));
|
||||
setAnchorRowId(String(params.id));
|
||||
const id = String(params.id);
|
||||
setHighlightedRowId(id);
|
||||
setAnchorRowId(id);
|
||||
onSelectPlace?.(id, 'toggle');
|
||||
}}
|
||||
onRowSelectionModelChange={(newSelection) => {
|
||||
// Handle Array, Object with ids, Set, or generic Iterable
|
||||
@ -529,25 +548,6 @@ export const CompetitorsGridView: React.FC<CompetitorsGridViewProps> = ({ compet
|
||||
</MuiThemeProvider>
|
||||
</div>
|
||||
|
||||
{showLocationDetail && activeDetailCompetitor && (
|
||||
<>
|
||||
{/* Drag Handle */}
|
||||
<div
|
||||
className="w-1 bg-gray-200 dark:bg-gray-700 hover:bg-indigo-500 cursor-col-resize flex items-center justify-center z-30 transition-colors shrink-0"
|
||||
onMouseDown={startResizing}
|
||||
>
|
||||
<GripVertical className="w-3 h-3 text-gray-400" />
|
||||
</div>
|
||||
|
||||
{/* Property Pane */}
|
||||
<div className="hidden lg:block h-full bg-white dark:bg-gray-800 z-20 overflow-hidden relative shrink-0" style={{ width: sidebarWidth }}>
|
||||
<LocationDetailView
|
||||
competitor={activeDetailCompetitor as unknown as CompetitorFull}
|
||||
onClose={() => setShowLocationDetail(false)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -110,6 +110,8 @@ interface CompetitorsMapViewProps {
|
||||
onClosePosterMode?: () => void;
|
||||
posterTheme?: string;
|
||||
setPosterTheme?: (theme: string) => void;
|
||||
selectedPlaceId?: string | null;
|
||||
onSelectPlace?: (id: string | null, behavior?: 'select' | 'open' | 'toggle') => void;
|
||||
}
|
||||
|
||||
|
||||
@ -181,7 +183,7 @@ const renderPopupHtml = (competitor: CompetitorFull) => {
|
||||
`;
|
||||
};
|
||||
|
||||
export const CompetitorsMapView: React.FC<CompetitorsMapViewProps> = ({ competitors, onMapCenterUpdate, initialCenter, initialZoom, initialPitch, initialBearing, onMapMove, enrich, isEnriching, enrichmentProgress, initialGadmRegions, initialSimulatorSettings, simulatorSettings, onSimulatorSettingsChange, liveAreas = [], liveRadii = [], liveNodes = [], liveScanner, preset = 'SearchView', customFeatures, onRegionsChange, isPosterMode, onClosePosterMode, posterTheme: controlledPosterTheme, setPosterTheme: setControlledPosterTheme }) => {
|
||||
export const CompetitorsMapView: React.FC<CompetitorsMapViewProps> = ({ competitors, onMapCenterUpdate, initialCenter, initialZoom, initialPitch, initialBearing, onMapMove, enrich, isEnriching, enrichmentProgress, initialGadmRegions, initialSimulatorSettings, simulatorSettings, onSimulatorSettingsChange, liveAreas = [], liveRadii = [], liveNodes = [], liveScanner, preset = 'SearchView', customFeatures, onRegionsChange, isPosterMode, onClosePosterMode, posterTheme: controlledPosterTheme, setPosterTheme: setControlledPosterTheme, selectedPlaceId, onSelectPlace }) => {
|
||||
const features: MapFeatures = useMemo(() => {
|
||||
if (isPosterMode) {
|
||||
return { ...MAP_PRESETS['Minimal'], enableSidebarTools: false };
|
||||
@ -214,6 +216,47 @@ export const CompetitorsMapView: React.FC<CompetitorsMapViewProps> = ({ competit
|
||||
|
||||
// Selection and Sidebar State
|
||||
const [selectedLocation, setSelectedLocation] = useState<CompetitorFull | null>(null);
|
||||
|
||||
const handleSelectLocation = useCallback((loc: CompetitorFull | null, behavior: 'select' | 'open' | 'toggle' = 'open') => {
|
||||
setSelectedLocation(loc);
|
||||
onSelectPlace?.(loc?.place_id || null, behavior);
|
||||
}, [onSelectPlace]);
|
||||
|
||||
// Add logic to label locations A, B, C...
|
||||
const validLocations = useMemo(() => {
|
||||
return competitors
|
||||
.map((c, i) => {
|
||||
const lat = c.gps_coordinates?.latitude ?? c.raw_data?.geo?.latitude ?? (c as any).lat;
|
||||
const lon = c.gps_coordinates?.longitude ?? c.raw_data?.geo?.longitude ?? (c as any).lon;
|
||||
|
||||
if (lat !== undefined && lon !== undefined && lat !== null && lon !== null) {
|
||||
return {
|
||||
...c,
|
||||
lat: Number(lat),
|
||||
lon: Number(lon),
|
||||
label: String.fromCharCode(65 + (i % 26)) + (Math.floor(i / 26) > 0 ? Math.floor(i / 26) : '')
|
||||
};
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter((c): c is NonNullable<typeof c> => c !== null);
|
||||
}, [competitors]);
|
||||
|
||||
const locationIds = useMemo(() => {
|
||||
return validLocations.map(l => l.place_id).sort().join(',');
|
||||
}, [validLocations]);
|
||||
|
||||
// Sync local selection with prop
|
||||
useEffect(() => {
|
||||
if (selectedPlaceId) {
|
||||
const loc = validLocations.find(l => l.place_id === selectedPlaceId);
|
||||
if (loc && loc.place_id !== selectedLocation?.place_id) {
|
||||
setSelectedLocation(loc);
|
||||
}
|
||||
} else {
|
||||
setSelectedLocation(null);
|
||||
}
|
||||
}, [selectedPlaceId, validLocations]);
|
||||
const [gadmPickerActive, setGadmPickerActive] = useState(false);
|
||||
const [sidebarOpen, setSidebarOpen] = useState(features.showSidebar ?? false);
|
||||
|
||||
@ -237,7 +280,7 @@ export const CompetitorsMapView: React.FC<CompetitorsMapViewProps> = ({ competit
|
||||
|
||||
// Layer Toggles
|
||||
const [showDensity, setShowDensity] = useState(false);
|
||||
const [showCenters, setShowCenters] = useState(true);
|
||||
const [showCenters, setShowCenters] = useState(false);
|
||||
|
||||
const [sidebarWidth, setSidebarWidth] = useState(400);
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
@ -248,9 +291,11 @@ export const CompetitorsMapView: React.FC<CompetitorsMapViewProps> = ({ competit
|
||||
const posterTheme = controlledPosterTheme ?? localPosterTheme;
|
||||
const setPosterTheme = setControlledPosterTheme ?? setLocalPosterTheme;
|
||||
|
||||
// Info Panel State
|
||||
const [infoPanelOpen, setInfoPanelOpen] = useState(false);
|
||||
|
||||
const handleCloseLocationDetail = useCallback(() => handleSelectLocation(null), [handleSelectLocation]);
|
||||
const handleCloseInfoPanel = useCallback(() => setInfoPanelOpen(false), []);
|
||||
|
||||
const { mapInternals, currentCenterLabel, isLocating, setupMapListeners, handleLocate, cleanupLocateMarker } = useMapControls(onMapCenterUpdate);
|
||||
|
||||
// Auto-load GADM region boundaries when enableAutoRegions is on
|
||||
@ -342,29 +387,6 @@ export const CompetitorsMapView: React.FC<CompetitorsMapViewProps> = ({ competit
|
||||
// Enrichment Hook - NOW PASSED VIA PROPS
|
||||
// const { enrich, isEnriching, progress: enrichmentProgress } = useLocationEnrichment();
|
||||
|
||||
// Add logic to label locations A, B, C...
|
||||
const validLocations = useMemo(() => {
|
||||
return competitors
|
||||
.map((c, i) => {
|
||||
const lat = c.gps_coordinates?.latitude ?? c.raw_data?.geo?.latitude ?? (c as any).lat;
|
||||
const lon = c.gps_coordinates?.longitude ?? c.raw_data?.geo?.longitude ?? (c as any).lon;
|
||||
|
||||
if (lat !== undefined && lon !== undefined && lat !== null && lon !== null) {
|
||||
return {
|
||||
...c,
|
||||
lat: Number(lat),
|
||||
lon: Number(lon),
|
||||
label: String.fromCharCode(65 + (i % 26)) + (Math.floor(i / 26) > 0 ? Math.floor(i / 26) : '')
|
||||
};
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter((c): c is NonNullable<typeof c> => c !== null);
|
||||
}, [competitors]);
|
||||
|
||||
const locationIds = useMemo(() => {
|
||||
return validLocations.map(l => l.place_id).sort().join(',');
|
||||
}, [validLocations]);
|
||||
|
||||
// Memoize the polygons FeatureCollection to avoid creating a new object reference on every render.
|
||||
// Without this, RegionLayers re-pushes GeoJSON data to the map source on every single render.
|
||||
@ -584,7 +606,7 @@ export const CompetitorsMapView: React.FC<CompetitorsMapViewProps> = ({ competit
|
||||
e.stopPropagation();
|
||||
|
||||
if (e.key !== 'Escape' && nextLoc) {
|
||||
setSelectedLocation(nextLoc);
|
||||
handleSelectLocation(nextLoc, 'select');
|
||||
if (map.current) {
|
||||
map.current.flyTo({
|
||||
center: [nextLoc.lon, nextLoc.lat],
|
||||
@ -754,10 +776,10 @@ export const CompetitorsMapView: React.FC<CompetitorsMapViewProps> = ({ competit
|
||||
competitors={validLocations}
|
||||
isDarkStyle={mapStyleKey === 'dark'}
|
||||
onSelect={(loc) => {
|
||||
setSelectedLocation(loc);
|
||||
handleSelectLocation(loc);
|
||||
setInfoPanelOpen(false);
|
||||
}}
|
||||
selectedId={selectedLocation?.place_id}
|
||||
selectedId={selectedPlaceId}
|
||||
/>
|
||||
{isPosterMode && (
|
||||
<MapPosterOverlay
|
||||
@ -792,6 +814,7 @@ export const CompetitorsMapView: React.FC<CompetitorsMapViewProps> = ({ competit
|
||||
|
||||
{/* Footer: Status Info & Toggles */}
|
||||
<MapFooter
|
||||
map={map.current}
|
||||
currentCenterLabel={currentCenterLabel}
|
||||
mapInternals={mapInternals}
|
||||
isLocating={isLocating}
|
||||
@ -856,8 +879,8 @@ export const CompetitorsMapView: React.FC<CompetitorsMapViewProps> = ({ competit
|
||||
</MapFooter>
|
||||
</div>
|
||||
|
||||
{/* Resizable Handle and Property Pane */}
|
||||
{((features.enableLocationDetails && selectedLocation) || (features.enableInfoPanel && infoPanelOpen)) && (
|
||||
{/* Info Panel (kept as sidebar-capable for now if it's separate from LocationDetail) */}
|
||||
{features.enableInfoPanel && infoPanelOpen && (
|
||||
<>
|
||||
{/* Drag Handle */}
|
||||
<div
|
||||
@ -869,20 +892,13 @@ export const CompetitorsMapView: React.FC<CompetitorsMapViewProps> = ({ competit
|
||||
|
||||
{/* Property Pane */}
|
||||
<div className="hidden lg:block h-full bg-white dark:bg-gray-800 z-20 overflow-hidden relative" style={{ width: sidebarWidth }}>
|
||||
{features.enableLocationDetails && selectedLocation ? (
|
||||
<LocationDetailView
|
||||
competitor={selectedLocation as unknown as CompetitorFull}
|
||||
onClose={() => setSelectedLocation(null)}
|
||||
/>
|
||||
) : features.enableInfoPanel && infoPanelOpen ? (
|
||||
<InfoPanel
|
||||
isOpen={infoPanelOpen}
|
||||
onClose={() => setInfoPanelOpen(false)}
|
||||
lat={mapInternals?.lat}
|
||||
lng={mapInternals?.lng}
|
||||
locationName={currentCenterLabel}
|
||||
/>
|
||||
) : null}
|
||||
<InfoPanel
|
||||
isOpen={infoPanelOpen}
|
||||
onClose={handleCloseInfoPanel}
|
||||
lat={mapInternals?.lat}
|
||||
lng={mapInternals?.lng}
|
||||
locationName={currentCenterLabel}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@ -24,23 +24,28 @@ interface InfoPanelProps {
|
||||
locationName?: string | null;
|
||||
}
|
||||
|
||||
export const InfoPanel: React.FC<InfoPanelProps> = ({ isOpen, onClose, lat, lng, locationName }) => {
|
||||
export const InfoPanel = React.memo(({ isOpen, onClose, lat, lng, locationName }: InfoPanelProps) => {
|
||||
const [articles, setArticles] = useState<WikiResult[]>([]);
|
||||
const [llmInfo, setLlmInfo] = useState<LlmInfo | null>(null);
|
||||
const [loadingWiki, setLoadingWiki] = useState(false);
|
||||
const [loadingLlm, setLoadingLlm] = useState(false);
|
||||
const [errorWiki, setErrorWiki] = useState<string | null>(null);
|
||||
|
||||
// Stability check for coordinates to avoid fetching on microscopic jitter
|
||||
const stableLat = lat ? Math.round(lat * 1000) / 1000 : null;
|
||||
const stableLng = lng ? Math.round(lng * 1000) / 1000 : null;
|
||||
|
||||
// Fetch Wiki Data
|
||||
useEffect(() => {
|
||||
if (!isOpen || !lat || !lng) return;
|
||||
if (!isOpen || !stableLat || !stableLng) return;
|
||||
|
||||
const fetchArticles = async () => {
|
||||
console.log("InfoPanel [Fetch Triggered]", stableLat, stableLng);
|
||||
setLoadingWiki(true);
|
||||
setErrorWiki(null);
|
||||
try {
|
||||
const apiUrl = import.meta.env.VITE_SERVER_IMAGE_API_URL || '';
|
||||
const res = await fetch(`${apiUrl}/api/locations/wiki?lat=${lat}&lon=${lng}&limit=20`);
|
||||
const res = await fetch(`${apiUrl}/api/locations/wiki?lat=${stableLat}&lon=${stableLng}&limit=20`);
|
||||
if (res.ok) {
|
||||
const json = await res.json();
|
||||
setArticles(json.data || []);
|
||||
@ -57,14 +62,13 @@ export const InfoPanel: React.FC<InfoPanelProps> = ({ isOpen, onClose, lat, lng,
|
||||
|
||||
const timer = setTimeout(fetchArticles, 500);
|
||||
return () => clearTimeout(timer);
|
||||
}, [isOpen, lat, lng]);
|
||||
}, [isOpen, stableLat, stableLng]);
|
||||
|
||||
// Fetch LLM Info
|
||||
useEffect(() => {
|
||||
if (!isOpen || !locationName) return;
|
||||
|
||||
const fetchLlmInfo = async () => {
|
||||
return
|
||||
setLoadingLlm(true);
|
||||
setLlmInfo(null);
|
||||
try {
|
||||
@ -82,7 +86,7 @@ export const InfoPanel: React.FC<InfoPanelProps> = ({ isOpen, onClose, lat, lng,
|
||||
};
|
||||
|
||||
// Debounce slightly to avoid rapid updates if panning quickly changes the name
|
||||
const timer = setTimeout(fetchLlmInfo, 500);
|
||||
const timer = setTimeout(fetchLlmInfo, 1000); // 1s debounce for LLM
|
||||
return () => clearTimeout(timer);
|
||||
|
||||
}, [isOpen, locationName]);
|
||||
@ -222,4 +226,4 @@ export const InfoPanel: React.FC<InfoPanelProps> = ({ isOpen, onClose, lat, lng,
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
@ -10,7 +10,7 @@ import MarkdownRenderer from '../../components/MarkdownRenderer';
|
||||
import { T, translate } from '../../i18n';
|
||||
|
||||
// Extracted Presentation Component
|
||||
export const LocationDetailView: React.FC<{ competitor: CompetitorFull; onClose?: () => void; livePhotos?: any }> = ({ competitor, onClose, livePhotos }) => {
|
||||
export const LocationDetailView = React.memo(({ competitor, onClose, livePhotos }: { competitor: CompetitorFull; onClose?: () => void; livePhotos?: any }) => {
|
||||
const [lightboxOpen, setLightboxOpen] = useState(false);
|
||||
const [lightboxIndex, setLightboxIndex] = useState(0);
|
||||
const [activeTab, setActiveTab] = useState<'overview' | 'homepage' | 'debug'>('overview');
|
||||
@ -445,7 +445,7 @@ export const LocationDetailView: React.FC<{ competitor: CompetitorFull; onClose?
|
||||
/>
|
||||
</div >
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
const LocationDetail: React.FC = () => {
|
||||
const { place_id } = useParams<{ place_id: string }>();
|
||||
|
||||
@ -1,8 +1,10 @@
|
||||
import React from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import maplibregl from 'maplibre-gl';
|
||||
import { Map as MapIcon, Loader2, Locate, Maximize, Minimize, Focus, Sun, Moon } from 'lucide-react';
|
||||
import type { MapStyleKey } from './map-styles';
|
||||
|
||||
export interface MapFooterProps {
|
||||
map?: maplibregl.Map | null;
|
||||
currentCenterLabel?: string | null;
|
||||
mapInternals?: { lat: number; lng: number; zoom: number } | null;
|
||||
isLocating?: boolean;
|
||||
@ -16,6 +18,7 @@ export interface MapFooterProps {
|
||||
}
|
||||
|
||||
export function MapFooter({
|
||||
map,
|
||||
currentCenterLabel,
|
||||
mapInternals,
|
||||
isLocating = false,
|
||||
@ -27,6 +30,34 @@ export function MapFooter({
|
||||
onStyleChange,
|
||||
children
|
||||
}: MapFooterProps) {
|
||||
const [liveCoords, setLiveCoords] = useState<{ lat: number; lng: number; zoom: number } | null>(null);
|
||||
|
||||
// Attach localized move listener for live coordinate display
|
||||
// This avoids triggering parent re-renders while still providing live info
|
||||
useEffect(() => {
|
||||
if (!map) return;
|
||||
|
||||
const handleMove = () => {
|
||||
const center = map.getCenter();
|
||||
setLiveCoords({
|
||||
lat: center.lat,
|
||||
lng: center.lng,
|
||||
zoom: map.getZoom()
|
||||
});
|
||||
};
|
||||
|
||||
map.on('move', handleMove);
|
||||
// Initial sync
|
||||
handleMove();
|
||||
|
||||
return () => {
|
||||
map.off('move', handleMove);
|
||||
};
|
||||
}, [map]);
|
||||
|
||||
// Use liveCoords for high-frequency display, fallback to mapInternals prop for static initialization
|
||||
const displayCoords = liveCoords || mapInternals;
|
||||
|
||||
return (
|
||||
<div className="px-4 py-2 bg-gray-50 dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 text-xs flex justify-between items-center z-20 shrink-0 w-full overflow-hidden">
|
||||
<div className="flex items-center gap-4 text-gray-600 dark:text-gray-400 overflow-hidden text-ellipsis whitespace-nowrap min-w-0">
|
||||
@ -39,11 +70,11 @@ export function MapFooter({
|
||||
<span>Pan map to view details</span>
|
||||
)}
|
||||
|
||||
{mapInternals && (
|
||||
{displayCoords && (
|
||||
<div className="font-mono flex gap-4 opacity-80 pl-4 border-l border-gray-300 dark:border-gray-600">
|
||||
<span>Lat: {mapInternals.lat.toFixed(4)}</span>
|
||||
<span>Lng: {mapInternals.lng.toFixed(4)}</span>
|
||||
<span>Zoom: {mapInternals.zoom.toFixed(1)}</span>
|
||||
<span>Lat: {displayCoords.lat.toFixed(4)}</span>
|
||||
<span>Lng: {displayCoords.lng.toFixed(4)}</span>
|
||||
<span>Zoom: {displayCoords.zoom.toFixed(1)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -58,12 +58,14 @@ export function LiveSearchLayers({ map, liveAreas, liveRadii, liveNodes, isDarkS
|
||||
}
|
||||
|
||||
return () => {
|
||||
map.off('style.load', initSources);
|
||||
if (map && map.getStyle()) {
|
||||
map.off('style.load', initSources);
|
||||
}
|
||||
};
|
||||
}, [map]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!map || !map.getSource('live-areas')) return;
|
||||
if (!map || !map.getStyle() || !map.getSource('live-areas')) return;
|
||||
|
||||
const features = liveAreas.map(a => {
|
||||
// Assuming the boundary comes as geojson
|
||||
@ -78,14 +80,16 @@ export function LiveSearchLayers({ map, liveAreas, liveRadii, liveNodes, isDarkS
|
||||
return null;
|
||||
}).filter(Boolean);
|
||||
|
||||
(map.getSource('live-areas') as maplibregl.GeoJSONSource).setData({
|
||||
type: 'FeatureCollection',
|
||||
features: features as any
|
||||
});
|
||||
try {
|
||||
(map.getSource('live-areas') as maplibregl.GeoJSONSource).setData({
|
||||
type: 'FeatureCollection',
|
||||
features: features as any
|
||||
});
|
||||
} catch (e) {}
|
||||
}, [map, liveAreas]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!map || !map.getSource('live-nodes')) return;
|
||||
if (!map || !map.getStyle() || !map.getSource('live-nodes')) return;
|
||||
|
||||
const features = liveNodes.map(n => {
|
||||
// Resolve coordinates from multiple possible formats
|
||||
@ -116,10 +120,12 @@ export function LiveSearchLayers({ map, liveAreas, liveRadii, liveNodes, isDarkS
|
||||
return null;
|
||||
}).filter(Boolean);
|
||||
|
||||
(map.getSource('live-nodes') as maplibregl.GeoJSONSource).setData({
|
||||
type: 'FeatureCollection',
|
||||
features: features as any
|
||||
});
|
||||
try {
|
||||
(map.getSource('live-nodes') as maplibregl.GeoJSONSource).setData({
|
||||
type: 'FeatureCollection',
|
||||
features: features as any
|
||||
});
|
||||
} catch (e) {}
|
||||
}, [map, liveNodes]);
|
||||
|
||||
return null;
|
||||
|
||||
@ -41,7 +41,7 @@ export function LocationLayers({
|
||||
|
||||
const isDarkStyleRef = useRef(isDarkStyle);
|
||||
isDarkStyleRef.current = isDarkStyle;
|
||||
|
||||
|
||||
const onSelectRef = useRef(onSelect);
|
||||
onSelectRef.current = onSelect;
|
||||
|
||||
@ -157,13 +157,13 @@ export function LocationLayers({
|
||||
|
||||
const clusterId = features[0].properties.cluster_id;
|
||||
const source = map.getSource('locations-source') as maplibregl.GeoJSONSource;
|
||||
|
||||
|
||||
try {
|
||||
// MapLibre v5+ getClusterExpansionZoom returns a Promise and doesn't take a callback
|
||||
const zoom = await (source as any).getClusterExpansionZoom(clusterId);
|
||||
map.easeTo({
|
||||
center: (features[0].geometry as any).coordinates,
|
||||
zoom: zoom
|
||||
//zoom: zoom
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Error expanding cluster', err);
|
||||
@ -200,21 +200,23 @@ export function LocationLayers({
|
||||
map.on('style.load', setupLayers);
|
||||
|
||||
return () => {
|
||||
map.off('styledata', setupLayers);
|
||||
map.off('style.load', setupLayers);
|
||||
if (map.getSource('locations-source')) {
|
||||
if (map.getLayer('clusters')) map.removeLayer('clusters');
|
||||
if (map.getLayer('cluster-count')) map.removeLayer('cluster-count');
|
||||
if (map.getLayer('unclustered-point-circle')) map.removeLayer('unclustered-point-circle');
|
||||
if (map.getLayer('unclustered-point-label')) map.removeLayer('unclustered-point-label');
|
||||
map.removeSource('locations-source');
|
||||
if (map && map.getStyle()) {
|
||||
map.off('styledata', setupLayers);
|
||||
map.off('style.load', setupLayers);
|
||||
if (map.getSource('locations-source')) {
|
||||
if (map.getLayer('clusters')) map.removeLayer('clusters');
|
||||
if (map.getLayer('cluster-count')) map.removeLayer('cluster-count');
|
||||
if (map.getLayer('unclustered-point-circle')) map.removeLayer('unclustered-point-circle');
|
||||
if (map.getLayer('unclustered-point-label')) map.removeLayer('unclustered-point-label');
|
||||
map.removeSource('locations-source');
|
||||
}
|
||||
}
|
||||
};
|
||||
}, [map, competitors]);
|
||||
|
||||
// Update data when it changes
|
||||
useEffect(() => {
|
||||
if (!map) return;
|
||||
if (!map || !map.getStyle()) return;
|
||||
const source = map.getSource('locations-source') as maplibregl.GeoJSONSource;
|
||||
if (source) {
|
||||
source.setData(data);
|
||||
@ -223,7 +225,7 @@ export function LocationLayers({
|
||||
|
||||
// Update style-dependent properties
|
||||
useEffect(() => {
|
||||
if (!map) return;
|
||||
if (!map || !map.getStyle()) return;
|
||||
if (map.getLayer('unclustered-point-circle')) {
|
||||
map.setPaintProperty('unclustered-point-circle', 'circle-color', [
|
||||
'case',
|
||||
|
||||
@ -131,9 +131,9 @@ export function RegionLayers({
|
||||
map.on('style.load', setupMapLayers);
|
||||
|
||||
return () => {
|
||||
map.off('styledata', setupMapLayers);
|
||||
map.off('style.load', setupMapLayers);
|
||||
if (map.getStyle()) {
|
||||
if (map && map.getStyle()) {
|
||||
map.off('styledata', setupMapLayers);
|
||||
map.off('style.load', setupMapLayers);
|
||||
if (map.getLayer('polygons-fill')) map.removeLayer('polygons-fill');
|
||||
if (map.getLayer('polygons-line')) map.removeLayer('polygons-line');
|
||||
if (map.getLayer('bboxes-fill')) map.removeLayer('bboxes-fill');
|
||||
@ -170,7 +170,7 @@ export function RegionLayers({
|
||||
|
||||
// Update GHS Centers Source
|
||||
useEffect(() => {
|
||||
if (!map) return;
|
||||
if (!map || !map.getStyle()) return;
|
||||
try {
|
||||
if (showCenters && pickerPolygons && pickerPolygons.length > 0) {
|
||||
const features: any[] = [];
|
||||
@ -226,7 +226,7 @@ export function RegionLayers({
|
||||
|
||||
// Update Bboxes and Polygons Features
|
||||
useEffect(() => {
|
||||
if (!map) return;
|
||||
if (!map || !map.getStyle()) return;
|
||||
if ((window as any).__MAP_PERF_DEBUG__) {
|
||||
const fc = polygonsFeatureCollection as any;
|
||||
console.log(`[MapPerf] RegionLayers setData() called — polygons features: ${fc?.features?.length ?? 0}`);
|
||||
|
||||
@ -204,13 +204,13 @@ export function SimulatorLayers({ map, isDarkStyle, simulatorData, simulatorPath
|
||||
map.on('style.load', setupMapLayers);
|
||||
|
||||
return () => {
|
||||
map.off('styledata', setupMapLayers);
|
||||
map.off('style.load', setupMapLayers);
|
||||
if (scannerMarkerRef.current) {
|
||||
scannerMarkerRef.current.remove();
|
||||
scannerMarkerRef.current = null;
|
||||
}
|
||||
if (map.getStyle()) {
|
||||
if (map && map.getStyle()) {
|
||||
map.off('styledata', setupMapLayers);
|
||||
map.off('style.load', setupMapLayers);
|
||||
if (scannerMarkerRef.current) {
|
||||
scannerMarkerRef.current.remove();
|
||||
scannerMarkerRef.current = null;
|
||||
}
|
||||
if (map.getLayer('simulator-grid-fill')) map.removeLayer('simulator-grid-fill');
|
||||
if (map.getLayer('simulator-path-line')) map.removeLayer('simulator-path-line');
|
||||
if (map.getSource('simulator-grid')) map.removeSource('simulator-grid');
|
||||
@ -236,7 +236,7 @@ export function SimulatorLayers({ map, isDarkStyle, simulatorData, simulatorPath
|
||||
|
||||
// Update grid + path data
|
||||
useEffect(() => {
|
||||
if (!map) return;
|
||||
if (!map || !map.getStyle()) return;
|
||||
try {
|
||||
if (map.getSource('simulator-grid')) (map.getSource('simulator-grid') as maplibregl.GeoJSONSource).setData(simulatorData || emptyFc as any);
|
||||
if (map.getSource('simulator-path')) (map.getSource('simulator-path') as maplibregl.GeoJSONSource).setData(simulatorPath || emptyFc as any);
|
||||
@ -247,7 +247,7 @@ export function SimulatorLayers({ map, isDarkStyle, simulatorData, simulatorPath
|
||||
|
||||
// Update pacman DOM marker position + rotation
|
||||
useEffect(() => {
|
||||
if (!map) return;
|
||||
if (!map || !map.getStyle()) return;
|
||||
|
||||
const fc = simulatorScanner as any;
|
||||
const feature = fc?.features?.[0];
|
||||
|
||||
@ -392,10 +392,18 @@ export function GadmPicker({ map, active, onClose, onSelectionChange, className
|
||||
async function handleSelectRegion(region: any, forceLevel?: number, isMulti: boolean = false) {
|
||||
const { levelOption, resolutionOption, enrich } = stateRef.current;
|
||||
const gadmLevel = region.raw?.level ?? (region.level !== undefined ? region.level : levelOption);
|
||||
const gid = region.gid || region[`GID_${gadmLevel}`] || region.GID_0;
|
||||
|
||||
// Robust GID resolution: prioritization
|
||||
// 1. region.gid (if explicitly passed)
|
||||
// 2. region.GID_{gadmLevel} (specific level match)
|
||||
// 3. Fallback to GID_0 ONLY if gadmLevel is 0, otherwise it's a failure (don't snap to country)
|
||||
const gid = region.gid || region[`GID_${gadmLevel}`] || (gadmLevel === 0 ? region.GID_0 : undefined);
|
||||
const name = region.gadmName || region.name || region[`NAME_${gadmLevel}`] || region.NAME_0;
|
||||
|
||||
if (!gid) return;
|
||||
if (!gid) {
|
||||
console.warn('GadmPicker: Could not resolve gid for region', region, 'at level', gadmLevel);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isMulti) {
|
||||
if (isFetchingSelectionRef.current) return;
|
||||
@ -589,9 +597,25 @@ export function GadmPicker({ map, active, onClose, onSelectionChange, className
|
||||
|
||||
// Import initialRegions prop (same logic as handleImportJson)
|
||||
useEffect(() => {
|
||||
if (!initialRegions || initialRegions.length === 0) return;
|
||||
if (!initialRegions || initialRegions.length === 0) {
|
||||
if (active && importedInitialRef.current !== null) {
|
||||
// If it became empty, we SHOULD clear
|
||||
handleClearAll();
|
||||
importedInitialRef.current = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const key = initialRegions.map(r => r.gid).sort().join(',');
|
||||
if (importedInitialRef.current === key) return;
|
||||
|
||||
// Check if current selectedRegions already contains exactly these gids
|
||||
const currentGids = selectedRegions.map(r => r.gid).sort().join(',');
|
||||
if (currentGids === key && importedInitialRef.current !== null) {
|
||||
importedInitialRef.current = key;
|
||||
return;
|
||||
}
|
||||
|
||||
importedInitialRef.current = key;
|
||||
|
||||
// Full reset, same as import handler
|
||||
@ -603,7 +627,9 @@ export function GadmPicker({ map, active, onClose, onSelectionChange, className
|
||||
setGeojsons({});
|
||||
|
||||
for (const item of initialRegions) {
|
||||
const raw = (item as any).raw ? { ...((item as any).raw), level: item.level } : { gid: item.gid, gadmName: item.name, level: item.level };
|
||||
const raw = (item as any).raw
|
||||
? { ...((item as any).raw), gid: item.gid, level: item.level }
|
||||
: { gid: item.gid, gadmName: item.name, level: item.level };
|
||||
handleSelectRegion(raw, item.level, true);
|
||||
}
|
||||
}, [initialRegions]);
|
||||
|
||||
@ -12,7 +12,7 @@ export interface GadmRegionCollectorProps {
|
||||
}
|
||||
export function GadmRegionCollector({
|
||||
resolutions = {},
|
||||
onChangeResolution = () => {}
|
||||
onChangeResolution = () => { }
|
||||
}: GadmRegionCollectorProps) {
|
||||
const {
|
||||
roots, setRoots,
|
||||
@ -26,7 +26,7 @@ export function GadmRegionCollector({
|
||||
const [suggestions, setSuggestions] = useState<any[]>([]);
|
||||
const [showSuggestions, setShowSuggestions] = useState(false);
|
||||
const [loadingSuggestions, setLoadingSuggestions] = useState(false);
|
||||
|
||||
|
||||
const suggestionsWrapRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [isLocating, setIsLocating] = useState(false);
|
||||
@ -95,25 +95,27 @@ export function GadmRegionCollector({
|
||||
if (data && !data.error && data.latitude && data.longitude) {
|
||||
const hierarchy = await fetchRegionHierarchy(data.latitude, data.longitude);
|
||||
if (hierarchy && hierarchy.length > 0) {
|
||||
const pathGids = hierarchy.map((d: any) => d.gid);
|
||||
|
||||
const l0 = hierarchy[0];
|
||||
|
||||
const rootNode: GadmNode = {
|
||||
name: l0.gadmName || l0.name || l0.gid,
|
||||
gid: l0.gid,
|
||||
level: l0.level,
|
||||
hasChildren: l0.level < 5,
|
||||
data: l0
|
||||
data: l0
|
||||
};
|
||||
|
||||
console.log('root node', rootNode)
|
||||
|
||||
setRoots(prev => {
|
||||
if (prev.find(p => p.gid === rootNode.gid)) return prev;
|
||||
return [...prev, rootNode];
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
treeApiRef.current?.expandPath(pathGids);
|
||||
// treeApiRef.current?.expandPath(pathGids);
|
||||
}, 100);
|
||||
|
||||
|
||||
const lastNode = hierarchy[hierarchy.length - 1];
|
||||
setRegionQuery(lastNode.gadmName || lastNode.name || lastNode.gid);
|
||||
}
|
||||
@ -135,12 +137,12 @@ export function GadmRegionCollector({
|
||||
// Ensure tree path expands to existing selections (e.g. when returning from back-navigation)
|
||||
useEffect(() => {
|
||||
if (selectedNodes.length === 0 || roots.length === 0) return;
|
||||
|
||||
|
||||
let expandedAnything = false;
|
||||
for (const node of selectedNodes) {
|
||||
const data = node.data;
|
||||
if (!data) continue;
|
||||
|
||||
|
||||
const pathGids: string[] = [];
|
||||
for (let i = 0; i <= node.level; i++) {
|
||||
if (data[`GID_${i}`]) {
|
||||
@ -156,7 +158,7 @@ export function GadmRegionCollector({
|
||||
}
|
||||
}, [roots.length]); // Relies on roots being loaded
|
||||
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (!regionQuery || regionQuery.length < 2) { setSuggestions([]); return; }
|
||||
const id = setTimeout(async () => {
|
||||
@ -212,7 +214,7 @@ export function GadmRegionCollector({
|
||||
{loadingSuggestions ? (
|
||||
<Loader2 className="w-6 h-6 animate-spin text-indigo-500" />
|
||||
) : (
|
||||
<button
|
||||
<button
|
||||
type="button"
|
||||
onClick={performLocate}
|
||||
disabled={isLocating}
|
||||
@ -248,7 +250,7 @@ export function GadmRegionCollector({
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex-1 min-h-0 border rounded-xl overflow-hidden bg-white dark:bg-gray-900 border-gray-200 dark:border-gray-700 flex flex-col">
|
||||
{roots.length === 0 && selectedNodes.length === 0 ? (
|
||||
<div className="flex-1 flex flex-col items-center justify-center text-gray-400">
|
||||
@ -259,48 +261,57 @@ export function GadmRegionCollector({
|
||||
<div className="flex flex-col h-full overflow-hidden">
|
||||
{roots.length > 0 && (
|
||||
<div className="flex-1 min-h-0 border-b border-gray-200 dark:border-gray-700">
|
||||
<GadmTreePicker
|
||||
<GadmTreePicker
|
||||
apiRef={treeApiRef}
|
||||
data={roots}
|
||||
data={roots}
|
||||
expandMapExternal={expandMap}
|
||||
setExpandMapExternal={setExpandMap}
|
||||
initialSelectedIds={selectedNodes.map(n => n.gid)}
|
||||
fetchChildren={async (node) => {
|
||||
const children = await fetchRegionChildren(node.gid, node.level + 1);
|
||||
const seen = new Set();
|
||||
return children.map((c: any) => {
|
||||
const actualName = c.name || c.gadmName || c[`NAME_${node.level + 1}`] || c.gid || 'Unknown';
|
||||
return {
|
||||
name: actualName,
|
||||
gid: c.gid || c[`GID_${node.level + 1}`] || c.id || Math.random().toString(),
|
||||
level: node.level + 1,
|
||||
hasChildren: node.level + 1 < 5,
|
||||
label: c.LABEL || undefined,
|
||||
data: c
|
||||
};
|
||||
}).filter((c: any) => {
|
||||
if (seen.has(c.gid)) return false;
|
||||
seen.add(c.gid);
|
||||
const display = c.label || c.name;
|
||||
if (display.toLowerCase() === 'unknown') return false;
|
||||
return true;
|
||||
});
|
||||
}}
|
||||
onActivate={(node) => {
|
||||
fetchChildren={async (node) => {
|
||||
const children = await fetchRegionChildren(node.gid, node.level + 1);
|
||||
const seen = new Set();
|
||||
return children.map((c: any) => {
|
||||
const actualName = c.name || c.gadmName || c[`NAME_${node.level + 1}`] || c.gid || 'Unknown';
|
||||
return {
|
||||
name: actualName,
|
||||
gid: c.gid || c[`GID_${node.level + 1}`] || c.id || Math.random().toString(),
|
||||
level: node.level + 1,
|
||||
hasChildren: node.level + 1 < 5,
|
||||
label: c.LABEL || undefined,
|
||||
data: c
|
||||
};
|
||||
}).filter((c: any) => {
|
||||
if (seen.has(c.gid)) return false;
|
||||
seen.add(c.gid);
|
||||
const display = c.label || c.name;
|
||||
if (display.toLowerCase() === 'unknown') return false;
|
||||
return true;
|
||||
});
|
||||
}}
|
||||
onActivate={(node) => {
|
||||
// Optional: trigger onSelect when activated, or just keep it distinct
|
||||
}}
|
||||
onSelectionChange={(treeNodes, treeSelectedIds) => {
|
||||
const missingIds = Array.from(treeSelectedIds).filter(id => !treeNodes.find(n => n.gid === id));
|
||||
const missingNodes = selectedNodes.filter(n => missingIds.includes(n.gid));
|
||||
setSelectedNodes([...missingNodes, ...treeNodes]);
|
||||
setSelectedNodes(prev => {
|
||||
const missingIds = Array.from(treeSelectedIds).filter(id => !treeNodes.find(n => n.gid === id));
|
||||
const missingNodes = prev.filter(n => missingIds.includes(n.gid));
|
||||
|
||||
const nextNodes = [...missingNodes, ...treeNodes];
|
||||
// Simple equality check to prevent infinite re-renders
|
||||
if (nextNodes.length === prev.length && nextNodes.every(n => prev.some(p => p.gid === n.gid))) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
return nextNodes;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedNodes.length > 0 && (
|
||||
<div className="shrink-0 max-h-[50%] overflow-y-auto p-4 bg-gray-50/30 dark:bg-gray-800/10">
|
||||
<GadmPickedRegions
|
||||
<GadmPickedRegions
|
||||
selectedNodes={selectedNodes}
|
||||
resolutions={resolutions}
|
||||
onRemove={handleRemovePicked}
|
||||
|
||||
@ -189,7 +189,7 @@ export const GadmTreePicker = React.forwardRef<HTMLDivElement, GadmTreePickerPro
|
||||
|
||||
// If it doesn't have parent GID_0 but is nested, walk up to construct the hierarchy
|
||||
if (!node.data?.GID_0 && r.depth > 0) {
|
||||
const syntheticData = { ...node.data };
|
||||
const syntheticData = { ...node.data, gid: node.gid, level: node.level };
|
||||
let curr: TreeRow | undefined = r;
|
||||
while (curr) {
|
||||
syntheticData[`GID_${curr.node.level}`] = curr.id;
|
||||
@ -200,9 +200,9 @@ export const GadmTreePicker = React.forwardRef<HTMLDivElement, GadmTreePickerPro
|
||||
return { ...node, data: syntheticData };
|
||||
}
|
||||
|
||||
// Always ensure GID of its own level is populated
|
||||
if (!node.data[`GID_${node.level}`]) {
|
||||
const syntheticData = { ...node.data };
|
||||
// Always ensure GID of its own level and explicit gid property are populated
|
||||
if (!node.data[`GID_${node.level}`] || !node.data.gid) {
|
||||
const syntheticData = { ...node.data, gid: node.gid, level: node.level };
|
||||
syntheticData[`GID_${node.level}`] = node.gid;
|
||||
syntheticData[`NAME_${node.level}`] = node.name;
|
||||
return { ...node, data: syntheticData };
|
||||
@ -211,7 +211,8 @@ export const GadmTreePicker = React.forwardRef<HTMLDivElement, GadmTreePickerPro
|
||||
return node;
|
||||
});
|
||||
onSelectionChangeRef.current(selectedNodes, selectedIds);
|
||||
}, [selectedIds]);
|
||||
console.log('selected nodes updated', selectedNodes.length);
|
||||
}, [selectedIds, rows, data.length]);
|
||||
|
||||
const toggleExpand = useCallback(async (row: TreeRow) => {
|
||||
if (!row.node.hasChildren || !fetchChildren) return;
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { useNavigate, useLocation, useSearchParams } from 'react-router-dom';
|
||||
import { PlusCircle, PanelLeftClose, PanelLeftOpen } from 'lucide-react';
|
||||
import { OngoingSearches } from './OngoingSearches';
|
||||
import { GridSearchWizard } from './GridSearchWizard';
|
||||
@ -8,8 +8,22 @@ import { useRestoredSearch, RestoredSearchProvider } from './RestoredSearchConte
|
||||
import { useAppStore } from '@/store/appStore';
|
||||
|
||||
function GridSearchLayout({ selectedJobId, setSelectedJobId }: { selectedJobId: string | null, setSelectedJobId: (id: string | null, forceView?: string) => void }) {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [sidebarWidth, setSidebarWidth] = useState(320);
|
||||
const [isSidebarOpen, setIsSidebarOpen] = useState(true);
|
||||
|
||||
const isSidebarOpen = searchParams.get('hasSidebar') !== 'false';
|
||||
const setIsSidebarOpen = useCallback((open: boolean) => {
|
||||
setSearchParams(prev => {
|
||||
const next = new URLSearchParams(prev);
|
||||
if (open) {
|
||||
next.delete('hasSidebar');
|
||||
} else {
|
||||
next.set('hasSidebar', 'false');
|
||||
}
|
||||
return next;
|
||||
}, { replace: true });
|
||||
}, [setSearchParams]);
|
||||
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
const [initialWizardSettings, setInitialWizardSettings] = useState<any>(null);
|
||||
const [wizardSessionKey, setWizardSessionKey] = useState(0);
|
||||
@ -80,6 +94,8 @@ function GridSearchLayout({ selectedJobId, setSelectedJobId }: { selectedJobId:
|
||||
|
||||
|
||||
|
||||
const handleToggleSidebar = useCallback(() => setIsSidebarOpen(!isSidebarOpen), [isSidebarOpen, setIsSidebarOpen]);
|
||||
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-64px)] w-full relative">
|
||||
|
||||
@ -137,7 +153,7 @@ function GridSearchLayout({ selectedJobId, setSelectedJobId }: { selectedJobId:
|
||||
</button>
|
||||
)}
|
||||
{selectedJobId ? (
|
||||
<JobViewer key={selectedJobId} jobId={selectedJobId} isSidebarOpen={isSidebarOpen} onToggleSidebar={() => setIsSidebarOpen(!isSidebarOpen)} />
|
||||
<JobViewer key={selectedJobId} jobId={selectedJobId} isSidebarOpen={isSidebarOpen} onToggleSidebar={handleToggleSidebar} />
|
||||
) : (
|
||||
<GridSearchWizard key={wizardSessionKey} initialSettings={initialWizardSettings} onJobSubmitted={(id) => setSelectedJobId(id, 'map')} setIsSidebarOpen={setIsSidebarOpen} />
|
||||
)}
|
||||
|
||||
@ -14,6 +14,8 @@ import { POSTER_THEMES } from '../utils/poster-themes';
|
||||
import { type CompetitorFull } from '@polymech/shared';
|
||||
import { type LogEntry } from '@/contexts/LogContext';
|
||||
import ChatLogBrowser from '@/components/ChatLogBrowser';
|
||||
import { GripVertical } from 'lucide-react';
|
||||
import { LocationDetailView } from '../LocationDetail';
|
||||
|
||||
type ViewMode = 'grid' | 'thumb' | 'map' | 'meta' | 'report' | 'log' | 'poster';
|
||||
|
||||
@ -45,7 +47,7 @@ const MOCK_SETTINGS = {
|
||||
auto_enrich: false
|
||||
};
|
||||
|
||||
export function GridSearchResults({ jobId, competitors, excludedTypes, updateExcludedTypes, liveAreas, liveRadii, liveNodes, liveScanner, stats, streaming, statusMessage, sseLogs, onExpandSubmitted, isOwner = false, isPublic, onTogglePublic, isSidebarOpen, onToggleSidebar }: GridSearchResultsProps) {
|
||||
export const GridSearchResults = React.memo(({ jobId, competitors, excludedTypes, updateExcludedTypes, liveAreas, liveRadii, liveNodes, liveScanner, stats, streaming, statusMessage, sseLogs, onExpandSubmitted, isOwner = false, isPublic, onTogglePublic, isSidebarOpen, onToggleSidebar }: GridSearchResultsProps) => {
|
||||
const filteredCompetitors = React.useMemo(() => {
|
||||
if (!excludedTypes || excludedTypes.length === 0) return competitors;
|
||||
const excludedSet = new Set(excludedTypes.map(t => t.toLowerCase()));
|
||||
@ -106,6 +108,49 @@ export function GridSearchResults({ jobId, competitors, excludedTypes, updateExc
|
||||
|
||||
const [posterTheme, setPosterTheme] = useState(() => searchParams.get('theme') || 'terracotta');
|
||||
|
||||
// Selection and Sidebar Panel state
|
||||
const [selectedPlaceId, setSelectedPlaceId] = useState<string | null>(null);
|
||||
const [showDetails, setShowDetails] = useState(false);
|
||||
const [sidebarWidth, setSidebarWidth] = useState(400);
|
||||
|
||||
const isResizingRef = useRef(false);
|
||||
const startResizing = useCallback(() => {
|
||||
isResizingRef.current = true;
|
||||
const onMove = (e: MouseEvent) => {
|
||||
if (!isResizingRef.current) return;
|
||||
// Calculate width from the right edge of the screen
|
||||
const newWidth = window.innerWidth - e.clientX;
|
||||
if (newWidth > 300 && newWidth < 800) setSidebarWidth(newWidth);
|
||||
};
|
||||
const onUp = () => {
|
||||
isResizingRef.current = false;
|
||||
window.removeEventListener('mousemove', onMove);
|
||||
window.removeEventListener('mouseup', onUp);
|
||||
};
|
||||
window.addEventListener('mousemove', onMove);
|
||||
window.addEventListener('mouseup', onUp);
|
||||
}, []);
|
||||
|
||||
const activeCompetitor = React.useMemo(() => {
|
||||
if (!selectedPlaceId) return null;
|
||||
return competitors.find(c => String(c.place_id || (c as any).placeId || (c as any).id) === selectedPlaceId);
|
||||
}, [selectedPlaceId, competitors]);
|
||||
|
||||
const handleSelectPlace = useCallback((id: string | null, behavior: 'select' | 'open' | 'toggle' = 'select') => {
|
||||
const isSame = id === selectedPlaceId;
|
||||
setSelectedPlaceId(id);
|
||||
|
||||
if (behavior === 'open') {
|
||||
setShowDetails(true);
|
||||
} else if (behavior === 'toggle') {
|
||||
if (isSame && showDetails) {
|
||||
setShowDetails(false);
|
||||
} else {
|
||||
setShowDetails(true);
|
||||
}
|
||||
}
|
||||
}, [selectedPlaceId, showDetails]);
|
||||
|
||||
const handleThemeChange = useCallback((e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
const theme = e.target.value;
|
||||
setPosterTheme(theme);
|
||||
@ -137,22 +182,27 @@ export function GridSearchResults({ jobId, competitors, excludedTypes, updateExc
|
||||
if ((window as any).__MAP_PERF_DEBUG__) console.log('[MapPerf] onMapMove debounce reset (prevented URL update)');
|
||||
clearTimeout(mapMoveTimerRef.current);
|
||||
}
|
||||
|
||||
mapMoveTimerRef.current = setTimeout(() => {
|
||||
if ((window as any).__MAP_PERF_DEBUG__) console.log('[MapPerf] onMapMove → setSearchParams committed (300ms debounce)');
|
||||
setSearchParams(prev => {
|
||||
const newParams = new URLSearchParams(prev);
|
||||
newParams.set('mapLat', state.lat.toFixed(6));
|
||||
newParams.set('mapLng', state.lng.toFixed(6));
|
||||
newParams.set('mapZoom', state.zoom.toFixed(2));
|
||||
if (state.pitch !== undefined) newParams.set('mapPitch', state.pitch.toFixed(0));
|
||||
if (state.bearing !== undefined) newParams.set('mapBearing', state.bearing.toFixed(0));
|
||||
if (prev.get('view') !== 'poster') {
|
||||
newParams.set('view', 'map');
|
||||
}
|
||||
return newParams;
|
||||
}, { replace: true });
|
||||
if ((window as any).__MAP_PERF_DEBUG__) console.log('[MapPerf] onMapMove → window.history.replaceState committed (300ms debounce)');
|
||||
|
||||
// USE MANUAL HISTORY API: Updates the URL bar silently WITHOUT triggering React re-renders/useSearchParams
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set('mapLat', state.lat.toFixed(6));
|
||||
url.searchParams.set('mapLng', state.lng.toFixed(6));
|
||||
url.searchParams.set('mapZoom', state.zoom.toFixed(2));
|
||||
if (state.pitch !== undefined) url.searchParams.set('mapPitch', state.pitch.toFixed(0));
|
||||
if (state.bearing !== undefined) url.searchParams.set('mapBearing', state.bearing.toFixed(0));
|
||||
|
||||
// Maintain view state if it's not locked to 'poster'
|
||||
if (url.searchParams.get('view') !== 'poster') {
|
||||
url.searchParams.set('view', 'map');
|
||||
}
|
||||
|
||||
// Silently update the browser history
|
||||
window.history.replaceState(null, '', url.pathname + url.search);
|
||||
}, 300);
|
||||
}, [setSearchParams]);
|
||||
}, []);
|
||||
|
||||
const settings = { ...MOCK_SETTINGS, excluded_types: excludedTypes };
|
||||
|
||||
@ -307,88 +357,121 @@ export function GridSearchResults({ jobId, competitors, excludedTypes, updateExc
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="gridsearch-scroll-area" className="flex-1 min-h-0 flex flex-col overflow-auto bg-white dark:bg-gray-900 rounded-b-2xl">
|
||||
{viewMode === 'grid' && (
|
||||
<CompetitorsGridView
|
||||
competitors={filteredCompetitors}
|
||||
loading={false}
|
||||
settings={settings}
|
||||
updateExcludedTypes={updateExcludedTypes || (async () => { })}
|
||||
/>
|
||||
)}
|
||||
|
||||
{viewMode === 'thumb' && (
|
||||
<CompetitorsThumbView
|
||||
competitors={filteredCompetitors}
|
||||
filters={[]}
|
||||
toggleFilter={() => { }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{(viewMode === 'map' || viewMode === 'poster') && (
|
||||
<CompetitorsMapView
|
||||
preset="SearchView"
|
||||
competitors={filteredCompetitors}
|
||||
isPosterMode={viewMode === 'poster'}
|
||||
posterTheme={posterTheme}
|
||||
setPosterTheme={setPosterTheme}
|
||||
onMapCenterUpdate={handleMapCenterUpdate}
|
||||
enrich={dummyEnrich}
|
||||
isEnriching={false}
|
||||
enrichmentProgress={null}
|
||||
liveAreas={liveAreas}
|
||||
liveRadii={liveRadii}
|
||||
liveNodes={liveNodes}
|
||||
liveScanner={liveScanner}
|
||||
initialGadmRegions={restoredGadmAreas}
|
||||
initialSimulatorSettings={restoredState?.run?.request?.guided?.settings}
|
||||
initialCenter={(() => {
|
||||
const lat = parseFloat(searchParams.get('mapLat') || '');
|
||||
const lng = parseFloat(searchParams.get('mapLng') || '');
|
||||
return (!isNaN(lat) && !isNaN(lng)) ? { lat, lng } : undefined;
|
||||
})()}
|
||||
initialZoom={(() => {
|
||||
const zoom = parseFloat(searchParams.get('mapZoom') || '');
|
||||
return !isNaN(zoom) ? zoom : undefined;
|
||||
})()}
|
||||
initialPitch={(() => {
|
||||
const p = parseFloat(searchParams.get('mapPitch') || '');
|
||||
return !isNaN(p) ? p : undefined;
|
||||
})()}
|
||||
initialBearing={(() => {
|
||||
const b = parseFloat(searchParams.get('mapBearing') || '');
|
||||
return !isNaN(b) ? b : undefined;
|
||||
})()}
|
||||
onMapMove={handleMapMove}
|
||||
onClosePosterMode={() => handleViewChange('map')}
|
||||
onRegionsChange={setPickedRegions}
|
||||
simulatorSettings={mapSimSettings}
|
||||
onSimulatorSettingsChange={setMapSimSettings}
|
||||
/>
|
||||
)}
|
||||
|
||||
{viewMode === 'meta' && (
|
||||
<CompetitorsMetaView
|
||||
competitors={filteredCompetitors}
|
||||
settings={settings}
|
||||
updateExcludedTypes={updateExcludedTypes || (async () => { })}
|
||||
/>
|
||||
)}
|
||||
|
||||
{viewMode === 'report' && (
|
||||
<CompetitorsReportView jobId={jobId} />
|
||||
)}
|
||||
|
||||
{viewMode === 'log' && import.meta.env.DEV && sseLogs && (
|
||||
<div className="h-full">
|
||||
<ChatLogBrowser
|
||||
logs={sseLogs}
|
||||
clearLogs={() => { }}
|
||||
title="SSE Stream Events"
|
||||
<div className="flex-1 flex flex-row min-h-0 overflow-hidden">
|
||||
<div id="gridsearch-scroll-area" className="flex-1 min-h-0 flex flex-col overflow-auto bg-white dark:bg-gray-900 rounded-b-2xl">
|
||||
{viewMode === 'grid' && (
|
||||
<CompetitorsGridView
|
||||
competitors={filteredCompetitors}
|
||||
loading={false}
|
||||
settings={settings}
|
||||
updateExcludedTypes={updateExcludedTypes || (async () => { })}
|
||||
selectedPlaceId={selectedPlaceId}
|
||||
onSelectPlace={handleSelectPlace}
|
||||
isOwner={isOwner}
|
||||
isPublic={isPublic}
|
||||
preset={(isPublic && !isOwner) || showDetails ? 'min' : 'full'}
|
||||
/>
|
||||
)}
|
||||
|
||||
{viewMode === 'thumb' && (
|
||||
<CompetitorsThumbView
|
||||
competitors={filteredCompetitors}
|
||||
filters={[]}
|
||||
toggleFilter={() => { }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{(viewMode === 'map' || viewMode === 'poster') && (
|
||||
<CompetitorsMapView
|
||||
preset="SearchView"
|
||||
competitors={filteredCompetitors}
|
||||
isPosterMode={viewMode === 'poster'}
|
||||
posterTheme={posterTheme}
|
||||
setPosterTheme={setPosterTheme}
|
||||
onMapCenterUpdate={handleMapCenterUpdate}
|
||||
enrich={dummyEnrich}
|
||||
isEnriching={false}
|
||||
enrichmentProgress={null}
|
||||
liveAreas={liveAreas}
|
||||
liveRadii={liveRadii}
|
||||
liveNodes={liveNodes}
|
||||
liveScanner={liveScanner}
|
||||
initialGadmRegions={restoredGadmAreas}
|
||||
initialSimulatorSettings={restoredState?.run?.request?.guided?.settings}
|
||||
initialCenter={(() => {
|
||||
const lat = parseFloat(searchParams.get('mapLat') || '');
|
||||
const lng = parseFloat(searchParams.get('mapLng') || '');
|
||||
return (!isNaN(lat) && !isNaN(lng)) ? { lat, lng } : undefined;
|
||||
})()}
|
||||
initialZoom={(() => {
|
||||
const zoom = parseFloat(searchParams.get('mapZoom') || '');
|
||||
return !isNaN(zoom) ? zoom : undefined;
|
||||
})()}
|
||||
initialPitch={(() => {
|
||||
const p = parseFloat(searchParams.get('mapPitch') || '');
|
||||
return !isNaN(p) ? p : undefined;
|
||||
})()}
|
||||
initialBearing={(() => {
|
||||
const b = parseFloat(searchParams.get('mapBearing') || '');
|
||||
return !isNaN(b) ? b : undefined;
|
||||
})()}
|
||||
onMapMove={handleMapMove}
|
||||
onClosePosterMode={() => handleViewChange('map')}
|
||||
onRegionsChange={setPickedRegions}
|
||||
simulatorSettings={mapSimSettings}
|
||||
onSimulatorSettingsChange={setMapSimSettings}
|
||||
selectedPlaceId={selectedPlaceId}
|
||||
onSelectPlace={handleSelectPlace}
|
||||
/>
|
||||
)}
|
||||
|
||||
{viewMode === 'meta' && (
|
||||
<CompetitorsMetaView
|
||||
competitors={filteredCompetitors}
|
||||
settings={settings}
|
||||
updateExcludedTypes={updateExcludedTypes || (async () => { })}
|
||||
/>
|
||||
)}
|
||||
|
||||
{viewMode === 'report' && (
|
||||
<CompetitorsReportView jobId={jobId} />
|
||||
)}
|
||||
|
||||
{viewMode === 'log' && import.meta.env.DEV && sseLogs && (
|
||||
<div className="h-full">
|
||||
<ChatLogBrowser
|
||||
logs={sseLogs}
|
||||
clearLogs={() => { }}
|
||||
title="SSE Stream Events"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Global Location Details Sidebar */}
|
||||
{showDetails && activeCompetitor && (
|
||||
<div className="flex h-full min-h-0 shrink-0">
|
||||
{/* Drag Handle */}
|
||||
<div
|
||||
className="w-1 bg-gray-200 dark:bg-gray-700 hover:bg-indigo-500 cursor-col-resize flex items-center justify-center z-30 transition-colors shrink-0"
|
||||
onMouseDown={startResizing}
|
||||
>
|
||||
<GripVertical className="w-3 h-3 text-gray-400" />
|
||||
</div>
|
||||
|
||||
{/* Property Pane */}
|
||||
<div
|
||||
className="h-full bg-white dark:bg-gray-800 z-20 overflow-hidden relative shrink-0 border-l border-gray-200 dark:border-gray-700"
|
||||
style={{ width: sidebarWidth }}
|
||||
>
|
||||
<LocationDetailView
|
||||
competitor={activeCompetitor}
|
||||
onClose={() => setShowDetails(false)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@ -1,12 +1,11 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import React, { useState, useEffect, useRef, useMemo } from 'react';
|
||||
import { Loader2, Search, MapPin, CheckCircle, ChevronRight, ChevronLeft, Settings } from 'lucide-react';
|
||||
import { submitPlacesGridSearchJob, getGridSearchExcludeTypes, saveGridSearchExcludeTypes, getPlacesTypes } from '../client-gridsearch';
|
||||
import { CompetitorsMapView } from '../CompetitorsMapView';
|
||||
import { GadmRegionCollector } from '../gadm-picker/GadmRegionCollector';
|
||||
import { GadmNode } from '../gadm-picker/GadmTreePicker';
|
||||
import { GadmPickerProvider, useGadmPicker } from '../gadm-picker/GadmPickerContext';
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { T, translate } from '../../../i18n';
|
||||
import { T, translate } from '@/i18n';
|
||||
|
||||
export function GridSearchWizard({ onJobSubmitted, initialSettings, setIsSidebarOpen }: { onJobSubmitted: (jobId: string) => void, initialSettings?: any, setIsSidebarOpen?: (open: boolean) => void }) {
|
||||
const initNodes = initialSettings?.guidedAreas?.map((a: any) => ({
|
||||
@ -34,20 +33,19 @@ function GridSearchWizardInner({ onJobSubmitted, initialSettings, setIsSidebarOp
|
||||
const [searchQuery, setSearchQuery] = useState(initialSettings?.searchQuery || '');
|
||||
|
||||
// Step 3: Preview
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
const [simulatorSettings, setSimulatorSettings] = useState<any>({
|
||||
gridMode: 'centers',
|
||||
pathOrder: 'snake',
|
||||
groupByRegion: true,
|
||||
cellSize: 5,
|
||||
cellSize: 10,
|
||||
cellOverlap: 0,
|
||||
centroidOverlap: 50,
|
||||
centroidOverlap: 0,
|
||||
ghsFilterMode: 'OR',
|
||||
maxCellsLimit: 50000,
|
||||
maxCellsLimit: 10000,
|
||||
maxElevation: 1000,
|
||||
minDensity: 10,
|
||||
minGhsPop: 26,
|
||||
minGhsBuilt: 154,
|
||||
minGhsPop: 1,
|
||||
minGhsBuilt: 0,
|
||||
allowMissingGhs: false,
|
||||
bypassFilters: true,
|
||||
...(initialSettings?.simulatorSettings || {})
|
||||
@ -57,6 +55,7 @@ function GridSearchWizardInner({ onJobSubmitted, initialSettings, setIsSidebarOp
|
||||
if (!initialSettings?.guidedAreas) return {};
|
||||
const res: Record<string, number> = {};
|
||||
initialSettings.guidedAreas.forEach((a: any) => { res[a.gid] = a.level; });
|
||||
console.log(`resolutions:`, res);
|
||||
return res;
|
||||
});
|
||||
const [excludeTypesStr, setExcludeTypesStr] = useState(initialSettings?.excludeTypes?.join(', ') || '');
|
||||
@ -68,7 +67,9 @@ function GridSearchWizardInner({ onJobSubmitted, initialSettings, setIsSidebarOp
|
||||
|
||||
const [pastSearches, setPastSearches] = useState<string[]>(() => {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem('gridSearchPastQueries') || '[]');
|
||||
const past = JSON.parse(localStorage.getItem('gridSearchPastQueries') || '[]');
|
||||
console.log(past);
|
||||
return past;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
@ -163,6 +164,14 @@ function GridSearchWizardInner({ onJobSubmitted, initialSettings, setIsSidebarOp
|
||||
};
|
||||
|
||||
|
||||
const initialGadmRegions = useMemo(() => {
|
||||
return collectedNodes.map(n => ({
|
||||
gid: n.gid,
|
||||
name: n.name,
|
||||
level: resolutions[n.gid] ?? n.level,
|
||||
raw: n.data
|
||||
}));
|
||||
}, [collectedNodes, resolutions]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center p-6 h-full border-box w-full">
|
||||
@ -193,7 +202,12 @@ function GridSearchWizardInner({ onJobSubmitted, initialSettings, setIsSidebarOp
|
||||
</div>
|
||||
<GadmRegionCollector
|
||||
resolutions={resolutions}
|
||||
onChangeResolution={(gid, lvl) => setResolutions(prev => ({ ...prev, [gid]: lvl }))}
|
||||
onChangeResolution={
|
||||
(gid, lvl) => {
|
||||
setResolutions(prev => ({ ...prev, [gid]: lvl }));
|
||||
console.log(`resolutions collector:`, resolutions);
|
||||
}
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@ -236,7 +250,7 @@ function GridSearchWizardInner({ onJobSubmitted, initialSettings, setIsSidebarOp
|
||||
onMapCenterUpdate={() => { }}
|
||||
enrich={async () => { }}
|
||||
isEnriching={false}
|
||||
initialGadmRegions={collectedNodes.map(n => ({ gid: n.gid, name: n.name, level: resolutions[n.gid] ?? n.level, raw: n.data }))}
|
||||
initialGadmRegions={initialGadmRegions}
|
||||
simulatorSettings={simulatorSettings}
|
||||
onSimulatorSettingsChange={setSimulatorSettings}
|
||||
onRegionsChange={(regions) => {
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { Loader2, AlertCircle, Lock, MapPin, Share2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { fetchPlacesGridSearchById, retryPlacesGridSearchJob, getGridSearchExcludeTypes, saveGridSearchExcludeTypes, updatePlacesGridSearchSettings } from '../client-gridsearch';
|
||||
@ -9,7 +9,7 @@ import { GridSearchStreamProvider, useGridSearchStream } from './GridSearchStrea
|
||||
import CollapsibleSection from '@/components/CollapsibleSection';
|
||||
import { T } from '@/i18n';
|
||||
|
||||
function LiveGridSearchResults({ jobId, excludedTypes, dummyUpdateExcluded, onExpandSubmitted, isOwner, isPublic, onTogglePublic, isSidebarOpen, onToggleSidebar }: any) {
|
||||
const LiveGridSearchResults = React.memo(({ jobId, excludedTypes, dummyUpdateExcluded, onExpandSubmitted, isOwner, isPublic, onTogglePublic, isSidebarOpen, onToggleSidebar }: any) => {
|
||||
const { competitors, liveAreas, liveRadii, liveNodes, stats, streaming, statusMessage, liveScanner, sseLogs } = useGridSearchStream();
|
||||
|
||||
return (
|
||||
@ -34,9 +34,87 @@ function LiveGridSearchResults({ jobId, excludedTypes, dummyUpdateExcluded, onEx
|
||||
onToggleSidebar={onToggleSidebar}
|
||||
/>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
export function JobViewer({ jobId, isSidebarOpen, onToggleSidebar }: { jobId: string; isSidebarOpen?: boolean; onToggleSidebar?: () => void }) {
|
||||
const JobMetadataDisplay = React.memo(({ jobId, jobData, foundTypes }: any) => (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-x-8 gap-y-2 text-sm text-gray-600 dark:text-gray-400 mt-2">
|
||||
<p><span className="font-medium"><T>Job ID:</T></span> <span className="text-gray-800 dark:text-gray-300 select-all">{jobId}</span></p>
|
||||
{jobData && (
|
||||
<>
|
||||
{jobData.request?.search?.types && (
|
||||
<p><span className="font-medium"><T>Queries:</T></span> <span className="text-gray-800 dark:text-gray-300">{jobData.request.search.types.join(', ')}</span></p>
|
||||
)}
|
||||
{(jobData.query?.region || jobData.request?.enumerate?.region) && (
|
||||
<p><span className="font-medium"><T>Region:</T></span> <span className="text-gray-800 dark:text-gray-300">{jobData.query?.region || jobData.request?.enumerate?.region}</span></p>
|
||||
)}
|
||||
{(jobData.query?.level || jobData.request?.enumerate?.level) && (
|
||||
<p><span className="font-medium"><T>Level:</T></span> <span className="text-gray-800 dark:text-gray-300">{jobData.query?.level || jobData.request?.enumerate?.level}</span></p>
|
||||
)}
|
||||
{jobData.areas && (
|
||||
<p><span className="font-medium"><T>Areas Scanned:</T></span> <span className="text-gray-800 dark:text-gray-300">{jobData.areas.length}</span></p>
|
||||
)}
|
||||
{jobData.generatedAt && (
|
||||
<p><span className="font-medium"><T>Generated At:</T></span> <span className="text-gray-800 dark:text-gray-300">{new Date(jobData.generatedAt).toLocaleString()}</span></p>
|
||||
)}
|
||||
{foundTypes.length > 0 && (
|
||||
<div className="col-span-full mt-1 flex flex-wrap items-center gap-x-2 gap-y-1.5">
|
||||
<span className="font-medium mr-1 text-gray-800 dark:text-gray-300"><T>Found Types:</T></span>
|
||||
{foundTypes.map((t: string) => (
|
||||
<span key={t} className="px-2 py-0.5 bg-indigo-50 text-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-300 border border-indigo-200 dark:border-indigo-800/50 rounded-md text-xs font-medium">
|
||||
{t}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
));
|
||||
|
||||
const GadmAreasDisplay = React.memo(({ guidedAreas, isLocked, isComplete }: any) => (
|
||||
<div className="mt-4 pt-4 border-t border-gray-100 dark:border-gray-700">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<MapPin className="w-3.5 h-3.5 text-amber-500" />
|
||||
<span className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
<T>GADM Areas</T> ({guidedAreas.length})
|
||||
</span>
|
||||
{isLocked && (
|
||||
<span className="inline-flex items-center gap-1 text-[10px] font-medium text-amber-700 dark:text-amber-400 bg-amber-50 dark:bg-amber-900/30 px-1.5 py-0.5 rounded">
|
||||
<Lock className="w-3 h-3" /> <T>Locked</T>
|
||||
</span>
|
||||
)}
|
||||
{isComplete && (
|
||||
<span className="text-[10px] font-medium text-green-700 dark:text-green-400 bg-green-50 dark:bg-green-900/30 px-1.5 py-0.5 rounded">
|
||||
<T>Complete</T>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{guidedAreas.map((area: any) => (
|
||||
<span
|
||||
key={area.gid}
|
||||
className="inline-flex items-center gap-1 text-xs bg-yellow-50 dark:bg-yellow-900/20 text-yellow-800 dark:text-yellow-300 border border-yellow-200 dark:border-yellow-800/50 rounded-md px-2 py-1"
|
||||
title={area.gid}
|
||||
>
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-yellow-500 flex-shrink-0" />
|
||||
{area.name}
|
||||
<span className="text-[10px] opacity-60">L{area.level}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
));
|
||||
|
||||
const SearchSettingsDisplay = React.memo(({ searchSettings }: any) => (
|
||||
<div className="mt-3 flex flex-wrap gap-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
{searchSettings.cellSize && <span className="bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded"><T>Cell:</T> {searchSettings.cellSize}km</span>}
|
||||
{searchSettings.minPopDensity > 0 && <span className="bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded"><T>Min Pop:</T> {searchSettings.minPopDensity}</span>}
|
||||
{searchSettings.pathOrder && <span className="bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded"><T>Path:</T> {searchSettings.pathOrder}</span>}
|
||||
{searchSettings.gridMode && <span className="bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded"><T>Grid:</T> {searchSettings.gridMode}</span>}
|
||||
</div>
|
||||
));
|
||||
|
||||
export const JobViewer = React.memo(({ jobId, isSidebarOpen, onToggleSidebar }: { jobId: string; isSidebarOpen?: boolean; onToggleSidebar?: () => void }) => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [competitors, setCompetitors] = useState<any[]>([]);
|
||||
@ -94,16 +172,16 @@ export function JobViewer({ jobId, isSidebarOpen, onToggleSidebar }: { jobId: st
|
||||
return () => { active = false; };
|
||||
}, [jobId, reloadKey]);
|
||||
|
||||
const handleUpdateExcluded = async (types: string[]) => {
|
||||
const handleUpdateExcluded = useCallback(async (types: string[]) => {
|
||||
setExcludedTypes(types);
|
||||
try {
|
||||
await saveGridSearchExcludeTypes(types);
|
||||
} catch (e) {
|
||||
console.error('Failed to save excluded types:', e);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleRetry = async () => {
|
||||
const handleRetry = useCallback(async () => {
|
||||
try {
|
||||
setRetrying(true);
|
||||
await retryPlacesGridSearchJob(jobId);
|
||||
@ -115,9 +193,9 @@ export function JobViewer({ jobId, isSidebarOpen, onToggleSidebar }: { jobId: st
|
||||
} finally {
|
||||
setRetrying(false);
|
||||
}
|
||||
};
|
||||
}, [jobId]);
|
||||
|
||||
const handleTogglePublic = async () => {
|
||||
const handleTogglePublic = useCallback(async () => {
|
||||
const newValue = !isSharingTarget;
|
||||
setIsSharingTarget(newValue);
|
||||
try {
|
||||
@ -134,7 +212,7 @@ export function JobViewer({ jobId, isSidebarOpen, onToggleSidebar }: { jobId: st
|
||||
setIsSharingTarget(!newValue); // revert on failure
|
||||
toast.error('Failed to update sharing settings');
|
||||
}
|
||||
};
|
||||
}, [isSharingTarget, jobId]);
|
||||
|
||||
|
||||
if (!jobId) return null;
|
||||
@ -202,7 +280,6 @@ export function JobViewer({ jobId, isSidebarOpen, onToggleSidebar }: { jobId: st
|
||||
|
||||
const allResults = jobData?.result?.searchResult?.results || competitors;
|
||||
const foundTypes = Array.from(new Set(allResults.flatMap((r: any) => r.types || []).filter(Boolean))).sort() as string[];
|
||||
console.log(JSON.stringify(foundTypes, null, 2));
|
||||
return (
|
||||
<div className="flex flex-col flex-1 w-full overflow-hidden min-h-0 mt-2 dark:bg-gray-800/70 p-1">
|
||||
<div className="w-full flex-1 min-h-0 flex flex-col rounded-2xl shadow-xl transition-all duration-300">
|
||||
@ -215,82 +292,16 @@ export function JobViewer({ jobId, isSidebarOpen, onToggleSidebar }: { jobId: st
|
||||
storageKey={`gridSearchViewMetaCollapse`}
|
||||
className=""
|
||||
>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-x-8 gap-y-2 text-sm text-gray-600 dark:text-gray-400 mt-2">
|
||||
<p><span className="font-medium"><T>Job ID:</T></span> <span className="text-gray-800 dark:text-gray-300 select-all">{jobId}</span></p>
|
||||
{jobData && (
|
||||
<>
|
||||
{jobData.request?.search?.types && (
|
||||
<p><span className="font-medium"><T>Queries:</T></span> <span className="text-gray-800 dark:text-gray-300">{jobData.request.search.types.join(', ')}</span></p>
|
||||
)}
|
||||
{(jobData.query?.region || jobData.request?.enumerate?.region) && (
|
||||
<p><span className="font-medium"><T>Region:</T></span> <span className="text-gray-800 dark:text-gray-300">{jobData.query?.region || jobData.request?.enumerate?.region}</span></p>
|
||||
)}
|
||||
{(jobData.query?.level || jobData.request?.enumerate?.level) && (
|
||||
<p><span className="font-medium"><T>Level:</T></span> <span className="text-gray-800 dark:text-gray-300">{jobData.query?.level || jobData.request?.enumerate?.level}</span></p>
|
||||
)}
|
||||
{jobData.areas && (
|
||||
<p><span className="font-medium"><T>Areas Scanned:</T></span> <span className="text-gray-800 dark:text-gray-300">{jobData.areas.length}</span></p>
|
||||
)}
|
||||
{jobData.generatedAt && (
|
||||
<p><span className="font-medium"><T>Generated At:</T></span> <span className="text-gray-800 dark:text-gray-300">{new Date(jobData.generatedAt).toLocaleString()}</span></p>
|
||||
)}
|
||||
{foundTypes.length > 0 && (
|
||||
<div className="col-span-full mt-1 flex flex-wrap items-center gap-x-2 gap-y-1.5">
|
||||
<span className="font-medium mr-1 text-gray-800 dark:text-gray-300"><T>Found Types:</T></span>
|
||||
{foundTypes.map((t: string) => (
|
||||
<span key={t} className="px-2 py-0.5 bg-indigo-50 text-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-300 border border-indigo-200 dark:border-indigo-800/50 rounded-md text-xs font-medium">
|
||||
{t}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<JobMetadataDisplay jobId={jobId} jobData={jobData} foundTypes={foundTypes} />
|
||||
|
||||
{/* Restored GADM Areas */}
|
||||
{guidedAreas.length > 0 && (
|
||||
<div className="mt-4 pt-4 border-t border-gray-100 dark:border-gray-700">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<MapPin className="w-3.5 h-3.5 text-amber-500" />
|
||||
<span className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
<T>GADM Areas</T> ({guidedAreas.length})
|
||||
</span>
|
||||
{isLocked && (
|
||||
<span className="inline-flex items-center gap-1 text-[10px] font-medium text-amber-700 dark:text-amber-400 bg-amber-50 dark:bg-amber-900/30 px-1.5 py-0.5 rounded">
|
||||
<Lock className="w-3 h-3" /> <T>Locked</T>
|
||||
</span>
|
||||
)}
|
||||
{isComplete && (
|
||||
<span className="text-[10px] font-medium text-green-700 dark:text-green-400 bg-green-50 dark:bg-green-900/30 px-1.5 py-0.5 rounded">
|
||||
<T>Complete</T>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{guidedAreas.map((area: any) => (
|
||||
<span
|
||||
key={area.gid}
|
||||
className="inline-flex items-center gap-1 text-xs bg-yellow-50 dark:bg-yellow-900/20 text-yellow-800 dark:text-yellow-300 border border-yellow-200 dark:border-yellow-800/50 rounded-md px-2 py-1"
|
||||
title={area.gid}
|
||||
>
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-yellow-500 flex-shrink-0" />
|
||||
{area.name}
|
||||
<span className="text-[10px] opacity-60">L{area.level}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<GadmAreasDisplay guidedAreas={guidedAreas} isLocked={isLocked} isComplete={isComplete} />
|
||||
)}
|
||||
|
||||
{/* Search Settings Summary */}
|
||||
{searchSettings && (
|
||||
<div className="mt-3 flex flex-wrap gap-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
{searchSettings.cellSize && <span className="bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded"><T>Cell:</T> {searchSettings.cellSize}km</span>}
|
||||
{searchSettings.minPopDensity > 0 && <span className="bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded"><T>Min Pop:</T> {searchSettings.minPopDensity}</span>}
|
||||
{searchSettings.pathOrder && <span className="bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded"><T>Path:</T> {searchSettings.pathOrder}</span>}
|
||||
{searchSettings.gridMode && <span className="bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded"><T>Grid:</T> {searchSettings.gridMode}</span>}
|
||||
</div>
|
||||
<SearchSettingsDisplay searchSettings={searchSettings} />
|
||||
)}
|
||||
</CollapsibleSection>
|
||||
</div>
|
||||
@ -313,4 +324,4 @@ export function JobViewer({ jobId, isSidebarOpen, onToggleSidebar }: { jobId: st
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@ -11,42 +11,21 @@ export function useMapControls(onMapCenterUpdate?: (loc: string, zoom?: number)
|
||||
const userLocationMarkerRef = useRef<maplibregl.Marker | null>(null);
|
||||
|
||||
const setupMapListeners = useCallback((map: maplibregl.Map, onMapMove?: (state: { lat: number; lng: number; zoom: number, pitch?: number, bearing?: number }) => void) => {
|
||||
let _moveFrames = 0;
|
||||
let _moveTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
const updateInternals = () => {
|
||||
const c = map.getCenter();
|
||||
const z = map.getZoom();
|
||||
const p = map.getPitch();
|
||||
const b = map.getBearing();
|
||||
_moveFrames++;
|
||||
setMapInternals({
|
||||
zoom: Math.round(z * 100) / 100,
|
||||
lat: Math.round(c.lat * 10000) / 10000,
|
||||
lng: Math.round(c.lng * 10000) / 10000,
|
||||
pitch: Math.round(p),
|
||||
bearing: Math.round(b)
|
||||
});
|
||||
};
|
||||
|
||||
// Log move events/sec when debug enabled: window.__MAP_PERF_DEBUG__ = true
|
||||
_moveTimer = setInterval(() => {
|
||||
if ((window as any).__MAP_PERF_DEBUG__ && _moveFrames > 0) {
|
||||
console.log(`[MapPerf] move events/sec: ${_moveFrames} (setMapInternals calls)`);
|
||||
}
|
||||
_moveFrames = 0;
|
||||
}, 1000);
|
||||
|
||||
const handleMoveEnd = async () => {
|
||||
const z = Math.round(map.getZoom() * 100) / 100;
|
||||
const c = map.getCenter();
|
||||
const z = map.getZoom();
|
||||
const p = map.getPitch();
|
||||
const b = map.getBearing();
|
||||
const lat = Math.round(c.lat * 10000) / 10000;
|
||||
const lng = Math.round(c.lng * 10000) / 10000;
|
||||
const p = Math.round(map.getPitch());
|
||||
const b = Math.round(map.getBearing());
|
||||
|
||||
if ((window as any).__MAP_PERF_DEBUG__) {
|
||||
console.log(`[MapPerf] moveend fired — lat=${c.lat.toFixed(4)} lng=${c.lng.toFixed(4)} z=${z.toFixed(1)}`);
|
||||
console.log(`[MapPerf] moveend fired — lat=${lat} lng=${lng} z=${z}`);
|
||||
}
|
||||
|
||||
// ONLY set state at the end of movement to avoid high-frequency re-renders
|
||||
setMapInternals({ zoom: z, lat, lng, pitch: p, bearing: b });
|
||||
|
||||
if (onMapMove) {
|
||||
onMapMove({ lat: c.lat, lng: c.lng, zoom: z, pitch: p, bearing: b });
|
||||
}
|
||||
@ -71,16 +50,13 @@ export function useMapControls(onMapCenterUpdate?: (loc: string, zoom?: number)
|
||||
}
|
||||
};
|
||||
|
||||
map.on('move', updateInternals);
|
||||
map.on('moveend', handleMoveEnd);
|
||||
|
||||
// Initial set
|
||||
updateInternals();
|
||||
handleMoveEnd();
|
||||
|
||||
return () => {
|
||||
map.off('move', updateInternals);
|
||||
map.off('moveend', handleMoveEnd);
|
||||
if (_moveTimer) clearInterval(_moveTimer);
|
||||
};
|
||||
}, [onMapCenterUpdate]);
|
||||
|
||||
|
||||
@ -7,21 +7,47 @@ import { TypeCell } from './TypeCell';
|
||||
import type { CompetitorSettings } from './useCompetitorSettings';
|
||||
import { T, translate } from '../../i18n';
|
||||
|
||||
export type GridPreset = 'full' | 'min';
|
||||
|
||||
interface UseGridColumnsProps {
|
||||
settings: CompetitorSettings;
|
||||
updateExcludedTypes: (types: string[]) => Promise<void>;
|
||||
}
|
||||
|
||||
export const getPresetVisibilityModel = (preset: GridPreset) => {
|
||||
const allFields = [
|
||||
'thumbnail', 'title', 'email', 'phone', 'address',
|
||||
'city', 'country', 'website', 'rating', 'types', 'social'
|
||||
];
|
||||
|
||||
const minFields = ['thumbnail', 'title', 'types', 'social', 'rating'];
|
||||
|
||||
const model: Record<string, boolean> = {};
|
||||
|
||||
if (preset === 'min') {
|
||||
allFields.forEach(f => {
|
||||
model[f] = minFields.includes(f);
|
||||
});
|
||||
} else {
|
||||
// 'full'
|
||||
allFields.forEach(f => {
|
||||
model[f] = f !== 'city'; // default full has no city
|
||||
});
|
||||
}
|
||||
|
||||
return model;
|
||||
};
|
||||
|
||||
// Helper: find a social URL from any of the known data shapes
|
||||
const findSocialUrl = (row: any, platform: string): string => {
|
||||
const match = (p: any) => p.source === platform || p.platform === platform;
|
||||
const rd = row.raw_data as any;
|
||||
return rd?.[platform] || // raw_data.instagram etc
|
||||
rd?.meta?.social?.find(match)?.url || // raw_data.meta.social[]
|
||||
row.meta?.social?.find(match)?.url || // meta.social[] (nested)
|
||||
row.social?.find(match)?.url || // social[] (DB singular)
|
||||
row.socials?.find(match)?.url || // socials[] (SSE plural)
|
||||
'';
|
||||
rd?.meta?.social?.find(match)?.url || // raw_data.meta.social[]
|
||||
row.meta?.social?.find(match)?.url || // meta.social[] (nested)
|
||||
row.social?.find(match)?.url || // social[] (DB singular)
|
||||
row.socials?.find(match)?.url || // socials[] (SSE plural)
|
||||
'';
|
||||
};
|
||||
|
||||
export const useGridColumns = ({
|
||||
@ -29,17 +55,6 @@ export const useGridColumns = ({
|
||||
updateExcludedTypes
|
||||
}: UseGridColumnsProps): GridColDef[] => {
|
||||
|
||||
// Helper for rendering social cells
|
||||
const renderSocialCell = (params: GridRenderCellParams, icon: React.ReactNode, colorClass: string) => {
|
||||
const url = params.value;
|
||||
|
||||
return url ? (
|
||||
<a href={url} target="_blank" rel="noopener noreferrer" className={`${colorClass} flex items-center justify-center h-full`}>
|
||||
{icon}
|
||||
</a>
|
||||
) : null;
|
||||
};
|
||||
|
||||
return React.useMemo(() => [
|
||||
{
|
||||
field: 'thumbnail',
|
||||
@ -157,9 +172,9 @@ export const useGridColumns = ({
|
||||
renderCell: (params: GridRenderCellParams) => {
|
||||
const rating = params.value;
|
||||
if (rating == null) return null;
|
||||
|
||||
|
||||
const reviews = params.row.raw_data?.reviews || params.row.meta?.reviews || params.row.reviews;
|
||||
|
||||
|
||||
return (
|
||||
<div className="flex flex-col justify-center h-full leading-tight">
|
||||
<div className="flex items-center gap-1 text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
@ -211,7 +226,7 @@ export const useGridColumns = ({
|
||||
{ key: 'youtube', icon: <Youtube className="h-4 w-4" />, color: 'text-red-600 hover:text-red-800 dark:text-red-400' },
|
||||
{ key: 'twitter', icon: <Twitter className="h-4 w-4" />, color: 'text-blue-400 hover:text-blue-600 dark:text-blue-300' },
|
||||
{ key: 'github', icon: <Github className="h-4 w-4" />, color: 'text-gray-700 hover:text-gray-900 dark:text-gray-300' },
|
||||
{ key: 'tiktok', icon: <svg className="h-4 w-4" viewBox="0 0 24 24" fill="currentColor"><path d="M19.59 6.69a4.83 4.83 0 01-3.77-4.25V2h-3.45v13.67a2.89 2.89 0 01-2.88 2.5 2.89 2.89 0 01-2.89-2.89 2.89 2.89 0 012.89-2.89c.28 0 .54.04.79.1v-3.5a6.37 6.37 0 00-.79-.05A6.34 6.34 0 003.15 15.2a6.34 6.34 0 006.34 6.34 6.34 6.34 0 006.34-6.34V8.79a8.18 8.18 0 004.76 1.52V6.88a4.84 4.84 0 01-1-.19z"/></svg>, color: 'text-gray-800 hover:text-black dark:text-gray-300' },
|
||||
{ key: 'tiktok', icon: <svg className="h-4 w-4" viewBox="0 0 24 24" fill="currentColor"><path d="M19.59 6.69a4.83 4.83 0 01-3.77-4.25V2h-3.45v13.67a2.89 2.89 0 01-2.88 2.5 2.89 2.89 0 01-2.89-2.89 2.89 2.89 0 012.89-2.89c.28 0 .54.04.79.1v-3.5a6.37 6.37 0 00-.79-.05A6.34 6.34 0 003.15 15.2a6.34 6.34 0 006.34 6.34 6.34 6.34 0 006.34-6.34V8.79a8.18 8.18 0 004.76 1.52V6.88a4.84 4.84 0 01-1-.19z" /></svg>, color: 'text-gray-800 hover:text-black dark:text-gray-300' },
|
||||
];
|
||||
|
||||
for (const p of platforms) {
|
||||
@ -232,5 +247,5 @@ export const useGridColumns = ({
|
||||
);
|
||||
},
|
||||
},
|
||||
], [settings, updateExcludedTypes]);
|
||||
] as GridColDef[], [settings, updateExcludedTypes]);
|
||||
};
|
||||
|
||||
Loading…
Reference in New Issue
Block a user