Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions components/map/coordinate-link.tsx
Original file line number Diff line number Diff line change
@@ -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<CoordinateLinkProps> = ({ 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 }];

Comment on lines +14 to +21

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The id is derived from raw lat,lng numbers. The same real-world coordinate can appear with different precision (e.g. 37.77 vs 37.7700) and become duplicate markers. Also, floating-point stringification can be inconsistent across sources.

A stable ID strategy (rounding to a fixed precision for IDs, or using an explicit unique ID from the tool when available) will prevent marker spam and make dedupe reliable.

Suggestion

Normalize coordinate IDs to a fixed precision (and consider including a namespace) so dedupe is consistent:

const norm = (n: number) => n.toFixed(6)
const id = `coord:${norm(lat)},${norm(lng)}`

Then use the same normalization in MapQueryHandler when generating IDs.

Reply with "@CharlieHelps yes please" if you'd like me to add a commit with this suggestion.

return {
...prev,
targetPosition: { lat, lng },
markers: newMarkers
};
});
}, [lat, lng, id, label, setMapData]);
Comment on lines +16 to +28

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The useEffect always sets targetPosition on mount, which means simply rendering historical chat messages containing coordinates will keep panning the map as the user scrolls / as React re-mounts nodes. This can create a very jumpy UX and makes map position dependent on render order rather than explicit user intent.

This is especially problematic because BotMessage will create a CoordinateLink for every match, so a single message with multiple coordinates can pan multiple times during initial render.

Suggestion

Consider not panning on mount. Split responsibilities:

  • On mount: only ensure the marker exists.
  • On click: pan (targetPosition) and optionally select/focus the marker.

Example:

useEffect(() => {
  setMapData(prev => {
    const exists = prev.markers?.some(m => m.id === id)
    if (exists) return prev

    return {
      ...prev,
      markers: [
        ...(prev.markers || []),
        { id, latitude: lat, longitude: lng, title: label || id }
      ]
    }
  })
}, [id, lat, lng, label, setMapData])

const handleClick = (e: React.MouseEvent) => {
  e.preventDefault()
  setMapData(prev => ({ ...prev, targetPosition: { lat, lng } }))
}

If you still want auto-pan, gate it behind a flag (e.g. autoPanOnMount?: boolean) and only enable it for tool outputs, not for every coordinate mention.

Reply with "@CharlieHelps yes please" if you'd like me to add a commit with this suggestion.


const handleClick = (e: React.MouseEvent) => {
e.preventDefault();
setMapData(prev => ({
...prev,
targetPosition: { lat, lng }
}));
};

return (
<a
href={`https://www.google.com/maps/search/?api=1&query=${lat},${lng}`}
target="_blank"
rel="noopener noreferrer"
onClick={handleClick}
className="text-primary font-semibold underline decoration-solid hover:text-primary/80 transition-colors cursor-pointer inline-flex items-center gap-1"
>
<span className="bg-primary/10 px-1 rounded">{label || `${lat.toFixed(4)}, ${lng.toFixed(4)}`}</span>
</a>
);
};
1 change: 1 addition & 0 deletions components/map/map-data-context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export interface MapData {
geometry: any;
}>;
markers?: Array<{
id: string;
latitude: number;
longitude: number;
title?: string;
Expand Down
27 changes: 17 additions & 10 deletions components/map/map-query-handler.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,16 +36,23 @@ export const MapQueryHandler: React.FC<MapQueryHandlerProps> = ({ 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
}
};
});
Comment on lines +39 to +55

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

id is computed as ${latitude},${longitude} directly from floats. Tool outputs often vary slightly between calls; this can lead to duplicated markers that represent the same real-world location.

Also, place_name is used as title and later used in Mapbox popup creation (currently via HTML). Even after fixing XSS, you probably still want a consistent normalization strategy shared with CoordinateLink.

Suggestion

Introduce and reuse a shared helper to generate marker IDs with fixed precision, e.g. coordId(lat, lng) using toFixed(6).

const id = coordId(latitude, longitude)

That keeps deduping stable and avoids near-duplicate markers.

Reply with "@CharlieHelps yes please" if you'd like me to add a commit with this suggestion.

} else {
console.warn("MapQueryHandler: Invalid latitude/longitude in toolOutput.mcp_response:", toolOutput.mcp_response.location);
// Clear target position if data is invalid
Expand Down
45 changes: 45 additions & 0 deletions components/map/mapbox-map.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export const Mapbox: React.FC<{ position?: { latitude: number; longitude: number
const rotationFrameRef = useRef<number | null>(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<number>(Date.now())
const isRotatingRef = useRef<boolean>(false)
const isUpdatingPositionRef = useRef<boolean>(false)
Expand Down Expand Up @@ -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(`<h3>${markerData.title}</h3>`) : 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)
Expand Down
64 changes: 64 additions & 0 deletions components/message.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> }) {
const [data, error, pending] = useStreamableValue(content)
Expand All @@ -23,6 +24,69 @@ export function BotMessage({ content }: { content: StreamableValue<string> }) {
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(
<CoordinateLink
key={`${lat}-${lng}-${match.index!}`}
lat={lat}
lng={lng}
label={fullMatch}
/>
);
lastIndex = match.index! + fullMatch.length;
}
}
}

if (parts.length === 0) return <>{value}</>;

if (lastIndex < value.length) {
parts.push(value.substring(lastIndex));
}

return <>{parts}</>;
}
}}
>
{processedData}
</MemoizedReactMarkdown>
Expand Down
40 changes: 32 additions & 8 deletions server.log
Original file line number Diff line number Diff line change
@@ -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.