Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"dev": "tsx watch --env-file-if-exists=.env src/server.ts",
"lint": "eslint .",
"start": "node --enable-source-maps --env-file-if-exists=.env dist/server.js",
"transit:import": "tsx src/transit/import-cli.ts",
"test": "vitest run",
"typecheck": "tsc"
},
Expand Down
17 changes: 17 additions & 0 deletions apps/api/src/db/seed.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,23 @@ describe('seed', () => {
}
});

// Criterion of #180: the access of every demo route, from the reference stations (D-050).
it('computes the access of every demo route, the station nearest its first step', async () => {
const rows = await app.db
.select({ id: routes.id, access: sql<unknown>`${routes.computed}->'access'` })
.from(routes)
.where(inArray(routes.id, demoRouteIds));
expect(rows).toHaveLength(10);
for (const row of rows) {
expect(row.access, row.id).toMatchObject({ station: expect.any(String) });
}
expect(rows.find((row) => row.id === routeId('montmartre-a-deux'))?.access).toEqual({
station: 'Abbesses',
lines: [{ mode: 'metro', line: '12' }],
distance_m: 63,
});
});

// Criterion of #32, with the operators of the central search query (wiki Modele-de-Donnees).
describe('answers every filter value, except the assumed zeros', () => {
const filters: Record<
Expand Down
4 changes: 3 additions & 1 deletion apps/api/src/db/seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import { certificationSettingKeys, defaultCertificationThresholds } from '@widoo/shared';
import { and, eq, inArray, sql } from 'drizzle-orm';
import { stableRecommendationOf } from '../recommendation/stable';
import { accessColumnOf } from '../transit/access';
import type { Db } from './client';
import { envelopeOf } from './geography';
import {
Expand Down Expand Up @@ -199,7 +200,8 @@ function routeRow(route: DemoRoute, cityId: string) {
tips: route.tips,
startLocation: locations[0],
bounds: envelopeOf(locations),
computed: route.computed,
// The access is computed like the editor does, from the reference stations (D-050).
computed: { ...route.computed, access: accessColumnOf(locations[0]) },
...bucketsOf(route),
publishedAt: seedAt,
// As the nightly job would compute it on the dataset date: no rating, every place verified.
Expand Down
5 changes: 4 additions & 1 deletion apps/api/src/editor/save.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,14 @@ import { envelopeOf } from '../db/geography';
import { routeRevisions, routes } from '../db/schema';
import { conflict } from '../errors';
import { mainTransportOf, replaceSteps, type RouteContent } from './content';
import { accessColumnOf } from '../transit/access';
import type { RouteRow } from './lifecycle';
import { evaluate, type Workspace } from './workspace';

/**
* Writes the content into the route itself, with what is derived from it: stored trips, transport,
* start, envelope, city of the first step, `computed` and its buckets.
* start, envelope, city of the first step, `computed` with the access of the first step (D-050)
* and its buckets: the access follows the first step at every save.
*/
export async function writeInPlace(
tx: Tx,
Expand Down Expand Up @@ -57,6 +59,7 @@ export async function writeInPlace(
walking_m: computed.walkingM,
effort: computed.effort,
warnings: warnings.map((issue) => issue.code),
access: accessColumnOf(located[0]?.location),
computed_at: computed.computedAt,
},
durationBucket: computed && durationBucketOf(computed.durationMin),
Expand Down
28 changes: 28 additions & 0 deletions apps/api/src/routes/editor-edit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,34 @@ describe('PUT /routes/:id/steps', () => {
expect((await rowOf(id))?.transport).toBe('bike');
});

// Criterion of #180: the access follows the first step (D-050).
it('computes the access of the first step, again when the first step changes', async () => {
const headers = await fixtures.signIn();
const { id } = OwnRoute.parse((await create(headers)).json());
const [victorHugo, carnavalet] = demoPlaceIds;
const accessOf = async () => ((await rowOf(id))?.computed as { access: unknown }).access;

await putSteps(headers, id, [
{ placeId: victorHugo, durationMin: 30 },
{ placeId: carnavalet, durationMin: 30 },
]);
expect(await accessOf()).toEqual({
station: 'Bastille',
lines: ['1', '5', '8'].map((line) => ({ mode: 'metro', line })),
distance_m: 316,
});

await putSteps(headers, id, [
{ placeId: carnavalet, durationMin: 30 },
{ placeId: victorHugo, durationMin: 30 },
]);
expect(await accessOf()).toEqual({
station: 'Saint-Paul',
lines: [{ mode: 'metro', line: '1' }],
distance_m: 333,
});
});

it('answers 422 to the step of another route, or a place that does not exist', async () => {
const headers = await fixtures.signIn();
const { id } = OwnRoute.parse((await create(headers)).json());
Expand Down
69 changes: 69 additions & 0 deletions apps/api/src/transit/access.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { describe, expect, it } from 'vitest';
import { demoDataset } from '../db/seed/demo-routes';
import { accessColumnOf, ACCESS_MAX_M, nearestAccess } from './access';
import type { TransitStation } from './stations';

const abbesses = { lat: 48.88459, lng: 2.33795 };
const stations: TransitStation[] = [
{
id: '1',
lines: [{ mode: 'metro', line: '12' }],
stops: [{ name: 'Abbesses', ...abbesses }],
},
{
id: '2',
lines: [
{ mode: 'metro', line: '2' },
{ mode: 'metro', line: '12' },
],
stops: [
{ name: 'Pigalle', lat: 48.88218, lng: 2.33727 },
{ name: 'Pigalle', lat: 48.88197, lng: 2.33707 },
],
},
];

describe('nearestAccess', () => {
it('names the stop nearest the start, every line of its station and the walk to it', () => {
const access = nearestAccess({ lat: 48.8845, lng: 2.3385 }, stations);
expect(access).toMatchObject({ station: 'Abbesses', lines: [{ mode: 'metro', line: '12' }] });
// About 41 m in a straight line, walked × 1.3.
expect(access?.distanceM).toBeGreaterThan(45);
expect(access?.distanceM).toBeLessThan(60);
});

it('measures to the nearest stop of a station with several', () => {
const access = nearestAccess({ lat: 48.8819, lng: 2.337 }, stations);
expect(access?.station).toBe('Pigalle');
expect(access?.lines.map((line) => line.line)).toEqual(['2', '12']);
expect(access?.distanceM).toBeLessThan(20);
});

it('gives none beyond a walk, nor without any station', () => {
// Saint-Germain-en-Laye forest: far from both demo stations.
expect(nearestAccess({ lat: 48.92, lng: 2.08 }, stations)).toBeNull();
expect(nearestAccess(abbesses, [])).toBeNull();
expect(ACCESS_MAX_M).toBe(1500);
});
});

describe('accessColumnOf', () => {
it('stores the access in snake case, and none without a start', () => {
expect(accessColumnOf({ lat: 48.88459, lng: 2.33795 })).toEqual({
station: 'Abbesses',
lines: [{ mode: 'metro', line: '12' }],
distance_m: 0,
});
expect(accessColumnOf(null)).toBeNull();
});

it('finds a station near the start of every demo route, in the reference file', () => {
const places = new Map(demoDataset.places.map((place) => [place.key, place.location]));
for (const route of demoDataset.routes) {
const start = places.get(route.steps[0]?.place ?? '');
const access = accessColumnOf(start);
expect(access, route.key).not.toBeNull();
expect(access?.distance_m, route.key).toBeLessThan(1000);
}
});
});
48 changes: 48 additions & 0 deletions apps/api/src/transit/access.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
/**
* Access of a route (Orchestration › Accès, D-050): the station nearest the first step, its lines
* and the distance walked to it, estimated like a walking trip (D-088): straight line × detour.
* Stored in `routes.computed.access`; null when no station is within a walk.
*/
import { estimateOfMode, straightLineM, WALK_PROPOSAL_MAX_M } from '@widoo/orchestration';
import type { LatLng } from '@widoo/shared';
import { transitDataset, type TransitLine, type TransitStation } from './stations';

export interface StationAccess {
/** Name of the stop nearest the start: `Abbesses`. */
station: string;
/** Every line of the station, metro first: « Métro Abbesses, ligne 12 ». */
lines: TransitLine[];
/** Walked, estimated: « à 50 m du départ ». */
distanceM: number;
}

/** Beyond it, the start is not « near » a station: the walk a trip would propose (1.5 km). */
export const ACCESS_MAX_M = WALK_PROPOSAL_MAX_M;

/** The station nearest the point, among the reference stations by default. */
export function nearestAccess(
location: LatLng,
stations: readonly TransitStation[] = transitDataset.stations,
): StationAccess | null {
let nearest: { station: TransitStation; name: string; straightM: number } | null = null;
for (const station of stations) {
for (const stop of station.stops) {
const straightM = straightLineM(location, stop);
if (!nearest || straightM < nearest.straightM)
nearest = { station, name: stop.name, straightM };
}
}
if (!nearest) return null;
const distanceM = Math.round(nearest.straightM * estimateOfMode.walk.detour);
if (distanceM > ACCESS_MAX_M) return null;
return { station: nearest.name, lines: nearest.station.lines, distanceM };
}

/** `routes.computed.access`: snake_case like the rest of `computed`. */
export type AccessColumn = { station: string; lines: TransitLine[]; distance_m: number } | null;

/** The access of a start as `computed` stores it; null without a start or a station near it. */
export function accessColumnOf(start: LatLng | null | undefined): AccessColumn {
const access = start ? nearestAccess(start) : null;
return access && { station: access.station, lines: access.lines, distance_m: access.distanceM };
}
Loading
Loading