map ole :)

This commit is contained in:
lovebird 2026-03-28 15:51:46 +01:00
parent 2d59f5df14
commit 0c0bb53915
10 changed files with 419 additions and 153 deletions

View File

@ -238,8 +238,8 @@ export const CompetitorsGridView: React.FC<CompetitorsGridViewProps> = ({ compet
}, [searchParams, columns, columnWidths]);
return (
<div className="space-y-4" style={{ width: '100%' }}>
<div className="overflow-hidden" style={{ height: 600, width: '100%' }}>
<div className="flex flex-col w-full h-full">
<div className="flex-1 min-h-0 w-full overflow-hidden">
<MuiThemeProvider theme={muiTheme}>
<DataGrid
rows={filteredCompetitors}

View File

@ -20,6 +20,7 @@ import { RegionLayers } from './components/map-layers/RegionLayers';
import { MapLayerToggles } from './components/MapLayerToggles';
import { MapOverlayToolbars } from './components/MapOverlayToolbars';
import { LiveSearchLayers } from './components/map-layers/LiveSearchLayers';
import { MapPosterOverlay } from './components/MapPosterOverlay';
import { T, translate } from '../../i18n';
const safeSetStyle = (m: maplibregl.Map, style: any) => {
@ -89,7 +90,9 @@ interface CompetitorsMapViewProps {
onMapCenterUpdate: (loc: string, zoom?: number) => void;
initialCenter?: { lat: number, lng: number };
initialZoom?: number;
onMapMove?: (state: { lat: number, lng: number, zoom: number }) => void;
initialPitch?: number;
initialBearing?: number;
onMapMove?: (state: { lat: number, lng: number, zoom: number, pitch?: number, bearing?: number }) => void;
enrich: (ids: string[], enrichers: string[]) => Promise<void>;
isEnriching: boolean;
enrichmentProgress?: { current: number; total: number; message: string } | null;
@ -102,6 +105,10 @@ interface CompetitorsMapViewProps {
liveNodes?: any[];
liveScanner?: any;
onRegionsChange?: (regions: any[]) => void;
isPosterMode?: boolean;
onClosePosterMode?: () => void;
posterTheme?: string;
setPosterTheme?: (theme: string) => void;
}
@ -173,13 +180,16 @@ const renderPopupHtml = (competitor: CompetitorFull) => {
`;
};
export const CompetitorsMapView: React.FC<CompetitorsMapViewProps> = ({ competitors, onMapCenterUpdate, initialCenter, initialZoom, onMapMove, enrich, isEnriching, enrichmentProgress, initialGadmRegions, initialSimulatorSettings, simulatorSettings, onSimulatorSettingsChange, liveAreas = [], liveRadii = [], liveNodes = [], liveScanner, preset = 'SearchView', customFeatures, onRegionsChange }) => {
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 }) => {
const features: MapFeatures = useMemo(() => {
if (isPosterMode) {
return { ...MAP_PRESETS['Minimal'], enableSidebarTools: false };
}
return {
...MAP_PRESETS[preset],
...customFeatures
};
}, [preset, customFeatures]);
}, [preset, customFeatures, isPosterMode]);
const mapContainer = useRef<HTMLDivElement>(null);
const mapWrapper = useRef<HTMLDivElement>(null);
@ -223,6 +233,10 @@ export const CompetitorsMapView: React.FC<CompetitorsMapViewProps> = ({ competit
const [leftSidebarWidth, setLeftSidebarWidth] = useState(384);
const [isLeftResizing, setIsLeftResizing] = useState(false);
const [localPosterTheme, setLocalPosterTheme] = useState('terracotta');
const posterTheme = controlledPosterTheme ?? localPosterTheme;
const setPosterTheme = setControlledPosterTheme ?? setLocalPosterTheme;
// Info Panel State
const [infoPanelOpen, setInfoPanelOpen] = useState(false);
@ -259,7 +273,7 @@ export const CompetitorsMapView: React.FC<CompetitorsMapViewProps> = ({ competit
// Fit map to loaded region boundaries (waits for map readiness)
const hasFittedBoundsRef = useRef(false);
useEffect(() => {
if (hasFittedBoundsRef.current || pickerPolygons.length === 0) return;
if (hasFittedBoundsRef.current || pickerPolygons.length === 0 || initialCenter) return;
const fitToPolygons = () => {
if (!map.current) return false;
@ -336,7 +350,8 @@ export const CompetitorsMapView: React.FC<CompetitorsMapViewProps> = ({ competit
style: MAP_STYLES[mapStyleKey],
center: [initialCenter?.lng ?? 10.45, initialCenter?.lat ?? 51.16], // Default center (Germany roughly) or prop
zoom: initialZoom ?? 5,
pitch: 0,
pitch: initialPitch ?? 0,
bearing: initialBearing ?? 0,
maxPitch: 85,
});
@ -430,11 +445,15 @@ export const CompetitorsMapView: React.FC<CompetitorsMapViewProps> = ({ competit
// Sync Theme/Style
useEffect(() => {
if (!map.current) return;
safeSetStyle(map.current, MAP_STYLES[mapStyleKey]);
let targetStyleKey = mapStyleKey;
if (isPosterMode && mapStyleKey === 'light') {
targetStyleKey = 'vector_light' as MapStyleKey;
}
safeSetStyle(map.current, MAP_STYLES[targetStyleKey]);
// Note: Re-adding sources/layers after style switch would be needed here for production resilience,
// but for now we assume style switching might reset them.
// A robust solution would re-initialize layers on 'style.load'.
}, [mapStyleKey]);
}, [mapStyleKey, isPosterMode]);
@ -589,21 +608,26 @@ export const CompetitorsMapView: React.FC<CompetitorsMapViewProps> = ({ competit
{/* Map Panel & Center Layout */}
<div className="relative h-full flex flex-col flex-1 transition-all duration-75 min-w-0 bg-white dark:bg-gray-900">
{/* Header: Search Region & Tools Overlay */}
<MapOverlayToolbars
features={features}
sidebarOpen={sidebarOpen}
gadmPickerActive={gadmPickerActive}
simulatorActive={simulatorActive}
setSidebarOpen={setSidebarOpen}
setGadmPickerActive={setGadmPickerActive}
setSimulatorActive={setSimulatorActive}
infoPanelOpen={infoPanelOpen}
setInfoPanelOpen={setInfoPanelOpen}
/>
{!isPosterMode && (
<MapOverlayToolbars
features={features}
sidebarOpen={sidebarOpen}
gadmPickerActive={gadmPickerActive}
simulatorActive={simulatorActive}
setSidebarOpen={setSidebarOpen}
setGadmPickerActive={setGadmPickerActive}
setSimulatorActive={setSimulatorActive}
infoPanelOpen={infoPanelOpen}
setInfoPanelOpen={setInfoPanelOpen}
/>
)}
{/* Map Viewport */}
<div className="relative flex-1 min-h-0 w-full overflow-hidden">
<div ref={mapContainer} className="w-full h-full" />
<div
ref={mapContainer}
className="w-full h-full relative"
/>
{map.current && (
<>
@ -629,6 +653,16 @@ export const CompetitorsMapView: React.FC<CompetitorsMapViewProps> = ({ competit
liveRadii={liveRadii}
liveNodes={liveNodes}
/>
{isPosterMode && (
<MapPosterOverlay
map={map.current}
pickerRegions={pickerRegions}
pickerPolygons={pickerPolygons}
posterTheme={posterTheme}
setPosterTheme={setPosterTheme}
onClose={onClosePosterMode || (() => {})}
/>
)}
</>
)}

View File

@ -24,6 +24,8 @@ export interface RestoredRunState {
result: any;
createdAt: string;
updatedAt: string;
isOwner?: boolean;
isPublic?: boolean;
};
areasSearched: number;
totalPlaces: number;
@ -139,6 +141,13 @@ export const fetchPlacesGridSearchRunState = async (id: string): Promise<Restore
return res.data;
};
export const updatePlacesGridSearchSettings = async (id: string, settings: { is_public?: boolean; shared_with?: string[] }): Promise<{ success: boolean; settings: any }> => {
return apiClient<{ success: boolean; settings: any }>(`/api/places/gridsearch/${id}/settings`, {
method: 'PATCH',
body: JSON.stringify(settings)
});
};
export const submitPlacesGridSearchJob = async (payload: GridSearchJobPayload): Promise<{ message: string; jobId: string }> => {
return apiClient<{ message: string; jobId: string }>('/api/places/gridsearch', {
method: 'POST',

View File

@ -1,6 +1,6 @@
import React, { useEffect, useState } from 'react';
import maplibregl from 'maplibre-gl';
import { POSTER_THEMES } from '../utils/poster-themes';
import { POSTER_THEMES, applyPosterTheme } from '../utils/poster-themes';
// Fallback feather icons if lucide-react unavailable, or just raw SVG
const XIcon = ({ className }: { className?: string }) => (
@ -96,6 +96,25 @@ export function MapPosterOverlay({ map, pickerRegions, pickerPolygons, posterThe
const [showGadmBorders, setShowGadmBorders] = useState(true);
// Apply exact map layer styling based on poster theme
useEffect(() => {
if (!map) return;
const _apply = () => {
const isDark = document.documentElement.classList.contains('dark');
applyPosterTheme(map, theme, isDark);
};
if (map.isStyleLoaded()) _apply();
map.on('style.load', _apply);
map.on('styledata', _apply);
return () => {
map.off('style.load', _apply);
map.off('styledata', _apply);
};
}, [map, theme]);
useEffect(() => {
if (!map || !pickerPolygons || pickerPolygons.length === 0) return;
@ -135,19 +154,8 @@ export function MapPosterOverlay({ map, pickerRegions, pickerPolygons, posterThe
<div className="w-full h-32 absolute top-0 left-0" style={{ background: gradientTop }} />
{/* Controls (pointer events auto) */}
<div id="poster-controls" className="absolute top-4 right-4 flex gap-2 pointer-events-auto z-50">
<div className="bg-white/90 dark:bg-gray-800/90 backdrop-blur rounded flex items-center p-1 border border-gray-200 dark:border-gray-700 shadow-sm text-gray-800 dark:text-gray-200">
<PaletteIcon className="w-4 h-4 ml-2 mr-1 opacity-50" />
<select
value={posterTheme}
onChange={e => setPosterTheme(e.target.value)}
className="bg-transparent text-sm font-medium border-none focus:ring-0 cursor-pointer outline-none px-2 py-1"
>
{Object.keys(POSTER_THEMES).map(k => (
<option key={k} value={k}>{POSTER_THEMES[k].name}</option>
))}
</select>
</div>
<div id="poster-controls" className="absolute top-4 left-4 flex gap-2 pointer-events-auto z-50">
{pickerPolygons && pickerPolygons.length > 0 && (
<button
onClick={() => setShowGadmBorders(!showGadmBorders)}

View File

@ -34,7 +34,8 @@ export const MAP_STYLE_OSM_3D = {
export const MAP_STYLES = {
light: MAP_STYLE_OSM_3D,
dark: 'https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json',
osm_raster: MAP_STYLE_OSM_3D
osm_raster: MAP_STYLE_OSM_3D,
vector_light: 'https://basemaps.cartocdn.com/gl/positron-gl-style/style.json'
};
export type MapStyleKey = keyof typeof MAP_STYLES;

View File

@ -4,40 +4,20 @@ import { PlusCircle, PanelLeftClose, PanelLeftOpen } from 'lucide-react';
import { OngoingSearches } from './OngoingSearches';
import { GridSearchWizard } from './GridSearchWizard';
import { JobViewer } from './JobViewer';
import { RestoredSearchProvider } from './RestoredSearchContext';
import { useRestoredSearch, RestoredSearchProvider } from './RestoredSearchContext';
import { useAppStore } from '@/store/appStore';
export default function GridSearch() {
function GridSearchLayout({ selectedJobId, setSelectedJobId }: { selectedJobId: string | null, setSelectedJobId: (id: string | null, forceView?: string) => void }) {
const [sidebarWidth, setSidebarWidth] = useState(320);
const [isSidebarOpen, setIsSidebarOpen] = useState(true);
const [isResizing, setIsResizing] = useState(false);
const [initialWizardSettings, setInitialWizardSettings] = useState<any>(null);
const [wizardSessionKey, setWizardSessionKey] = useState(0);
const { setShowGlobalFooter } = useAppStore();
const { state: restoredState } = useRestoredSearch();
const isSharedView = restoredState !== null && restoredState.run?.isOwner === false;
useEffect(() => {
setShowGlobalFooter(false);
return () => setShowGlobalFooter(true);
}, [setShowGlobalFooter]);
const navigate = useNavigate();
const location = useLocation();
const searchParams = new URLSearchParams(location.search);
const selectedJobId = searchParams.get('jobId');
const setSelectedJobId = useCallback((id: string | null, forceView?: string) => {
const next = new URLSearchParams(location.search);
if (id) {
next.set('jobId', id);
if (forceView) {
next.set('view', forceView);
}
} else {
next.delete('jobId');
}
navigate(`${location.pathname}?${next.toString()}`, { replace: true });
}, [navigate, location.search, location.pathname]);
const startResizing = useCallback((shouldStart: boolean) => {
setIsResizing(shouldStart);
@ -98,69 +78,104 @@ export default function GridSearch() {
return () => window.removeEventListener('mousemove', handleDrag);
}, [isResizing]);
const [wizardSessionKey, setWizardSessionKey] = useState(0);
return (
<div className="flex h-[calc(100vh-64px)] overflow-hidden bg-gray-50 dark:bg-gray-900 w-full relative">
<div className="flex h-[calc(100vh-64px)] w-full relative">
{/* Left Sidebar */}
<div
ref={sidebarRef}
className={`flex-shrink-0 bg-white dark:bg-gray-950 border-r border-gray-200 dark:border-gray-800 flex flex-col relative transition-[width] duration-300 ${!isSidebarOpen ? 'overflow-hidden' : ''}`}
style={{ width: isSidebarOpen ? `${sidebarWidth}px` : '0px' }}
>
<div className="p-4 border-b border-gray-200 dark:border-gray-800 shrink-0">
<button
onClick={() => { setSelectedJobId(null); setInitialWizardSettings(null); setWizardSessionKey(k => k + 1); }}
className={`w-full flex items-center justify-center p-3 rounded-xl font-medium transition-colors ${!selectedJobId ? 'bg-indigo-600 text-white shadow-md' : 'bg-indigo-50 text-indigo-700 hover:bg-indigo-100 dark:bg-indigo-900/30 dark:text-indigo-300 dark:hover:bg-indigo-900/50'}`}
>
<PlusCircle className="w-5 h-5 mr-2" />
New Search
</button>
</div>
{!isSharedView && (
<div
ref={sidebarRef}
className={`flex-shrink-0 bg-white dark:bg-gray-950 border-r border-gray-200 dark:border-gray-800 flex flex-col relative transition-[width] duration-300 ${!isSidebarOpen ? 'overflow-hidden' : ''}`}
style={{ width: isSidebarOpen ? `${sidebarWidth}px` : '0px' }}
>
<div className="p-4 border-b border-gray-200 dark:border-gray-800 shrink-0">
<button
onClick={() => { setSelectedJobId(null); setInitialWizardSettings(null); setWizardSessionKey(k => k + 1); }}
className={`w-full flex items-center justify-center p-3 rounded-xl font-medium transition-colors ${!selectedJobId ? 'bg-indigo-600 text-white shadow-md' : 'bg-indigo-50 text-indigo-700 hover:bg-indigo-100 dark:bg-indigo-900/30 dark:text-indigo-300 dark:hover:bg-indigo-900/50'}`}
>
<PlusCircle className="w-5 h-5 mr-2" />
New Search
</button>
</div>
<div className="flex-1 overflow-y-auto no-scrollbar pb-10">
<OngoingSearches
selectedJobId={selectedJobId}
onSelectJob={setSelectedJobId}
onSearchDeleted={(deletedId) => {
if (selectedJobId === deletedId) {
setSelectedJobId(null);
setWizardSessionKey(k => k + 1);
}
}}
/>
</div>
<div className="flex-1 overflow-y-auto no-scrollbar pb-10">
<OngoingSearches
selectedJobId={selectedJobId}
onSelectJob={setSelectedJobId}
onSearchDeleted={(deletedId) => {
if (selectedJobId === deletedId) {
setSelectedJobId(null);
setWizardSessionKey(k => k + 1);
}
}}
/>
</div>
{/* Resizer handle */}
{isSidebarOpen && (
<div
className="absolute top-0 right-0 w-1.5 h-full cursor-col-resize hover:bg-indigo-500/50 active:bg-indigo-500 z-50 transition-colors"
onMouseDown={handleDragStart}
/>
)}
</div>
{/* Sidebar Toggle Button */}
<button
onClick={() => setIsSidebarOpen(!isSidebarOpen)}
className="absolute top-4 left-4 z-50 p-2 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-sm hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-500 dark:text-gray-400 transition-colors"
style={{ transform: isSidebarOpen ? `translateX(${sidebarWidth - 16}px)` : 'translateX(0)' }}
>
{isSidebarOpen ? <PanelLeftClose className="w-4 h-4" /> : <PanelLeftOpen className="w-4 h-4" />}
</button>
{/* Main Content Area */}
<RestoredSearchProvider key={selectedJobId || 'none'} jobId={selectedJobId}>
<div className="flex-1 flex flex-col min-w-0 relative z-10 w-full overflow-hidden bg-gradient-to-br from-indigo-50/50 via-white to-blue-50/50 dark:from-gray-900 dark:via-gray-900 dark:to-gray-800">
{selectedJobId ? (
<JobViewer key={selectedJobId} jobId={selectedJobId} />
) : (
<GridSearchWizard key={wizardSessionKey} initialSettings={initialWizardSettings} onJobSubmitted={(id) => setSelectedJobId(id, 'map')} />
{/* Resizer handle */}
{isSidebarOpen && (
<div
className="absolute top-0 right-0 w-1.5 h-full cursor-col-resize hover:bg-indigo-500/50 active:bg-indigo-500 z-50 transition-colors"
onMouseDown={handleDragStart}
/>
)}
</div>
</RestoredSearchProvider>
)}
{/* Sidebar Toggle Button */}
{!isSharedView && (
<button
onClick={() => setIsSidebarOpen(!isSidebarOpen)}
className="absolute top-4 left-4 z-50 p-2 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-sm hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-500 dark:text-gray-400 transition-colors"
style={{ transform: isSidebarOpen ? `translateX(${sidebarWidth - 16}px)` : 'translateX(0)' }}
>
{isSidebarOpen ? <PanelLeftClose className="w-4 h-4" /> : <PanelLeftOpen className="w-4 h-4" />}
</button>
)}
{/* Main Content Area */}
<div className="flex-1 flex flex-col min-w-0 relative z-10 w-full overflow-hidden">
{selectedJobId ? (
<JobViewer key={selectedJobId} jobId={selectedJobId} />
) : (
<GridSearchWizard key={wizardSessionKey} initialSettings={initialWizardSettings} onJobSubmitted={(id) => setSelectedJobId(id, 'map')} />
)}
</div>
</div>
);
}
export default function GridSearch() {
const { setShowGlobalFooter } = useAppStore();
useEffect(() => {
setShowGlobalFooter(false);
return () => setShowGlobalFooter(true);
}, [setShowGlobalFooter]);
const navigate = useNavigate();
const location = useLocation();
const searchParams = new URLSearchParams(location.search);
const selectedJobId = searchParams.get('jobId');
const setSelectedJobId = useCallback((id: string | null, forceView?: string) => {
const next = new URLSearchParams(location.search);
if (id) {
next.set('jobId', id);
if (forceView) {
next.set('view', forceView);
}
} else {
next.delete('jobId');
}
navigate(`${location.pathname}?${next.toString()}`, { replace: true });
}, [navigate, location.search, location.pathname]);
return (
<RestoredSearchProvider key={selectedJobId || 'none'} jobId={selectedJobId}>
<GridSearchLayout selectedJobId={selectedJobId} setSelectedJobId={setSelectedJobId} />
</RestoredSearchProvider>
);
}

View File

@ -1,6 +1,6 @@
import React, { useState, useCallback } from 'react';
import { useSearchParams } from 'react-router-dom';
import { LayoutGrid, List, Map as MapIcon, PieChart, FileText, Terminal, PlusCircle, Loader2 } from 'lucide-react';
import { LayoutGrid, List, Map as MapIcon, PieChart, FileText, Terminal, PlusCircle, Loader2, Share2, Image as ImageIcon, Palette } from 'lucide-react';
import { CompetitorsGridView } from '../CompetitorsGridView';
import { CompetitorsMapView } from '../CompetitorsMapView';
@ -9,12 +9,13 @@ import { CompetitorsMetaView } from '../CompetitorsMetaView';
import { CompetitorsReportView } from './CompetitorsReportView';
import { useRestoredSearch } from './RestoredSearchContext';
import { expandPlacesGridSearch } from '../client-gridsearch';
import { POSTER_THEMES } from '../utils/poster-themes';
import { type CompetitorFull } from '@polymech/shared';
import { type LogEntry } from '@/contexts/LogContext';
import ChatLogBrowser from '@/components/ChatLogBrowser';
type ViewMode = 'grid' | 'thumb' | 'map' | 'meta' | 'report' | 'log';
type ViewMode = 'grid' | 'thumb' | 'map' | 'meta' | 'report' | 'log' | 'poster';
interface GridSearchResultsProps {
jobId: string;
@ -30,6 +31,9 @@ interface GridSearchResultsProps {
statusMessage?: string;
sseLogs?: LogEntry[];
onExpandSubmitted?: () => void;
isOwner?: boolean;
isPublic?: boolean;
onTogglePublic?: () => void;
}
const MOCK_SETTINGS = {
@ -39,7 +43,7 @@ const MOCK_SETTINGS = {
auto_enrich: false
};
export function GridSearchResults({ jobId, competitors, excludedTypes, updateExcludedTypes, liveAreas, liveRadii, liveNodes, liveScanner, stats, streaming, statusMessage, sseLogs, onExpandSubmitted }: GridSearchResultsProps) {
export function GridSearchResults({ jobId, competitors, excludedTypes, updateExcludedTypes, liveAreas, liveRadii, liveNodes, liveScanner, stats, streaming, statusMessage, sseLogs, onExpandSubmitted, isOwner = false, isPublic, onTogglePublic }: GridSearchResultsProps) {
const filteredCompetitors = React.useMemo(() => {
if (!excludedTypes || excludedTypes.length === 0) return competitors;
const excludedSet = new Set(excludedTypes.map(t => t.toLowerCase()));
@ -93,11 +97,23 @@ export function GridSearchResults({ jobId, competitors, excludedTypes, updateExc
const [viewMode, setViewMode] = useState<ViewMode>(() => {
const urlView = searchParams.get('view') as ViewMode;
if (urlView && ['grid', 'thumb', 'map', 'meta', 'report', ...(import.meta.env.DEV ? ['log'] : [])].includes(urlView)) return urlView;
if (urlView && ['grid', 'thumb', 'map', 'meta', 'report', 'poster', ...(import.meta.env.DEV ? ['log'] : [])].includes(urlView)) return urlView;
return 'grid';
});
const [posterTheme, setPosterTheme] = useState(() => searchParams.get('theme') || 'terracotta');
const handleThemeChange = useCallback((e: React.ChangeEvent<HTMLSelectElement>) => {
const theme = e.target.value;
setPosterTheme(theme);
setSearchParams(prev => {
const newParams = new URLSearchParams(prev);
newParams.set('theme', theme);
return newParams;
}, { replace: true });
}, [setSearchParams]);
const handleViewChange = useCallback((mode: ViewMode) => {
setViewMode(mode);
setSearchParams(prev => {
@ -118,7 +134,7 @@ export function GridSearchResults({ jobId, competitors, excludedTypes, updateExc
<div className="flex justify-between mb-4 mt-2 px-2">
{/* Expand button — only visible when fresh regions are picked */}
<div className="flex items-center gap-2">
{freshRegions.length > 0 && (
{isOwner && freshRegions.length > 0 && (
<button
onClick={handleExpand}
disabled={expanding || streaming}
@ -133,7 +149,7 @@ export function GridSearchResults({ jobId, competitors, excludedTypes, updateExc
Expand +{freshRegions.length}
</button>
)}
{expandError && (
{isOwner && expandError && (
<span className="text-xs text-red-500">{expandError}</span>
)}
</div>
@ -175,6 +191,13 @@ export function GridSearchResults({ jobId, competitors, excludedTypes, updateExc
>
<MapIcon className="h-4 w-4" />
</button>
<button
onClick={() => handleViewChange('poster')}
className={`p-1.5 rounded-md transition-all ${viewMode === 'poster' ? 'bg-indigo-50 text-indigo-600 dark:bg-indigo-900/50 dark:text-indigo-400' : 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'}`}
title="Poster Map View"
>
<ImageIcon className="h-4 w-4" />
</button>
<button
onClick={() => handleViewChange('meta')}
className={`p-1.5 rounded-md transition-all ${viewMode === 'meta' ? 'bg-indigo-50 text-indigo-600 dark:bg-indigo-900/50 dark:text-indigo-400' : 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'}`}
@ -198,11 +221,50 @@ export function GridSearchResults({ jobId, competitors, excludedTypes, updateExc
<Terminal className="h-4 w-4" />
</button>
)}
{viewMode === 'poster' && (
<>
<div className="h-4 w-px bg-gray-200 dark:bg-gray-700 mx-1 self-center" />
<div className="flex items-center gap-1.5 px-2 bg-indigo-50/50 dark:bg-indigo-900/30 rounded-md">
<Palette className="h-3.5 w-3.5 text-indigo-500" />
<select
value={posterTheme}
onChange={handleThemeChange}
className="bg-transparent border-none text-xs font-medium focus:ring-0 cursor-pointer p-0 pr-4 appearance-none outline-none text-gray-700 dark:text-gray-300"
>
{Object.keys(POSTER_THEMES).map(k => (
<option key={k} value={k}>{POSTER_THEMES[k].name}</option>
))}
</select>
</div>
</>
)}
{isOwner ? (
<>
<div className="h-4 w-px bg-gray-200 dark:bg-gray-700 mx-1 self-center" />
<button
onClick={onTogglePublic}
className={`p-1.5 rounded-md transition-all ${isPublic ? 'bg-emerald-50 text-emerald-600 dark:bg-emerald-900/50 dark:text-emerald-400' : 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'}`}
title={isPublic ? "Public Share Enabled" : "Enable Public Share"}
>
<Share2 className="h-4 w-4" />
</button>
</>
) : isPublic ? (
<>
<div className="h-4 w-px bg-gray-200 dark:bg-gray-700 mx-1 self-center" />
<div
className="p-1.5 rounded-md flex items-center justify-center text-emerald-600 dark:text-emerald-400 cursor-help"
title="Shared View (Read Only)"
>
<Share2 className="h-4 w-4" />
</div>
</>
) : null}
</div>
</div>
</div>
<div id="gridsearch-scroll-area" className="flex-1 min-h-0 overflow-auto bg-white dark:bg-gray-900 rounded-b-2xl">
<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}
@ -220,10 +282,13 @@ export function GridSearchResults({ jobId, competitors, excludedTypes, updateExc
/>
)}
{viewMode === 'map' && (
{(viewMode === 'map' || viewMode === 'poster') && (
<CompetitorsMapView
preset="SearchView"
competitors={filteredCompetitors}
isPosterMode={viewMode === 'poster'}
posterTheme={posterTheme}
setPosterTheme={setPosterTheme}
onMapCenterUpdate={handleMapCenterUpdate}
enrich={dummyEnrich}
isEnriching={false}
@ -243,16 +308,31 @@ export function GridSearchResults({ jobId, competitors, excludedTypes, updateExc
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={(state) => {
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));
newParams.set('view', 'map');
if (state.pitch !== undefined) newParams.set('mapPitch', state.pitch.toFixed(0));
if (state.bearing !== undefined) newParams.set('mapBearing', state.bearing.toFixed(0));
// Only force 'map' view if we aren't already in 'poster' mode.
if (prev.get('view') !== 'poster') {
newParams.set('view', 'map');
}
return newParams;
}, { replace: true });
}}
onClosePosterMode={() => handleViewChange('map')}
onRegionsChange={setPickedRegions}
simulatorSettings={mapSimSettings}
onSimulatorSettingsChange={setMapSimSettings}

View File

@ -1,6 +1,7 @@
import { useState, useEffect } from 'react';
import { Loader2, AlertCircle, Lock, MapPin } from 'lucide-react';
import { fetchPlacesGridSearchById, retryPlacesGridSearchJob, getGridSearchExcludeTypes, saveGridSearchExcludeTypes } from '../client-gridsearch';
import { Loader2, AlertCircle, Lock, MapPin, Share2 } from 'lucide-react';
import { toast } from 'sonner';
import { fetchPlacesGridSearchById, retryPlacesGridSearchJob, getGridSearchExcludeTypes, saveGridSearchExcludeTypes, updatePlacesGridSearchSettings } from '../client-gridsearch';
import { GridSearchResults } from './GridSearchResults';
import { useRestoredSearch } from './RestoredSearchContext';
@ -8,7 +9,7 @@ import { GridSearchStreamProvider, useGridSearchStream } from './GridSearchStrea
import CollapsibleSection from '@/components/CollapsibleSection';
import { T } from '@/i18n';
function LiveGridSearchResults({ jobId, excludedTypes, dummyUpdateExcluded, onExpandSubmitted }: any) {
function LiveGridSearchResults({ jobId, excludedTypes, dummyUpdateExcluded, onExpandSubmitted, isOwner, isPublic, onTogglePublic }: any) {
const { competitors, liveAreas, liveRadii, liveNodes, stats, streaming, statusMessage, liveScanner, sseLogs } = useGridSearchStream();
return (
@ -26,6 +27,9 @@ function LiveGridSearchResults({ jobId, excludedTypes, dummyUpdateExcluded, onEx
statusMessage={statusMessage}
sseLogs={sseLogs}
onExpandSubmitted={onExpandSubmitted}
isOwner={isOwner}
isPublic={isPublic}
onTogglePublic={onTogglePublic}
/>
);
}
@ -43,9 +47,11 @@ export function JobViewer({ jobId }: { jobId: string }) {
const [retrying, setRetrying] = useState(false);
const [reloadKey, setReloadKey] = useState(0);
const [isSharingTarget, setIsSharingTarget] = useState(false);
// For CompetitorsGridView props
const [excludedTypes, setExcludedTypes] = useState<string[]>([]);
// Load initial exclude types
useEffect(() => {
getGridSearchExcludeTypes().then(types => setExcludedTypes(types));
@ -72,6 +78,7 @@ export function JobViewer({ jobId }: { jobId: string }) {
} else {
setCompetitors([]);
}
setIsSharingTarget(payload?.isPublic === true);
} catch (err: any) {
if (active) setError(err.message || 'Failed to load data');
console.error('Failed to load job data:', err);
@ -108,6 +115,25 @@ export function JobViewer({ jobId }: { jobId: string }) {
}
};
const handleTogglePublic = async () => {
const newValue = !isSharingTarget;
setIsSharingTarget(newValue);
try {
await updatePlacesGridSearchSettings(jobId, { is_public: newValue });
if (newValue) {
const url = window.location.href;
await navigator.clipboard.writeText(url);
toast.success('Public link enabled and copied to clipboard');
} else {
toast.success('Public link disabled');
}
} catch (e) {
console.error(e);
setIsSharingTarget(!newValue); // revert on failure
toast.error('Failed to update sharing settings');
}
};
if (!jobId) return null;
@ -152,19 +178,21 @@ export function JobViewer({ jobId }: { jobId: string }) {
<div className="text-red-500 dark:text-red-400 max-w-lg mx-auto bg-red-50 dark:bg-red-900/50 p-4 rounded-xl font-mono text-sm break-all border border-red-200 dark:border-red-900">
{errorMsg}
</div>
<div className="pt-4">
<button
onClick={handleRetry}
disabled={retrying}
className="px-6 py-2.5 bg-red-600 hover:bg-red-700 text-white font-medium rounded-xl transition-colors disabled:opacity-50 inline-flex items-center"
>
{retrying ? (
<><Loader2 className="w-4 h-4 inline mr-2 animate-spin" /> <T>Retrying...</T></>
) : (
<T>Retry Job</T>
)}
</button>
</div>
{jobData?.isOwner && (
<div className="pt-4">
<button
onClick={handleRetry}
disabled={retrying}
className="px-6 py-2.5 bg-red-600 hover:bg-red-700 text-white font-medium rounded-xl transition-colors disabled:opacity-50 inline-flex items-center"
>
{retrying ? (
<><Loader2 className="w-4 h-4 inline mr-2 animate-spin" /> <T>Retrying...</T></>
) : (
<T>Retry Job</T>
)}
</button>
</div>
)}
</div>
</div>
);
@ -173,20 +201,16 @@ export function JobViewer({ jobId }: { jobId: string }) {
const allResults = jobData?.result?.searchResult?.results || competitors;
const foundTypes = Array.from(new Set(allResults.flatMap((r: any) => r.types || []).filter(Boolean))).sort() as string[];
return (
<div className="flex flex-col p-6 flex-1 w-full box-border overflow-hidden min-h-0">
<div className="w-full flex-1 min-h-0 flex flex-col bg-white dark:bg-gray-800 rounded-2xl shadow-xl transition-all duration-300">
<div className="flex flex-col md:flex-row md:items-start justify-between p-6 pb-2 border-b dark:border-gray-700">
<div className="flex flex-col flex-1 w-full overflow-hidden min-h-0 mt-2">
<div className="w-full flex-1 min-h-0 flex flex-col rounded-2xl shadow-xl transition-all duration-300">
<div className="flex flex-col md:flex-row md:items-start justify-between">
<div className="w-full">
<div className="flex justify-between items-center mb-2">
<h2 className="text-2xl font-bold text-gray-800 dark:text-gray-100"><T>Search Results</T></h2>
</div>
<CollapsibleSection
title={<T>Search Configuration & Metadata</T>}
minimal
initiallyOpen={false}
storageKey={`gridSearchViewMetaCollapse`}
className="mt-2"
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>
@ -275,6 +299,9 @@ export function JobViewer({ jobId }: { jobId: string }) {
excludedTypes={excludedTypes}
dummyUpdateExcluded={handleUpdateExcluded}
onExpandSubmitted={refetch}
isOwner={jobData?.isOwner}
isPublic={isSharingTarget}
onTogglePublic={handleTogglePublic}
/>
</GridSearchStreamProvider>
</div>

View File

@ -4,29 +4,35 @@ import { fetchReverseGeocode, fetchIpLocation } from '../client-gridsearch';
export function useMapControls(onMapCenterUpdate?: (loc: string, zoom?: number) => void) {
const [currentCenterLabel, setCurrentCenterLabel] = useState<string | null>(null);
const [mapInternals, setMapInternals] = useState<{ zoom: number; lat: number; lng: number } | null>(null);
const [mapInternals, setMapInternals] = useState<{ zoom: number; lat: number; lng: number, pitch?: number, bearing?: number } | null>(null);
const [isLocating, setIsLocating] = useState(false);
// Optional reference to user's location marker
const userLocationMarkerRef = useRef<maplibregl.Marker | null>(null);
const setupMapListeners = useCallback((map: maplibregl.Map, onMapMove?: (state: { lat: number; lng: number; zoom: number }) => void) => {
const setupMapListeners = useCallback((map: maplibregl.Map, onMapMove?: (state: { lat: number; lng: number; zoom: number, pitch?: number, bearing?: number }) => void) => {
const updateInternals = () => {
const c = map.getCenter();
const z = map.getZoom();
const p = map.getPitch();
const b = map.getBearing();
setMapInternals({
zoom: Math.round(z * 100) / 100,
lat: Math.round(c.lat * 10000) / 10000,
lng: Math.round(c.lng * 10000) / 10000
lng: Math.round(c.lng * 10000) / 10000,
pitch: Math.round(p),
bearing: Math.round(b)
});
};
const handleMoveEnd = async () => {
const c = map.getCenter();
const z = map.getZoom();
const p = map.getPitch();
const b = map.getBearing();
if (onMapMove) {
onMapMove({ lat: c.lat, lng: c.lng, zoom: z });
onMapMove({ lat: c.lat, lng: c.lng, zoom: z, pitch: p, bearing: b });
}
try {

View File

@ -271,3 +271,89 @@ export const POSTER_THEMES: Record<string, PosterTheme> = {
"road_default": "#C9BBAA"
},
};
export function applyPosterTheme(map: maplibregl.Map | any, theme: PosterTheme, isDark: boolean = false) {
if (!map || !map.getStyle()) return;
const setPaintSafe = (layer: string, prop: string, val: string | number) => {
if (map.getLayer(layer)) {
try {
map.setPaintProperty(layer, prop, val);
} catch (e) {
// Ignore unsupported paint properties for this layer type
}
}
};
const layers = map.getStyle().layers || [];
layers.forEach((layer: any) => {
const id = layer.id.toLowerCase();
// Hide all symbols (labels, POIs, etc)
if (layer.type === 'symbol') {
map.setLayoutProperty(id, 'visibility', 'none');
return;
}
// Background
if (id === 'background') {
setPaintSafe(id, 'background-color', theme.bg);
return;
}
// Water
if (id.includes('water') && !id.includes('shadow')) {
setPaintSafe(id, 'fill-color', theme.water);
setPaintSafe(id, 'line-color', theme.water);
return;
}
// Parks/Green/Landcover
if (id.includes('park') || id.includes('landcover') || id.includes('green') || id.includes('cemetery') || id.includes('pitch')) {
const isWaterBlock = id.includes('water'); // Filter edgecase if landcover_water
if (!isWaterBlock) {
setPaintSafe(id, 'fill-color', theme.parks);
setPaintSafe(id, 'line-color', theme.parks);
}
return;
}
// Buildings
if (id.includes('building')) {
setPaintSafe(id, 'fill-color', theme.road_residential || theme.road_default);
setPaintSafe(id, 'line-color', theme.road_residential || theme.road_default);
// Try wrapping opacity changes safely
try { map.setPaintProperty(id, 'fill-opacity', 0.2); } catch(e){}
try { map.setPaintProperty(id, 'line-opacity', 0.1); } catch(e){}
return;
}
// Roads / Transport Infrastructure
if (id.includes('road') || id.includes('highway') || id.includes('tunnel') || id.includes('bridge') || id.includes('aeroway')) {
if (id.includes('mot') || id.includes('motorway') || id.includes('trunk') || id.includes('runway')) {
setPaintSafe(id, 'line-color', theme.road_motorway);
}
else if (id.includes('pri') || id.includes('primary') || id.includes('major')) {
setPaintSafe(id, 'line-color', theme.road_primary);
}
else if (id.includes('sec') || id.includes('secondary')) {
setPaintSafe(id, 'line-color', theme.road_secondary);
}
else if (id.includes('ter') || id.includes('tertiary')) {
setPaintSafe(id, 'line-color', theme.road_tertiary);
}
else {
// Minor / Residential / Path / Track / Service / Taxiway
setPaintSafe(id, 'line-color', theme.road_residential || theme.road_default);
}
return;
}
});
// Make sure we update the GADM borders manually if they exist
if (map.getLayer('poster-gadm-borders')) {
map.setPaintProperty('poster-gadm-borders', 'line-color', theme.text);
}
}