Skip to content

Commit 238baa2

Browse files
committed
feat: persist Colab route geometry bundle for CityScope parity
- Add colab_route_geometry_bundle (jsonb) on submission_batches and migration extending submit_unified_submission_write with p_colab_route_geometry_bundle. - Serialize the painted IntegratedRoute via colabRouteGeometryExport on unified submit (MapPage + unifiedSubmission). - Return/select the bundle where submission batches are loaded (submissionBatches). - Add tests for the export helper.
1 parent dbfa5e3 commit 238baa2

6 files changed

Lines changed: 426 additions & 2 deletions

File tree

src/pages/MapPage/index.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
flattenIntegratedRouteForPersistence,
2525
parseDefaultLinePaths,
2626
} from "../../utils/pinkLineRoute";
27+
import { serializeIntegratedRouteToColabBundle } from "../../utils/colabRouteGeometryExport";
2728
import { addDetourPaintToMap } from "../../map/pinkDetourLeaflet";
2829
import { routeLineStylesForDisplayColor } from "./mapLineStyles";
2930
import {
@@ -1406,6 +1407,11 @@ const MapPage = () => {
14061407

14071408
setSubmitting(true);
14081409
try {
1410+
const colabRouteGeometryBundle =
1411+
includePink && integratedPinkRoute
1412+
? serializeIntegratedRouteToColabBundle(integratedPinkRoute)
1413+
: null;
1414+
14091415
const useOverwrite = isEditingExistingSubmission && submitEditDisposition === "overwrite";
14101416

14111417
if (useOverwrite) {
@@ -1427,6 +1433,7 @@ const MapPage = () => {
14271433
pinkNodes,
14281434
pinkRoutePoints: includePink ? pinkRouteForPersistence : [],
14291435
memorialSites: memorialRows,
1436+
colabRouteGeometryBundle,
14301437
...overwriteColorPayload,
14311438
});
14321439
} else {
@@ -1444,6 +1451,7 @@ const MapPage = () => {
14441451
pinkNodes,
14451452
pinkRoutePoints: includePink ? pinkRouteForPersistence : [],
14461453
memorialSites: memorialRows,
1454+
colabRouteGeometryBundle,
14471455
...newColorPayload,
14481456
});
14491457
selectedSubmissionIdRef.current = submissionId;

src/supabase/submissionBatches.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { GeoJSON } from "geojson";
22
import supabase from ".";
33
import type { PendingSite } from "./memorialSites";
4+
import type { ColabRouteGeometryBundle } from "../utils/colabRouteGeometryExport";
45

56
/** Matches `PendingPinkNode` in MapPage (`tempId` is stable per geo_feature row for hydration). */
67
export interface SubmissionBatchPinkNodeState {
@@ -32,6 +33,7 @@ export interface SubmissionBatchDetail {
3233
pinkNodes: SubmissionBatchPinkNodeState[];
3334
centralSite: PendingSite | null;
3435
localSites: PendingSite[];
36+
colabRouteGeometryBundle: ColabRouteGeometryBundle | null;
3537
}
3638

3739
export type MapSubmissionListContext = {
@@ -313,15 +315,23 @@ export async function loadSubmissionBatchMapDetail(
313315
const { data: batch, error: batchErr } = await supabase
314316
.from("submission_batches")
315317
.select(
316-
"submission_id, submission_name, display_color, created_by, created_at, updated_at"
318+
"submission_id, submission_name, display_color, created_by, created_at, updated_at, colab_route_geometry_bundle"
317319
)
318320
.eq("submission_id", submissionId)
319321
.maybeSingle();
320322

321323
if (batchErr) throw batchErr;
322324
if (!batch) throw new Error(`Submission batch not found: ${submissionId}`);
323325

324-
const b = batch as SubmissionBatchRow;
326+
const b = batch as SubmissionBatchRow & {
327+
colab_route_geometry_bundle?: unknown;
328+
};
329+
const colabRouteGeometryBundleRaw = b.colab_route_geometry_bundle;
330+
const colabRouteGeometryBundle: ColabRouteGeometryBundle | null =
331+
colabRouteGeometryBundleRaw != null &&
332+
typeof colabRouteGeometryBundleRaw === "object"
333+
? (colabRouteGeometryBundleRaw as ColabRouteGeometryBundle)
334+
: null;
325335

326336
type FeatureRow = {
327337
id: string;
@@ -421,6 +431,7 @@ export async function loadSubmissionBatchMapDetail(
421431
pinkNodes,
422432
centralSite,
423433
localSites,
434+
colabRouteGeometryBundle,
424435
};
425436
}
426437

src/supabase/unifiedSubmission.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import supabase from ".";
33
import { MemorialFeatureType } from "./memorialSites";
44
import type { MapWorkspaceProjectIds } from "./submissionBatches";
55
import { isAllowedSubmissionDisplayColor } from "../submission/submissionDisplayColor";
6+
import type { ColabRouteGeometryBundle } from "../utils/colabRouteGeometryExport";
67

78
export interface PendingPinkNodeSubmission {
89
name: string | null;
@@ -31,6 +32,7 @@ type SubmitUnifiedFeaturesParamsBase = {
3132
pinkRoutePoints?: Array<[number, number]>;
3233
memorialSites: PendingMemorialSubmission[];
3334
submissionDisplayColor?: string | null;
35+
colabRouteGeometryBundle?: ColabRouteGeometryBundle | null;
3436
};
3537

3638
/** Create a new submission batch row and insert features (default). */
@@ -207,6 +209,9 @@ export async function submitUnifiedFeatures(params: SubmitUnifiedFeaturesParams)
207209
p_memorial_project_id: workspace.memorialProjectId,
208210
p_feature_rows: rowsToRpcPayload(rows),
209211
p_submission_display_color: params.submissionDisplayColor ?? null,
212+
...(params.colabRouteGeometryBundle != null
213+
? { p_colab_route_geometry_bundle: params.colabRouteGeometryBundle }
214+
: {}),
210215
});
211216
if (error) throw error;
212217
await insertPinkRouteLineIfNeeded(
@@ -237,6 +242,9 @@ export async function submitUnifiedFeatures(params: SubmitUnifiedFeaturesParams)
237242
p_memorial_project_id: params.memorialProjectId,
238243
p_feature_rows: rowsToRpcPayload(rows),
239244
p_submission_display_color: params.submissionDisplayColor ?? null,
245+
...(params.colabRouteGeometryBundle != null
246+
? { p_colab_route_geometry_bundle: params.colabRouteGeometryBundle }
247+
: {}),
240248
});
241249
if (error) throw error;
242250
await insertPinkRouteLineIfNeeded(
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { describe, expect, it } from "vitest";
2+
import {
3+
COLAB_ROUTE_GEOMETRY_EXPORT_VERSION,
4+
serializeIntegratedRouteToColabBundle,
5+
} from "./colabRouteGeometryExport";
6+
import type { IntegratedRoute } from "./pinkLineRoute";
7+
8+
describe("serializeIntegratedRouteToColabBundle", () => {
9+
it("maps LatLng to GeoJSON lng/lat and splits detourPaint kinds", () => {
10+
const route: IntegratedRoute = {
11+
solid: [
12+
[
13+
[32.0, 34.9],
14+
[32.001, 34.901],
15+
],
16+
],
17+
dashed: [],
18+
removed: [
19+
[
20+
[32.01, 34.91],
21+
[32.02, 34.92],
22+
],
23+
],
24+
detourPaint: [
25+
{
26+
kind: "road",
27+
points: [
28+
[32.1, 35.0],
29+
[32.11, 35.01],
30+
],
31+
},
32+
{
33+
kind: "offroad",
34+
roadEnd: [32.11, 35.01],
35+
target: [32.2, 35.1],
36+
},
37+
],
38+
degradedDashedSegments: 0,
39+
};
40+
41+
const bundle = serializeIntegratedRouteToColabBundle(route);
42+
43+
expect(bundle.detour_export_version).toBe(COLAB_ROUTE_GEOMETRY_EXPORT_VERSION);
44+
expect(bundle.integrated_route.solid[0]!.coordinates[0]).toEqual([34.9, 32.0]);
45+
expect(bundle.integrated_route.removed[0]!.coordinates[0]).toEqual([34.91, 32.01]);
46+
expect(bundle.detour_paint.road[0]!.coordinates[0]).toEqual([35.0, 32.1]);
47+
expect(bundle.detour_paint.offroad[0]!.road_end).toEqual([35.01, 32.11]);
48+
expect(bundle.detour_paint.offroad[0]!.target).toEqual([35.1, 32.2]);
49+
expect(bundle.detour_paint.junctions).toEqual([[35.01, 32.11]]);
50+
});
51+
52+
it("uses dashed road legs when detourPaint is empty", () => {
53+
const route: IntegratedRoute = {
54+
solid: [],
55+
dashed: [
56+
[
57+
[32.0, 35.0],
58+
[32.1, 35.1],
59+
],
60+
],
61+
removed: [],
62+
degradedDashedSegments: 0,
63+
};
64+
const bundle = serializeIntegratedRouteToColabBundle(route);
65+
expect(bundle.detour_paint.road).toHaveLength(1);
66+
expect(bundle.detour_paint.offroad).toHaveLength(0);
67+
expect(bundle.detour_paint.junctions).toEqual([]);
68+
});
69+
});
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import type { DetourPaintPiece, IntegratedRoute, LatLng } from "./pinkLineRoute";
2+
3+
/** GeoJSON position order in exported JSON (RFC 7946). Internal `LatLng` remains [lat, lng]. */
4+
export type LngLat = [number, number];
5+
6+
export const COLAB_ROUTE_GEOMETRY_EXPORT_VERSION = 1 as const;
7+
8+
export interface ColabRouteGeometryBundle {
9+
detour_export_version: typeof COLAB_ROUTE_GEOMETRY_EXPORT_VERSION;
10+
integrated_route: {
11+
solid: Array<{ coordinates: LngLat[] }>;
12+
removed: Array<{ coordinates: LngLat[] }>;
13+
};
14+
detour_paint: {
15+
road: Array<{ coordinates: LngLat[] }>;
16+
offroad: Array<{ road_end: LngLat; target: LngLat }>;
17+
junctions: LngLat[];
18+
};
19+
}
20+
21+
function toLngLat([lat, lng]: LatLng): LngLat {
22+
return [lng, lat];
23+
}
24+
25+
function ringLngLat(ring: LatLng[]): LngLat[] {
26+
return ring.map(toLngLat);
27+
}
28+
29+
function collectJunctionsFromPaint(pieces: DetourPaintPiece[]): LngLat[] {
30+
const out: LngLat[] = [];
31+
for (const p of pieces) {
32+
if (p.kind === "offroad") {
33+
out.push(toLngLat(p.roadEnd));
34+
}
35+
}
36+
return out;
37+
}
38+
39+
/**
40+
* Serializes the same `IntegratedRoute` snapshot used for map painting after routing.
41+
* When `detourPaint` is absent, proposed geometry is taken from `dashed` as road polylines.
42+
*/
43+
export function serializeIntegratedRouteToColabBundle(
44+
route: IntegratedRoute
45+
): ColabRouteGeometryBundle {
46+
const solid = route.solid.map((ring) => ({ coordinates: ringLngLat(ring) }));
47+
const removed = route.removed.map((ring) => ({ coordinates: ringLngLat(ring) }));
48+
49+
const road: Array<{ coordinates: LngLat[] }> = [];
50+
const offroad: Array<{ road_end: LngLat; target: LngLat }> = [];
51+
52+
if (route.detourPaint && route.detourPaint.length > 0) {
53+
for (const piece of route.detourPaint) {
54+
if (piece.kind === "road") {
55+
road.push({ coordinates: ringLngLat(piece.points) });
56+
} else {
57+
offroad.push({
58+
road_end: toLngLat(piece.roadEnd),
59+
target: toLngLat(piece.target),
60+
});
61+
}
62+
}
63+
} else {
64+
for (const seg of route.dashed) {
65+
if (seg.length >= 2) road.push({ coordinates: ringLngLat(seg) });
66+
}
67+
}
68+
69+
const junctions =
70+
route.detourPaint && route.detourPaint.length > 0
71+
? collectJunctionsFromPaint(route.detourPaint)
72+
: [];
73+
74+
return {
75+
detour_export_version: COLAB_ROUTE_GEOMETRY_EXPORT_VERSION,
76+
integrated_route: { solid, removed },
77+
detour_paint: { road, offroad, junctions },
78+
};
79+
}

0 commit comments

Comments
 (0)