Skip to content

Commit 7f10ef4

Browse files
authored
Merge pull request #499 from QueueLab/feat/dual-image-resolution-search-14866169086758664624
Dual Image Resolution Search and QCX-TERRA Analysis
2 parents 2ecc4fc + 3c3f44b commit 7f10ef4

File tree

8 files changed

+426
-76
lines changed

8 files changed

+426
-76
lines changed

app/actions.tsx

Lines changed: 60 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { BotMessage } from '@/components/message'
2121
import { SearchSection } from '@/components/search-section'
2222
import SearchRelated from '@/components/search-related'
2323
import { GeoJsonLayer } from '@/components/map/geojson-layer'
24+
import { ResolutionCarousel } from '@/components/resolution-carousel'
2425
import { ResolutionImage } from '@/components/resolution-image'
2526
import { CopilotDisplay } from '@/components/copilot-display'
2627
import RetrieveSection from '@/components/retrieve-section'
@@ -50,18 +51,29 @@ async function submit(formData?: FormData, skip?: boolean) {
5051
}
5152

5253
if (action === 'resolution_search') {
53-
const file = formData?.get('file') as File;
54+
const file_mapbox = formData?.get('file_mapbox') as File;
55+
const file_google = formData?.get('file_google') as File;
56+
const file = (formData?.get('file') as File) || file_mapbox || file_google;
5457
const timezone = (formData?.get('timezone') as string) || 'UTC';
58+
const lat = formData?.get('latitude') ? parseFloat(formData.get('latitude') as string) : undefined;
59+
const lng = formData?.get('longitude') ? parseFloat(formData.get('longitude') as string) : undefined;
60+
const location = (lat !== undefined && lng !== undefined) ? { lat, lng } : undefined;
5561

5662
if (!file) {
5763
throw new Error('No file provided for resolution search.');
5864
}
5965

66+
const mapboxBuffer = file_mapbox ? await file_mapbox.arrayBuffer() : null;
67+
const mapboxDataUrl = mapboxBuffer ? `data:${file_mapbox.type};base64,${Buffer.from(mapboxBuffer).toString('base64')}` : null;
68+
69+
const googleBuffer = file_google ? await file_google.arrayBuffer() : null;
70+
const googleDataUrl = googleBuffer ? `data:${file_google.type};base64,${Buffer.from(googleBuffer).toString('base64')}` : null;
71+
6072
const buffer = await file.arrayBuffer();
6173
const dataUrl = `data:${file.type};base64,${Buffer.from(buffer).toString('base64')}`;
6274

6375
const messages: CoreMessage[] = [...(aiState.get().messages as any[])].filter(
64-
message =>
76+
(message: any) =>
6577
message.role !== 'tool' &&
6678
message.type !== 'followup' &&
6779
message.type !== 'related' &&
@@ -89,7 +101,7 @@ async function submit(formData?: FormData, skip?: boolean) {
89101

90102
async function processResolutionSearch() {
91103
try {
92-
const streamResult = await resolutionSearch(messages, timezone, drawnFeatures);
104+
const streamResult = await resolutionSearch(messages, timezone, drawnFeatures, location);
93105

94106
let fullSummary = '';
95107
for await (const partialObject of streamResult.partialObjectStream) {
@@ -113,7 +125,7 @@ async function submit(formData?: FormData, skip?: boolean) {
113125

114126
messages.push({ role: 'assistant', content: analysisResult.summary || 'Analysis complete.' });
115127

116-
const sanitizedMessages: CoreMessage[] = messages.map(m => {
128+
const sanitizedMessages: CoreMessage[] = messages.map((m: any) => {
117129
if (Array.isArray(m.content)) {
118130
return {
119131
...m,
@@ -124,7 +136,7 @@ async function submit(formData?: FormData, skip?: boolean) {
124136
})
125137

126138
const currentMessages = aiState.get().messages;
127-
const sanitizedHistory = currentMessages.map(m => {
139+
const sanitizedHistory = currentMessages.map((m: any) => {
128140
if (m.role === "user" && Array.isArray(m.content)) {
129141
return {
130142
...m,
@@ -159,7 +171,9 @@ async function submit(formData?: FormData, skip?: boolean) {
159171
role: 'assistant',
160172
content: JSON.stringify({
161173
...analysisResult,
162-
image: dataUrl
174+
image: dataUrl,
175+
mapboxImage: mapboxDataUrl,
176+
googleImage: googleDataUrl
163177
}),
164178
type: 'resolution_search_result'
165179
},
@@ -190,7 +204,11 @@ async function submit(formData?: FormData, skip?: boolean) {
190204

191205
uiStream.update(
192206
<Section title="response">
193-
<ResolutionImage src={dataUrl} />
207+
<ResolutionCarousel
208+
mapboxImage={mapboxDataUrl || undefined}
209+
googleImage={googleDataUrl || undefined}
210+
initialImage={dataUrl}
211+
/>
194212
<BotMessage content={summaryStream.value} />
195213
</Section>
196214
);
@@ -203,43 +221,20 @@ async function submit(formData?: FormData, skip?: boolean) {
203221
};
204222
}
205223

206-
const messages: CoreMessage[] = [...(aiState.get().messages as any[])].filter(
207-
message =>
208-
message.role !== 'tool' &&
209-
message.type !== 'followup' &&
210-
message.type !== 'related' &&
211-
message.type !== 'end' &&
212-
message.type !== 'resolution_search_result'
213-
).map(m => {
214-
if (Array.isArray(m.content)) {
215-
return {
216-
...m,
217-
content: m.content.filter((part: any) =>
218-
part.type !== "image" || (typeof part.image === "string" && part.image.startsWith("data:"))
219-
)
220-
} as any
221-
}
222-
return m
223-
})
224-
225-
const groupeId = nanoid()
226-
const useSpecificAPI = process.env.USE_SPECIFIC_API_FOR_WRITER === 'true'
227-
const maxMessages = useSpecificAPI ? 5 : 10
228-
messages.splice(0, Math.max(messages.length - maxMessages, 0))
229-
224+
const file = !skip ? (formData?.get('file') as File) : undefined
230225
const userInput = skip
231226
? `{"action": "skip"}`
232227
: ((formData?.get('related_query') as string) ||
233228
(formData?.get('input') as string))
234229

235-
if (userInput.toLowerCase().trim() === 'what is a planet computer?' || userInput.toLowerCase().trim() === 'what is qcx-terra?') {
230+
if (userInput && (userInput.toLowerCase().trim() === 'what is a planet computer?' || userInput.toLowerCase().trim() === 'what is qcx-terra?')) {
236231
const definition = userInput.toLowerCase().trim() === 'what is a planet computer?'
237232
? `A planet computer is a proprietary environment aware system that interoperates weather forecasting, mapping and scheduling using cutting edge multi-agents to streamline automation and exploration on a planet. Available for our Pro and Enterprise customers. [QCX Pricing](https://www.queue.cx/#pricing)`
238-
239233
: `QCX-Terra is a model garden of pixel level precision geospatial foundational models for efficient land feature predictions from satellite imagery. Available for our Pro and Enterprise customers. [QCX Pricing] (https://www.queue.cx/#pricing)`;
240234

241235
const content = JSON.stringify(Object.fromEntries(formData!));
242236
const type = 'input';
237+
const groupeId = nanoid();
243238

244239
aiState.update({
245240
...aiState.get(),
@@ -299,10 +294,9 @@ async function submit(formData?: FormData, skip?: boolean) {
299294
id: nanoid(),
300295
isGenerating: isGenerating.value,
301296
component: uiStream.value,
302-
isCollapsed: isCollapsed.value,
297+
isCollapsed: isCollapsed.value
303298
};
304299
}
305-
const file = !skip ? (formData?.get('file') as File) : undefined
306300

307301
if (!userInput && !file) {
308302
isGenerating.done(false)
@@ -314,6 +308,30 @@ async function submit(formData?: FormData, skip?: boolean) {
314308
}
315309
}
316310

311+
const messages: CoreMessage[] = [...(aiState.get().messages as any[])].filter(
312+
(message: any) =>
313+
message.role !== 'tool' &&
314+
message.type !== 'followup' &&
315+
message.type !== 'related' &&
316+
message.type !== 'end' &&
317+
message.type !== 'resolution_search_result'
318+
).map((m: any) => {
319+
if (Array.isArray(m.content)) {
320+
return {
321+
...m,
322+
content: m.content.filter((part: any) =>
323+
part.type !== "image" || (typeof part.image === "string" && part.image.startsWith("data:"))
324+
)
325+
} as any
326+
}
327+
return m
328+
})
329+
330+
const groupeId = nanoid()
331+
const useSpecificAPI = process.env.USE_SPECIFIC_API_FOR_WRITER === 'true'
332+
const maxMessages = useSpecificAPI ? 5 : 10
333+
messages.splice(0, Math.max(messages.length - maxMessages, 0))
334+
317335
const messageParts: {
318336
type: 'text' | 'image'
319337
text?: string
@@ -725,12 +743,18 @@ export const getUIStateFromAIState = (aiState: AIState): UIState => {
725743
const analysisResult = JSON.parse(content as string);
726744
const geoJson = analysisResult.geoJson as FeatureCollection;
727745
const image = analysisResult.image as string;
746+
const mapboxImage = analysisResult.mapboxImage as string;
747+
const googleImage = analysisResult.googleImage as string;
728748

729749
return {
730750
id,
731751
component: (
732752
<>
733-
{image && <ResolutionImage src={image} />}
753+
<ResolutionCarousel
754+
mapboxImage={mapboxImage}
755+
googleImage={googleImage}
756+
initialImage={image}
757+
/>
734758
{geoJson && (
735759
<GeoJsonLayer id={id} data={geoJson} />
736760
)}

components/compare-slider.tsx

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
'use client'
2+
3+
import React, { useState, useRef, useEffect } from 'react'
4+
import { cn } from '@/lib/utils'
5+
6+
interface CompareSliderProps {
7+
leftImage: string
8+
rightImage: string
9+
className?: string
10+
}
11+
12+
export function CompareSlider({ leftImage, rightImage, className }: CompareSliderProps) {
13+
const [sliderPosition, setSliderPosition] = useState(50)
14+
const containerRef = useRef<HTMLDivElement>(null)
15+
const [containerWidth, setContainerWidth] = useState(0)
16+
17+
useEffect(() => {
18+
if (!containerRef.current) return
19+
20+
const observer = new ResizeObserver((entries) => {
21+
for (let entry of entries) {
22+
setContainerWidth(entry.contentRect.width)
23+
}
24+
})
25+
26+
observer.observe(containerRef.current)
27+
return () => observer.disconnect()
28+
}, [])
29+
30+
const handleMove = (event: React.MouseEvent | React.TouchEvent) => {
31+
if (!containerRef.current) return
32+
33+
const containerRect = containerRef.current.getBoundingClientRect()
34+
const x = 'touches' in event ? event.touches[0].clientX : (event as React.MouseEvent).clientX
35+
const relativeX = x - containerRect.left
36+
const position = Math.max(0, Math.min(100, (relativeX / containerRect.width) * 100))
37+
38+
setSliderPosition(position)
39+
}
40+
41+
return (
42+
<div
43+
ref={containerRef}
44+
className={cn("relative w-full aspect-square sm:aspect-video overflow-hidden cursor-ew-resize rounded-lg border bg-muted", className)}
45+
onMouseMove={handleMove}
46+
onTouchMove={handleMove}
47+
>
48+
{/* Right Image (Google Satellite) */}
49+
<img
50+
src={rightImage}
51+
alt="Google Satellite"
52+
className="absolute inset-0 w-full h-full object-cover select-none pointer-events-none"
53+
/>
54+
55+
{/* Left Image (Mapbox) */}
56+
<div
57+
className="absolute inset-0 overflow-hidden"
58+
style={{ width: `${sliderPosition}%` }}
59+
>
60+
<div style={{ width: containerWidth }} className="h-full relative">
61+
<img
62+
src={leftImage}
63+
alt="Mapbox"
64+
className="absolute inset-0 w-full h-full object-cover select-none pointer-events-none"
65+
/>
66+
</div>
67+
</div>
68+
69+
{/* Slider Handle */}
70+
<div
71+
className="absolute inset-y-0 w-1 bg-white shadow-[0_0_10px_rgba(0,0,0,0.5)] pointer-events-none"
72+
style={{ left: `${sliderPosition}%` }}
73+
>
74+
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-10 h-10 bg-white rounded-full border-2 border-primary flex items-center justify-center shadow-xl">
75+
<div className="flex gap-1">
76+
<div className="w-1 h-4 bg-primary/40 rounded-full" />
77+
<div className="w-1 h-4 bg-primary/40 rounded-full" />
78+
</div>
79+
</div>
80+
</div>
81+
82+
{/* Labels */}
83+
<div className="absolute bottom-2 left-2 px-2 py-1 bg-black/50 text-white text-[10px] rounded pointer-events-none">
84+
MAPBOX
85+
</div>
86+
<div className="absolute bottom-2 right-2 px-2 py-1 bg-black/50 text-white text-[10px] rounded pointer-events-none">
87+
GOOGLE SATELLITE
88+
</div>
89+
</div>
90+
)
91+
}

0 commit comments

Comments
 (0)