/// /** * Location Track Module - GPS coordinate tracking over time * * A composable pattern that can be used standalone or embedded in containers * like Record. Captures GPS coordinates with timestamps, storing them as an * array for track/breadcrumb logging. * * TODO: Add confirmation dialog before "Clear All" to prevent accidental data loss * TODO: Add allowMultiple: true to MODULE_METADATA for multiple tracks per record * TODO: Consider increasing remove button tap target to 44x44px for mobile accessibility (currently 32x32px) * TODO: Add export functionality (GPX, GeoJSON, CSV) * TODO: Add virtualization for large point lists (100+ points) * TODO: Expose continuous tracking mode from ct-location * TODO: Add schema fields for altitudeAccuracy, heading, speed * TODO: Consider adding to "place" template or creating "trip" template */ import { computed, type Default, handler, ifElse, NAME, pattern, UI, Writable, } from "commontools"; import type { ModuleMetadata } from "./container-protocol.ts"; // ===== Types ===== /** * Single GPS point with metadata */ export interface LocationPoint { /** Unique ID for this point */ id: string; /** Latitude in decimal degrees */ latitude: number; /** Longitude in decimal degrees */ longitude: number; /** Accuracy in meters */ accuracy: number; /** Altitude in meters (if available) */ altitude?: number; /** Altitude accuracy in meters (if available) */ altitudeAccuracy?: number; /** Heading in degrees 0-360 (if available) */ heading?: number; /** Speed in m/s (if available) */ speed?: number; /** Unix timestamp in milliseconds */ timestamp: number; } // ===== Self-Describing Metadata ===== export const MODULE_METADATA: ModuleMetadata = { type: "location-track", label: "Location Track", icon: "\u{1F310}", // globe emoji schema: { locations: { type: "array", items: { type: "object", properties: { id: { type: "string" }, latitude: { type: "number" }, longitude: { type: "number" }, accuracy: { type: "number" }, altitude: { type: "number" }, timestamp: { type: "number" }, }, }, description: "Array of GPS location points with timestamps", }, label: { type: "string", description: "Label for this track" }, }, fieldMapping: ["locations", "label"], }; // ===== Module Input Type ===== export interface LocationTrackModuleInput { /** Array of captured location points */ locations: Default; /** Optional label for this track (e.g., "Morning run", "Commute") */ label: Default; } // ===== Handlers ===== /** * Handler for when ct-location emits a new location * Appends the location to the array */ const handleLocationUpdate = handler< { detail: { location: LocationPoint } }, { locations: Writable } >(({ detail }, { locations }) => { const newPoint = detail.location; // Validate required fields exist if ( newPoint && typeof newPoint.latitude === "number" && typeof newPoint.longitude === "number" && typeof newPoint.timestamp === "number" ) { locations.push(newPoint); } }); /** * Handler to clear all locations */ const clearLocations = handler< unknown, { locations: Writable } >((_event, { locations }) => { locations.set([]); }); /** * Handler to remove a specific location by index */ const removeLocation = handler< unknown, { locations: Writable; index: number } >((_event, { locations, index }) => { const current = locations.get() || []; locations.set(current.toSpliced(index, 1)); }); // ===== Helper Functions ===== function formatCoords(lat: number, lng: number): string { if ( typeof lat !== "number" || typeof lng !== "number" || isNaN(lat) || isNaN(lng) ) { return "Invalid coordinates"; } return `${lat.toFixed(6)}, ${lng.toFixed(6)}`; } function formatTimestamp(ts: number): string { if (typeof ts !== "number" || isNaN(ts)) { return "Invalid time"; } const date = new Date(ts); return date.toLocaleString(undefined, { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit", }); } function formatAccuracy(accuracy: number): string { if (typeof accuracy !== "number" || isNaN(accuracy)) { return ""; } if (accuracy < 100) { return `\u00B1${accuracy.toFixed(0)}m`; } return `\u00B1${(accuracy / 1000).toFixed(1)}km`; } // ===== The Pattern ===== export const LocationTrackModule = pattern< LocationTrackModuleInput, LocationTrackModuleInput >(({ locations, label }) => { // Local writable cell for ct-location binding (not stored) const currentCapture = Writable.of(null); // Computed display text const displayText = computed(() => { const count = locations.length || 0; const labelText = label ? `${label}: ` : ""; return count > 0 ? `${labelText}${count} point${count !== 1 ? "s" : ""}` : `${labelText}No points`; }); const hasPoints = computed(() => locations.length > 0); const hasMultiplePoints = computed(() => locations.length > 1); // Pre-compute filtered locations with indices for the list // IMPORTANT: We pre-compute index here because closures over index in .map() callbacks // don't work correctly with the reactive system const validLocationsWithIndex = computed(() => { return locations .map((point, index) => ({ point, index })) .filter(({ point }) => point && typeof point.latitude === "number"); }); return { [NAME]: computed(() => `${MODULE_METADATA.icon} Track: ${displayText}`), [UI]: ( {/* Label input */} {/* Location capture button */} {/* Points summary */} {computed(() => { const count = locations.length || 0; return `${count} point${count !== 1 ? "s" : ""} captured`; })} {ifElse( hasPoints, Clear All , null, )} {/* Points list (collapsible for many points) */} {ifElse( hasMultiplePoints, View all points {validLocationsWithIndex.map(({ point, index }) => ( {/* Point number - badge style */} {index + 1} {/* Main content: coords + metadata stacked */} {/* Coordinates */} {formatCoords(point.latitude, point.longitude)} {/* Timestamp + Accuracy on same line */} {formatTimestamp(point.timestamp)}{" "} ·{" "} {formatAccuracy(point.accuracy)} {/* Delete button - more visible */} ))} , null, )} ), locations, label, }; }); export default LocationTrackModule;