Merge pull request #150 from trails-cool/fix/populate-postgis-geom
Populate PostGIS geom column from GPX track data
This commit is contained in:
commit
feeca58064
3 changed files with 45 additions and 4 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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", {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue