-
-
Notifications
You must be signed in to change notification settings - Fork 6
Description
PDF Report Generation — Implementation Plan for QCX
This feature does not exist yet in the codebase. There are zero existing PDF, report, export, or download utilities anywhere in the project. However, all the raw data you need is already being captured and stored. Here is a precise breakdown of what exists and exactly what needs to be built.
What Data Is Already Available
1. Questions & Responses (Chat Messages)
The full conversation history lives in AIState.messages, an array of AIMessage objects. Each message has a type field that distinguishes user questions from AI answers:
- User questions:
type === 'input'ortype === 'input_related' - AI answers:
type === 'response' - Map analysis results:
type === 'resolution_search_result'1
The AIState shape (holding all messages for the session) and the getUIStateFromAIState function (which reconstructs the full conversation from stored state) are both in app/actions.tsx: 2 3
Messages are persisted to a Postgres database via the messages and chats tables and can be retrieved by chat ID using getMessagesByChatId: 4 5
2. Map Screenshots (Images)
When a resolution search is run, the Mapbox screenshot, Google Satellite screenshot, and original image are all captured as base64 data URLs and stored directly in the resolution_search_result message content: 6
These are later parsed and rendered in ResolutionCarousel and ResolutionImage components: 7
Additionally, the live Mapbox map instance is initialized with preserveDrawingBuffer: true, which means at any moment you can call map.getCanvas().toDataURL() to capture the current map view as a PNG — even with drawn features visible: 8
The live map instance is accessible globally via the MapContext / useMap() hook: 9
3. Drawn Features & Measurements
All polygons and line strings drawn by the user, along with their computed area/distance measurements, are stored in MapData.drawnFeatures via the MapDataContext. Each entry contains the feature type, its geometry (GeoJSON), and its human-readable measurement string: 10
These are computed and stored whenever a shape is drawn or updated via updateMeasurementLabels, which builds the currentDrawnFeatures array from MapboxDraw's state: 11
The drawn features are also passed to the AI on every form submission via a hidden drawnFeatures field: 12
4. GeoJSON Layers
When the AI performs a geospatial analysis, it may return a geoJson field inside the resolution_search_result message, which is rendered as a map overlay: 13
What Needs to Be Built
Step 1 — Add a PDF Library
No PDF library exists in the project dependencies: 14
You need to add one. The two most compatible options with this Next.js 15 / React 19 stack are:
@react-pdf/renderer— compose the PDF as React components; works entirely client-sidejsPDF+html2canvas— screenshot DOM sections and embed them; simpler for rich content
Step 2 — Create a PDF Report Generator Utility
Create a new file (e.g., lib/utils/report-generator.ts) that accepts the following inputs:
messages: AIMessage[]— fromgetAIState()oruseAIState()(already typed inlib/types/index.ts)drawnFeatures: MapData['drawnFeatures']— fromuseMapData()contextmapSnapshot: string— frommap.getCanvas().toDataURL()using theuseMap()hookchatTitle: string— from thechatstable title
The report structure should be:
1. Cover page (chat title, date, map snapshot)
2. For each Q&A turn:
a. User question (type: 'input' or 'input_related')
b. AI response (type: 'response') — render markdown
c. Map images if present (type: 'resolution_search_result' → image, mapboxImage, googleImage)
d. GeoJSON summary if present
3. Drawings appendix (each drawn feature: type, measurement, coordinates)
Step 3 — Add a "Download Report" Button
The best place to add this is in the header, next to the existing action buttons (Calendar, Map Toggle, etc.): 15
The button component should:
- Call
useAIState()to readaiState.messages - Call
useMapData()to readmapData.drawnFeatures - Call
useMap()to get the live map instance and call.getCanvas().toDataURL() - Call the report generator, then trigger a browser download via
URL.createObjectURL(blob)
The sharing button in chat-share.tsx is fully disabled and returns null, so there is no existing share/export UI to reuse: 16
Step 4 — Wire Up Message Filtering
Use the same filtering logic already applied in submit() to isolate only user-facing content (exclude 'tool', 'followup', 'related', 'end' types): 17
For 'resolution_search_result' messages, parse the JSON content to extract image, mapboxImage, googleImage, and geoJson: 7
Architecture Diagram
flowchart TD
A["User clicks 'Download Report' button"] --> B["useAIState() → AIMessage[]"]
A --> C["useMapData() → drawnFeatures[]"]
A --> D["useMap() → map.getCanvas().toDataURL()"]
B --> E["Filter messages:\n input, response,\n resolution_search_result"]
E --> F["Parse Q&A pairs"]
E --> G["Parse map images\n (mapboxImage, googleImage, image)"]
E --> H["Parse GeoJSON layers"]
C --> I["Drawings appendix:\n type + measurement + geometry"]
D --> J["Live map snapshot PNG"]
F & G & H & I & J --> K["PDF Composer\n (@react-pdf/renderer or jsPDF)"]
K --> L["Blob download triggered\n in browser"]
Notes
preserveDrawingBuffer: trueon the Mapbox map is the critical enabler for live map screenshots. This is already set in the codebase and means no workaround is needed.- The
compressImageutility inlib/utils/image-utils.tscan be reused to reduce the size of map images before embedding them in the PDF. 18 - Chat history is persisted to Postgres, so reports can also be generated server-side (e.g., in a new API route under
app/api/) from a past chat ID usinggetMessagesByChatId(chatId), not just from the live session. 4 - The
calendarNotestable also links notes to chats and could optionally be included in the report as a "scheduled observations" section. 19 - The
visualizationstable stores GeoJSON map layers linked to chats, which could serve as an additional data source for reports. 20
Citations
File: lib/types/index.ts (L59-77)
export type AIMessage = {
role: 'user' | 'assistant' | 'system' | 'function' | 'data' | 'tool'
content: CoreMessage['content']
id: string
name?: string
createdAt?: Date // Added optional createdAt timestamp
type?:
| 'response'
| 'related'
| 'skip'
| 'inquiry'
| 'input'
| 'input_related'
| 'tool'
| 'followup'
| 'end'
| 'drawing_context' // Added custom type for drawing context messages
| 'resolution_search_result'
}File: app/actions.tsx (L116-124)
if (analysisResult.geoJson) {
uiStream.append(
<GeoJsonLayer
id={groupeId}
data={analysisResult.geoJson as FeatureCollection}
/>
);
}File: app/actions.tsx (L159-183)
aiState.done({
...aiState.get(),
messages: [
...aiState.get().messages,
{
id: groupeId,
role: 'assistant',
content: analysisResult.summary || 'Analysis complete.',
type: 'response'
},
{
id: groupeId,
role: 'assistant',
content: JSON.stringify({
...analysisResult,
image: dataUrl,
mapboxImage: mapboxDataUrl,
googleImage: googleDataUrl
}),
type: 'resolution_search_result'
},
{
id: groupeId,
role: 'assistant',
content: JSON.stringify(relatedQueries),File: app/actions.tsx (L311-328)
const messages: CoreMessage[] = [...(aiState.get().messages as any[])].filter(
(message: any) =>
message.role !== 'tool' &&
message.type !== 'followup' &&
message.type !== 'related' &&
message.type !== 'end' &&
message.type !== 'resolution_search_result'
).map((m: any) => {
if (Array.isArray(m.content)) {
return {
...m,
content: m.content.filter((part: any) =>
part.type !== "image" || (typeof part.image === "string" && part.image.startsWith("data:"))
)
} as any
}
return m
})File: app/actions.tsx (L561-572)
export type AIState = {
messages: AIMessage[]
chatId: string
isSharePage?: boolean
}
export type UIState = {
id: string
component: React.ReactNode
isGenerating?: StreamableValue<boolean>
isCollapsed?: StreamableValue<boolean>
}[]File: app/actions.tsx (L662-848)
export const getUIStateFromAIState = (aiState: AIState): UIState => {
const chatId = aiState.chatId
const isSharePage = aiState.isSharePage
return aiState.messages
.map((message, index) => {
const { role, content, id, type, name } = message
if (
!type ||
type === 'end' ||
(isSharePage && type === 'related') ||
(isSharePage && type === 'followup')
)
return null
switch (role) {
case 'user':
switch (type) {
case 'input':
case 'input_related':
let messageContent: string | any[]
try {
const json = JSON.parse(content as string)
messageContent =
type === 'input' ? json.input : json.related_query
} catch (e) {
messageContent = content
}
return {
id,
component: (
<UserMessage
content={messageContent}
chatId={chatId}
showShare={index === 0 && !isSharePage}
/>
)
}
case 'inquiry':
return {
id,
component: <CopilotDisplay content={content as string} />
}
}
break
case 'assistant':
const answer = createStreamableValue(content as string)
answer.done(content as string)
switch (type) {
case 'response':
return {
id,
component: (
<Section title="response">
<BotMessage content={answer.value} />
</Section>
)
}
case 'related':
const relatedQueries = createStreamableValue<RelatedQueries>({
items: []
})
relatedQueries.done(JSON.parse(content as string))
return {
id,
component: (
<Section title="Related" separator={true}>
<SearchRelated relatedQueries={relatedQueries.value} />
</Section>
)
}
case 'followup':
return {
id,
component: (
<Section title="Follow-up" className="pb-8">
<FollowupPanel />
</Section>
)
}
case 'resolution_search_result': {
const analysisResult = JSON.parse(content as string);
const geoJson = analysisResult.geoJson as FeatureCollection;
const image = analysisResult.image as string;
const mapboxImage = analysisResult.mapboxImage as string;
const googleImage = analysisResult.googleImage as string;
return {
id,
component: (
<>
<ResolutionCarousel
mapboxImage={mapboxImage}
googleImage={googleImage}
initialImage={image}
/>
{geoJson && (
<GeoJsonLayer id={id} data={geoJson} />
)}
</>
)
}
}
}
break
case 'tool':
try {
const toolOutput = JSON.parse(content as string)
const isCollapsed = createStreamableValue(true)
isCollapsed.done(true)
if (
toolOutput.type === 'MAP_QUERY_TRIGGER' &&
name === 'geospatialQueryTool'
) {
const mapUrl = toolOutput.mcp_response?.mapUrl;
const placeName = toolOutput.mcp_response?.location?.place_name;
return {
id,
component: (
<>
{mapUrl && (
<ResolutionImage
src={mapUrl}
className="mb-0"
alt={placeName ? `Map of ${placeName}` : 'Map Preview'}
/>
)}
<MapQueryHandler toolOutput={toolOutput} />
</>
),
isCollapsed: false
}
}
const searchResults = createStreamableValue(
JSON.stringify(toolOutput)
)
searchResults.done(JSON.stringify(toolOutput))
switch (name) {
case 'search':
return {
id,
component: <SearchSection result={searchResults.value} />,
isCollapsed: isCollapsed.value
}
case 'retrieve':
return {
id,
component: <RetrieveSection data={toolOutput} />,
isCollapsed: isCollapsed.value
}
case 'videoSearch':
return {
id,
component: (
<VideoSearchSection result={searchResults.value} />
),
isCollapsed: isCollapsed.value
}
default:
console.warn(
`Unhandled tool result in getUIStateFromAIState: ${name}`
)
return { id, component: null }
}
} catch (error) {
console.error(
'Error parsing tool content in getUIStateFromAIState:',
error
)
return {
id,
component: null
}
}
break
default:
return {
id,
component: null
}
}
})
.filter(message => message !== null) as UIState
}File: lib/actions/chat-db.ts (L198-214)
export async function getMessagesByChatId(chatId: string): Promise<Message[]> {
if (!chatId) {
console.warn('getMessagesByChatId called without chatId');
return [];
}
try {
const result = await db
.select()
.from(messages)
.where(eq(messages.chatId, chatId))
.orderBy(asc(messages.createdAt)); // Order messages chronologically
return result;
} catch (error) {
console.error(`Error fetching messages for chat ${chatId}:`, error);
return [];
}
}File: lib/db/schema.ts (L52-61)
export const messages = pgTable('messages', {
id: uuid('id').primaryKey().defaultRandom(),
chatId: uuid('chat_id').notNull().references(() => chats.id, { onDelete: 'cascade' }),
userId: uuid('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
role: text('role').notNull(),
content: text('content').notNull(),
embedding: vector('embedding'),
locationId: uuid('location_id').references(() => locations.id, { onDelete: 'set null' }),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
});File: lib/db/schema.ts (L82-90)
export const visualizations = pgTable('visualizations', {
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
chatId: uuid('chat_id').references(() => chats.id, { onDelete: 'cascade' }),
type: text('type').notNull().default('map_layer'),
data: jsonb('data').notNull(),
geometry: geometry('geometry'),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
});File: lib/db/schema.ts (L92-103)
export const calendarNotes = pgTable('calendar_notes', {
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
chatId: uuid('chat_id').references(() => chats.id, { onDelete: 'cascade' }),
date: timestamp('date', { withTimezone: true }).notNull(),
content: text('content').notNull(),
locationTags: jsonb('location_tags'),
userTags: text('user_tags').array(),
mapFeatureId: text('map_feature_id'),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
});File: components/map/mapbox-map.tsx (L67-168)
const updateMeasurementLabels = useCallback(() => {
if (!map.current || !drawRef.current) return
// Remove existing labels
Object.values(polygonLabelsRef.current).forEach(marker => marker.remove())
Object.values(lineLabelsRef.current).forEach(marker => marker.remove())
polygonLabelsRef.current = {}
lineLabelsRef.current = {}
const features = drawRef.current.getAll().features
const currentDrawnFeatures: Array<{ id: string; type: 'Polygon' | 'LineString'; measurement: string; geometry: any }> = []
features.forEach(feature => {
const id = feature.id as string
let featureType: 'Polygon' | 'LineString' | null = null;
let measurement = '';
if (feature.geometry.type === 'Polygon') {
featureType = 'Polygon';
// Calculate area for polygons
const area = turf.area(feature)
const formattedArea = formatMeasurement(area, true)
measurement = formattedArea;
// Get centroid for label placement
const centroid = turf.centroid(feature)
const coordinates = centroid.geometry.coordinates
// Create a label
const el = document.createElement('div')
el.className = 'area-label'
el.style.background = 'rgba(255, 255, 255, 0.8)'
el.style.padding = '4px 8px'
el.style.borderRadius = '4px'
el.style.fontSize = '12px'
el.style.fontWeight = 'bold'
el.style.color = '#333333' // Added darker color
el.style.boxShadow = '0 2px 4px rgba(0,0,0,0.2)'
el.style.pointerEvents = 'none'
el.textContent = formattedArea
// Add marker for the label
if (map.current) {
const marker = new mapboxgl.Marker({ element: el })
.setLngLat(coordinates as [number, number])
.addTo(map.current)
polygonLabelsRef.current[id] = marker
}
}
else if (feature.geometry.type === 'LineString') {
featureType = 'LineString';
// Calculate length for lines
const length = turf.length(feature, { units: 'kilometers' }) * 1000 // Convert to meters
const formattedLength = formatMeasurement(length, false)
measurement = formattedLength;
// Get midpoint for label placement
const line = feature.geometry.coordinates
const midIndex = Math.floor(line.length / 2) - 1
const midpoint = midIndex >= 0 ? line[midIndex] : line[0]
// Create a label
const el = document.createElement('div')
el.className = 'distance-label'
el.style.background = 'rgba(255, 255, 255, 0.8)'
el.style.padding = '4px 8px'
el.style.borderRadius = '4px'
el.style.fontSize = '12px'
el.style.fontWeight = 'bold'
el.style.color = '#333333' // Added darker color
el.style.boxShadow = '0 2px 4px rgba(0,0,0,0.2)'
el.style.pointerEvents = 'none'
el.textContent = formattedLength
// Add marker for the label
if (map.current) {
const marker = new mapboxgl.Marker({ element: el })
.setLngLat(midpoint as [number, number])
.addTo(map.current)
lineLabelsRef.current[id] = marker
}
}
if (featureType && id && measurement && feature.geometry) {
currentDrawnFeatures.push({
id,
type: featureType,
measurement,
geometry: feature.geometry,
});
}
})
setMapData(prevData => ({ ...prevData, drawnFeatures: currentDrawnFeatures }))
}, [formatMeasurement, setMapData])File: components/map/mapbox-map.tsx (L396-406)
map.current = new mapboxgl.Map({
container: mapContainer.current,
style: 'mapbox://styles/mapbox/satellite-streets-v12',
center: initialCenter,
zoom: initialZoom,
pitch: initialPitch,
bearing: initialBearing,
maxZoom: 22,
attributionControl: true,
preserveDrawingBuffer: true
})File: components/map/map-context.tsx (L1-30)
'use client'
import { createContext, useContext, useState, ReactNode } from 'react'
import type { Map as MapboxMap } from 'mapbox-gl'
// A more direct context to hold the map instance itself.
type MapContextType = {
map: MapboxMap | null;
setMap: (map: MapboxMap | null) => void;
};
const MapContext = createContext<MapContextType | undefined>(undefined);
export const MapProvider = ({ children }: { children: ReactNode }) => {
const [map, setMap] = useState<MapboxMap | null>(null);
return (
<MapContext.Provider value={{ map, setMap }}>
{children}
</MapContext.Provider>
);
};
export const useMap = (): MapContextType => {
const context = useContext(MapContext);
if (!context) {
throw new Error('useMap must be used within a MapProvider');
}
return context;
};File: components/map/map-data-context.tsx (L15-32)
export interface MapData {
targetPosition?: { lat: number; lng: number } | null; // For flying to a location
cameraState?: CameraState; // For saving camera state
currentTimezone?: string; // Current timezone identifier
// TODO: Add other relevant map data types later (e.g., routeGeoJSON, poiList)
mapFeature?: any | null; // Generic feature from MCP hook's processLocationQuery
drawnFeatures?: Array<{ // Added to store drawn features and their measurements
id: string;
type: 'Polygon' | 'LineString';
measurement: string;
geometry: any;
}>;
markers?: Array<{
latitude: number;
longitude: number;
title?: string;
}>;
}File: components/chat-panel.tsx (L118-120)
// Include drawn features in the form data
formData.append('drawnFeatures', JSON.stringify(mapData.drawnFeatures || []))File: package.json (L18-101)
"dependencies": {
"@ai-sdk/amazon-bedrock": "^1.1.6",
"@ai-sdk/anthropic": "^1.2.12",
"@ai-sdk/google": "^1.2.22",
"@ai-sdk/openai": "^1.3.24",
"@ai-sdk/xai": "^1.2.18",
"@composio/core": "^0.3.3",
"@google-cloud/storage": "^7.18.0",
"@google/generative-ai": "^0.24.1",
"@heroicons/react": "^2.2.0",
"@hookform/resolvers": "3.3.4",
"@mapbox/mapbox-gl-draw": "^1.5.0",
"@modelcontextprotocol/sdk": "^1.13.0",
"@radix-ui/react-alert-dialog": "^1.1.10",
"@radix-ui/react-avatar": "^1.1.6",
"@radix-ui/react-checkbox": "^1.2.2",
"@radix-ui/react-collapsible": "^1.1.7",
"@radix-ui/react-dialog": "^1.1.10",
"@radix-ui/react-dropdown-menu": "^2.1.11",
"@radix-ui/react-label": "^2.1.4",
"@radix-ui/react-radio-group": "^1.3.4",
"@radix-ui/react-separator": "^1.1.4",
"@radix-ui/react-slider": "^1.3.2",
"@radix-ui/react-slot": "^1.2.0",
"@radix-ui/react-switch": "^1.2.2",
"@radix-ui/react-tabs": "^1.1.9",
"@radix-ui/react-toast": "^1.2.11",
"@radix-ui/react-tooltip": "^1.2.3",
"@supabase/ssr": "^0.3.0",
"@supabase/supabase-js": "^2.0.0",
"@tailwindcss/typography": "^0.5.16",
"@tavily/core": "^0.6.4",
"@turf/turf": "^7.2.0",
"@types/mapbox__mapbox-gl-draw": "^1.4.8",
"@types/pg": "^8.15.4",
"@upstash/redis": "^1.35.0",
"@vercel/analytics": "^1.5.0",
"@vercel/speed-insights": "^1.2.0",
"@vis.gl/react-google-maps": "^1.7.1",
"ai": "^4.3.19",
"build": "^0.1.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cookie": "^0.6.0",
"csv-parse": "^6.1.0",
"dotenv": "^16.5.0",
"drizzle-kit": "^0.31.1",
"drizzle-orm": "^0.29.0",
"embla-carousel-react": "^8.6.0",
"exa-js": "^1.6.13",
"framer-motion": "^12.23.24",
"geotiff": "^2.1.4-beta.1",
"glassmorphic": "^0.0.3",
"katex": "^0.16.22",
"lodash": "^4.17.21",
"lottie-react": "^2.4.1",
"lucide-react": "^0.507.0",
"mapbox-gl": "^3.11.0",
"next": "15.3.8",
"next-themes": "^0.3.0",
"open-codex": "^0.1.30",
"pg": "^8.16.2",
"proj4": "^2.20.2",
"radix-ui": "^1.3.4",
"react": "19.1.2",
"react-dom": "19.1.2",
"react-hook-form": "^7.56.2",
"react-icons": "^5.5.0",
"react-markdown": "^9.1.0",
"react-textarea-autosize": "^8.5.9",
"react-toastify": "^10.0.6",
"rehype-external-links": "^3.0.0",
"rehype-katex": "^7.0.1",
"remark-gfm": "^4.0.1",
"remark-math": "^6.0.0",
"sonner": "^1.7.4",
"tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7",
"tz-lookup": "^6.1.25",
"use-mcp": "^0.0.9",
"uuid": "^9.0.0",
"zod": "^3.25.0",
"zustand": "^5.0.9"
},File: components/header.tsx (L69-87)
<div className="flex-1 hidden md:flex justify-center gap-10 items-center z-10">
<ProfileToggle/>
<MapToggle />
<Button variant="ghost" size="icon" onClick={toggleCalendar} title="Open Calendar" data-testid="calendar-toggle">
<CalendarDays className="h-[1.2rem] w-[1.2rem]" />
</Button>
<div id="header-search-portal" className="contents" />
<Button variant="ghost" size="icon" onClick={handleUsageToggle}>
<TentTree className="h-[1.2rem] w-[1.2rem]" />
</Button>
<ModeToggle />
<HistoryContainer location="header" />
</div>File: components/chat-share.tsx (L63-66)
// TODO: Re-evaluate/reimplement sharing with Supabase. For now, disable the UI.
if (true) { // Conditionally disable the share button/dialog
return null; // Or return a disabled button: <Button className={className} variant="ghost" size="icon" disabled><Share size={14} /></Button>
}File: lib/utils/image-utils.ts (L4-51)
export async function compressImage(blob: Blob, maxWidth = 1024, maxHeight = 1024, quality = 0.8): Promise<Blob> {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => {
let width = img.width;
let height = img.height;
if (width > height) {
if (width > maxWidth) {
height *= maxWidth / width;
width = maxWidth;
}
} else {
if (height > maxHeight) {
width *= maxHeight / height;
height = maxHeight;
}
}
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
if (!ctx) {
reject(new Error('Failed to get canvas context'));
return;
}
ctx.drawImage(img, 0, 0, width, height);
canvas.toBlob(
(compressedBlob) => {
if (compressedBlob) {
resolve(compressedBlob);
} else {
reject(new Error('Failed to compress image'));
}
},
'image/jpeg',
quality
);
};
img.onerror = () => reject(new Error('Failed to load image for compression'));
img.src = URL.createObjectURL(blob);
});
}