diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0a92033..afac24d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,7 +10,7 @@ jobs: steps: - name: Setup node - uses: actions/setup-node@v1.1.0 + uses: actions/setup-node@v1.4.4 - name: Checkout code uses: actions/checkout@v2 @@ -19,4 +19,4 @@ jobs: run: npm i - name: Run unit tests - run: npm run test + run: npm run test \ No newline at end of file diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 892f10a..64c05f4 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -10,7 +10,7 @@ jobs: steps: - name: Setup node - uses: actions/setup-node@v1.1.0 + uses: actions/setup-node@v1.4.4 - name: Checkout code uses: actions/checkout@v2 @@ -19,4 +19,4 @@ jobs: run: npm i - name: Run unit tests - run: npm run test + run: npm run test \ No newline at end of file diff --git a/README.md b/README.md index a5142ef..490898f 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,7 @@ Use it for planning a route, or getting a sense of your route after a workout. ## Changelog +- 11/28/20: Refactored a bit to better abstract out mapbox API, localStorage interactions - 6/14/20: Updates to the testing experience, take advantage of existing libraries to simplify things - 3/28/20: Audits, enabled Github workflows, also added extra accessiblity/aria context - 8/13/19: Allowed selecting map visual style diff --git a/src/animation-controller.ts b/src/animation-service.ts similarity index 99% rename from src/animation-controller.ts rename to src/animation-service.ts index 4610eb4..995f065 100644 --- a/src/animation-controller.ts +++ b/src/animation-service.ts @@ -8,7 +8,7 @@ import { FeatureCollection, LineString } from 'geojson'; * the length of the line. Requests while drawing will complete the * current animation, then kick off the next segment. */ -export class AnimationController { +export class AnimationService { private map: Map; private animationFrame: number; diff --git a/src/current-run.spec.ts b/src/current-run.spec.ts index 5ef7352..68297ce 100644 --- a/src/current-run.spec.ts +++ b/src/current-run.spec.ts @@ -1,16 +1,16 @@ import { CurrentRun, RunStart, RunSegment } from './current-run'; -import { LngLat, Point, Marker } from 'mapbox-gl'; +import { LngLat, Marker } from 'mapbox-gl'; import { LineString } from 'geojson'; describe('CurrentRun class', () => { it('should initialize with a run start', () => { - let start = new RunStart({} as LngLat, {} as Point); + let start = new RunStart({} as LngLat); let currentRun = new CurrentRun(start); expect(currentRun.distance).toBe(0, 'No segments should be added with just a run start.'); }); it('should allow setting and updating a marker', () => { - let start = new RunStart({} as LngLat, {} as Point); + let start = new RunStart({} as LngLat); let marker = getMockMarker(); spyOn(marker, 'remove').and.stub(); start.setMarker(marker); @@ -21,10 +21,10 @@ describe('CurrentRun class', () => { }); it('updates with a new RunSegment', () => { - let currentRun = new CurrentRun(new RunStart({} as LngLat, {} as Point)); + let currentRun = new CurrentRun(new RunStart({} as LngLat)); let initialExpectedDistance = 500; - let firstSegment = new RunSegment('some-uuid', {} as LngLat, {} as Point, initialExpectedDistance, {} as LineString); + let firstSegment = new RunSegment('some-uuid', {} as LngLat, initialExpectedDistance, {} as LineString); let marker = getMockMarker(); currentRun.addSegment(firstSegment, marker); @@ -32,14 +32,14 @@ describe('CurrentRun class', () => { expect(firstSegment.marker).toBe(marker); let secondDistance = 1337; - let secondSegment = new RunSegment('different-uuid', {} as LngLat, {} as Point, secondDistance, {} as LineString); + let secondSegment = new RunSegment('different-uuid', {} as LngLat, secondDistance, {} as LineString); currentRun.addSegment(secondSegment, getMockMarker()); expect(currentRun.distance).toBe(initialExpectedDistance + secondDistance, 'Distance did not correctly add the incoming distance response value.'); }); it('gets the start\'s LngLat', () => { let expectedLngLat = { lng: 101, lat: 202 } as LngLat; - let runStart = new RunStart(expectedLngLat, {} as Point); + let runStart = new RunStart(expectedLngLat); let currentRun = new CurrentRun(runStart); let lastPosition = currentRun.getLastPosition(); @@ -47,12 +47,12 @@ describe('CurrentRun class', () => { }); it('removes the last run segment and decrements distance correctly', () => { - let runStart = new RunStart({} as LngLat, {} as Point); + let runStart = new RunStart({} as LngLat); let currentRun = new CurrentRun(runStart); let expectedLngLat = { lng: 101, lat: 202 } as LngLat; let expectedDistance = 100; - let segment = new RunSegment('some-uuid', expectedLngLat, {} as Point, expectedDistance, {} as LineString); + let segment = new RunSegment('some-uuid', expectedLngLat, expectedDistance, {} as LineString); let marker = getMockMarker(); spyOn(marker, 'remove').and.stub(); currentRun.addSegment(segment, marker); @@ -71,7 +71,7 @@ describe('CurrentRun class', () => { }); it('does not remove the run start', () => { - let currentRun = new CurrentRun(new RunStart({} as LngLat, {} as Point)); + let currentRun = new CurrentRun(new RunStart({} as LngLat)); let removed = currentRun.removeLastSegment(); expect(removed).toBeUndefined('Removing the last point should return undefined (no segments to remove).'); }); diff --git a/src/current-run.ts b/src/current-run.ts index b3ec8b4..63a0329 100644 --- a/src/current-run.ts +++ b/src/current-run.ts @@ -1,14 +1,12 @@ -import { LngLat, Point, Marker } from 'mapbox-gl'; +import { LngLat, Marker } from 'mapbox-gl'; import { LineString } from 'geojson'; export class RunStart { public lngLat: LngLat; - public point: Point; public marker: Marker; - constructor(lngLat: LngLat, point: Point) { + constructor(lngLat: LngLat) { this.lngLat = lngLat; - this.point = point; } public setMarker(newMarker: Marker) { @@ -25,8 +23,8 @@ export class RunSegment extends RunStart { public distance: number; // in meters public geometry: LineString; - constructor(id: string, lngLat: LngLat, point: Point, distance: number, geometry: LineString) { - super(lngLat, point); + constructor(id: string, lngLat: LngLat, distance: number, geometry: LineString) { + super(lngLat); this.id = id; this.distance = distance; this.geometry = geometry; diff --git a/src/index.ts b/src/index.ts index ae5ae9a..9471887 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,29 +1,21 @@ import mapboxgl, { Map, Marker, MapMouseEvent, NavigationControl, GeolocateControl, LngLat } from 'mapbox-gl'; -import { v4 as uuid } from 'uuid'; import { LineString } from 'geojson'; -import { length, lineString } from '@turf/turf'; -import { SdkConfig } from '@mapbox/mapbox-sdk/lib/classes/mapi-client'; -import { MapiResponse } from '@mapbox/mapbox-sdk/lib/classes/mapi-response'; -import DirectionsFactory, { DirectionsService, DirectionsResponse } from '@mapbox/mapbox-sdk/services/directions'; import { CurrentRun, RunStart, RunSegment } from './current-run'; import { getFormattedDistance } from './distance-formatter'; -import { MapFocus } from './map-focus'; import { getStyleById } from './map-style'; import { ps } from './appsettings.secrets'; -import { AnimationController } from './animation-controller'; +import { AnimationService } from './animation-service'; +import { NextSegmentService } from './next-segment-service'; +import { PreferenceService } from './preference-service'; -const LAST_FOCUS_KEY = 'runmap-last_focus'; -const STORAGE_NOTICE_KEY = 'runmap-help_notice'; -const USE_METRIC_KEY = 'runmap-use_metric'; -const FOLLOW_ROADS_KEY = 'runmap-follow_roads'; -const MAP_STYLE_KEY = 'runmap-map_style'; +let preferenceService = new PreferenceService(); -let useMetric = loadBooleanPreference(USE_METRIC_KEY); -let followRoads = loadBooleanPreference(FOLLOW_ROADS_KEY); +let useMetric = preferenceService.getUseMetric(); +let followRoads = preferenceService.getShouldFollowRoads(); let isWaiting = false; -const initialFocus = loadLastOrDefaultFocus(); -const mapStyle = getStyleById(loadStringPreference(MAP_STYLE_KEY, 'street-style')); +const initialFocus = preferenceService.getLastOrDefaultFocus(); +const mapStyle = getStyleById(preferenceService.getMapStyle()); const mbk = atob(ps); (mapboxgl as any)[atob('YWNjZXNzVG9rZW4=')] = mbk; let map = new Map({ @@ -34,12 +26,11 @@ let map = new Map({ style: mapStyle }); -let cfg = {} as SdkConfig; -((cfg as any)[atob('YWNjZXNzVG9rZW4=')] = mbk); -let directionsService: DirectionsService = DirectionsFactory(cfg); +let nextSegmentService = new NextSegmentService(mbk); + let currentRun: CurrentRun = undefined; -let animationController = new AnimationController(map); +let animationService = new AnimationService(map); let lengthElement = document.getElementById('run-length'); let unitsElement = document.getElementById('run-units'); @@ -58,8 +49,8 @@ const mapStyleElements = [streetStyleElement, satelliteStyleElement, darkStyleEl let removeLastElement = document.getElementById('remove-last'); -let storageElement = document.getElementById('help-notice'); -let acceptStorageElement = document.getElementById('dismiss-notice'); +let helpElement = document.getElementById('help-notice'); +let dismissHelpElement = document.getElementById('dismiss-notice'); setupUserControls(); map.on('load', () => { @@ -74,8 +65,8 @@ map.on('load', () => { enableHighAccuracy: true }, trackUserLocation: false - }).on('geolocate', (e: Position) => { - stashCurrentFocus(e); + }).on('geolocate', (p: Position) => { + preferenceService.saveCurrentFocus(p, map.getZoom()); }), 'bottom-right'); }); @@ -93,19 +84,18 @@ map.on('click', (e: MapMouseEvent) => { longitude: center.lng } } as Position; - stashCurrentFocus(pos); + preferenceService.saveCurrentFocus(pos, map.getZoom()); }); // triggered upon map style changed map.on('style.load', () => { - animationController.readdRunToMap(currentRun); + animationService.readdRunToMap(currentRun); }); function addNewPoint(e: MapMouseEvent): void { if (currentRun === undefined) { let start = new RunStart( - e.lngLat, - e.point + e.lngLat ); start.setMarker(addMarker(e.lngLat, true)); currentRun = new CurrentRun(start); @@ -116,128 +106,44 @@ function addNewPoint(e: MapMouseEvent): void { } else { let prev = currentRun.getLastPosition(); if (followRoads) { - segmentFromDirectionsResponse(prev, e); + addSegmentFromDirectionsResponse(prev, e); } else { - segmentFromStraightLine(prev, e); + addSegmentFromStraightLine(prev, e); } } setWaiting(false); } -function segmentFromDirectionsResponse(previousPoint: LngLat, e: MapMouseEvent) { - directionsService.getDirections({ - profile: 'walking', - waypoints: [ - { - coordinates: [previousPoint.lng, previousPoint.lat] - }, - { - coordinates: [e.lngLat.lng, e.lngLat.lat] - } - ], - geometries: 'geojson' - }).send().then((res: MapiResponse) => { - if (res.statusCode === 200) { - const directionsResponse = res.body as DirectionsResponse; - if (directionsResponse.routes.length <= 0) { - alert('No routes found between the two points.'); - return; - } - - const route = directionsResponse.routes[0]; - let newSegment = new RunSegment( - uuid(), - e.lngLat, - e.point, - route.distance, - route.geometry as LineString - ); - - const line = route.geometry as LineString; +function addSegmentFromDirectionsResponse(previousLngLat: LngLat, e: MapMouseEvent) { + nextSegmentService.getSegmentFromDirectionsService(previousLngLat, e.lngLat) + .then((newSegment: RunSegment) => { + + const line = newSegment.geometry as LineString; const coordinates = line.coordinates; - animationController.animateSegment(newSegment); + animationService.animateSegment(newSegment); // use ending coordinate from route for the marker const segmentEnd = coordinates[coordinates.length - 1]; const marker = addMarker(new LngLat(segmentEnd[0], segmentEnd[1]), false); currentRun.addSegment(newSegment, marker); updateLengthElement(); - } else { - alert(`Non-successful status code when getting directions: ${JSON.stringify(res)}`); - } - }, err => { - alert(`An error occurred: ${JSON.stringify(err)}`); - }); + }, err => { + alert(`An error occurred getting directions: ${err}`); + }); } -function segmentFromStraightLine(previousPoint: LngLat, e: MapMouseEvent): void { - const lineCoordinates = [ - [previousPoint.lng, previousPoint.lat], - [e.lngLat.lng, e.lngLat.lat] - ]; - - const distance = length(lineString(lineCoordinates), { units: 'meters' }); - const line = { type: 'LineString', coordinates: lineCoordinates } as LineString; - let newSegment = new RunSegment( - uuid(), - e.lngLat, - e.point, - distance, - line - ); - animationController.animateSegment(newSegment); +function addSegmentFromStraightLine(previousLngLat: LngLat, e: MapMouseEvent): void { + const newSegment = nextSegmentService.segmentFromStraightLine(previousLngLat, e.lngLat); + + animationService.animateSegment(newSegment); const marker = addMarker(e.lngLat, false); currentRun.addSegment(newSegment, marker); updateLengthElement(); } -function loadLastOrDefaultFocus(): MapFocus { - let initialPosition = JSON.parse(localStorage.getItem(LAST_FOCUS_KEY)) as MapFocus; - if (initialPosition === null) { - initialPosition = { - lng: -79.93775232392454, - lat: 32.78183341484467, - zoom: 14 - }; - } - return initialPosition; -} - -function stashCurrentFocus(pos: Position): void { - const zoom = map.getZoom(); - const currentFocus = { - lng: pos.coords.longitude, - lat: pos.coords.latitude, - zoom: zoom - } as MapFocus; - localStorage.setItem(LAST_FOCUS_KEY, JSON.stringify(currentFocus)); -} - -function loadBooleanPreference(settingKey: string): boolean { - const setting = localStorage.getItem(settingKey); - if (setting === null) { - return true; - } else { - return setting === 'true'; - } -} - -function loadStringPreference(settingKey: string, defaultValue: string): string { - const setting = localStorage.getItem(settingKey); - if (setting === null) { - return defaultValue; - } else { - return setting; - } -} - -function saveBooleanPreference(settingKey: string, value: boolean): void { - localStorage.setItem(settingKey, '' + value); // ugh -} - function setupUserControls(): void { showHelpElementIfNecessary(); - acceptStorageElement.onclick = hideStorageElement; + dismissHelpElement.onclick = hideStorageElement; removeLastElement.onclick = removeLastSegment; @@ -253,7 +159,7 @@ function setupUserControls(): void { followRoadsElement.onclick = () => closeMenuAction(toggleFollowRoads); clearRunElement.onclick = () => closeMenuAction(clearRun); - const id = loadStringPreference(MAP_STYLE_KEY, 'street-style'); + const id = preferenceService.getMapStyle(); setSelectedMapToggleStyles(document.getElementById(id)); streetStyleElement.onclick = () => closeMenuAction(() => setSelectedMapToggleStyles(streetStyleElement)); satelliteStyleElement.onclick = () => closeMenuAction(() => setSelectedMapToggleStyles(satelliteStyleElement)); @@ -266,20 +172,20 @@ function closeMenuAction(fn: () => void) { } function showHelpElementIfNecessary(): void { - if (!JSON.parse(localStorage.getItem(STORAGE_NOTICE_KEY))) { - storageElement.style.display = 'block'; + if (!preferenceService.getHasAcknowledgedHelp()) { + helpElement.style.display = 'block'; } } function hideStorageElement(): void { - storageElement.style.display = 'none'; - localStorage.setItem(STORAGE_NOTICE_KEY, JSON.stringify(true)); + helpElement.style.display = 'none'; + preferenceService.saveHasAcknowledgedHelp(true); } function toggleDistanceUnits(): void { useMetric = !useMetric; updateLengthElement(); - saveBooleanPreference(USE_METRIC_KEY, useMetric); + preferenceService.saveUseMetric(useMetric); } function toggleFollowRoads(): void { @@ -291,7 +197,7 @@ function setSelectedMapToggleStyles(selected: HTMLElement): void { const elementId = selected.id; const style = getStyleById(elementId); map.setStyle(style); // layers readded on style.load - localStorage.setItem(MAP_STYLE_KEY, elementId); + preferenceService.saveMapStyle(elementId); for (let element of mapStyleElements) { element.style.color = 'inherit'; } @@ -367,5 +273,5 @@ function setFollowRoads(value: boolean) { followRoadsElement.setAttribute('aria-value', 'disabled'); } followRoads = value; - saveBooleanPreference(FOLLOW_ROADS_KEY, value); + preferenceService.saveShouldFollowRoads(value); } diff --git a/src/next-segment-service.ts b/src/next-segment-service.ts new file mode 100644 index 0000000..7fbfadb --- /dev/null +++ b/src/next-segment-service.ts @@ -0,0 +1,82 @@ +import { SdkConfig } from '@mapbox/mapbox-sdk/lib/classes/mapi-client'; +import { MapiResponse } from '@mapbox/mapbox-sdk/lib/classes/mapi-response'; +import DirectionsFactory, { DirectionsService, DirectionsResponse } from '@mapbox/mapbox-sdk/services/directions'; +import { LngLat } from 'mapbox-gl'; +import { length, lineString, LineString } from '@turf/turf'; +import uuid from 'uuid'; +import { RunSegment } from './current-run'; + +/** + * Abstracts out the two means of adding a new segment to a run: + * - from the mapbox directions service + * - as a straight line between the previous and next points + */ +export class NextSegmentService { + private directionsService: DirectionsService; + + constructor(mbk: string) { + let cfg = {} as SdkConfig; + ((cfg as any)[atob('YWNjZXNzVG9rZW4=')] = mbk); + this.directionsService = DirectionsFactory(cfg); + } + + /** + * Get the next segment for the run using a mapbox directions service request + * @param previousLngLat The last LngLat in the run, the starting point for the next segment + * @param nextLngLat The next LngLat in the run, the ending point for the next segment + */ + public getSegmentFromDirectionsService(previousLngLat: LngLat, nextLngLat: LngLat): Promise { + return this.directionsService.getDirections({ + profile: 'walking', + waypoints: [ + { + coordinates: [previousLngLat.lng, previousLngLat.lat] + }, + { + coordinates: [nextLngLat.lng, nextLngLat.lat] + } + ], + geometries: 'geojson' + }).send().then((res: MapiResponse) => { + if (res.statusCode === 200) { + const directionsResponse = res.body as DirectionsResponse; + if (directionsResponse.routes.length <= 0) { + throw new Error('No routes found between the two points.'); + } + + const route = directionsResponse.routes[0]; + return new RunSegment( + uuid(), + nextLngLat, + route.distance, + route.geometry as LineString + ); + } else { + throw new Error(`Non-successful status code when getting directions: ${JSON.stringify(res)}`); + } + }, err => { + throw new Error(`An error occurred: ${JSON.stringify(err)}`); + }); + } + + /** + * Get the next segment as a straight line between the previous and next points + * @param previousLngLat The previous point in the run + * @param nextLngLat The next point in the run + */ + public segmentFromStraightLine(previousLngLat: LngLat, nextLngLat: LngLat): RunSegment { + const lineCoordinates = [ + [previousLngLat.lng, previousLngLat.lat], + [nextLngLat.lng, nextLngLat.lat] + ]; + + const distance = length(lineString(lineCoordinates), { units: 'meters' }); + const line = { type: 'LineString', coordinates: lineCoordinates } as LineString; + return new RunSegment( + uuid(), + nextLngLat, + distance, + line + ); + } +} diff --git a/src/preference-service.ts b/src/preference-service.ts new file mode 100644 index 0000000..71b6eb3 --- /dev/null +++ b/src/preference-service.ts @@ -0,0 +1,96 @@ +import { MapFocus } from './map-focus'; + +/** + * Load user preferences for a variety of settings, currently + * an abstraction over localStorage + */ +export class PreferenceService { + private LAST_FOCUS_KEY = 'runmap-last_focus'; + private STORAGE_NOTICE_KEY = 'runmap-help_notice'; + private USE_METRIC_KEY = 'runmap-use_metric'; + private FOLLOW_ROADS_KEY = 'runmap-follow_roads'; + private MAP_STYLE_KEY = 'runmap-map_style'; + + public getLastOrDefaultFocus(): MapFocus { + let initialPosition = JSON.parse(localStorage.getItem(this.LAST_FOCUS_KEY)) as MapFocus; + if (initialPosition === null) { + initialPosition = { + lng: -79.93775232392454, + lat: 32.78183341484467, + zoom: 14 + }; + } + return initialPosition; + } + + public saveCurrentFocus(pos: Position, zoom: number): void { + const currentFocus = { + lng: pos.coords.longitude, + lat: pos.coords.latitude, + zoom: zoom + } as MapFocus; + this.saveJsonPreference(this.LAST_FOCUS_KEY, currentFocus); + } + + public getUseMetric(): boolean { + return this.loadBooleanPreference(this.USE_METRIC_KEY); + } + + public saveUseMetric(value: boolean): void { + this.saveBooleanPreference(this.USE_METRIC_KEY, value); + } + + public getShouldFollowRoads(): boolean { + return this.loadBooleanPreference(this.FOLLOW_ROADS_KEY); + } + + public saveShouldFollowRoads(value: boolean): void { + this.saveBooleanPreference(this.FOLLOW_ROADS_KEY, value); + } + + public getMapStyle(): string { + return this.loadStringPreference(this.MAP_STYLE_KEY, 'street-style'); + } + + public saveMapStyle(value: string) { + this.saveStringPreference(this.MAP_STYLE_KEY, value); + } + + public getHasAcknowledgedHelp(): boolean { + return this.loadBooleanPreference(this.STORAGE_NOTICE_KEY); + } + + public saveHasAcknowledgedHelp(value: boolean): void { + this.saveBooleanPreference(this.STORAGE_NOTICE_KEY, value); + } + + private loadBooleanPreference(settingKey: string): boolean { + const setting = localStorage.getItem(settingKey); + if (setting === null) { + return true; + } else { + return setting === 'true'; + } + } + + private loadStringPreference(settingKey: string, defaultValue: string): string { + const setting = localStorage.getItem(settingKey); + if (setting === null) { + return defaultValue; + } else { + return setting; + } + } + + private saveBooleanPreference(settingKey: string, value: boolean): void { + localStorage.setItem(settingKey, '' + value); // ugh + } + + private saveStringPreference(settingKey: string, value: string): void { + localStorage.setItem(settingKey, value); + } + + private saveJsonPreference(settingKey: string, value: any): void { + localStorage.setItem(settingKey, JSON.stringify(value)); + } +}