diff --git a/apps/journal/app/lib/activities.server.ts b/apps/journal/app/lib/activities.server.ts index e51c7b4..ecb9b1a 100644 --- a/apps/journal/app/lib/activities.server.ts +++ b/apps/journal/app/lib/activities.server.ts @@ -1,8 +1,9 @@ import { randomUUID } from "node:crypto"; import { eq, desc } from "drizzle-orm"; import { getDb } from "./db.ts"; -import { activities, routes } from "@trails-cool/db/schema/journal"; +import { activities, routes, lineStringFromCoords } from "@trails-cool/db/schema/journal"; import { parseGpxAsync } from "@trails-cool/gpx"; +import type { SQL } from "drizzle-orm"; export interface ActivityInput { name: string; @@ -19,6 +20,7 @@ export async function createActivity(ownerId: string, input: ActivityInput) { let elevationGain: number | null = null; let elevationLoss: number | null = null; let startedAt: Date | null = null; + let geom: SQL | null = null; if (input.gpx) { try { @@ -28,6 +30,9 @@ export async function createActivity(ownerId: string, input: ActivityInput) { elevationGain = gpxData.elevation.gain; elevationLoss = gpxData.elevation.loss; + const coords = gpxData.tracks.flat().map((p) => [p.lon, p.lat] as [number, number]); + if (coords.length >= 2) geom = lineStringFromCoords(coords); + // Try to extract start time from first track point if (gpxData.tracks[0]?.[0]?.time) { startedAt = new Date(gpxData.tracks[0][0].time); @@ -48,6 +53,7 @@ export async function createActivity(ownerId: string, input: ActivityInput) { elevationGain, elevationLoss, startedAt, + geom, }); return id; @@ -82,6 +88,17 @@ export async function createRouteFromActivity(activityId: string, ownerId: strin if (!activity?.gpx) return null; const routeId = randomUUID(); + let routeGeom: SQL | null = null; + if (activity.gpx) { + try { + const gpxData = await parseGpxAsync(activity.gpx); + const coords = gpxData.tracks.flat().map((p) => [p.lon, p.lat] as [number, number]); + if (coords.length >= 2) routeGeom = lineStringFromCoords(coords); + } catch { + // Continue without geom + } + } + await db.insert(routes).values({ id: routeId, ownerId, @@ -91,6 +108,7 @@ export async function createRouteFromActivity(activityId: string, ownerId: strin distance: activity.distance, elevationGain: activity.elevationGain, elevationLoss: activity.elevationLoss, + geom: routeGeom, }); // Link the activity to the new route diff --git a/apps/journal/app/lib/routes.server.ts b/apps/journal/app/lib/routes.server.ts index 1d36af3..2b0efe4 100644 --- a/apps/journal/app/lib/routes.server.ts +++ b/apps/journal/app/lib/routes.server.ts @@ -1,8 +1,9 @@ import { randomUUID } from "node:crypto"; import { eq, desc, and } from "drizzle-orm"; import { getDb } from "./db.ts"; -import { routes, routeVersions } from "@trails-cool/db/schema/journal"; +import { routes, routeVersions, lineStringFromCoords } from "@trails-cool/db/schema/journal"; import { parseGpx } from "@trails-cool/gpx"; +import type { SQL } from "drizzle-orm"; export interface RouteInput { name: string; @@ -18,12 +19,14 @@ export async function createRoute(ownerId: string, input: RouteInput) { let distance: number | null = null; let elevationGain: number | null = null; let elevationLoss: number | null = null; + let geom: SQL | null = null; if (input.gpx) { const stats = computeRouteStats(input.gpx); distance = stats.distance; elevationGain = stats.elevationGain; elevationLoss = stats.elevationLoss; + geom = stats.geom; } await db.insert(routes).values({ @@ -36,6 +39,7 @@ export async function createRoute(ownerId: string, input: RouteInput) { distance, elevationGain, elevationLoss, + geom, }); // Create initial version if GPX provided @@ -99,6 +103,7 @@ export async function updateRoute( updateData.distance = stats.distance; updateData.elevationGain = stats.elevationGain; updateData.elevationLoss = stats.elevationLoss; + updateData.geom = stats.geom; // Get next version number const existingVersions = await db @@ -136,6 +141,7 @@ export async function deleteRoute(id: string, ownerId: string) { function computeRouteStats(gpxString: string) { try { const gpxData = parseGpx(gpxString); + const coords = gpxData.tracks.flat().map((p) => [p.lon, p.lat] as [number, number]); return { distance: Math.round( gpxData.elevation.profile.length > 0 @@ -144,8 +150,9 @@ function computeRouteStats(gpxString: string) { ), elevationGain: gpxData.elevation.gain, elevationLoss: gpxData.elevation.loss, + geom: coords.length >= 2 ? lineStringFromCoords(coords) : null, }; } catch { - return { distance: null, elevationGain: null, elevationLoss: null }; + return { distance: null, elevationGain: null, elevationLoss: null, geom: null }; } } diff --git a/packages/db/src/schema/journal.ts b/packages/db/src/schema/journal.ts index 788dcad..0454d2d 100644 --- a/packages/db/src/schema/journal.ts +++ b/packages/db/src/schema/journal.ts @@ -7,6 +7,7 @@ import { jsonb, customType, } from "drizzle-orm/pg-core"; +import { sql, type SQL } from "drizzle-orm"; const bytea = customType<{ data: Buffer }>({ dataType() { @@ -14,12 +15,27 @@ const bytea = customType<{ data: Buffer }>({ }, }); -const lineString = customType<{ data: string }>({ +const lineString = customType<{ data: string | SQL }>({ dataType() { return "geometry(LineString, 4326)"; }, + toDriver(value) { + return value; + }, }); +/** + * Build a SQL expression to create a PostGIS LineString from coordinate pairs. + * Coordinates are [lon, lat] (GeoJSON order). + */ +export function lineStringFromCoords(coords: [number, number][]): SQL { + const geojson = JSON.stringify({ + type: "LineString", + coordinates: coords, + }); + return sql`ST_GeomFromGeoJSON(${geojson})`; +} + export const journalSchema = pgSchema("journal"); export const users = journalSchema.table("users", {