← Back to articles

Building a GPS Dataset Manager

Path: Computer Tech/Development/Tech Companies/Google/Google Maps Platform/GPS Dataset Catalog/Building a GPS Dataset Manager.mdUpdated: 2/3/2026

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:

  1. Browse and create GPS datasets
  2. Add/edit/delete GPS objects with rich metadata
  3. Semantic search with natural language queries
  4. Interactive map visualization (Google Maps)
  5. Sync status and manual sync triggers
  6. Link GPS objects to songs (NNT integration)
  7. Export to GeoJSON for version control
  8. 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