diff --git a/src/app.module.ts b/src/app.module.ts index 33d12ac3d4f46c276275fc53f8c38bc57a136ec5..d3a7ebdbbc56ff9f03b326d78405b5cb81008137 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -7,6 +7,7 @@ import { CategoriesModule } from './categories/categories.module'; import { AuthModule } from './auth/auth.module'; import { UsersModule } from './users/users.module'; import { MailerModule } from './mailer/mailer.module'; +import { TclModule } from './tcl/tcl.module'; @Module({ imports: [ ConfigurationModule, @@ -18,6 +19,7 @@ import { MailerModule } from './mailer/mailer.module'; AuthModule, UsersModule, MailerModule, + TclModule, ], controllers: [AppController], }) diff --git a/src/tcl/interfaces/pgis.coord.ts b/src/tcl/interfaces/pgis.coord.ts new file mode 100644 index 0000000000000000000000000000000000000000..61d354af70a9bb79fae1aedbb22bfd2ec52fccd6 --- /dev/null +++ b/src/tcl/interfaces/pgis.coord.ts @@ -0,0 +1,4 @@ +export interface PgisCoord { + type: string; + coordinates: [number, number]; +} diff --git a/src/tcl/tcl.module.ts b/src/tcl/tcl.module.ts new file mode 100644 index 0000000000000000000000000000000000000000..f34de0b278a15d2ade2b6eaa2513ba23886026ea --- /dev/null +++ b/src/tcl/tcl.module.ts @@ -0,0 +1,12 @@ +import { HttpModule, Module } from '@nestjs/common'; +import { TclStopPointService } from './tclStopPoint.service'; +import { TclStopPointController } from './tclStopPoint.controller'; +import { MongooseModule } from '@nestjs/mongoose'; +import { TclStopPoint, TclStopPointSchema } from './tclStopPoint.schema'; + +@Module({ + imports: [MongooseModule.forFeature([{ name: TclStopPoint.name, schema: TclStopPointSchema }]), HttpModule], + providers: [TclStopPointService], + controllers: [TclStopPointController], +}) +export class TclModule {} diff --git a/src/tcl/tclStopPoint.controller.ts b/src/tcl/tclStopPoint.controller.ts new file mode 100644 index 0000000000000000000000000000000000000000..2f8b75148e41fd90186763ecec5958e4c2d8c388 --- /dev/null +++ b/src/tcl/tclStopPoint.controller.ts @@ -0,0 +1,35 @@ +import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common'; +import { ApiOperation, ApiResponse } from '@nestjs/swagger'; +import { PgisCoord } from './interfaces/pgis.coord'; +import { TclStopPoint } from './tclStopPoint.schema'; +import { TclStopPointService } from './tclStopPoint.service'; + +@Controller('tcl') +export class TclStopPointController { + constructor(private tclStopPointService: TclStopPointService) {} + + @ApiOperation({ + description: `Mettre à jour les points d'arrêt TCL à partir de Data Grand Lyon`, + }) + @ApiResponse({ + status: 204, + description: 'The stop points have been updated successfully.', + }) + @Get('/update') + //TODO: protect with admin guard when available + public updateStopPoints(): Promise<void> { + return this.tclStopPointService.updateStopPoints(); + } + + @ApiOperation({ + description: `Récupérer les arrêts les plus proches d'un point géographique`, + }) + @ApiResponse({ + status: 200, + description: 'The closest stop points have been fetched successfully.', + }) + @Post('/closest') + public getClosestStopPoints(@Body() pgisCoord: PgisCoord): Promise<TclStopPoint[]> { + return this.tclStopPointService.getClosestStopPoints(pgisCoord); + } +} diff --git a/src/tcl/tclStopPoint.schema.ts b/src/tcl/tclStopPoint.schema.ts new file mode 100644 index 0000000000000000000000000000000000000000..acc072fa3925d225627e39b5f675c565ee192c3e --- /dev/null +++ b/src/tcl/tclStopPoint.schema.ts @@ -0,0 +1,50 @@ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; +import { Document } from 'mongoose'; + +export type TclStopPointDocument = TclStopPoint & Document; + +@Schema() +export class TclStopPoint { + @Prop() + id: number; + + @Prop() + name?: string; + + @Prop() + busLines?: string[]; + + @Prop() + subLines?: string[]; + + @Prop() + tramLines?: string[]; + + @Prop() + prm?: boolean; + + @Prop() + elevator?: boolean; + + @Prop() + escalator?: boolean; + + @Prop() + gid: number; + + @Prop() + lastUpdate?: Date; + + @Prop() + lastUpdateFme?: Date; + + @Prop() + pgisCoord?: string | any; + + @Prop() + distance?: number; +} + +export const TclStopPointSchema = SchemaFactory.createForClass(TclStopPoint); + +TclStopPointSchema.index({ pgisCoord: '2dsphere' }); diff --git a/src/tcl/tclStopPoint.service.spec.ts b/src/tcl/tclStopPoint.service.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..4958e59315416027ed003c26d79fbbe8d82fcbf6 --- /dev/null +++ b/src/tcl/tclStopPoint.service.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { TclStopPointService } from './tclStopPoint.service'; + +describe('TclService', () => { + let service: TclStopPointService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [TclStopPointService], + }).compile(); + + service = module.get<TclStopPointService>(TclStopPointService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); +}); diff --git a/src/tcl/tclStopPoint.service.ts b/src/tcl/tclStopPoint.service.ts new file mode 100644 index 0000000000000000000000000000000000000000..390ff30b18db65e405a2a51a48925da2f0a9f0a3 --- /dev/null +++ b/src/tcl/tclStopPoint.service.ts @@ -0,0 +1,293 @@ +import { HttpService, Injectable } from '@nestjs/common'; +import { InjectModel } from '@nestjs/mongoose'; +import { Model } from 'mongoose'; +import { PgisCoord } from './interfaces/pgis.coord'; +import { TclStopPoint, TclStopPointDocument } from './tclStopPoint.schema'; + +interface ReceivedStopPoint { + type: string; + properties: { + id: string; + nom: string; + desserte: string; + pmr: string; + ascenseur: string; + escalator: string; + gid: string; + last_update: string; + last_update_fme: string; + }; + geometry: PgisCoord; +} + +interface Lines { + busLines: string[]; + subLines: string[]; + tramLines: string[]; +} + +@Injectable() +export class TclStopPointService { + private receivedStopPoints: any[]; + private receivedBusLines: any[]; + private receivedSubLines: any[]; + private receivedTramLines: any[]; + + constructor( + private http: HttpService, + @InjectModel(TclStopPoint.name) private tclStopPointModel: Model<TclStopPointDocument> + ) {} + + /** + * Clear 'tclstoppoint' and fill it with data from Data Grand Lyon + */ + public async updateStopPoints(): Promise<void> { + await this.getUpdatedData(); + const newStopPoints = await this.processReceivedStopPoints(this.receivedStopPoints); + + this.tclStopPointModel.deleteMany({}, () => { + this.tclStopPointModel.insertMany(newStopPoints); + }); + } + + /** + * Get all tcl data from Data Grand Lyon + */ + private async getUpdatedData(): Promise<void> { + this.receivedStopPoints = await this.http + .get( + // tslint:disable-next-line: max-line-length + 'https://download.data.grandlyon.com/wfs/rdata?SERVICE=WFS&VERSION=2.0.0&request=GetFeature&typename=tcl_sytral.tclarret&outputFormat=application/json; subtype=geojson&SRSNAME=EPSG:4326&startIndex=0' + ) + .toPromise() + .then(async (res) => res.data.features); + + this.receivedBusLines = await this.http + .get(`https://download.data.grandlyon.com/ws/rdata/tcl_sytral.tcllignebus_2_0_0/all.json`) + .toPromise() + .then(async (res) => res.data.values); + + this.receivedSubLines = await this.http + .get(`https://download.data.grandlyon.com/ws/rdata/tcl_sytral.tcllignemf_2_0_0/all.json`) + .toPromise() + .then(async (res) => res.data.values); + + this.receivedTramLines = await this.http + .get(`https://download.data.grandlyon.com/ws/rdata/tcl_sytral.tcllignetram_2_0_0/all.json`) + .toPromise() + .then(async (res) => res.data.values); + } + + /** + * Get all lines names and remove duplications + */ + private async processReceivedStopPoints(receivedStopPoints: ReceivedStopPoint[]): Promise<TclStopPoint[]> { + const newStopPoints: TclStopPoint[] = []; + + for (const receivedStopPoint of receivedStopPoints) { + const lines: Lines = await this.processReceivedLines(receivedStopPoint.properties.desserte); + + const newStopPoint = new TclStopPoint(); + newStopPoint.id = parseInt(receivedStopPoint.properties.id, 10); + newStopPoint.name = receivedStopPoint.properties.nom; + newStopPoint.busLines = [...new Set(lines.busLines)]; + newStopPoint.subLines = lines.subLines; + newStopPoint.tramLines = lines.tramLines; + newStopPoint.prm = JSON.parse(receivedStopPoint.properties.pmr); + newStopPoint.elevator = JSON.parse(receivedStopPoint.properties.ascenseur); + newStopPoint.escalator = JSON.parse(receivedStopPoint.properties.escalator); + newStopPoint.gid = parseInt(receivedStopPoint.properties.gid, 10); + newStopPoint.lastUpdate = new Date(receivedStopPoint.properties.last_update); + newStopPoint.lastUpdateFme = new Date(receivedStopPoint.properties.last_update_fme); + newStopPoint.pgisCoord = receivedStopPoint.geometry; + + newStopPoints.push(newStopPoint); + } + + return newStopPoints; + } + + /** + * Based on received data, check type and get it's real name in order to sort it. + */ + private async processReceivedLines(receivedLines: string): Promise<Lines> { + const receivedLinesArray = receivedLines.split(','); + const lines: Lines = { + busLines: [], + subLines: [], + tramLines: [], + }; + + for (let line of receivedLinesArray) { + line = line.split(':')[0]; + let cleanLine: string; + let lineType: string[]; + + if (this.isExceptionLine(line)) { + // Ne rien faire + } else if (this.isSubLine(line)) { + cleanLine = await this.getCleanSubLine(line); + lineType = lines.subLines; + } else if (this.isTramLine(line)) { + cleanLine = await this.getCleanTramLine(line); + lineType = lines.tramLines; + } else { + /* Les codes des lignes de bus ne respectant pas de logique générale, + on considère que toutes les lignes qui n'ont pas été interceptées au dessus + sont des lignes de bus */ + cleanLine = await this.getCleanBusLine(line); + lineType = lines.busLines; + } + + if (cleanLine) { + lineType.push(cleanLine); + } + } + + return lines; + } + + /** + * Return true if bus line code is : XXX11 + */ + private isSubLine(line: string): boolean { + const regex = /^3\d{2}/; // NOSONAR + return regex.test(line); + } + + /** + * Return true if bus line code is starting with a T + */ + private isTramLine(line: string): boolean { + const regex = /^T/; // NOSONAR + return regex.test(line); + } + + /** + * Return true if it's a known exception (ex: Rhônexpress) + */ + private isExceptionLine(line: string): boolean { + const regex = /(^RX|^TGS|^BGS|^NAV)/; // NOSONAR + return regex.test(line); + } + + /** + * Get back bus line name from TCL code in the corresponding table + */ + private async getCleanLine(line: string, receivedLines: any[]): Promise<string> { + const foundLine = receivedLines.find((receivedLine) => receivedLine.code_ligne === line); + + // Exception for line 132. Does'nt exist anymore + if (foundLine && foundLine.ligne && foundLine.ligne !== '132') { + return foundLine.ligne; + } else { + return ''; + } + } + + /** + * Get back bus line name from TCL code + */ + private async getCleanBusLine(line: string): Promise<string> { + return this.getCleanLine(line, this.receivedBusLines); + } + + /** + * Get back bus subway name from TCL code + */ + private async getCleanSubLine(line: string): Promise<string> { + return this.getCleanLine(line, this.receivedSubLines); + } + + /** + * Get back tram line name from TCL code + */ + private async getCleanTramLine(line: string): Promise<string> { + return this.getCleanLine(line, this.receivedTramLines); + } + + /** + * Get TCL nearast point. + * If none is found, we increase the default search radius. + * The amount of stop return if defined in the function. + */ + public async getClosestStopPoints(pgisCoord: PgisCoord): Promise<TclStopPoint[]> { + const NUMBER_STOPS = 5; + const RADIUS_FIRST_TRY = 100; + const RADIUS_SECOND_TRY = 500; + + let stopPoints = await this.getStopPointsByDistance(pgisCoord, RADIUS_FIRST_TRY); + + if (!stopPoints.length) { + stopPoints = await this.getStopPointsByDistance(pgisCoord, RADIUS_SECOND_TRY); + } + + stopPoints = this.groupStopPointsByName(stopPoints); + return stopPoints.slice(0, NUMBER_STOPS); + } + + /** + * Aggregate stops + */ + private groupStopPointsByName(stopPoints: TclStopPoint[]): TclStopPoint[] { + const uniqueStopPoints: TclStopPoint[] = []; + + for (const stopPoint of stopPoints) { + const stopPointIndex = uniqueStopPoints.findIndex((uniqueStopPoint) => uniqueStopPoint.name === stopPoint.name); + + if (stopPointIndex > -1) { + uniqueStopPoints[stopPointIndex].busLines = this.getUniqueCombinedLines( + uniqueStopPoints[stopPointIndex].busLines, + stopPoint.busLines + ); + + uniqueStopPoints[stopPointIndex].subLines = this.getUniqueCombinedLines( + uniqueStopPoints[stopPointIndex].subLines, + stopPoint.subLines + ); + + uniqueStopPoints[stopPointIndex].tramLines = this.getUniqueCombinedLines( + uniqueStopPoints[stopPointIndex].tramLines, + stopPoint.tramLines + ); + } else { + uniqueStopPoints.push(stopPoint); + } + } + + return uniqueStopPoints; + } + + /** + * Merge two lines array and return a map of unique lines ordered alphabetically + */ + private getUniqueCombinedLines(stop1Lines: string[], stop2Lines: string[]): string[] { + // Natural line order + // Ex : 69, 296, C7, C25 instead of 296, 69, C25, C7 + const collator = new Intl.Collator(undefined, { + numeric: true, + sensitivity: 'base', + }); + + return Array.from(new Set([...stop1Lines, ...stop2Lines])).sort(collator.compare); + } + + /** + * Query collection to get neareast coord + * @param pgisCoord PgisCoord + * @param maxDistance number + */ + public async getStopPointsByDistance(pgisCoord: PgisCoord, maxDistance: number): Promise<TclStopPoint[]> { + return this.tclStopPointModel + .find({ + pgisCoord: { + $near: { + $geometry: pgisCoord, + $maxDistance: maxDistance, + }, + }, + }) + .sort('-distance') + .exec(); + } +}