layer/geotiff/utm.tstypescript
/**
 * High precision Transverse Mercator (UTM) forward & inverse projection for WGS84 ellipsoid.
 * Supports EPSG:32601 - EPSG:32660 (UTM North zones 1N-60N)
 * and EPSG:32701 - EPSG:32760 (UTM South zones 1S-60S).
 */

const WGS84_A = 6378137.0;
const WGS84_F = 1.0 / 298.257223563;
const WGS84_E2 = 2 * WGS84_F - WGS84_F * WGS84_F;
const WGS84_E_PRIME2 = WGS84_E2 / (1.0 - WGS84_E2);
const UTM_K0 = 0.9996;

/**
 * Projects WGS84 LonLat degrees [lon, lat] to UTM coordinates [easting, northing] in meters.
 */
export function forwardUTM(lon: number, lat: number, zone: number, isNorth: boolean): [number, number] {
    const latRad = (lat * Math.PI) / 180;
    const lonRad = (lon * Math.PI) / 180;
    const centralLonRad = ((zone - 1) * 6 - 180 + 3) * (Math.PI / 180);
    const dLon = lonRad - centralLonRad;

    const sinLat = Math.sin(latRad);
    const cosLat = Math.cos(latRad);
    const tanLat = Math.tan(latRad);

    const N = WGS84_A / Math.sqrt(1 - WGS84_E2 * sinLat * sinLat);
    const T = tanLat * tanLat;
    const C = WGS84_E_PRIME2 * cosLat * cosLat;
    const A = cosLat * dLon;

    const M =
        WGS84_A *
        ((1 - WGS84_E2 / 4 - (3 * WGS84_E2 * WGS84_E2) / 64 - (5 * WGS84_E2 * WGS84_E2 * WGS84_E2) / 256) * latRad -
            ((3 * WGS84_E2) / 8 + (3 * WGS84_E2 * WGS84_E2) / 32 + (45 * WGS84_E2 * WGS84_E2 * WGS84_E2) / 1024) *
                Math.sin(2 * latRad) +
            ((15 * WGS84_E2 * WGS84_E2) / 256 + (45 * WGS84_E2 * WGS84_E2 * WGS84_E2) / 1024) * Math.sin(4 * latRad) -
            ((35 * WGS84_E2 * WGS84_E2 * WGS84_E2) / 3072) * Math.sin(6 * latRad));

    const x =
        UTM_K0 *
            N *
            (A +
                ((1 - T + C) * Math.pow(A, 3)) / 6 +
                ((5 - 18 * T + T * T + 72 * C - 58 * WGS84_E_PRIME2) * Math.pow(A, 5)) / 120) +
        500000;

    let y =
        UTM_K0 *
        (M +
            N *
                tanLat *
                (Math.pow(A, 2) / 2 +
                    ((5 - T + 9 * C + 4 * C * C) * Math.pow(A, 4)) / 24 +
                    ((61 - 58 * T + T * T + 600 * C - 330 * WGS84_E_PRIME2) * Math.pow(A, 6)) / 720));

    if (!isNorth) {
        y += 10000000;
    }

    return [x, y];
}

/**
 * Unprojects UTM coordinates [easting, northing] in meters to WGS84 [lon, lat] in degrees.
 */
