diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..cb14d106 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,47 @@ +# How to contribute. + +## Required tools + +- node.js +- npm +- on mac-os, you have to install xcode command line developer tools (run xcode-select --install) +- gcloud +- docker +- the IDE of your choice + + +## Project setup + +- run `npm install` +- add default keys definitions + - `cp apps/fxc-front/src/app/keys.ts.dist apps/fxc-front/src/app/keys.ts` + - `cp libs/common/src/lib/keys.ts.dist libs/common/src/lib/keys.ts` + +### Simplistic configuration + +**redis server** +- `cd docker; docker compose up -d redis` + +**pubsub** +- `cd docker; docker compose up -d pubsub` + +**datastore** + +For the moment, it does not work with docker compose. But if you install the cloud-datastore-emulator, you will have a working configuration. + +***Installation*** +- `gcloud components install cloud-datastore-emulator` + +***run:*** +- `gcloud beta emulators datastore start` + +**before npm run dev:** + +define the required env variables: +``` +export DATASTORE_DATASET=flyxc +export DATASTORE_EMULATOR_HOST=localhost:8081 +export DATASTORE_EMULATOR_HOST_PATH=localhost:8081/datastore +export DATASTORE_HOST=http://localhost:8081 +export DATASTORE_PROJECT_ID=flyxc +``` diff --git a/CONTRIBUTION.md b/CONTRIBUTION.md deleted file mode 100644 index 866e2604..00000000 --- a/CONTRIBUTION.md +++ /dev/null @@ -1,38 +0,0 @@ -# How to contribute. - -## Required tools - -- node.js -- npm -- on mac-os, you have to install xcode command line developer tools (run xcode-select --install) -- gcloud -- docker -- the IDE of your choice - - -## Project setup - -- run `npm install` -- add default keys definitions - - `cp apps/fxc-front/src/app/keys.ts.dist apps/fxc-front/src/app/keys.ts` - - `cp libs/common/src/lib/keys.ts.dist libs/common/src/lib/keys.ts` - -### Simplistic configuration - -**redis server** -- docker run -ti --rm -p 6378:6379 redis - -For a simplistic local configuration, you can install and use gcloud local simulators for storage db and pubsup - -**installation:** -- gcloud components install pubsub-emulator -- gcloud components install cloud-datastore-emulator - -**run:** -- gcloud beta emulators datastore start -- gcloud beta emulators pubsub start - -**before npm run dev:** -set environment variables given by this 2 commands -- gcloud beta emulators datastore env-init -- gcloud beta emulators pubsub env-init diff --git a/apps/fxc-front/src/app/components/2d/planner-element.ts b/apps/fxc-front/src/app/components/2d/planner-element.ts index 3760e710..4efbbd33 100644 --- a/apps/fxc-front/src/app/components/2d/planner-element.ts +++ b/apps/fxc-front/src/app/components/2d/planner-element.ts @@ -9,9 +9,9 @@ import * as units from '../../logic/units'; import { decrementSpeed, incrementSpeed, setSpeed } from '../../redux/planner-slice'; import { RootState, store } from '../../redux/store'; import { scoreTrack } from '../../logic/track'; -import * as common from "@flyxc/common"; -import { currentTrack } from "../../redux/selectors"; -import { LEAGUES } from "../../logic/score/league/leagues"; +// introduce here a circular dependency. Issue to solve later +import * as common from '@flyxc/common'; +import { currentLeague, currentTrack } from '../../redux/selectors'; const ICON_MINUS = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAkAAAAJAQMAAADaX5RTAAAABlBMVEX///9xe4e/5menAAAAE0lEQVQImWP438DQAEP7kNj/GwCK4wo9HA2mvgAAAABJRU5ErkJggg=='; @@ -37,9 +37,7 @@ export class PlannerElement extends connect(store)(LitElement) { @state() private isFreeDrawing = false; @state() - private track?: common.RuntimeTrack; - @state() - private league?: string; + private track: common.RuntimeTrack | undefined; private duration?: number; private readonly closeHandler = () => this.dispatchEvent(new CustomEvent('close')); @@ -57,7 +55,6 @@ export class PlannerElement extends connect(store)(LitElement) { this.duration = ((this.distance / this.speed) * 60) / 1000; this.isFreeDrawing = state.planner.isFreeDrawing; this.track = currentTrack(state); - this.league = LEAGUES[state.planner.league].name ; } static get styles(): CSSResult { @@ -148,10 +145,11 @@ export class PlannerElement extends connect(store)(LitElement) { ${when( this.track, - () => html ` -
-
πŸ†• Compute score πŸ†•
-
` + () => html`
+
+ πŸ†•ScoreπŸ†• +
+
`, )}
${this.score.circuit}
@@ -160,7 +158,8 @@ export class PlannerElement extends connect(store)(LitElement) {
-
Points = ${this.getMultiplier()}
${this.league}
+
Points = ${this.getMultiplier()}
+
${store.getState().planner.leagueName}
${this.score.points.toFixed(1)}
@@ -277,9 +276,10 @@ export class PlannerElement extends connect(store)(LitElement) { store.dispatch(e.deltaY > 0 ? incrementSpeed() : decrementSpeed()); } - private computeScore() { + // compute score on the current selected track + private scoreTrack() { if (this.track) { - scoreTrack(this.track); + scoreTrack(this.track, currentLeague(store.getState())); } } } diff --git a/apps/fxc-front/src/app/components/ui/main-menu.ts b/apps/fxc-front/src/app/components/ui/main-menu.ts index c1f9da2d..c58d9be4 100644 --- a/apps/fxc-front/src/app/components/ui/main-menu.ts +++ b/apps/fxc-front/src/app/components/ui/main-menu.ts @@ -514,8 +514,8 @@ export class TrackItems extends connect(store)(LitElement) { pushCurrentState(); addUrlParamValues(ParamNames.groupId, ids); el.value = ''; + await menuController.close(); } - await menuController.close(); } } diff --git a/apps/fxc-front/src/app/components/ui/track-modal.ts b/apps/fxc-front/src/app/components/ui/track-modal.ts index 27e1a5a9..5f57eab6 100644 --- a/apps/fxc-front/src/app/components/ui/track-modal.ts +++ b/apps/fxc-front/src/app/components/ui/track-modal.ts @@ -37,7 +37,7 @@ export class TrackModal extends connect(store)(LitElement) { ${this.tracks.map( (track: RuntimeTrack) => - html` { if (!_solver) { const { solver } = await import('igc-xc-score'); @@ -18,14 +20,18 @@ let _solver: ( config?: { [key: string]: any } | undefined, ) => Iterator; +// ScoreAndWaypoints could be more appropriate here? export type ScoreAndRoute = { score: Score; route: Point[] }; -export type ScoringTrack = { lat: number[]; lon: number[]; timeSec: number[]; minTimeSec: number }; + +// ScoringTrack is a subset of RuntimeTrack +// we define it for the sake of clarity and define the minimal information required to invoke the scoreTrack function +export type ScoringTrack = Pick export async function scoreTrack(track: ScoringTrack, leagueCode: LeagueCode): Promise { const scoringRules = getScoringRules(leagueCode); if (scoringRules) { const solver = await lazyLoadedSolver(); - const solutions = solver(igcFile(track), scoringRules, undefined); + const solutions = solver(createIgcFile(track), scoringRules, undefined); const solution = solutions.next().value; return { score: toScore(solution), route: toRoute(solution) }; } @@ -43,7 +49,6 @@ function toScore(solution: Solution): Score { }); } -// duplicated code from apps/fxc-front/src/app/logic/track.ts type CircuitTypeCode = 'od' | 'tri' | 'fai' | 'oar'; function toCircuitType(code: CircuitTypeCode) { @@ -59,36 +64,46 @@ function toCircuitType(code: CircuitTypeCode) { } } -// end duplication - +// return indices of solution points +// Pay attention to the high coupling between getIndexes and toRoute function. +// They HAVE TO use the same points in the same order +// May be a visitor pattern would be valuable here. function getIndexes(solution: Solution) { let currentIndex = -1; const entryPointsStart = getEntryPointsStart(solution); const result = entryPointsStart ? [currentIndex++] : []; const closingPointsIn = getClosingPointsIn(solution); - if (closingPointsIn) result.push(currentIndex++); + if (closingPointsIn) { + result.push(currentIndex++); + } solution.scoreInfo?.legs?.map((leg) => leg.start.r).forEach(() => result.push(currentIndex++)); const closingPointsOut = getClosingPointsOut(solution); - if (closingPointsOut) result.push(currentIndex++); + if (closingPointsOut) { + result.push(currentIndex++); + } const entryPointsFinish = getEntryPointsFinish(solution); - if (entryPointsFinish) result.push(currentIndex++); + if (entryPointsFinish) { + result.push(currentIndex++); + } return result; } -function push(point: XcScorePoint | undefined, route: Point[]) { - if (point) route.push(getPoint(point)); -} - function toRoute(solution: Solution): Point[] { const route: Point[] = []; push(getEntryPointsStart(solution), route); const closingPointsIn = getClosingPointsIn(solution); - if (closingPointsIn) route.push(getPoint(closingPointsIn)); + if (closingPointsIn) { + route.push(getPoint(closingPointsIn)); + } solution.scoreInfo?.legs?.map((leg) => leg.start).forEach((it) => route.push(getPoint(it))); const closingPointsOut = getClosingPointsOut(solution); - if (closingPointsOut) route.push(getPoint(closingPointsOut)); + if (closingPointsOut) { + route.push(getPoint(closingPointsOut)); + } const entryPointsFinish = getEntryPointsFinish(solution); - if (entryPointsFinish) route.push(getPoint(entryPointsFinish)); + if (entryPointsFinish) { + route.push(getPoint(entryPointsFinish)); + } return route; } @@ -112,22 +127,35 @@ function getPoint(point: XcScorePoint): Point { return { ...point }; } -// build a fake igc file from a track -function igcFile(track: ScoringTrack): IGCFile { +function push(point: XcScorePoint | undefined, route: Point[]) { + if (point) { + route.push(getPoint(point)); + } +} + +// build a fake igc file from a track, so that the solver can use it. +function createIgcFile(track: ScoringTrack): IGCFile { const fixes: BRecord[] = []; for (let i = 0; i < track.lon.length; i++) { - // @ts-ignore + const timeMilliseconds = track.timeSec[i]*1000; const record: BRecord = { - timestamp: track.timeSec[i], + timestamp: timeMilliseconds, + time: new Date(timeMilliseconds).toISOString(), latitude: track.lat[i], longitude: track.lon[i], valid: true, + pressureAltitude: null, + gpsAltitude: track.alt[i], + extensions: {}, + fixAccuracy: null, + enl: null, }; fixes.push(record); } + // we ignore some properties of the igc-file, as they are not required for the computation // @ts-ignore return { - date: new Date(track.minTimeSec).toISOString(), + date: new Date(track.minTimeSec*1000).toISOString(), fixes: fixes, }; } @@ -136,6 +164,9 @@ function getScoringRules(leagueCode: LeagueCode): object | undefined { return leaguesScoringRules.get(leagueCode); } +// scoring rules could have been defined individually in each League subclass, but as the definition of rules is +// tedious and error-prone, it seems more practical to define all rules here. +// The downside is that we have to define a "coupling key" (LeagueCode) in each League const scoringBaseModel = scoringRules['XContest']; const openDistanceBase = scoringBaseModel[0]; const freeTriangleBase = scoringBaseModel[1]; @@ -216,16 +247,17 @@ const wxcScoringRule = [ { ...faiTriangleBase, multiplier: 2, closingDistanceFixed: 0.2 }, ]; -const leaguesScoringRules: Map = new Map() - .set('czl', czlScoringRule) - .set('cze', czeScoringRule) - .set('czo', czoScoringRule) - .set('fr', scoringRules['FFVL']) - .set('leo', leoScoringRule) - .set('nor', norScoringRule) - .set('ukc', ukcScoringRule) - .set('uki', ukiScoringRule) - .set('ukn', uknScoringRule) - .set('xc', scoringRules.XContest) - .set('xcppg', xcppgScoringRule) - .set('wxc', wxcScoringRule); +const leaguesScoringRules: Map = new Map([ + ['czl', czlScoringRule], + ['cze', czeScoringRule], + ['czo', czoScoringRule], + ['fr', scoringRules['FFVL']], + ['leo', leoScoringRule], + ['nor', norScoringRule], + ['ukc', ukcScoringRule], + ['uki', ukiScoringRule], + ['ukn', uknScoringRule], + ['xc', scoringRules.XContest], + ['xcppg', xcppgScoringRule], + ['wxc', wxcScoringRule], +]); diff --git a/apps/fxc-front/src/app/logic/score/league.ts b/apps/fxc-front/src/app/logic/score/league.ts index dae4f084..7cd990fd 100644 --- a/apps/fxc-front/src/app/logic/score/league.ts +++ b/apps/fxc-front/src/app/logic/score/league.ts @@ -1,50 +1,13 @@ import { Measure } from './measure'; import { Score } from './scorer'; -import { ScoreAndRoute, scoreTrack, ScoringTrack } from './improvedScorer'; -import { LatLon } from '@flyxc/common'; export abstract class League { abstract name: string; abstract code: LeagueCode; abstract score(measure: Measure): Score[]; - - // An attempt for using the new scorer in League classes - async scorePoints(latLons: LatLon[]): Promise { - return await scoreTrack(toTrack(latLons), this.code); - } } +// allowed league codes +// ensure that all league codes defined in each League sub classes are in this +// closed set. export type LeagueCode = 'czl' | 'cze' | 'czo' | 'fr' | 'leo' | 'nor' | 'ukc' | 'uki' | 'ukn' | 'xc' | 'xcppg' | 'wxc'; - -function toTrack(latLons: LatLon[]): ScoringTrack { - let copy = latLons.map((it) => it); - while (copy.length < 6) { - copy = doublePoints(copy); - } - const date = new Date(); - const minTimeSec = date.getSeconds(); - date.setSeconds(1); - return { - lat: copy.map((it) => it.lat), - lon: copy.map((it) => it.lon), - timeSec: copy.map((_value, index) => minTimeSec + index), - minTimeSec: minTimeSec, - }; -} - -function doublePoints(source: LatLon[]): LatLon[] { - const result = []; - for (let i = 0; i < source.length - 1; i++) { - result.push(source[i]); - result.push(middle(source[i], source[i + 1])); - } - result.push(source[source.length - 1]); - return result; -} - -function middle(p1: LatLon, p2: LatLon): LatLon { - return { - lat: (p1.lat + p2.lat) / 2, - lon: (p1.lon + p2.lon) / 2, - }; -} diff --git a/apps/fxc-front/src/app/logic/score/scorer.ts b/apps/fxc-front/src/app/logic/score/scorer.ts index 094f98d8..c5319d68 100644 --- a/apps/fxc-front/src/app/logic/score/scorer.ts +++ b/apps/fxc-front/src/app/logic/score/scorer.ts @@ -22,9 +22,7 @@ export class Score { this.indexes = score.indexes || []; this.multiplier = score.multiplier || 1; this.circuit = score.circuit || CircuitType.OpenDistance; - // when score.closingRadius = 0 - // 'score.closingRadius || null' expression is ... null, because "0 is false", WTF! - this.closingRadius = score.closingRadius != null ? score.closingRadius : null; + this.closingRadius = score.closingRadius ?? null; this.points = score.points ? score.points : (this.distance * this.multiplier) / 1000; } } diff --git a/apps/fxc-front/src/app/logic/track.ts b/apps/fxc-front/src/app/logic/track.ts index 6f03437b..71f6d214 100644 --- a/apps/fxc-front/src/app/logic/track.ts +++ b/apps/fxc-front/src/app/logic/track.ts @@ -3,11 +3,9 @@ import { extractGroupId, Point, RuntimeTrack } from '@flyxc/common'; import { unwrapResult } from '@reduxjs/toolkit'; import { AppDispatch, store } from '../redux/store'; -import { currentLeague } from '../redux/selectors'; import { fetchTrack } from '../redux/track-slice'; // @ts-ignore import ScoreWorker from '../workers/score-track?worker'; -import { Request as ScoreRequest } from '../workers/score-track'; import { setEnabled as setPlannerEnabled, setIsFreeDrawing as setPlannerIsFreeDrawing, @@ -15,8 +13,11 @@ import { setScore as setPlannerScore, } from '../redux/planner-slice'; import { ScoreAndRoute } from './score/improvedScorer'; +import { LeagueCode } from "./score/league"; // Uploads files to the server and adds the tracks. +// after loading, the planner menu is displayed to permit +// score computation on loaded tracks export async function uploadTracks(files: File[]): Promise { if (files.length == 0) { return []; @@ -60,9 +61,8 @@ async function fetchAndReturnGroupIds(url: string, options?: RequestInit): Promi return Array.from(groupIds); } -export function scoreTrack(track: RuntimeTrack) { - const request: ScoreRequest = { track: track, leagueCode: currentLeague(store.getState()) }; - getScoreWorker(store.dispatch).postMessage(request); +export function scoreTrack(track: RuntimeTrack, leagueCode: LeagueCode) { + getScoreWorker(store.dispatch).postMessage({ track, leagueCode }); } let scoreWorker: Worker | undefined; diff --git a/apps/fxc-front/src/app/redux/planner-slice.ts b/apps/fxc-front/src/app/redux/planner-slice.ts index d7e8e27e..46055210 100644 --- a/apps/fxc-front/src/app/redux/planner-slice.ts +++ b/apps/fxc-front/src/app/redux/planner-slice.ts @@ -3,12 +3,14 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit'; import { deleteUrlParam, getUrlParamValues, ParamNames, setUrlParamValue } from '../logic/history'; import { Score } from '../logic/score/scorer'; import { LeagueCode } from '../logic/score/league'; +import { LEAGUES } from "../logic/score/league/leagues"; export type PlannerState = { score?: Score; speed: number; distance: number; league: LeagueCode; + leagueName: string; enabled: boolean; // Encoded route. route: string; @@ -18,11 +20,16 @@ export type PlannerState = { const route = getUrlParamValues(ParamNames.route)[0] ?? ''; const enabled = route.length > 0; +function getLeagueCode() { + return (getUrlParamValues(ParamNames.league)[0] ?? localStorage.getItem('league') ?? 'xc') as LeagueCode; +} + const initialState: PlannerState = { score: undefined, speed: Number(getUrlParamValues(ParamNames.speed)[0] ?? 20), distance: 0, - league: (getUrlParamValues(ParamNames.league)[0] ?? localStorage.getItem('league') ?? 'xc') as LeagueCode, + league: getLeagueCode(), + leagueName: LEAGUES[getLeagueCode()].name, enabled, route, isFreeDrawing: false, @@ -47,6 +54,7 @@ const plannerSlice = createSlice({ setUrlParamValue(ParamNames.league, leagueCode); localStorage.setItem('league', leagueCode); state.league = leagueCode; + state.leagueName = LEAGUES[leagueCode].name; }, incrementSpeed: (state) => { state.speed = Math.floor(state.speed + 1); diff --git a/apps/fxc-front/src/app/redux/track-slice.ts b/apps/fxc-front/src/app/redux/track-slice.ts index 4a90969e..7a8df36a 100644 --- a/apps/fxc-front/src/app/redux/track-slice.ts +++ b/apps/fxc-front/src/app/redux/track-slice.ts @@ -15,7 +15,6 @@ import TrackWorker from '../workers/track?worker'; import { setTimeSec } from './app-slice'; import { setEnabled, setRoute } from './planner-slice'; import { AppDispatch, AppThunk, RootState } from './store'; -import { Score } from '../logic/score/scorer'; const FETCH_EVERY_SECONDS = 15; export const FETCH_FOR_MINUTES = 3; @@ -45,8 +44,6 @@ export type TrackState = { loaded: boolean; }; -export type RuntimeTrackId = Pick; - const initialState: TrackState = { currentTrackId: undefined, fetching: false, @@ -149,10 +146,8 @@ export const fetchTrack = createAsyncThunk('track/fetch', async (params: FetchTr if (route && route.alt.length > 0) { const coords = []; for (let i = 0; i < route.alt.length; i++) { - // @ts-ignore funky error: google name not found coords.push(new google.maps.LatLng(route.lat[i], route.lon[i])); } - // @ts-ignore same error api.dispatch(setRoute(google.maps.geometry.encoding.encodePath(coords))); api.dispatch(setEnabled(true)); } @@ -212,8 +207,12 @@ const trackSlice = createSlice({ state.currentTrackId = String(state.tracks.ids[(index + 1) % state.tracks.ids.length]); } }, - patchTrack: (state, action: PayloadAction & RuntimeTrackId>) => { - doPatchTrack(state, action.payload); + patchTrack: (state, action: PayloadAction & Pick>) => { + const update = action.payload; + trackAdapter.updateOne(state.tracks, { + id: update.id, + changes: update, + }); }, setFetchingMetadata: (state, action: PayloadAction) => { state.metadata.fetchPending = action.payload; @@ -235,9 +234,6 @@ const trackSlice = createSlice({ } } }, - setScore: (state, action: PayloadAction) => { - doPatchTrack(state, { score: action.payload, id: action.payload.id }); - }, }, extraReducers: (builder) => { builder @@ -302,13 +298,6 @@ const trackSlice = createSlice({ }, }); -function doPatchTrack(state: TrackState, update: Partial & RuntimeTrackId) { - trackAdapter.updateOne(state.tracks, { - id: update.id, - changes: update, - }); -} - export const reducer = trackSlice.reducer; export const { removeTracksByGroupIds, @@ -318,6 +307,5 @@ export const { setLockOnPilot, setTrackDomain, setTrackLoaded, - setScore, } = trackSlice.actions; export const trackAdapterSelector = trackAdapter.getSelectors((state: RootState) => state.track.tracks); diff --git a/apps/fxc-front/src/app/workers/score-track.ts b/apps/fxc-front/src/app/workers/score-track.ts index 153180c2..47a35f9a 100644 --- a/apps/fxc-front/src/app/workers/score-track.ts +++ b/apps/fxc-front/src/app/workers/score-track.ts @@ -17,7 +17,6 @@ export interface Response { const w: Worker = self as any; w.onmessage = async (message: MessageEvent) => { - console.info('scoring: received', message); try { const request = message.data; if (request.leagueCode) { diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 488040e9..9945240f 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -1,16 +1,23 @@ +# This file permits to launch docker images of services required for this app +# services: + # launch it with ' docker compose up -d redis' redis: image: redis:latest ports: - 6378:6379 + # launch it with ' docker compose up -d pubsub' pubsub: image: gcr.io/google.com/cloudsdktool/google-cloud-cli:latest ports: - 8085:8085 command: gcloud beta emulators pubsub start + # could not manage to make it work (port issue?) + # instead you can use the emulator on your workstation + # by launching "gcloud beta emulators datastore start" datastore: image: gcr.io/google.com/cloudsdktool/google-cloud-cli:latest command: gcloud beta emulators datastore start --project flyxc diff --git a/libs/common/src/lib/runtime-track.ts b/libs/common/src/lib/runtime-track.ts index 6161f7c2..5c7a6d7f 100644 --- a/libs/common/src/lib/runtime-track.ts +++ b/libs/common/src/lib/runtime-track.ts @@ -2,7 +2,6 @@ import { getDistance } from 'geolib'; import * as protos from '../protos/track'; import { diffDecodeArray, diffEncodeArray } from './math'; -import { Score } from '../../../../apps/fxc-front/src/app/logic/score/scorer'; export type Point = { x: number; @@ -53,7 +52,6 @@ export type RuntimeTrack = { // maximum distance between two consecutive points. maxDistance: number; airspaces?: protos.Airspaces; - score?: Score; }; // Creates a runtime track id from the datastore id and the group index.