From 86b311535d3a34a0399ac54189de99eab059edc8 Mon Sep 17 00:00:00 2001 From: Daniel Schultz Date: Fri, 4 Sep 2026 11:02:25 -0400 Subject: [PATCH 1/3] Add GPS coordinate formatting and parsing This utility is going to be used for the upcoming location picker UX. There are a few ways to note GPS coordinates and we're standardizing on DMS for rendering but will accept degree format for now. If the string doesn't parse we return null because that's actually something a caller can handler and understand. Issue #1159 Implement UX for location coordinate entry Claude-Session: https://claude.ai/code/session_01HM1anu1T97zKvtb1TKTcfc --- src/app/shared/utilities/coordinates.spec.ts | 162 ++++++++++++++++++ src/app/shared/utilities/coordinates.ts | 164 +++++++++++++++++++ 2 files changed, 326 insertions(+) create mode 100644 src/app/shared/utilities/coordinates.spec.ts create mode 100644 src/app/shared/utilities/coordinates.ts diff --git a/src/app/shared/utilities/coordinates.spec.ts b/src/app/shared/utilities/coordinates.spec.ts new file mode 100644 index 000000000..b85c4d4cb --- /dev/null +++ b/src/app/shared/utilities/coordinates.spec.ts @@ -0,0 +1,162 @@ +import { + coordinatesFromLocation, + formatCoordinates, + parseCoordinates, +} from './coordinates'; + +describe('formatCoordinates', () => { + it('writes the pair as degrees, minutes and seconds', () => { + expect( + formatCoordinates({ latitude: 38.70786, longitude: -9.400139 }), + ).toBe(`38°42'28.3" N 9°24'00.5" W`); + }); + + it('names the southern and eastern hemispheres', () => { + expect(formatCoordinates({ latitude: -33.8688, longitude: 151.2093 })).toBe( + `33°52'07.7" S 151°12'33.5" E`, + ); + }); + + it('treats the equator and prime meridian as north and east', () => { + expect(formatCoordinates({ latitude: 0, longitude: 0 })).toBe( + `0°00'00.0" N 0°00'00.0" E`, + ); + }); + + it('carries a rounded-up second into the minute above it', () => { + // 0.99999° is 59'59.996", which must not print as 59'60.0". + expect(formatCoordinates({ latitude: 0.99999, longitude: 0 })).toBe( + `1°00'00.0" N 0°00'00.0" E`, + ); + }); +}); + +describe('parseCoordinates', () => { + it('reads back what formatCoordinates wrote', () => { + const parsed = parseCoordinates(`38°42'28.3" N 9°24'00.5" W`); + + expect(parsed.latitude).toBeCloseTo(38.707861, 6); + expect(parsed.longitude).toBeCloseTo(-9.400139, 6); + }); + + it('reads decimal degrees separated by a comma', () => { + expect(parseCoordinates('38.7078, -9.4001')).toEqual({ + latitude: 38.7078, + longitude: -9.4001, + }); + }); + + it('reads decimal degrees separated by whitespace alone', () => { + expect(parseCoordinates('38.7078 -9.4001')).toEqual({ + latitude: 38.7078, + longitude: -9.4001, + }); + }); + + it('reads decimal degrees carrying hemisphere letters', () => { + expect(parseCoordinates('38.7078° N; 9.4001° W')).toEqual({ + latitude: 38.7078, + longitude: -9.4001, + }); + }); + + it('accepts lowercase hemisphere letters', () => { + expect(parseCoordinates('38.7078 n, 9.4001 w')).toEqual({ + latitude: 38.7078, + longitude: -9.4001, + }); + }); + + it('reads degrees and minutes without seconds', () => { + expect(parseCoordinates(`38°42' N 9°24' W`)).toEqual({ + latitude: 38.7, + longitude: -9.4, + }); + }); + + it('lets the hemisphere letters name the axis when longitude comes first', () => { + expect(parseCoordinates('9.4001° W, 38.7078° N')).toEqual({ + latitude: 38.7078, + longitude: -9.4001, + }); + }); + + it('assumes latitude first when no letters name the axis', () => { + expect(parseCoordinates('9.4001, 38.7078')).toEqual({ + latitude: 9.4001, + longitude: 38.7078, + }); + }); + + it('rejects two angles from the same axis', () => { + expect(parseCoordinates('38° N, 9° N')).toBeNull(); + }); + + it('rejects a sign that contradicts its hemisphere letter', () => { + expect(parseCoordinates('-38 N, -9 W')).toBeNull(); + expect(parseCoordinates('-38 S, 9 E')).toBeNull(); + }); + + it('still accepts a sign on the half that carries no letter', () => { + expect(parseCoordinates('-38, 9 E')).toEqual({ + latitude: -38, + longitude: 9, + }); + }); + + it('rejects a latitude beyond the poles', () => { + expect(parseCoordinates('91, 9')).toBeNull(); + }); + + it('rejects a longitude beyond half a turn', () => { + expect(parseCoordinates('38, 181')).toBeNull(); + }); + + it('rejects minutes and seconds that overflow', () => { + expect(parseCoordinates(`38°60'00.0" N 9°00'00.0" W`)).toBeNull(); + expect(parseCoordinates(`38°00'60.0" N 9°00'00.0" W`)).toBeNull(); + }); + + it('rejects a half-typed pair rather than guessing at the rest', () => { + expect(parseCoordinates('38.70, ')).toBeNull(); + expect(parseCoordinates('')).toBeNull(); + expect(parseCoordinates(null)).toBeNull(); + }); + + it('rejects a lone number, decimal point and all', () => { + expect(parseCoordinates('38')).toBeNull(); + // Without a separator this reads as 38.7 and 0. + expect(parseCoordinates('38.70')).toBeNull(); + }); + + it('rejects text that is not a coordinate pair at all', () => { + expect(parseCoordinates('Lisbon, Portugal')).toBeNull(); + }); +}); + +describe('coordinatesFromLocation', () => { + it('reads a stored pair', () => { + expect( + coordinatesFromLocation({ latitude: 38.7078, longitude: -9.4001 }), + ).toEqual({ latitude: 38.7078, longitude: -9.4001 }); + }); + + it('reads a pair that was stored as strings', () => { + expect( + coordinatesFromLocation({ latitude: '38.7078', longitude: '-9.4001' }), + ).toEqual({ latitude: 38.7078, longitude: -9.4001 }); + }); + + it('returns null when the location carries no pair', () => { + expect(coordinatesFromLocation({ city: 'Lisbon' })).toBeNull(); + expect(coordinatesFromLocation({ latitude: 38.7078 })).toBeNull(); + expect(coordinatesFromLocation({ latitude: '', longitude: '' })).toBeNull(); + expect(coordinatesFromLocation(null)).toBeNull(); + }); + + it('returns null when what was stored is not a number', () => { + expect( + coordinatesFromLocation({ latitude: 'somewhere', longitude: 'nice' }), + ).toBeNull(); + }); +}); diff --git a/src/app/shared/utilities/coordinates.ts b/src/app/shared/utilities/coordinates.ts new file mode 100644 index 000000000..590f5221e --- /dev/null +++ b/src/app/shared/utilities/coordinates.ts @@ -0,0 +1,164 @@ +import { LocnVOData } from '@models'; + +export interface Coordinates { + latitude: number; + longitude: number; +} + +const MAX_LATITUDE = 90; +const MAX_LONGITUDE = 180; +const MINUTES_PER_DEGREE = 60; +const SECONDS_PER_MINUTE = 60; +const SECONDS_PER_DEGREE = MINUTES_PER_DEGREE * SECONDS_PER_MINUTE; +const TENTHS_PER_SECOND = 10; + +const PAIR_SEPARATOR = ' '; + +const SIGNED_NUMBER = String.raw`[+-]?\d+(?:\.\d+)?`; +const UNSIGNED_NUMBER = String.raw`\d+(?:\.\d+)?`; +const DEGREE_MARK = String.raw`[°º]?`; +const MINUTE_MARK = String.raw`['′]`; +const SECOND_MARK = String.raw`''|"|″`; +const HEMISPHERE_LETTER = String.raw`[NSEW]`; + +const ANGLE = String.raw`(${SIGNED_NUMBER})\s*${DEGREE_MARK}\s*(?:(${UNSIGNED_NUMBER})\s*${MINUTE_MARK}\s*(?:(${UNSIGNED_NUMBER})\s*(?:${SECOND_MARK})\s*)?)?(${HEMISPHERE_LETTER})?`; +const CAPTURES_PER_ANGLE = 4; + +/** + * Required, because `38.70` otherwise reads as the pair 38.7 and 0: the + * decimal point ends the first number and the digits after it begin a second. + */ +const ANGLE_SEPARATOR = String.raw`(?:\s*[,;]\s*|\s+)`; + +const COORDINATE_PAIR = new RegExp( + `^\\s*${ANGLE}${ANGLE_SEPARATOR}${ANGLE}\\s*$`, + 'i', +); + +const LATITUDE_HEMISPHERES = ['N', 'S']; +const NEGATIVE_HEMISPHERES = ['S', 'W']; + +interface Angle { + degrees: number; + minutes: number; + seconds: number; + hemisphere: string | null; +} + +const twoDigits = (value: number): string => String(value).padStart(2, '0'); + +const toDegreesMinutesSeconds = ( + value: number, + positiveHemisphere: string, + negativeHemisphere: string, +): string => { + const hemisphere = value < 0 ? negativeHemisphere : positiveHemisphere; + const totalTenths = Math.round( + Math.abs(value) * SECONDS_PER_DEGREE * TENTHS_PER_SECOND, + ); + const tenths = totalTenths % TENTHS_PER_SECOND; + const totalSeconds = Math.floor(totalTenths / TENTHS_PER_SECOND); + const seconds = totalSeconds % SECONDS_PER_MINUTE; + const totalMinutes = Math.floor(totalSeconds / SECONDS_PER_MINUTE); + const minutes = totalMinutes % MINUTES_PER_DEGREE; + const degrees = Math.floor(totalMinutes / MINUTES_PER_DEGREE); + + return `${degrees}°${twoDigits(minutes)}'${twoDigits(seconds)}.${tenths}" ${hemisphere}`; +}; + +export const formatCoordinates = (coordinates: Coordinates): string => + [ + toDegreesMinutesSeconds(coordinates.latitude, 'N', 'S'), + toDegreesMinutesSeconds(coordinates.longitude, 'E', 'W'), + ].join(PAIR_SEPARATOR); + +const toDecimalDegrees = (angle: Angle): number => { + const magnitude = + Math.abs(angle.degrees) + + angle.minutes / MINUTES_PER_DEGREE + + angle.seconds / SECONDS_PER_DEGREE; + const isNegative = angle.hemisphere + ? NEGATIVE_HEMISPHERES.includes(angle.hemisphere) + : angle.degrees < 0; + + return isNegative ? -magnitude : magnitude; +}; + +const hasValidMinutesAndSeconds = (angle: Angle): boolean => + angle.minutes < MINUTES_PER_DEGREE && angle.seconds < SECONDS_PER_MINUTE; + +const contradictsItsHemisphere = (angle: Angle): boolean => + angle.hemisphere !== null && angle.degrees < 0; + +const isLatitudeHemisphere = (angle: Angle): boolean | null => + angle.hemisphere === null + ? null + : LATITUDE_HEMISPHERES.includes(angle.hemisphere); + +const readAngles = (match: RegExpExecArray): Angle[] => + [0, CAPTURES_PER_ANGLE].map((offset) => ({ + degrees: Number(match[offset + 1]), + minutes: Number(match[offset + 2] ?? 0), + seconds: Number(match[offset + 3] ?? 0), + hemisphere: match[offset + 4]?.toUpperCase() ?? null, + })); + +export const namesAPlaceOnEarth = ({ + latitude, + longitude, +}: Coordinates): boolean => + Math.abs(latitude) <= MAX_LATITUDE && Math.abs(longitude) <= MAX_LONGITUDE; + +export const parseCoordinates = ( + text: string | null | undefined, +): Coordinates | null => { + const match = COORDINATE_PAIR.exec(text ?? ''); + if (!match) { + return null; + } + + const angles = readAngles(match); + if (!angles.every(hasValidMinutesAndSeconds)) { + return null; + } + if (angles.some(contradictsItsHemisphere)) { + return null; + } + + const [firstIsLatitude, secondIsLatitude] = angles.map(isLatitudeHemisphere); + const namesTheSameAxis = + firstIsLatitude !== null && + secondIsLatitude !== null && + firstIsLatitude === secondIsLatitude; + if (namesTheSameAxis) { + return null; + } + + const isLongitudeFirst = + firstIsLatitude === false || secondIsLatitude === true; + const [latitude, longitude] = ( + isLongitudeFirst ? [angles[1], angles[0]] : angles + ).map(toDecimalDegrees); + + return namesAPlaceOnEarth({ latitude, longitude }) + ? { latitude, longitude } + : null; +}; + +export const coordinatesFromLocation = ( + location: LocnVOData | null | undefined, +): Coordinates | null => { + const hasLatitude = location?.latitude != null && location.latitude !== ''; + const hasLongitude = location?.longitude != null && location.longitude !== ''; + if (!hasLatitude || !hasLongitude) { + return null; + } + + const latitude = Number(location.latitude); + const longitude = Number(location.longitude); + if (Number.isNaN(latitude) || Number.isNaN(longitude)) { + return null; + } + + return { latitude, longitude }; +}; From fa4afd934a30e41ca4ac27e7ee63bcfc554bb743 Mon Sep 17 00:00:00 2001 From: Daniel Schultz Date: Fri, 4 Sep 2026 11:32:01 -0400 Subject: [PATCH 2/3] Add a reusable coordinate map input The coordinate map input only has a single intended use right now but I think there is benefit in having well-scoped components from a testability and consolidation of complexity perspective. Issue #1159 Implement UX for location coordinate entry Claude-Session: https://claude.ai/code/session_01HM1anu1T97zKvtb1TKTcfc --- .../location-picker.component.ts | 17 +- .../coordinate-map-input.component.html | 20 + .../coordinate-map-input.component.scss | 15 + .../coordinate-map-input.component.spec.ts | 367 ++++++++++++++++++ .../coordinate-map-input.component.ts | 165 ++++++++ src/app/shared/utilities/map-view.ts | 7 + src/assets/svg/map-pin.svg | 4 + 7 files changed, 586 insertions(+), 9 deletions(-) create mode 100644 src/app/shared/components/coordinate-map-input/coordinate-map-input.component.html create mode 100644 src/app/shared/components/coordinate-map-input/coordinate-map-input.component.scss create mode 100644 src/app/shared/components/coordinate-map-input/coordinate-map-input.component.spec.ts create mode 100644 src/app/shared/components/coordinate-map-input/coordinate-map-input.component.ts create mode 100644 src/app/shared/utilities/map-view.ts create mode 100644 src/assets/svg/map-pin.svg diff --git a/src/app/file-browser/components/location-picker/location-picker.component.ts b/src/app/file-browser/components/location-picker/location-picker.component.ts index 7bc586c50..53dda6708 100644 --- a/src/app/file-browser/components/location-picker/location-picker.component.ts +++ b/src/app/file-browser/components/location-picker/location-picker.component.ts @@ -21,12 +21,11 @@ import { MessageService } from '@shared/services/message/message.service'; import { EditService } from '@core/services/edit/edit.service'; import { ProfileItemVOData } from '@models/profile-item-vo'; import { ProfileService } from '@shared/services/profile/profile.service'; - -const DEFAULT_ZOOM = 12; -const DEFAULT_CENTER: google.maps.LatLngLiteral = { - lat: 39.8333333, - lng: -98.585522, -}; +import { + CONTINENTAL_US_CENTER, + LOCATED_ZOOM, + WHOLE_COUNTRY_ZOOM, +} from '@shared/utilities/map-view'; @Component({ selector: 'pr-location-picker', @@ -41,15 +40,15 @@ export class LocationPickerComponent implements OnInit, AfterViewInit { @Input() archive: ArchiveVO; mapOptions: google.maps.MapOptions = { - zoom: DEFAULT_ZOOM, + zoom: LOCATED_ZOOM, streetViewControl: false, fullscreenControl: false, mapTypeControl: false, clickableIcons: false, }; - zoom = 4; - center: google.maps.LatLng = new google.maps.LatLng(DEFAULT_CENTER); + zoom = WHOLE_COUNTRY_ZOOM; + center: google.maps.LatLng = new google.maps.LatLng(CONTINENTAL_US_CENTER); height: string; width: string; diff --git a/src/app/shared/components/coordinate-map-input/coordinate-map-input.component.html b/src/app/shared/components/coordinate-map-input/coordinate-map-input.component.html new file mode 100644 index 000000000..2699d1838 --- /dev/null +++ b/src/app/shared/components/coordinate-map-input/coordinate-map-input.component.html @@ -0,0 +1,20 @@ + + @if (markerPosition(); as position) { + + } + + +
+ +
diff --git a/src/app/shared/components/coordinate-map-input/coordinate-map-input.component.scss b/src/app/shared/components/coordinate-map-input/coordinate-map-input.component.scss new file mode 100644 index 000000000..dfbfe483d --- /dev/null +++ b/src/app/shared/components/coordinate-map-input/coordinate-map-input.component.scss @@ -0,0 +1,15 @@ +@import 'colors'; + +:host { + display: block; + position: relative; +} + +.pr-coordinate-field { + position: absolute; + top: 16px; + left: 16px; + right: 16px; + border-radius: 6px; + box-shadow: 0 2px 8px rgba($PR-brand-black, 0.16); +} diff --git a/src/app/shared/components/coordinate-map-input/coordinate-map-input.component.spec.ts b/src/app/shared/components/coordinate-map-input/coordinate-map-input.component.spec.ts new file mode 100644 index 000000000..d90000308 --- /dev/null +++ b/src/app/shared/components/coordinate-map-input/coordinate-map-input.component.spec.ts @@ -0,0 +1,367 @@ +import { Component, CUSTOM_ELEMENTS_SCHEMA, ViewChild } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { GoogleMapsModule } from '@angular/google-maps'; +import { Coordinates } from '@shared/utilities/coordinates'; +import { CoordinateMapInputComponent } from './coordinate-map-input.component'; + +const LISBON: Coordinates = { latitude: 38.70786, longitude: -9.400139 }; +const LISBON_AS_TEXT = `38°42'28.3" N 9°24'00.5" W`; + +const mapClickAt = (lat: number, lng: number): google.maps.MapMouseEvent => + ({ + latLng: { lat: () => lat, lng: () => lng }, + }) as google.maps.MapMouseEvent; + +@Component({ + standalone: true, + imports: [CoordinateMapInputComponent], + template: ``, +}) +class TestHostComponent { + @ViewChild(CoordinateMapInputComponent) input: CoordinateMapInputComponent; + coordinates: Coordinates | null = null; + emitted: (Coordinates | null)[] = []; + validity: boolean[] = []; + + onCoordinatesChange(coordinates: Coordinates | null): void { + this.emitted.push(coordinates); + } + + onValidityChange(isValid: boolean): void { + this.validity.push(isValid); + } +} + +describe('CoordinateMapInputComponent', () => { + let fixture: ComponentFixture; + let host: TestHostComponent; + + const setUp = async (coordinates: Coordinates | null): Promise => { + await TestBed.configureTestingModule({ + imports: [TestHostComponent], + }) + .overrideComponent(CoordinateMapInputComponent, { + remove: { imports: [GoogleMapsModule] }, + add: { schemas: [CUSTOM_ELEMENTS_SCHEMA] }, + }) + .compileComponents(); + + fixture = TestBed.createComponent(TestHostComponent); + host = fixture.componentInstance; + host.coordinates = coordinates; + fixture.detectChanges(); + }; + + const getField = (): HTMLInputElement => + fixture.nativeElement.querySelector('.pr-icon-text-input-control'); + + const typeInField = (value: string): void => { + const field = getField(); + field.value = value; + field.dispatchEvent(new Event('input')); + fixture.detectChanges(); + }; + + describe('with no coordinates', () => { + beforeEach(async () => { + await setUp(null); + }); + + it('should start with an empty field', () => { + expect(getField().value).toBe(''); + }); + + it('should show no pin', () => { + expect(host.input.markerPosition()).toBeNull(); + }); + + it('should open on the whole country', () => { + expect(host.input.mapOptions.zoom).toBe(4); + }); + }); + + describe('with coordinates given', () => { + beforeEach(async () => { + await setUp(LISBON); + }); + + it('should write them into the field', () => { + expect(getField().value).toBe(LISBON_AS_TEXT); + }); + + it('should drop the pin on them', () => { + expect(host.input.markerPosition()).toEqual({ + lat: LISBON.latitude, + lng: LISBON.longitude, + }); + }); + + it('should centre the map on them', () => { + expect(host.input.mapOptions.center).toEqual({ + lat: LISBON.latitude, + lng: LISBON.longitude, + }); + + expect(host.input.mapOptions.zoom).toBe(12); + }); + + it('should not report anything it was merely given', () => { + expect(host.emitted).toEqual([]); + }); + + it('should report itself valid', () => { + expect(host.validity).toEqual([true]); + }); + }); + + describe('with coordinates that name no place on earth', () => { + beforeEach(async () => { + await setUp({ latitude: 200, longitude: 0 }); + }); + + it('should report itself invalid rather than let them be confirmed', () => { + expect(host.validity).toEqual([false]); + }); + + it('should still show them, so they can be corrected', () => { + expect(getField().value).not.toBe(''); + }); + + it('should drop no pin on a position Google Maps cannot hold', () => { + expect(host.input.markerPosition()).toBeNull(); + }); + + it('should leave the map on the whole country', () => { + expect(host.input.mapOptions.zoom).toBe(4); + }); + + it('should open on the first real place typed instead', () => { + typeInField('38.70786, -9.400139'); + + expect(host.input.mapOptions.center).toEqual({ + lat: LISBON.latitude, + lng: LISBON.longitude, + }); + }); + }); + + describe('clicking the map', () => { + beforeEach(async () => { + await setUp(null); + }); + + it('should move the pin to the click', () => { + host.input.onMapClick(mapClickAt(LISBON.latitude, LISBON.longitude)); + fixture.detectChanges(); + + expect(host.input.markerPosition()).toEqual({ + lat: LISBON.latitude, + lng: LISBON.longitude, + }); + }); + + it('should write the clicked pair into the field', () => { + host.input.onMapClick(mapClickAt(LISBON.latitude, LISBON.longitude)); + fixture.detectChanges(); + + expect(getField().value).toBe(LISBON_AS_TEXT); + }); + + it('should report the clicked pair', () => { + host.input.onMapClick(mapClickAt(LISBON.latitude, LISBON.longitude)); + + expect(host.emitted).toEqual([LISBON]); + }); + + it('should keep the view the click was made in, panning to pairs typed after it', () => { + const panTo = jasmine.createSpy('panTo'); + host.input.map = { + panTo, + googleMap: {}, + } as unknown as typeof host.input.map; + + host.input.onMapClick(mapClickAt(LISBON.latitude, LISBON.longitude)); + fixture.detectChanges(); + typeInField('-33.8688, 151.2093'); + + expect(host.input.mapOptions.zoom).toBe(4); + expect(panTo).toHaveBeenCalledOnceWith({ + lat: -33.8688, + lng: 151.2093, + }); + }); + + it('should ignore a click that names no position', () => { + host.input.onMapClick({} as google.maps.MapMouseEvent); + + expect(host.input.markerPosition()).toBeNull(); + expect(host.emitted).toEqual([]); + }); + }); + + describe('when the coordinates it was given change', () => { + beforeEach(async () => { + await setUp(LISBON); + }); + + it('should follow them to a new place', () => { + const sydney = { latitude: -33.8688, longitude: 151.2093 }; + host.coordinates = sydney; + fixture.detectChanges(); + + expect(host.input.markerPosition()).toEqual({ + lat: sydney.latitude, + lng: sydney.longitude, + }); + }); + + it('should keep the opening view rather than re-centring on every change', () => { + host.coordinates = { latitude: -33.8688, longitude: 151.2093 }; + fixture.detectChanges(); + + expect(host.input.mapOptions.center).toEqual({ + lat: LISBON.latitude, + lng: LISBON.longitude, + }); + }); + + it('should empty the field when they are taken away', () => { + host.coordinates = null; + fixture.detectChanges(); + + expect(getField().value).toBe(''); + }); + }); + + describe('typing into the field', () => { + let panTo: jasmine.Spy; + + beforeEach(async () => { + await setUp(null); + panTo = jasmine.createSpy('panTo'); + host.input.map = { + panTo, + googleMap: {}, + } as unknown as typeof host.input.map; + }); + + it('should report a pair typed in full', () => { + typeInField('38.70786, -9.400139'); + + expect(host.emitted).toEqual([LISBON]); + }); + + it('should open on the first pair typed', () => { + typeInField('38.70786, -9.400139'); + + expect(host.input.mapOptions.center).toEqual({ + lat: LISBON.latitude, + lng: LISBON.longitude, + }); + + expect(host.input.mapOptions.zoom).toBe(12); + }); + + it('should follow later pairs with the map', () => { + typeInField('38.70786, -9.400139'); + typeInField('-33.8688, 151.2093'); + + expect(panTo).toHaveBeenCalledOnceWith({ + lat: -33.8688, + lng: 151.2093, + }); + }); + + it('should leave the typed text as typed', () => { + typeInField('38.70786, -9.400139'); + + expect(getField().value).toBe('38.70786, -9.400139'); + }); + + it('should leave the map alone until the Maps API has loaded', () => { + host.input.map = { panTo } as unknown as typeof host.input.map; + + typeInField('38.70786, -9.400139'); + typeInField('-33.8688, 151.2093'); + + expect(panTo).not.toHaveBeenCalled(); + }); + + it('should open on a pair typed before the Maps API has loaded', () => { + host.input.map = { panTo } as unknown as typeof host.input.map; + + typeInField('38.70786, -9.400139'); + + expect(host.input.mapOptions.center).toEqual({ + lat: LISBON.latitude, + lng: LISBON.longitude, + }); + + expect(host.input.mapOptions.zoom).toBe(12); + }); + + it('should open on the latest pair typed before the Maps API has loaded', () => { + host.input.map = { panTo } as unknown as typeof host.input.map; + + typeInField('38.70786, -9.400139'); + typeInField('-33.8688, 151.2093'); + + expect(host.input.mapOptions.center).toEqual({ + lat: -33.8688, + lng: 151.2093, + }); + + expect(host.input.mapOptions.zoom).toBe(12); + }); + + it('should hold the pin still while the pair is half typed', () => { + typeInField('38.70786, -9.400139'); + typeInField('38.70786, -'); + + expect(host.input.markerPosition()).toEqual({ + lat: LISBON.latitude, + lng: LISBON.longitude, + }); + }); + + it('should report itself invalid while the text names no pair', () => { + typeInField('38.70786, -'); + + expect(host.input.isValid()).toBeFalse(); + expect(host.validity.pop()).toBeFalse(); + }); + + it('should mark the field invalid to the eye as well', () => { + typeInField('38.70786, -'); + + expect( + fixture.nativeElement.querySelector('.pr-icon-text-input.invalid'), + ).not.toBeNull(); + }); + + it('should drop the pin when the field is emptied', () => { + typeInField('38.70786, -9.400139'); + typeInField(' '); + + expect(host.input.markerPosition()).toBeNull(); + expect(host.emitted.pop()).toBeNull(); + }); + + it('should call an emptied field valid, since that clears the pair', () => { + typeInField(' '); + + expect(host.input.isValid()).toBeTrue(); + }); + + it('should become valid again once the text reads as a pair', () => { + typeInField('40.7, -74.'); + typeInField('40.7, -74.0'); + + expect(host.input.isValid()).toBeTrue(); + }); + }); +}); diff --git a/src/app/shared/components/coordinate-map-input/coordinate-map-input.component.ts b/src/app/shared/components/coordinate-map-input/coordinate-map-input.component.ts new file mode 100644 index 000000000..a55df53e8 --- /dev/null +++ b/src/app/shared/components/coordinate-map-input/coordinate-map-input.component.ts @@ -0,0 +1,165 @@ +import { + Component, + computed, + effect, + input, + linkedSignal, + model, + output, + ViewChild, +} from '@angular/core'; +import { GoogleMap, GoogleMapsModule } from '@angular/google-maps'; +import { IconTextInputComponent } from '@shared/components/icon-text-input/icon-text-input.component'; +import { + Coordinates, + formatCoordinates, + namesAPlaceOnEarth, + parseCoordinates, +} from '@shared/utilities/coordinates'; +import { + CONTINENTAL_US_CENTER, + LOCATED_ZOOM, + WHOLE_COUNTRY_ZOOM, +} from '@shared/utilities/map-view'; +import { faLocationCrosshairs } from '@fortawesome/pro-regular-svg-icons'; + +const MAP_PIN_ICON_URL = 'assets/svg/map-pin.svg'; + +const toLatLngLiteral = ( + coordinates: Coordinates, +): google.maps.LatLngLiteral => ({ + lat: coordinates.latitude, + lng: coordinates.longitude, +}); + +const isSamePlace = (a: Coordinates | null, b: Coordinates | null): boolean => + a?.latitude === b?.latitude && a?.longitude === b?.longitude; + +const textNamesPlace = ( + text: string, + coordinates: Coordinates | null, +): boolean => { + const parsed = parseCoordinates(text); + return parsed !== null && isSamePlace(parsed, coordinates); +}; + +@Component({ + selector: 'pr-coordinate-map-input', + standalone: true, + imports: [GoogleMapsModule, IconTextInputComponent], + templateUrl: './coordinate-map-input.component.html', + styleUrls: ['./coordinate-map-input.component.scss'], +}) +export class CoordinateMapInputComponent { + coordinates = model(null); + label = input('Add coordinates…'); + height = input('400px'); + + validityChange = output(); + + readonly coordinateIcon = faLocationCrosshairs; + + readonly markerOptions: google.maps.MarkerOptions = { + icon: { url: MAP_PIN_ICON_URL }, + }; + + mapOptions: google.maps.MapOptions = { + zoom: WHOLE_COUNTRY_ZOOM, + center: CONTINENTAL_US_CENTER, + streetViewControl: false, + fullscreenControl: false, + mapTypeControl: false, + clickableIcons: false, + }; + + coordinateText = linkedSignal({ + source: this.coordinates, + computation: (coordinates, previous) => { + if (previous && textNamesPlace(previous.value, coordinates)) { + return previous.value; + } + return coordinates ? formatCoordinates(coordinates) : ''; + }, + }); + + markerPosition = computed(() => { + const coordinates = this.coordinates(); + return coordinates && namesAPlaceOnEarth(coordinates) + ? toLatLngLiteral(coordinates) + : null; + }); + + isValid = computed(() => { + const text = this.coordinateText().trim(); + return !text || parseCoordinates(text) !== null; + }); + + @ViewChild(GoogleMap) map?: GoogleMap; + + private hasCentredOnAPlace = false; + + constructor() { + effect(() => this.centreOnFirstPlace(this.coordinates())); + effect(() => this.validityChange.emit(this.isValid())); + } + + public onMapClick(event: google.maps.MapMouseEvent): void { + if (!event.latLng) { + return; + } + this.hasCentredOnAPlace = true; + this.coordinates.set({ + latitude: event.latLng.lat(), + longitude: event.latLng.lng(), + }); + } + + public onCoordinateTextChange(text: string): void { + this.coordinateText.set(text); + + if (!text.trim()) { + this.coordinates.set(null); + return; + } + + const coordinates = parseCoordinates(text); + if (!coordinates) { + return; + } + if (this.hasCentredOnAPlace) { + this.panMapTo(coordinates); + } + this.coordinates.set(coordinates); + } + + private centreOnFirstPlace(coordinates: Coordinates | null): void { + if ( + !coordinates || + !namesAPlaceOnEarth(coordinates) || + this.hasCentredOnAPlace + ) { + return; + } + this.hasCentredOnAPlace = true; + this.mapOptions = { + ...this.mapOptions, + zoom: LOCATED_ZOOM, + center: toLatLngLiteral(coordinates), + }; + } + + private isGoogleMapsApiReady(): boolean { + return Boolean(this.map?.googleMap); + } + + private panMapTo(coordinates: Coordinates): void { + if (!this.isGoogleMapsApiReady()) { + this.mapOptions = { + ...this.mapOptions, + center: toLatLngLiteral(coordinates), + }; + return; + } + this.map.panTo(toLatLngLiteral(coordinates)); + } +} diff --git a/src/app/shared/utilities/map-view.ts b/src/app/shared/utilities/map-view.ts new file mode 100644 index 000000000..232c9c771 --- /dev/null +++ b/src/app/shared/utilities/map-view.ts @@ -0,0 +1,7 @@ +export const CONTINENTAL_US_CENTER: google.maps.LatLngLiteral = { + lat: 39.8333333, + lng: -98.585522, +}; + +export const WHOLE_COUNTRY_ZOOM = 4; +export const LOCATED_ZOOM = 12; diff --git a/src/assets/svg/map-pin.svg b/src/assets/svg/map-pin.svg new file mode 100644 index 000000000..c92e364a8 --- /dev/null +++ b/src/assets/svg/map-pin.svg @@ -0,0 +1,4 @@ + + + + From 4712a13afdaa4ee55518f36b753838d0e80b40ee Mon Sep 17 00:00:00 2001 From: Daniel Schultz Date: Fri, 4 Sep 2026 11:02:32 -0400 Subject: [PATCH 3/3] Add a GPS coordinate picker modal This modal will allow users to pick GPS by dropping a pin on a map *or* by pasting in text. Unparsable text will result in an inability to click save, since that's something the user should resolve and simply falling back might be confusing. Issue #1159 Implement UX for location coordinate entry Claude-Session: https://claude.ai/code/session_01HM1anu1T97zKvtb1TKTcfc --- .../coordinate-picker.component.html | 11 ++ .../coordinate-picker.component.scss | 5 + .../coordinate-picker.component.spec.ts | 143 ++++++++++++++++++ .../coordinate-picker.component.ts | 69 +++++++++ 4 files changed, 228 insertions(+) create mode 100644 src/app/file-browser/components/coordinate-picker/coordinate-picker.component.html create mode 100644 src/app/file-browser/components/coordinate-picker/coordinate-picker.component.scss create mode 100644 src/app/file-browser/components/coordinate-picker/coordinate-picker.component.spec.ts create mode 100644 src/app/file-browser/components/coordinate-picker/coordinate-picker.component.ts diff --git a/src/app/file-browser/components/coordinate-picker/coordinate-picker.component.html b/src/app/file-browser/components/coordinate-picker/coordinate-picker.component.html new file mode 100644 index 000000000..3f3b85bee --- /dev/null +++ b/src/app/file-browser/components/coordinate-picker/coordinate-picker.component.html @@ -0,0 +1,11 @@ + + + diff --git a/src/app/file-browser/components/coordinate-picker/coordinate-picker.component.scss b/src/app/file-browser/components/coordinate-picker/coordinate-picker.component.scss new file mode 100644 index 000000000..24aaa6e8d --- /dev/null +++ b/src/app/file-browser/components/coordinate-picker/coordinate-picker.component.scss @@ -0,0 +1,5 @@ +:host { + display: block; + width: 640px; + max-width: 100%; +} diff --git a/src/app/file-browser/components/coordinate-picker/coordinate-picker.component.spec.ts b/src/app/file-browser/components/coordinate-picker/coordinate-picker.component.spec.ts new file mode 100644 index 000000000..f5454f494 --- /dev/null +++ b/src/app/file-browser/components/coordinate-picker/coordinate-picker.component.spec.ts @@ -0,0 +1,143 @@ +import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { DIALOG_DATA, DialogRef } from '@angular/cdk/dialog'; +import { GoogleMapsModule } from '@angular/google-maps'; +import { CoordinateMapInputComponent } from '@shared/components/coordinate-map-input/coordinate-map-input.component'; +import { + CoordinatePickerComponent, + CoordinatePickerData, +} from './coordinate-picker.component'; + +const LISBON = { latitude: 38.70786, longitude: -9.400139 }; + +describe('CoordinatePickerComponent', () => { + let fixture: ComponentFixture; + let component: CoordinatePickerComponent; + let dialogRef: jasmine.SpyObj; + + const setUp = async (dialogData: CoordinatePickerData): Promise => { + dialogRef = jasmine.createSpyObj('DialogRef', ['close']); + + await TestBed.configureTestingModule({ + imports: [CoordinatePickerComponent], + providers: [ + { provide: DIALOG_DATA, useValue: dialogData }, + { provide: DialogRef, useValue: dialogRef }, + ], + }) + .overrideComponent(CoordinateMapInputComponent, { + remove: { imports: [GoogleMapsModule] }, + add: { schemas: [CUSTOM_ELEMENTS_SCHEMA] }, + }) + .compileComponents(); + + fixture = TestBed.createComponent(CoordinatePickerComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }; + + const query = (selector: string): T => + fixture.nativeElement.querySelector(selector); + + describe('with nothing to start from', () => { + beforeEach(async () => { + await setUp({}); + }); + + it('should render a titled dialog', () => { + expect(query('.pr-dialog-header h2').textContent.trim()).toBe( + 'Choose GPS Coordinates', + ); + }); + + it('should hand the map input nothing to start from', () => { + expect(component.coordinates()).toBeNull(); + }); + }); + + describe('when the location already has coordinates', () => { + beforeEach(async () => { + await setUp({ location: { ...LISBON, city: 'Lisbon' } }); + }); + + it('should hand the stored pair to the map input', () => { + expect(component.coordinates()).toEqual(LISBON); + }); + + it('should keep the address it was given when saving', () => { + component.save(); + + expect(dialogRef.close).toHaveBeenCalledWith({ + location: { ...LISBON, city: 'Lisbon' }, + }); + }); + + it('should clear the stored pair when the map input reports none', () => { + component.coordinates.set(null); + component.save(); + + expect(dialogRef.close).toHaveBeenCalledWith({ + location: { latitude: null, longitude: null, city: 'Lisbon' }, + }); + }); + }); + + describe('when the location has an address but no coordinates', () => { + beforeEach(async () => { + await setUp({ location: { city: 'Lisbon' } }); + }); + + it('should still carry the address through a save', () => { + component.save(); + + expect(dialogRef.close).toHaveBeenCalledWith({ + location: { city: 'Lisbon', latitude: null, longitude: null }, + }); + }); + }); + + describe('when the map input reports unreadable text', () => { + beforeEach(async () => { + await setUp({ location: { ...LISBON } }); + }); + + it('should disable the confirm button', () => { + component.onValidityChange(false); + fixture.detectChanges(); + + expect(query('.pr-btn-confirm').disabled).toBeTrue(); + }); + + it('should refuse to save the pair the field is no longer showing', () => { + component.onValidityChange(false); + component.save(); + + expect(dialogRef.close).not.toHaveBeenCalled(); + }); + }); + + describe('leaving the dialog', () => { + beforeEach(async () => { + await setUp({}); + }); + + it('should close with nothing when cancelled', () => { + query('.pr-btn-cancel').click(); + + expect(dialogRef.close).toHaveBeenCalledWith(); + }); + + it('should close with nothing when dismissed from the header', () => { + query('.pr-close-button').click(); + + expect(dialogRef.close).toHaveBeenCalledWith(); + }); + + it('should close with the pair when saved', () => { + component.coordinates.set(LISBON); + query('.pr-btn-confirm').click(); + + expect(dialogRef.close).toHaveBeenCalledWith({ location: LISBON }); + }); + }); +}); diff --git a/src/app/file-browser/components/coordinate-picker/coordinate-picker.component.ts b/src/app/file-browser/components/coordinate-picker/coordinate-picker.component.ts new file mode 100644 index 000000000..e179079af --- /dev/null +++ b/src/app/file-browser/components/coordinate-picker/coordinate-picker.component.ts @@ -0,0 +1,69 @@ +import { Component, Inject, OnInit, Optional, signal } from '@angular/core'; +import { DIALOG_DATA, DialogRef } from '@angular/cdk/dialog'; +import { LocnVOData } from '@models'; +import { CoordinateMapInputComponent } from '@shared/components/coordinate-map-input/coordinate-map-input.component'; +import { DialogFrameComponent } from '@shared/components/dialog-frame/dialog-frame.component'; +import { + Coordinates, + coordinatesFromLocation, +} from '@shared/utilities/coordinates'; + +export interface CoordinatePickerData { + location?: LocnVOData; +} + +export interface CoordinatePickerResult { + location: LocnVOData; +} + +@Component({ + selector: 'pr-coordinate-picker', + standalone: true, + imports: [CoordinateMapInputComponent, DialogFrameComponent], + templateUrl: './coordinate-picker.component.html', + styleUrls: ['./coordinate-picker.component.scss'], +}) +export class CoordinatePickerComponent implements OnInit { + coordinates = signal(null); + isValid = signal(true); + + private locationBeingEdited: LocnVOData = {}; + + constructor( + @Optional() + @Inject(DIALOG_DATA) + public dialogData?: CoordinatePickerData, + @Optional() private dialogRef?: DialogRef, + ) {} + + ngOnInit(): void { + const existing = this.dialogData?.location; + if (!existing) { + return; + } + this.locationBeingEdited = { ...existing }; + this.coordinates.set(coordinatesFromLocation(existing)); + } + + public onValidityChange(isValid: boolean): void { + this.isValid.set(isValid); + } + + public cancel(): void { + this.dialogRef?.close(); + } + + public save(): void { + if (!this.isValid()) { + return; + } + const coordinates = this.coordinates(); + this.dialogRef?.close({ + location: { + ...this.locationBeingEdited, + latitude: coordinates?.latitude ?? null, + longitude: coordinates?.longitude ?? null, + }, + }); + } +}