export function inverseUTM(x: number, y: number, zone: number, isNorth: boolean): [number, number] {
    const e1 = (1 - Math.sqrt(1 - WGS84_E2)) / (1 + Math.sqrt(1 - WGS84_E2));
    const xAdj = x - 500000;
    const yAdj = isNorth ? y : y - 10000000;

    const M = yAdj / UTM_K0;
    const mu =
        M /
        (WGS84_A * (1 - WGS84_E2 / 4 - (3 * WGS84_E2 * WGS84_E2) / 64 - (5 * WGS84_E2 * WGS84_E2 * WGS84_E2) / 256));

    const phi1Rad =
        mu +
        ((3 * e1) / 2 - (27 * Math.pow(e1, 3)) / 32) * Math.sin(2 * mu) +
        ((21 * e1 * e1) / 16 - (55 * Math.pow(e1, 4)) / 32) * Math.sin(4 * mu) +
        ((151 * Math.pow(e1, 3)) / 96) * Math.sin(6 * mu) +
        ((1097 * Math.pow(e1, 4)) / 512) * Math.sin(8 * mu);

    const sinPhi1 = Math.sin(phi1Rad);
    const cosPhi1 = Math.cos(phi1Rad);
    const tanPhi1 = Math.tan(phi1Rad);

    const N1 = WGS84_A / Math.sqrt(1 - WGS84_E2 * sinPhi1 * sinPhi1);
    const T1 = tanPhi1 * tanPhi1;
    const C1 = WGS84_E_PRIME2 * cosPhi1 * cosPhi1;
    const R1 = (WGS84_A * (1 - WGS84_E2)) / Math.pow(1 - WGS84_E2 * sinPhi1 * sinPhi1, 1.5);
    const D = xAdj / (N1 * UTM_K0);

    const latRad =
        phi1Rad -
        ((N1 * tanPhi1) / R1) *
            ((D * D) / 2 -
                ((5 + 3 * T1 + 10 * C1 - 4 * C1 * C1 - 9 * WGS84_E_PRIME2) * Math.pow(D, 4)) / 24 +
                ((61 + 90 * T1 + 298 * C1 + 45 * T1 * T1 - 252 * WGS84_E_PRIME2 - 3 * C1 * C1) * Math.pow(D, 6)) / 720);

    const centralLon = (zone - 1) * 6 - 180 + 3;
    const lonRad =
        (D -
            ((1 + 2 * T1 + C1) * Math.pow(D, 3)) / 6 +
            ((5 - 2 * C1 + 28 * T1 - 3 * C1 * C1 + 8 * WGS84_E_PRIME2 + 24 * T1 * T1) * Math.pow(D, 5)) / 120) /
        cosPhi1;

    return [centralLon + (lonRad * 180) / Math.PI, (latRad * 180) / Math.PI];
}

export interface IProjectionHelper {
    project: (p: number[]) => number[];
    unproject: (p: number[]) => number[];
}

/**
 * Returns a projection helper { project, unproject } if the given code is a supported UTM EPSG code
 * (EPSG:32601-32660 or EPSG:32701-32760), or if proj4 is globally registered.
 */
export function getProjectionHelper(
    crsCode: number,
    customProjFunc?: (code: number) => IProjectionHelper | undefined
): IProjectionHelper | null {
    if (customProjFunc) {
        const p = customProjFunc(crsCode);
        if (p) return p;
    }

    // Check UTM North (32601 - 32660)
    if (crsCode >= 32601 && crsCode <= 32660) {
        const zone = crsCode - 32600;
        return {
            project: (p: number[]) => forwardUTM(p[0], p[1], zone, true),
            unproject: (p: number[]) => inverseUTM(p[0], p[1], zone, true)
        };
    }

    // Check UTM South (32701 - 32760)
    if (crsCode >= 32701 && crsCode <= 32760) {
        const zone = crsCode - 32700;
        return {
            project: (p: number[]) => forwardUTM(p[0], p[1], zone, false),
            unproject: (p: number[]) => inverseUTM(p[0], p[1], zone, false)
        };
    }

    // Check global proj4
    const globalProj4 =
        (typeof window !== "undefined" && (window as any).proj4) ||
        (typeof globalThis !== "undefined" && (globalThis as any).proj4);

    if (globalProj4 && typeof globalProj4 === "function") {
        try {
            const epsgStr = `EPSG:${crsCode}`;
            return {
                project: (p: number[]) => globalProj4("EPSG:4326", epsgStr, p),
                unproject: (p: number[]) => globalProj4(epsgStr, "EPSG:4326", p)
            };
        } catch {
            return null;
        }
    }

    return null;
}