mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 06:26:26 +00:00
feat(map): amap as map api provider and display template
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
import { fromAmapLocation, gcj02ToWgs84, isOutsideChina, toAmapLocation, wgs84ToGcj02 } from './gcj02';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
/** Metres between two coordinates — good enough for an assertion at this scale. */
|
||||
function metresApart(aLat: number, aLng: number, bLat: number, bLng: number): number {
|
||||
const R = 6371000;
|
||||
const dLat = ((bLat - aLat) * Math.PI) / 180;
|
||||
const dLng = ((bLng - aLng) * Math.PI) / 180;
|
||||
const mLat = ((aLat + bLat) / 2) * (Math.PI / 180);
|
||||
const x = dLng * Math.cos(mLat);
|
||||
return Math.sqrt(dLat * dLat + x * x) * R;
|
||||
}
|
||||
|
||||
describe('isOutsideChina', () => {
|
||||
it('covers the mainland, Hong Kong and Taiwan', () => {
|
||||
expect(isOutsideChina(39.9042, 116.4074)).toBe(false); // Beijing
|
||||
expect(isOutsideChina(22.3193, 114.1694)).toBe(false); // Hong Kong
|
||||
expect(isOutsideChina(25.033, 121.5654)).toBe(false); // Taipei
|
||||
});
|
||||
|
||||
it('excludes the rest of the world so a foreign trip is never shifted', () => {
|
||||
expect(isOutsideChina(48.8566, 2.3522)).toBe(true); // Paris
|
||||
expect(isOutsideChina(35.6762, 139.6503)).toBe(true); // Tokyo
|
||||
expect(isOutsideChina(-33.8688, 151.2093)).toBe(true); // Sydney
|
||||
});
|
||||
|
||||
it('treats a non-finite coordinate as outside rather than shifting NaN', () => {
|
||||
expect(isOutsideChina(Number.NaN, 116)).toBe(true);
|
||||
expect(isOutsideChina(39, Number.POSITIVE_INFINITY)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('wgs84ToGcj02', () => {
|
||||
// Reference values from the published approximation every coordtransform
|
||||
// implementation shares; the tolerance is the metre-or-two the algorithm is
|
||||
// accurate to, not a precision claim about our arithmetic.
|
||||
it('shifts Tiananmen Square by the expected few hundred metres', () => {
|
||||
const gcj = wgs84ToGcj02(39.90869, 116.39124);
|
||||
expect(gcj.lat).toBeCloseTo(39.91009, 4);
|
||||
expect(gcj.lng).toBeCloseTo(116.39745, 4);
|
||||
// The whole point of the datum: the offset is big enough to be visibly wrong
|
||||
// on a map if it were skipped.
|
||||
expect(metresApart(39.90869, 116.39124, gcj.lat, gcj.lng)).toBeGreaterThan(300);
|
||||
});
|
||||
|
||||
it('leaves a coordinate outside China exactly as it came in', () => {
|
||||
const paris = wgs84ToGcj02(48.8566, 2.3522);
|
||||
expect(paris).toEqual({ lat: 48.8566, lng: 2.3522 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('gcj02ToWgs84', () => {
|
||||
it('round-trips to sub-metre accuracy across China', () => {
|
||||
const points: [number, number][] = [
|
||||
[39.90869, 116.39124], // Beijing
|
||||
[31.2304, 121.4737], // Shanghai
|
||||
[22.5431, 114.0579], // Shenzhen
|
||||
[29.6534, 91.1719], // Lhasa — far west, where the offset is largest
|
||||
[43.8256, 87.6168], // Ürümqi
|
||||
[18.2528, 109.5119], // Sanya — far south
|
||||
];
|
||||
for (const [lat, lng] of points) {
|
||||
const gcj = wgs84ToGcj02(lat, lng);
|
||||
const back = gcj02ToWgs84(gcj.lat, gcj.lng);
|
||||
expect(metresApart(lat, lng, back.lat, back.lng)).toBeLessThan(1);
|
||||
}
|
||||
});
|
||||
|
||||
it('is the exact inverse, not the subtract-the-same-offset shortcut', () => {
|
||||
// The shortcut is out by a few metres; this asserts we are well inside that.
|
||||
const gcj = wgs84ToGcj02(39.90869, 116.39124);
|
||||
const back = gcj02ToWgs84(gcj.lat, gcj.lng);
|
||||
expect(back.lat).toBeCloseTo(39.90869, 7);
|
||||
expect(back.lng).toBeCloseTo(116.39124, 7);
|
||||
});
|
||||
|
||||
it('leaves a coordinate outside China alone', () => {
|
||||
expect(gcj02ToWgs84(35.6762, 139.6503)).toEqual({ lat: 35.6762, lng: 139.6503 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('toAmapLocation', () => {
|
||||
it('emits lng,lat in GCJ-02 with six decimals', () => {
|
||||
expect(toAmapLocation(39.90869, 116.39124)).toBe('116.397481,39.910091');
|
||||
});
|
||||
});
|
||||
|
||||
describe('fromAmapLocation', () => {
|
||||
it('parses lng,lat back into WGS-84', () => {
|
||||
const parsed = fromAmapLocation('116.397481,39.910091');
|
||||
expect(parsed).not.toBeNull();
|
||||
expect(metresApart(39.90869, 116.39124, parsed!.lat, parsed!.lng)).toBeLessThan(1);
|
||||
});
|
||||
|
||||
it('returns null for the shapes Amap actually sends for a POI with no geometry', () => {
|
||||
expect(fromAmapLocation([])).toBeNull();
|
||||
expect(fromAmapLocation('')).toBeNull();
|
||||
expect(fromAmapLocation(undefined)).toBeNull();
|
||||
expect(fromAmapLocation('116.397481')).toBeNull();
|
||||
expect(fromAmapLocation('nope,nope')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* WGS-84 ⇄ GCJ-02 conversion.
|
||||
*
|
||||
* Everything TREK stores and draws is WGS-84: the database columns, Nominatim,
|
||||
* Overpass, OSRM, GPX and KML, and every Leaflet/MapLibre coordinate. Chinese
|
||||
* providers are not allowed to publish that datum — Amap (高德) returns and
|
||||
* expects GCJ-02, the "Mars coordinates" obfuscation, which is offset from
|
||||
* WGS-84 by roughly 300–800 m depending on where you are.
|
||||
*
|
||||
* So this file exists to keep exactly one datum inside the app. It is used at
|
||||
* two boundaries and nowhere else:
|
||||
*
|
||||
* - server/src/nest/maps/providers/amap.provider.ts converts on the way out
|
||||
* and back in, so a place that reaches the database is always WGS-84.
|
||||
* - client/src/components/Map/gcj02Crs.ts folds the shift into a Leaflet
|
||||
* projection, so GCJ-02 raster tiles line up with WGS-84 markers without
|
||||
* any component having to know which basemap is loaded.
|
||||
*
|
||||
* The algorithm is the widely used published approximation (the same one every
|
||||
* `coordtransform`-style library implements). It is not the state secret itself
|
||||
* and is accurate to a metre or two, which is well inside the precision a POI
|
||||
* search result carries. `gcj02ToWgs84` inverts it numerically rather than with
|
||||
* the common one-shot "subtract the same offset" trick: the offset is a function
|
||||
* of position, so subtracting it at the wrong position leaves a few metres of
|
||||
* error, and a round trip through a search result and back into a share link
|
||||
* would drift visibly.
|
||||
*/
|
||||
|
||||
/** Krasovsky 1940 semi-major axis, the ellipsoid the published offset uses. */
|
||||
const A = 6378245;
|
||||
/** Its first eccentricity squared. */
|
||||
const EE = 0.006_693_421_622_965_943;
|
||||
const PI = Math.PI;
|
||||
|
||||
/**
|
||||
* True when GCJ-02 does not apply and the coordinate must pass through
|
||||
* untouched.
|
||||
*
|
||||
* Without this every foreign trip in TREK would move: the offset formula is
|
||||
* happy to evaluate anywhere on earth, so applying it to Paris shifts Paris.
|
||||
* The bounding box is deliberately the crude one every implementation uses —
|
||||
* it over-covers, reaching into the sea and into neighbouring countries, but
|
||||
* erring that way only means a coordinate in the border area gets a shift of a
|
||||
* few hundred metres applied consistently in both directions, which round-trips
|
||||
* cleanly. A tighter polygon would risk the far worse failure of converting one
|
||||
* way and not the other.
|
||||
*
|
||||
* Hong Kong, Macau and Taiwan are inside the box. That matches what Amap
|
||||
* actually serves for them, so it is not an oversight.
|
||||
*/
|
||||
export function isOutsideChina(lat: number, lng: number): boolean {
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lng)) return true;
|
||||
return lng < 72.004 || lng > 137.8347 || lat < 0.8293 || lat > 55.8271;
|
||||
}
|
||||
|
||||
function transformLat(x: number, y: number): number {
|
||||
let ret = -100 + 2 * x + 3 * y + 0.2 * y * y + 0.1 * x * y + 0.2 * Math.sqrt(Math.abs(x));
|
||||
ret += ((20 * Math.sin(6 * x * PI) + 20 * Math.sin(2 * x * PI)) * 2) / 3;
|
||||
ret += ((20 * Math.sin(y * PI) + 40 * Math.sin((y / 3) * PI)) * 2) / 3;
|
||||
ret += ((160 * Math.sin((y / 12) * PI) + 320 * Math.sin((y * PI) / 30)) * 2) / 3;
|
||||
return ret;
|
||||
}
|
||||
|
||||
function transformLng(x: number, y: number): number {
|
||||
let ret = 300 + x + 2 * y + 0.1 * x * x + 0.1 * x * y + 0.1 * Math.sqrt(Math.abs(x));
|
||||
ret += ((20 * Math.sin(6 * x * PI) + 20 * Math.sin(2 * x * PI)) * 2) / 3;
|
||||
ret += ((20 * Math.sin(x * PI) + 40 * Math.sin((x / 3) * PI)) * 2) / 3;
|
||||
ret += ((150 * Math.sin((x / 12) * PI) + 300 * Math.sin((x / 30) * PI)) * 2) / 3;
|
||||
return ret;
|
||||
}
|
||||
|
||||
/** The position-dependent offset added to a WGS-84 point to get GCJ-02. */
|
||||
function offset(lat: number, lng: number): { dLat: number; dLng: number } {
|
||||
const x = lng - 105;
|
||||
const y = lat - 35;
|
||||
let dLat = transformLat(x, y);
|
||||
let dLng = transformLng(x, y);
|
||||
const radLat = (lat / 180) * PI;
|
||||
let magic = Math.sin(radLat);
|
||||
magic = 1 - EE * magic * magic;
|
||||
const sqrtMagic = Math.sqrt(magic);
|
||||
dLat = (dLat * 180) / (((A * (1 - EE)) / (magic * sqrtMagic)) * PI);
|
||||
dLng = (dLng * 180) / ((A / sqrtMagic) * Math.cos(radLat) * PI);
|
||||
return { dLat, dLng };
|
||||
}
|
||||
|
||||
export interface LatLng {
|
||||
lat: number;
|
||||
lng: number;
|
||||
}
|
||||
|
||||
/** WGS-84 → GCJ-02. Outside China the input is returned unchanged. */
|
||||
export function wgs84ToGcj02(lat: number, lng: number): LatLng {
|
||||
if (isOutsideChina(lat, lng)) return { lat, lng };
|
||||
const { dLat, dLng } = offset(lat, lng);
|
||||
return { lat: lat + dLat, lng: lng + dLng };
|
||||
}
|
||||
|
||||
/**
|
||||
* GCJ-02 → WGS-84, solved rather than approximated.
|
||||
*
|
||||
* The forward offset is a function of the *WGS-84* position, so the usual
|
||||
* `wgs - (gcj_of_gcj - gcj)` shortcut evaluates it at the wrong point and
|
||||
* leaves several metres of error. Newton would need the Jacobian of a sum of
|
||||
* sines; a fixed-point iteration converges just as fast here because the offset
|
||||
* varies slowly with position, and it cannot diverge. Three or four passes reach
|
||||
* the double-precision floor; the loop below stops as soon as a pass moves the
|
||||
* answer by less than ~1e-9° (about 0.1 mm) and is capped so a pathological
|
||||
* input cannot spin.
|
||||
*/
|
||||
export function gcj02ToWgs84(lat: number, lng: number): LatLng {
|
||||
if (isOutsideChina(lat, lng)) return { lat, lng };
|
||||
let guessLat = lat;
|
||||
let guessLng = lng;
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const forward = wgs84ToGcj02(guessLat, guessLng);
|
||||
const errLat = forward.lat - lat;
|
||||
const errLng = forward.lng - lng;
|
||||
if (Math.abs(errLat) < 1e-9 && Math.abs(errLng) < 1e-9) break;
|
||||
guessLat -= errLat;
|
||||
guessLng -= errLng;
|
||||
}
|
||||
return { lat: guessLat, lng: guessLng };
|
||||
}
|
||||
|
||||
/**
|
||||
* Amap spells a coordinate `lng,lat` in a single string, with six decimals, and
|
||||
* rejects more than that on some endpoints. Longitude first is the opposite of
|
||||
* every other coordinate in this codebase, so the ordering lives here once
|
||||
* instead of at each call site.
|
||||
*/
|
||||
export function toAmapLocation(lat: number, lng: number): string {
|
||||
const gcj = wgs84ToGcj02(lat, lng);
|
||||
return `${gcj.lng.toFixed(6)},${gcj.lat.toFixed(6)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse Amap's `"lng,lat"` back into a WGS-84 pair, or null when the field is
|
||||
* missing or unparseable — which Amap does do: a POI with no geometry comes
|
||||
* back with `location: []` rather than with the key absent.
|
||||
*/
|
||||
export function fromAmapLocation(location: unknown): LatLng | null {
|
||||
if (typeof location !== 'string') return null;
|
||||
const parts = location.split(',');
|
||||
if (parts.length !== 2) return null;
|
||||
// Destructured with a default rather than indexed: under noUncheckedIndexedAccess
|
||||
// an index into a length-checked array is still `string | undefined`.
|
||||
const [lngText = '', latText = ''] = parts;
|
||||
const lng = Number.parseFloat(lngText);
|
||||
const lat = Number.parseFloat(latText);
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lng)) return null;
|
||||
return gcj02ToWgs84(lat, lng);
|
||||
}
|
||||
Reference in New Issue
Block a user