diff --git a/components/map/coordinate-link.tsx b/components/map/coordinate-link.tsx new file mode 100644 index 00000000..f748fc2a --- /dev/null +++ b/components/map/coordinate-link.tsx @@ -0,0 +1,49 @@ +'use client'; + +import React, { useEffect } from 'react'; +import { useMapData } from './map-data-context'; + +interface CoordinateLinkProps { + lat: number; + lng: number; + label?: string; +} + +export const CoordinateLink: React.FC = ({ lat, lng, label }) => { + const { setMapData } = useMapData(); + const id = `${lat},${lng}`; + + useEffect(() => { + // Automatically add marker and pan to it when the component mounts + setMapData(prev => { + const exists = prev.markers?.some(m => m.id === id); + const newMarkers = exists ? prev.markers : [...(prev.markers || []), { id, latitude: lat, longitude: lng, title: label || id }]; + + return { + ...prev, + targetPosition: { lat, lng }, + markers: newMarkers + }; + }); + }, [lat, lng, id, label, setMapData]); + + const handleClick = (e: React.MouseEvent) => { + e.preventDefault(); + setMapData(prev => ({ + ...prev, + targetPosition: { lat, lng } + })); + }; + + return ( + + {label || `${lat.toFixed(4)}, ${lng.toFixed(4)}`} + + ); +}; diff --git a/components/map/map-data-context.tsx b/components/map/map-data-context.tsx index b96d7018..581367c1 100644 --- a/components/map/map-data-context.tsx +++ b/components/map/map-data-context.tsx @@ -24,6 +24,7 @@ export interface MapData { geometry: any; }>; markers?: Array<{ + id: string; latitude: number; longitude: number; title?: string; diff --git a/components/map/map-query-handler.tsx b/components/map/map-query-handler.tsx index ea460170..d5053538 100644 --- a/components/map/map-query-handler.tsx +++ b/components/map/map-query-handler.tsx @@ -36,16 +36,23 @@ export const MapQueryHandler: React.FC = ({ toolOutput }) if (typeof latitude === 'number' && typeof longitude === 'number') { console.log(`MapQueryHandler: Received data from geospatialTool. Place: ${place_name}, Lat: ${latitude}, Lng: ${longitude}`); - setMapData(prevData => ({ - ...prevData, - targetPosition: { lat: latitude, lng: longitude }, - // Optionally store more info from mcp_response if needed by MapboxMap component later - mapFeature: { - place_name, - // Potentially add mapUrl or other details from toolOutput.mcp_response - mapUrl: toolOutput.mcp_response?.mapUrl - } - })); + const id = `${latitude},${longitude}`; + setMapData(prevData => { + const exists = prevData.markers?.some(m => m.id === id); + const newMarkers = exists ? prevData.markers : [...(prevData.markers || []), { id, latitude, longitude, title: place_name || id }]; + + return { + ...prevData, + targetPosition: { lat: latitude, lng: longitude }, + markers: newMarkers, + // Optionally store more info from mcp_response if needed by MapboxMap component later + mapFeature: { + place_name, + // Potentially add mapUrl or other details from toolOutput.mcp_response + mapUrl: toolOutput.mcp_response?.mapUrl + } + }; + }); } else { console.warn("MapQueryHandler: Invalid latitude/longitude in toolOutput.mcp_response:", toolOutput.mcp_response.location); // Clear target position if data is invalid diff --git a/components/map/mapbox-map.tsx b/components/map/mapbox-map.tsx index 3dd390cd..00e74af3 100644 --- a/components/map/mapbox-map.tsx +++ b/components/map/mapbox-map.tsx @@ -23,6 +23,7 @@ export const Mapbox: React.FC<{ position?: { latitude: number; longitude: number const rotationFrameRef = useRef(null) const polygonLabelsRef = useRef<{ [id: string]: mapboxgl.Marker }>({}) const lineLabelsRef = useRef<{ [id: string]: mapboxgl.Marker }>({}) + const markersRef = useRef<{ [id: string]: mapboxgl.Marker }>({}) const lastInteractionRef = useRef(Date.now()) const isRotatingRef = useRef(false) const isUpdatingPositionRef = useRef(false) @@ -554,6 +555,50 @@ export const Mapbox: React.FC<{ position?: { latitude: number; longitude: number // } }, [mapData.targetPosition, mapData.mapFeature, updateMapPosition]); + // Effect to synchronize markers from MapDataContext + useEffect(() => { + if (!map.current || !initializedRef.current) return; + + // Remove markers that are no longer in mapData + const currentMarkerIds = new Set(mapData.markers?.map(m => m.id) || []); + Object.keys(markersRef.current).forEach(id => { + if (!currentMarkerIds.has(id)) { + markersRef.current[id].remove(); + delete markersRef.current[id]; + } + }); + + // Add or update markers from mapData + mapData.markers?.forEach(markerData => { + if (!markersRef.current[markerData.id]) { + // Create a custom element for the marker to include a label + const el = document.createElement('div'); + el.className = 'custom-marker'; + + // Marker icon/dot + const dot = document.createElement('div'); + dot.className = 'w-4 h-4 bg-primary border-2 border-white rounded-full shadow-lg'; + el.appendChild(dot); + + // Label + if (markerData.title) { + const label = document.createElement('div'); + label.className = 'absolute bottom-full left-1/2 -translate-x-1/2 mb-1 px-2 py-1 bg-white/90 text-primary text-[10px] font-bold rounded shadow-sm whitespace-nowrap pointer-events-none border border-primary/20'; + label.textContent = markerData.title; + el.appendChild(label); + } + + const marker = new mapboxgl.Marker({ element: el }) + .setLngLat([markerData.longitude, markerData.latitude]) + .setPopup(markerData.title ? new mapboxgl.Popup({ offset: 25 }).setHTML(`

${markerData.title}

`) : null) + .addTo(map.current!); + markersRef.current[markerData.id] = marker; + } else { + markersRef.current[markerData.id].setLngLat([markerData.longitude, markerData.latitude]); + } + }); + }, [mapData.markers]); + // Long-press handlers const handleMouseDown = useCallback(() => { // Only activate long press if not in real-time mode (as that mode has its own interactions) diff --git a/components/message.tsx b/components/message.tsx index 264aa1f6..98c9c56c 100644 --- a/components/message.tsx +++ b/components/message.tsx @@ -7,6 +7,7 @@ import remarkGfm from 'remark-gfm' import remarkMath from 'remark-math' import rehypeKatex from 'rehype-katex' import 'katex/dist/katex.min.css' +import { CoordinateLink } from './map/coordinate-link' export function BotMessage({ content }: { content: StreamableValue }) { const [data, error, pending] = useStreamableValue(content) @@ -23,6 +24,69 @@ export function BotMessage({ content }: { content: StreamableValue }) { rehypePlugins={[[rehypeExternalLinks, { target: '_blank' }], rehypeKatex]} remarkPlugins={[remarkGfm, remarkMath]} className="prose-sm prose-neutral prose-a:text-accent-foreground/50" + components={{ + text: (props) => { + const { children } = props; + if (typeof children !== 'string') return <>{children}; + const value = children; + + // Highly inclusive regex for coordinates: + // Matches numbers with optional degrees/ordinal indicators and N/S/E/W suffixes. + const coordRegex = /(-?\d+(?:\.\d+)?)\s*[°\u00B0\u00BA]?\s*([NS])?[\s,°\u00B0\u00BA]+(-?\d+(?:\.\d+)?)\s*[°\u00B0\u00BA]?\s*([EW])?/gi; + + const parts = []; + let lastIndex = 0; + const matches = Array.from(value.matchAll(coordRegex)); + + for (const match of matches) { + const fullMatch = match[0]; + let lat = parseFloat(match[1]); + const latSuffix = match[2]; + let lng = parseFloat(match[3]); + const lngSuffix = match[4]; + + // Apply suffixes + if (latSuffix) { + if (latSuffix.toUpperCase() === 'S') lat = -Math.abs(lat); + else if (latSuffix.toUpperCase() === 'N') lat = Math.abs(lat); + } + if (lngSuffix) { + if (lngSuffix.toUpperCase() === 'W') lng = -Math.abs(lng); + else if (lngSuffix.toUpperCase() === 'E') lng = Math.abs(lng); + } + + // Validate range + if (!isNaN(lat) && !isNaN(lng) && lat >= -90 && lat <= 90 && lng >= -180 && lng <= 180) { + // Ensure we have some context (suffix, comma, or degree symbol) to avoid matching arbitrary pairs of numbers + const hasContext = !!(latSuffix || lngSuffix || fullMatch.includes('°') || fullMatch.includes('\u00B0') || fullMatch.includes('\u00BA') || fullMatch.includes(',')); + const isDecimal = match[1].includes('.') || match[3].includes('.'); + + if (hasContext || isDecimal) { + if (match.index! > lastIndex) { + parts.push(value.substring(lastIndex, match.index!)); + } + parts.push( + + ); + lastIndex = match.index! + fullMatch.length; + } + } + } + + if (parts.length === 0) return <>{value}; + + if (lastIndex < value.length) { + parts.push(value.substring(lastIndex)); + } + + return <>{parts}; + } + }} > {processedData} diff --git a/server.log b/server.log index 45044476..251d4835 100644 --- a/server.log +++ b/server.log @@ -1,11 +1,35 @@ -$ next dev --turbo - ⚠ Port 3000 is in use, using available port 3003 instead. + +> QCX@0.1.0 dev +> next dev --turbo + ▲ Next.js 15.3.6 (Turbopack) - - Local: http://localhost:3003 - - Network: http://192.168.0.2:3003 - - Environments: .env.local, .env + - Local: http://localhost:3000 + - Network: http://192.168.0.2:3000 + - Environments: .env ✓ Starting... - ○ Compiling middleware ... - ✓ Compiled middleware in 648ms - ✓ Ready in 2.5s + ✓ Compiled middleware in 359ms + ✓ Ready in 1716ms + ○ Compiling / ... + ✓ Compiled / in 27.5s +Chat DB actions loaded. Ensure getCurrentUserId() is correctly implemented for server-side usage if applicable. +[Upstash Redis] The 'url' property is missing or undefined in your Redis config. +[Upstash Redis] The 'token' property is missing or undefined in your Redis config. + GET / 200 in 31388ms + GET / 200 in 934ms + ○ Compiling /search/[id] ... + ✓ Compiled /search/[id] in 12.1s +Chat DB actions loaded. Ensure getCurrentUserId() is correctly implemented for server-side usage if applicable. +[Upstash Redis] The 'url' property is missing or undefined in your Redis config. +[Upstash Redis] The 'token' property is missing or undefined in your Redis config. + ⨯ [TypeError: action is not a function] { digest: '2341665709' } + POST /search/4hlC3SIEkZPxlRUV5KUmP 500 in 15389ms +[DEBUG] getSelectedModel - Reading from path: "/app/config/model.json" +[DEBUG] getSelectedModel - Raw file content: "{ + "selectedModel": null +} +" + POST /search/4hlC3SIEkZPxlRUV5KUmP 200 in 945ms +Chat DB actions loaded. Ensure getCurrentUserId() is correctly implemented for server-side usage if applicable. +[Upstash Redis] The 'url' property is missing or undefined in your Redis config. +[Upstash Redis] The 'token' property is missing or undefined in your Redis config.