Building a GPS Dataset Manager
Building a GPS Dataset Manager
Problem Statement
Challenge: Create a React component that allows you to manage GPS datasets with full CRUD operations, semantic search, map visualization, and sync controlsβall integrated with your existing NNT ecosystem.
Requirements:
- Browse and create GPS datasets
- Add/edit/delete GPS objects with rich metadata
- Semantic search with natural language queries
- Interactive map visualization (Google Maps)
- Sync status and manual sync triggers
- Link GPS objects to songs (NNT integration)
- Export to GeoJSON for version control
- Responsive UI for desktop and tablet
Solution: React component library using Convex for real-time data, Google Maps JavaScript API for visualization, and Langchain.js for search.
Component Architecture
GPSDatasetManager/
βββ DatasetList.tsx # Browse datasets
βββ DatasetEditor.tsx # Create/edit datasets
βββ ObjectList.tsx # Browse GPS objects in dataset
βββ ObjectEditor.tsx # Add/edit GPS object
βββ MapViewer.tsx # Google Maps with objects
βββ SearchInterface.tsx # Semantic search UI
βββ SyncControls.tsx # Manual sync triggers
βββ hooks/
βββ useDatasets.ts # Convex queries
βββ useGPSObjects.ts # Object CRUD
βββ useSemanticSearch.ts # Search with embeddings
βββ useSyncStatus.ts # Sync monitoring
Core Component: DatasetList
tsx// components/GPSDatasetManager/DatasetList.tsx import { useQuery } from "convex/react"; import { api } from "../../convex/_generated/api"; import { useState } from "react"; export function DatasetList() { const datasets = useQuery(api.gpsDatasets.list); const [selectedDataset, setSelectedDataset] = useState(null); return ( <div className="dataset-list"> <div className="header"> <h2>GPS Datasets</h2> <button onClick={() => setShowCreateModal(true)}> + New Dataset </button> </div> <div className="datasets-grid"> {datasets?.map(dataset => ( <DatasetCard key={dataset._id} dataset={dataset} onSelect={setSelectedDataset} /> ))} </div> {selectedDataset && ( <DatasetEditor datasetId={selectedDataset} /> )} </div> ); } function DatasetCard({ dataset, onSelect }) { const objectCount = useQuery(api.gpsObjects.count, { datasetId: dataset._id }); const syncStatus = useQuery(api.sync.getStatus, { datasetId: dataset._id }); return ( <div className="dataset-card" onClick={() => onSelect(dataset._id)} > <div className="dataset-icon"> {getIconForType(dataset.datasetType)} </div> <h3>{dataset.name}</h3> <p className="description">{dataset.description}</p> <div className="stats"> <span>{objectCount} objects</span> <span className={`sync-status ${syncStatus?.status}`}> {syncStatus?.unsyncedChanges > 0 ? `${syncStatus.unsyncedChanges} unsynced` : "β Synced"} </span> </div> <div className="dataset-type-badge"> {dataset.datasetType} </div> </div> ); }
DatasetEditor: Create & Manage Datasets
tsx// components/GPSDatasetManager/DatasetEditor.tsx import { useMutation, useQuery } from "convex/react"; import { api } from "../../convex/_generated/api"; import { useState } from "react"; export function DatasetEditor({ datasetId }) { const dataset = useQuery(api.gpsDatasets.get, { datasetId }); const objects = useQuery(api.gpsObjects.list, { datasetId }); const [activeTab, setActiveTab] = useState<"objects" | "map" | "search">("objects"); if (!dataset) return <div>Loading...</div>; return ( <div className="dataset-editor"> <div className="editor-header"> <h1>{dataset.name}</h1> <SyncControls datasetId={datasetId} /> </div> <div className="tabs"> <button className={activeTab === "objects" ? "active" : ""} onClick={() => setActiveTab("objects")} > Objects ({objects?.length ?? 0}) </button> <button className={activeTab === "map" ? "active" : ""} onClick={() => setActiveTab("map")} > Map View </button> <button className={activeTab === "search" ? "active" : ""} onClick={() => setActiveTab("search")} > Search </button> </div> <div className="tab-content"> {activeTab === "objects" && ( <ObjectList datasetId={datasetId} objects={objects} /> )} {activeTab === "map" && ( <MapViewer datasetId={datasetId} objects={objects} /> )} {activeTab === "search" && ( <SearchInterface datasetId={datasetId} /> )} </div> </div> ); }
ObjectEditor: Add/Edit GPS Objects
tsx// components/GPSDatasetManager/ObjectEditor.tsx import { useMutation } from "convex/react"; import { api } from "../../convex/_generated/api"; import { useState } from "react"; export function ObjectEditor({ datasetId, objectId = null }) { const object = useQuery(api.gpsObjects.get, objectId ? { objectId } : undefined); const createObject = useMutation(api.gpsObjects.create); const updateObject = useMutation(api.gpsObjects.update); const [formData, setFormData] = useState({ name: object?.name ?? "", description: object?.description ?? "", geometry: object?.geometry ?? { type: "Point", coordinates: [0, 0] }, properties: object?.properties ?? {}, tags: object?.tags ?? [], songId: object?.songId ?? null }); const handleSubmit = async (e) => { e.preventDefault(); if (objectId) { await updateObject({ objectId, updates: formData }); } else { await createObject({ datasetId, ...formData }); } onClose(); }; return ( <form className="object-editor" onSubmit={handleSubmit}> <h2>{objectId ? "Edit Object" : "Add GPS Object"}</h2> {/* Basic Info */} <div className="form-section"> <h3>Basic Information</h3> <label> Name * <input type="text" value={formData.name} onChange={(e) => setFormData({ ...formData, name: e.target.value })} required /> </label> <label> Description <textarea value={formData.description} onChange={(e) => setFormData({ ...formData, description: e.target.value })} rows={3} /> </label> <label> Tags (comma-separated) <input type="text" value={formData.tags.join(", ")} onChange={(e) => setFormData({ ...formData, tags: e.target.value.split(",").map(t => t.trim()) })} /> </label> </div> {/* Location */} <div className="form-section"> <h3>Location</h3> <LocationPicker geometry={formData.geometry} onChange={(geometry) => setFormData({ ...formData, geometry })} /> </div> {/* Properties (Dynamic based on dataset type) */} <div className="form-section"> <h3>Properties</h3> <PropertiesEditor datasetType={dataset.datasetType} properties={formData.properties} onChange={(properties) => setFormData({ ...formData, properties })} /> </div> {/* NNT Integration */} {dataset.datasetType === "audio_recordings" && ( <div className="form-section"> <h3>Link to Song</h3> <SongPicker selectedSongId={formData.songId} onChange={(songId) => setFormData({ ...formData, songId })} /> </div> )} <div className="form-actions"> <button type="button" onClick={onClose}>Cancel</button> <button type="submit"> {objectId ? "Update" : "Create"} Object </button> </div> </form> ); }
LocationPicker: Interactive Coordinate Selection
tsx// components/GPSDatasetManager/LocationPicker.tsx import { GoogleMap, Marker, useLoadScript } from "@react-google-maps/api"; import { useState } from "react"; export function LocationPicker({ geometry, onChange }) { const { isLoaded } = useLoadScript({ googleMapsApiKey: process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY! }); const [mapCenter, setMapCenter] = useState({ lat: geometry.coordinates[1], lng: geometry.coordinates[0] }); const handleMapClick = (e: google.maps.MapMouseEvent) => { if (!e.latLng) return; const lat = e.latLng.lat(); const lng = e.latLng.lng(); onChange({ type: "Point", coordinates: [lng, lat] }); setMapCenter({ lat, lng }); }; if (!isLoaded) return <div>Loading map...</div>; return ( <div className="location-picker"> <div className="coordinates-input"> <label> Latitude <input type="number" step="0.000001" value={geometry.coordinates[1]} onChange={(e) => onChange({ ...geometry, coordinates: [geometry.coordinates[0], parseFloat(e.target.value)] })} /> </label> <label> Longitude <input type="number" step="0.000001" value={geometry.coordinates[0]} onChange={(e) => onChange({ ...geometry, coordinates: [parseFloat(e.target.value), geometry.coordinates[1]] })} /> </label> </div> <GoogleMap mapContainerStyle={{ width: "100%", height: "400px" }} center={mapCenter} zoom={14} onClick={handleMapClick} > <Marker position={{ lat: geometry.coordinates[1], lng: geometry.coordinates[0] }} draggable onDragEnd={(e) => { if (!e.latLng) return; onChange({ type: "Point", coordinates: [e.latLng.lng(), e.latLng.lat()] }); }} /> </GoogleMap> <p className="help-text"> Click map or drag marker to set location. Or enter coordinates manually. </p> </div> ); }
PropertiesEditor: Dynamic Fields by Dataset Type
tsx// components/GPSDatasetManager/PropertiesEditor.tsx import { useState } from "react"; export function PropertiesEditor({ datasetType, properties, onChange }) { // Render fields based on dataset type switch (datasetType) { case "audio_recordings": return <AudioRecordingProperties properties={properties} onChange={onChange} />; case "music_venues": return <MusicVenueProperties properties={properties} onChange={onChange} />; case "water_infrastructure": return <WaterInfrastructureProperties properties={properties} onChange={onChange} />; default: return <GenericPropertiesEditor properties={properties} onChange={onChange} />; } } function AudioRecordingProperties({ properties, onChange }) { return ( <div className="properties-form"> <label> Recording Date <input type="datetime-local" value={properties.recording_date?.slice(0, 16) ?? ""} onChange={(e) => onChange({ ...properties, recording_date: new Date(e.target.value).toISOString() })} /> </label> <label> Equipment <input type="text" value={properties.equipment ?? ""} onChange={(e) => onChange({ ...properties, equipment: e.target.value })} placeholder="Zoom H6, Rode NTG3" /> </label> <label> Duration (mm:ss) <input type="text" value={properties.duration ?? ""} onChange={(e) => onChange({ ...properties, duration: e.target.value })} placeholder="45:32" /> </label> <label> Audio URL <input type="url" value={properties.audio_url ?? ""} onChange={(e) => onChange({ ...properties, audio_url: e.target.value })} /> </label> <label> Weather <input type="text" value={properties.weather ?? ""} onChange={(e) => onChange({ ...properties, weather: e.target.value })} placeholder="Clear, 18Β°C, light winds" /> </label> <label> Notes <textarea value={properties.notes ?? ""} onChange={(e) => onChange({ ...properties, notes: e.target.value })} rows={3} placeholder="Additional recording notes..." /> </label> </div> ); } function MusicVenueProperties({ properties, onChange }) { return ( <div className="properties-form"> <label> Address <input type="text" value={properties.address ?? ""} onChange={(e) => onChange({ ...properties, address: e.target.value })} /> </label> <label> Capacity <input type="number" value={properties.capacity ?? ""} onChange={(e) => onChange({ ...properties, capacity: parseInt(e.target.value) })} /> </label> <label> Genres (comma-separated) <input type="text" value={properties.genres?.join(", ") ?? ""} onChange={(e) => onChange({ ...properties, genres: e.target.value.split(",").map(g => g.trim()) })} /> </label> <label> Phone <input type="tel" value={properties.phone ?? ""} onChange={(e) => onChange({ ...properties, phone: e.target.value })} /> </label> <label> Website <input type="url" value={properties.website ?? ""} onChange={(e) => onChange({ ...properties, website: e.target.value })} /> </label> <div className="checkbox-group"> <label> <input type="checkbox" checked={properties.has_piano ?? false} onChange={(e) => onChange({ ...properties, has_piano: e.target.checked })} /> Has Piano </label> <label> <input type="checkbox" checked={properties.has_drums ?? false} onChange={(e) => onChange({ ...properties, has_drums: e.target.checked })} /> Has Drums </label> </div> </div> ); }
MapViewer: Google Maps with Dataset Objects
tsx// components/GPSDatasetManager/MapViewer.tsx import { GoogleMap, Marker, InfoWindow, useLoadScript } from "@react-google-maps/api"; import { useState } from "react"; export function MapViewer({ datasetId, objects }) { const { isLoaded } = useLoadScript({ googleMapsApiKey: process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY! }); const [selectedObject, setSelectedObject] = useState(null); // Calculate map center from objects const center = calculateCenter(objects); if (!isLoaded) return <div>Loading map...</div>; return ( <div className="map-viewer"> <GoogleMap mapContainerStyle={{ width: "100%", height: "600px" }} center={center} zoom={12} > {objects?.map(obj => { if (obj.geometry.type !== "Point") return null; return ( <Marker key={obj._id} position={{ lat: obj.geometry.coordinates[1], lng: obj.geometry.coordinates[0] }} onClick={() => setSelectedObject(obj)} icon={{ url: getIconForDatasetType(obj.datasetType), scaledSize: new google.maps.Size(40, 40) }} /> ); })} {selectedObject && ( <InfoWindow position={{ lat: selectedObject.geometry.coordinates[1], lng: selectedObject.geometry.coordinates[0] }} onCloseClick={() => setSelectedObject(null)} > <ObjectInfoCard object={selectedObject} /> </InfoWindow> )} </GoogleMap> <MapLegend datasetType={objects?.[0]?.datasetType} /> </div> ); } function ObjectInfoCard({ object }) { return ( <div className="object-info-card"> <h3>{object.name}</h3> {object.description && <p>{object.description}</p>} {object.properties.audio_url && ( <audio controls src={object.properties.audio_url} /> )} <div className="object-properties"> {Object.entries(object.properties).map(([key, value]) => ( <div key={key} className="property-row"> <span className="property-key">{formatKey(key)}:</span> <span className="property-value">{formatValue(value)}</span> </div> ))} </div> <div className="object-actions"> <button onClick={() => editObject(object._id)}>Edit</button> <button onClick={() => viewDetails(object._id)}>Details</button> </div> </div> ); }
SearchInterface: Semantic Search UI
tsx// components/GPSDatasetManager/SearchInterface.tsx import { useState } from "react"; import { useQuery } from "convex/react"; import { api } from "../../convex/_generated/api"; export function SearchInterface({ datasetId }) { const [query, setQuery] = useState(""); const [filters, setFilters] = useState({ nearLocation: null, tags: [], dateRange: null }); const results = useQuery( api.search.hybridSearch, query ? { query, datasetId, ...filters, limit: 20 } : "skip" ); return ( <div className="search-interface"> <div className="search-bar"> <input type="text" value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Find recordings with heavy bass near the border..." className="search-input" /> <button onClick={() => setShowFilters(!showFilters)}> Filters {filters.tags.length + (filters.nearLocation ? 1 : 0) > 0 && `(${count})`} </button> </div> {showFilters && ( <SearchFilters filters={filters} onChange={setFilters} /> )} {results && ( <SearchResults results={results} onSelectObject={handleSelectObject} /> )} </div> ); } function SearchResults({ results, onSelectObject }) { return ( <div className="search-results"> <div className="results-header"> <h3>Found {results.length} results</h3> </div> {results.map(result => ( <div key={result.object._id} className="result-card" onClick={() => onSelectObject(result.object)} > <div className="result-header"> <h4>{result.object.name}</h4> <span className="similarity-score"> {(result.scores.composite * 100).toFixed(0)}% match </span> </div> <p className="result-description"> {result.object.description} </p> <div className="result-scores"> <span title="Semantic similarity"> π {(result.scores.semantic * 100).toFixed(0)}% </span> <span title="Location relevance"> π {(result.scores.spatial * 100).toFixed(0)}% </span> <span title="Recency"> β±οΈ {(result.scores.recency * 100).toFixed(0)}% </span> </div> <p className="result-explanation"> {result.explanation} </p> <div className="result-tags"> {result.object.tags.map(tag => ( <span key={tag} className="tag">{tag}</span> ))} </div> </div> ))} </div> ); }
SyncControls: Manual Sync & Status
tsx// components/GPSDatasetManager/SyncControls.tsx import { useMutation, useQuery } from "convex/react"; import { api } from "../../convex/_generated/api"; export function SyncControls({ datasetId }) { const syncStatus = useQuery(api.sync.getStatus, { datasetId }); const triggerSync = useMutation(api.sync.triggerManualSync); const exportToGeoJSON = useMutation(api.sync.exportToGeoJSON); const [syncing, setSyncing] = useState(false); const handleSync = async () => { setSyncing(true); try { await triggerSync({ datasetId }); alert("Sync completed successfully!"); } catch (error) { alert(`Sync failed: ${error.message}`); } finally { setSyncing(false); } }; const handleExport = async () => { const geojson = await exportToGeoJSON({ datasetId }); // Download as file const blob = new Blob([JSON.stringify(geojson, null, 2)], { type: "application/json" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = `${syncStatus.datasetName}.geojson`; a.click(); }; return ( <div className="sync-controls"> <div className="sync-status"> <span className={`status-indicator ${syncStatus?.status}`}> {syncStatus?.status === "synced" ? "β" : "β "} </span> <div className="status-details"> {syncStatus?.unsyncedChanges > 0 ? ( <p>{syncStatus.unsyncedChanges} unsynced changes</p> ) : ( <p>All changes synced</p> )} {syncStatus?.lastSyncedAt && ( <p className="last-sync"> Last synced: {formatTimestamp(syncStatus.lastSyncedAt)} </p> )} </div> </div> <div className="sync-actions"> <button onClick={handleSync} disabled={syncing || syncStatus?.unsyncedChanges === 0} > {syncing ? "Syncing..." : "Sync Now"} </button> <button onClick={handleExport}> Export GeoJSON </button> </div> </div> ); }
Custom Hooks
useGPSObjects Hook
typescript// hooks/useGPSObjects.ts import { useMutation, useQuery } from "convex/react"; import { api } from "../../convex/_generated/api"; export function useGPSObjects(datasetId: string) { const objects = useQuery(api.gpsObjects.list, { datasetId }); const createObject = useMutation(api.gpsObjects.create); const updateObject = useMutation(api.gpsObjects.update); const deleteObject = useMutation(api.gpsObjects.delete); return { objects, createObject, updateObject, deleteObject, isLoading: objects === undefined }; }
useSemanticSearch Hook
typescript// hooks/useSemanticSearch.ts import { useQuery } from "convex/react"; import { api } from "../../convex/_generated/api"; import { useState, useEffect } from "react"; import { useDebouncedCallback } from "use-debounce"; export function useSemanticSearch(datasetId: string) { const [query, setQuery] = useState(""); const [debouncedQuery, setDebouncedQuery] = useState(""); // Debounce query to avoid excessive API calls const debouncedSetQuery = useDebouncedCallback( (value: string) => setDebouncedQuery(value), 500 ); useEffect(() => { debouncedSetQuery(query); }, [query, debouncedSetQuery]); const results = useQuery( api.search.hybridSearch, debouncedQuery ? { query: debouncedQuery, datasetId, limit: 20 } : "skip" ); return { query, setQuery, results, isSearching: query !== debouncedQuery, hasResults: results && results.length > 0 }; }
Integration with NNT Ecosystem
Link GPS Objects to Songs
tsx// components/GPSDatasetManager/SongPicker.tsx import { useQuery } from "convex/react"; import { api } from "../../convex/_generated/api"; export function SongPicker({ selectedSongId, onChange }) { const [searchQuery, setSearchQuery] = useState(""); const songs = useQuery(api.songs.search, { query: searchQuery }); return ( <div className="song-picker"> <input type="text" placeholder="Search songs..." value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} /> <div className="song-list"> {songs?.map(song => ( <div key={song._id} className={`song-card ${selectedSongId === song._id ? "selected" : ""}`} onClick={() => onChange(song._id)} > <h4>{song.title}</h4> <p>{song.artist}</p> </div> ))} </div> {selectedSongId && ( <div className="selected-song"> <TScribePreview songId={selectedSongId} /> </div> )} </div> ); }
Deployment Considerations
Environment Variables
bash# .env.local NEXT_PUBLIC_CONVEX_URL=https://your-project.convex.cloud NEXT_PUBLIC_GOOGLE_MAPS_API_KEY=your-api-key OPENAI_API_KEY=your-openai-key GOOGLE_CLOUD_KEY_PATH=/path/to/service-account.json GCS_BUCKET=your-gcs-bucket
Permissions
typescript// convex/auth.config.ts export default { providers: [/* ... */], // Role-based access control rules: [ { // Admin can edit all datasets resource: "gpsDatasets", action: ["read", "write", "delete"], role: "admin" }, { // Students can read public datasets resource: "gpsDatasets", action: "read", condition: (user, dataset) => dataset.isPublic } ] };
See Also
- GPS Dataset Architecture with Convex - Database schema
- Syncing Maps Datasets API with Convex - Sync workflows
- Semantic Search for GPS Objects - Search implementation
- React Component State Management - NNT ecosystem patterns
- Maps JavaScript API - Google Maps integration