Merge pull request #150 from trails-cool/fix/populate-postgis-geom

Populate PostGIS geom column from GPX track data
This commit is contained in:
Ullrich Schäfer 2026-04-02 17:07:26 +01:00 committed by GitHub
commit feeca58064
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 45 additions and 4 deletions

View file

@ -1,8 +1,9 @@
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { eq, desc } from "drizzle-orm"; import { eq, desc } from "drizzle-orm";
import { getDb } from "./db.ts"; 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 { parseGpxAsync } from "@trails-cool/gpx";
import type { SQL } from "drizzle-orm";
export interface ActivityInput { export interface ActivityInput {
name: string; name: string;
@ -19,6 +20,7 @@ export async function createActivity(ownerId: string, input: ActivityInput) {
let elevationGain: number | null = null; let elevationGain: number | null = null;
let elevationLoss: number | null = null; let elevationLoss: number | null = null;
let startedAt: Date | null = null; let startedAt: Date | null = null;
let geom: SQL | null = null;
if (input.gpx) { if (input.gpx) {
try { try {
@ -28,6 +30,9 @@ export async function createActivity(ownerId: string, input: ActivityInput) {
elevationGain = gpxData.elevation.gain; elevationGain = gpxData.elevation.gain;
elevationLoss = gpxData.elevation.loss; 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 // Try to extract start time from first track point
if (gpxData.tracks[0]?.[0]?.time) { if (gpxData.tracks[0]?.[0]?.time) {
startedAt = new Date(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, elevationGain,
elevationLoss, elevationLoss,
startedAt, startedAt,
geom,
}); });
return id; return id;
@ -82,6 +88,17 @@ export async function createRouteFromActivity(activityId: string, ownerId: strin
if (!activity?.gpx) return null; if (!activity?.gpx) return null;
const routeId = randomUUID(); 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({ await db.insert(routes).values({
id: routeId, id: routeId,
ownerId, ownerId,
@ -91,6 +108,7 @@ export async function createRouteFromActivity(activityId: string, ownerId: strin
distance: activity.distance, distance: activity.distance,
elevationGain: activity.elevationGain, elevationGain: activity.elevationGain,
elevationLoss: activity.elevationLoss, elevationLoss: activity.elevationLoss,
geom: routeGeom,
}); });
// Link the activity to the new route // Link the activity to the new route

View file

@ -1,8 +1,9 @@
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { eq, desc, and } from "drizzle-orm"; import { eq, desc, and } from "drizzle-orm";
import { getDb } from "./db.ts"; 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 { parseGpx } from "@trails-cool/gpx";
import type { SQL } from "drizzle-orm";
export interface RouteInput { export interface RouteInput {
name: string; name: string;
@ -18,12 +19,14 @@ export async function createRoute(ownerId: string, input: RouteInput) {
let distance: number | null = null; let distance: number | null = null;
let elevationGain: number | null = null; let elevationGain: number | null = null;
let elevationLoss: number | null = null; let elevationLoss: number | null = null;
let geom: SQL | null = null;
if (input.gpx) { if (input.gpx) {
const stats = computeRouteStats(input.gpx); const stats = computeRouteStats(input.gpx);
distance = stats.distance; distance = stats.distance;
elevationGain = stats.elevationGain; elevationGain = stats.elevationGain;
elevationLoss = stats.elevationLoss; elevationLoss = stats.elevationLoss;
geom = stats.geom;
} }
await db.insert(routes).values({ await db.insert(routes).values({
@ -36,6 +39,7 @@ export async function createRoute(ownerId: string, input: RouteInput) {
distance, distance,
elevationGain, elevationGain,
elevationLoss, elevationLoss,
geom,
}); });
// Create initial version if GPX provided // Create initial version if GPX provided
@ -99,6 +103,7 @@ export async function updateRoute(
updateData.distance = stats.distance; updateData.distance = stats.distance;
updateData.elevationGain = stats.elevationGain; updateData.elevationGain = stats.elevationGain;
updateData.elevationLoss = stats.elevationLoss; updateData.elevationLoss = stats.elevationLoss;
updateData.geom = stats.geom;
// Get next version number // Get next version number
const existingVersions = await db const existingVersions = await db
@ -136,6 +141,7 @@ export async function deleteRoute(id: string, ownerId: string) {
function computeRouteStats(gpxString: string) { function computeRouteStats(gpxString: string) {
try { try {
const gpxData = parseGpx(gpxString); const gpxData = parseGpx(gpxString);
const coords = gpxData.tracks.flat().map((p) => [p.lon, p.lat] as [number, number]);
return { return {
distance: Math.round( distance: Math.round(
gpxData.elevation.profile.length > 0 gpxData.elevation.profile.length > 0
@ -144,8 +150,9 @@ function computeRouteStats(gpxString: string) {
), ),
elevationGain: gpxData.elevation.gain, elevationGain: gpxData.elevation.gain,
elevationLoss: gpxData.elevation.loss, elevationLoss: gpxData.elevation.loss,
geom: coords.length >= 2 ? lineStringFromCoords(coords) : null,
}; };
} catch { } catch {
return { distance: null, elevationGain: null, elevationLoss: null }; return { distance: null, elevationGain: null, elevationLoss: null, geom: null };
} }
} }

View file

@ -7,6 +7,7 @@ import {
jsonb, jsonb,
customType, customType,
} from "drizzle-orm/pg-core"; } from "drizzle-orm/pg-core";
import { sql, type SQL } from "drizzle-orm";
const bytea = customType<{ data: Buffer }>({ const bytea = customType<{ data: Buffer }>({
dataType() { dataType() {
@ -14,12 +15,27 @@ const bytea = customType<{ data: Buffer }>({
}, },
}); });
const lineString = customType<{ data: string }>({ const lineString = customType<{ data: string | SQL }>({
dataType() { dataType() {
return "geometry(LineString, 4326)"; 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 journalSchema = pgSchema("journal");
export const users = journalSchema.table("users", { export const users = journalSchema.table("users", {