geo-url.js

import { GeoParams, setGeoParamsURL } from "./geo-params.js"
import { setURLPathname } from "./url.js"
import { parseNumber, formatNumber } from "./number.js"

const formatZ = n => formatNumber(n, 9)
const formatU = n => formatNumber(n, 9)
const formatCoord = n => formatNumber(n, 12)

/**
 * URL interface for geo URI with an arbitrary CRS
 */
export class GeoURL {
	#url

	/**
	 * Create a GeoURL from a string or another URL object
	 *
	 * Expected to be almost always invoked with one parameter because there's not much use of relative references in case of geo URIs.
	 * At best you can add a hash to a base geo URI using a relative reference, but hashes in geo URIs a probably not widely used.
	 * @param {string|URL|GeoURL} url - geo URI or relative reference
	 * @param {string|URL|GeoURL} [base] - base geo URI
	 * @throws {TypeError} if the protocol is not `geo:`
	 * @throws {TypeError} if contains less than two or more than three coordinates, or if some coordinates aren't numbers
	 * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/URL/URL|MDN} for URL constructor
	 */
	constructor(url, base) {
		this.#url = new URL(url, base)
		if (this.#url.protocol != "geo:") {
			throw new TypeError(`Invalid protocol ${this.#url.protocol}`)
		}
		this._parseCoordinatesString(this.coordinatesString)
	}

	/**
	 * Create a GeoURL or return null on error
	 * @param {string|URL|GeoURL} url - geo URI or relative reference
	 * @param {string|URL|GeoURL} [base] - base geo URI
	 * @returns {GeoURL|null}
	 * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/URL/parse_static|MDN} for parse() static method
	 */
	static parse(url, base) {
		try {
			return new this(url, base)
		} catch {
			return null
		}
	}

	/**
	 * Check if url is parsable as a valid geo URI
	 * @param {string|URL|GeoURL} url - geo URI or relative reference
	 * @param {string|URL|GeoURL} [base] - base geo URI
	 * @returns {boolean}
	 * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/URL/canParse_static|MDN} for canParse() static method
	 */
	static canParse(url, base) {
		return this.parse(url, base) !== null
	}

	/**
	 * Serialize the URL, which is the same as converting it to a string
	 * @returns {string}
	 * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/URL/toJSON|MDN} for toJSON() method
	 */
	toJSON() {
		return this.#url.toJSON()
	}
	/**
	 * Convert the URL to a string
	 * @returns {string}
	 * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/URL/toString|MDN} for toString() method
	 */
	toString() {
		return this.#url.toString()
	}

	get href() {
		return this.#url.href
	}
	get origin() {
		return this.#url.origin
	}
	get protocol() {
		return this.#url.protocol
	}
	get username() {
		return this.#url.username
	}
	get password() {
		return this.#url.password
	}
	get host() {
		return this.#url.host
	}
	get hostname() {
		return this.#url.hostname
	}
	get port() {
		return this.#url.port
	}
	get pathname() {
		return this.#url.pathname
	}

	/**
	 * Search/query string
	 * @type {string}
	 * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/URL/search|MDN} for search property
	 */
	get search() {
		return this.#url.search
	}
	/**
	 * Set search/query string
	 * @method
	 * @instance
	 * @memberof GeoURL
	 * @name set search
	 * @param {string} value
	 * @see {@link GeoURL#search} for the corresponding getter
	 */
	set search(value) {
		this.#url.search = value
	}

	/**
	 * URLSearchParams object
	 * @type {URLSearchParams}
	 * @readonly
	 * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/URL/searchParams|MDN} for searchParams property
	 */
	get searchParams() {
		return this.#url.searchParams
	}

	/**
	 * Hash property containing the fragment identifier
	 * @type {string}
	 * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/URL/hash|MDN} for hash property
	 */
	get hash() {
		return this.#url.hash
	}
	/**
	 * Set hash property
	 * @method
	 * @instance
	 * @memberof GeoURL
	 * @name set hash
	 * @param {string} value - a string containing the URL fragment, with optional leading `#`
	 * @see {@link GeoURL#hash} for the corresponding getter
	 */
	set hash(value) {
		this.#url.hash = value
	}

	/**
	 * Zoom level
	 *
	 * Equals to undefined for missing `z` search parameter.
	 * Numeric precision is the same as when setting {@link GeoURL#u} because Google documentation doesn't specify it.
	 *
	 * Example values:
	 * - 0 is for the entire world in one map tile
	 * - 14 is when buildings become visible on OpenStreetMap standard map rendering
	 * @type {number|undefined}
	 * @see {@link https://developers.google.com/maps/documentation/urls/android-intents#display-a-map|Google Maps Intents} for parameter description
	 */
	get z() {
		return parseNumber(this.searchParams.get("z"))
	}
	/**
	 * Set zoom level
	 *
	 * Setting to undefined deletes the `z` search parameter.
	 * @method
	 * @instance
	 * @memberof GeoURL
	 * @name set z
	 * @param {number|undefined} value
	 * @see {@link GeoURL#z} for the corresponding getter
	 */
	set z(value) {
		if (value != null) {
			this.#url.searchParams.set("z", formatZ(value))
		} else {
			this.#url.searchParams.delete("z")
		}
	}
	/**
	 * Zoom level
	 *
	 * A longer name for {@link GeoURL#z}
	 * @type {number|undefined}
	 */
	get zoom() {
		return this.z
	}
	set zoom(value) {
		this.z = value
	}

	/**
	 * geo URI parameters object
	 * @readonly
	 * @type {GeoParams}
	 */
	get geoParams() {
		const gp = new GeoParams("")
		setGeoParamsURL(gp, this.#url)
		return gp
	}

	/**
	 * Coordinate reference system
	 *
	 * Converted to lowercase if present in the URL.
	 *
	 * Has the default value of `wgs84` if not present.
	 * @type {string}
	 */
	get crs() {
		const crsWithPreservedCase = this.geoParams.get("crs") || "wgs84"
		return crsWithPreservedCase.toLowerCase()
	}
	/**
	 * Set coordinate reference system
	 *
	 * The value is converted to lowercase on write.
	 *
	 * The `crs` geo parameter is deleted when setting to `wgs84`, ignoring the case.
	 * @method
	 * @instance
	 * @memberof GeoURL
	 * @name set crs
	 * @param {string} value
	 * @see {@link GeoURL#crs} for the corresponding getter
	 */
	set crs(value) {
		const lcValue = value.toLowerCase()
		if (lcValue != "wgs84") {
			this.geoParams.set("crs", lcValue)
		} else {
			this.geoParams.delete("crs")
		}
	}

	/**
	 * Coordinate reference system
	 *
	 * An alternative name for {@link GeoURL#crs}
	 * @type {string}
	 */
	get CRS() {
		return this.crs
	}
	set CRS(value) {
		this.crs = value
	}

	/**
	 * Coordinates as a string of two or three comma-separated numbers, as they appear in the URL.
	 * @type {string}
	 * @example
	 * const url = new GeoURL("geo:60,30;u=10")
	 * console.log(url.coordinatesString) // outputs "60,30"
	 */
	get coordinatesString() {
		const [coordinatesString] = this.pathname.split(";")
		return coordinatesString
	}
	/**
	 * Set coordinates to a string
	 * @method
	 * @instance
	 * @memberof GeoURL
	 * @name set coordinatesString
	 * @param {string} value - a string of two or three comma-separated numbers, as they appear in the URL
	 * @throws {TypeError} if set to a string that has less than two or more than three comma-separated values, or if some values aren't finite numbers
	 * @example
	 * const url = new GeoURL("geo:60,30;u=10")
	 * url.coordinatesString = "61,31,5"
	 * console.log(url.toString()) // outputs "geo:61,31,5;u=10"
	 * @see {@link GeoURL#coordinatesString} for the corresponding getter
	 */
	set coordinatesString(value) {
		this._parseCoordinatesString(value)
		const [, ...params] = this.pathname.split(";")
		setURLPathname(this.#url, [value, ...params].join(";"))
	}

	/**
	 * Coordinates array
	 * @type {number[]}
	 * @example
	 * const url = new GeoURL("geo:60,30;u=10")
	 * console.log(url.coordinates) // outputs "[ 60, 30 ]"
	 */
	get coordinates() {
		return this.coordinatesString.split(",").map(parseNumber)
	}
	/**
	 * Set coordinates to an array of numbers
	 * @method
	 * @instance
	 * @memberof GeoURL
	 * @name set coordinates
	 * @param {number[]} value - an array with two or three numbers
	 * @throws {TypeError} if set to an array with unexpected number of elements, or if some values aren't finite numbers
	 * @example
	 * const url = new GeoURL("geo:60,30;u=10")
	 * url.coordinates = [61, 31, 5]
	 * console.log(url.toString()) // outputs "geo:61,31,5;u=10"
	 * @see {@link GeoURL#coordinates} for the corresponding getter
	 */
	set coordinates(value) {
		this._validateCoordinates(value)
		const newCoordinatesString = value.map(formatCoord).join(",")
		const [, ...params] = this.pathname.split(";")
		setURLPathname(this.#url, [newCoordinatesString, ...params].join(";"))
	}

	/**
	 * 1st coordinate in an arbitrary CRS
	 * @type {number}
	 * @example
	 * const url = new GeoURL("geo:60,30")
	 * console.log(url.coordA) // outputs "60"
	 */
	get coordA() {
		return this.coordinates[0]
	}
	/**
	 * Set the 1st coordinate
	 * @method
	 * @instance
	 * @memberof GeoURL
	 * @name set coordA
	 * @param {number} value
	 * @throws {TypeError} if the value isn't a finite number
	 * @example
	 * const url = new GeoURL("geo:60,30")
	 * url.coordA = 61
	 * console.log(url.toString()) // outputs "geo:61,30"
	 * @see {@link GeoURL#coordA} for the corresponding getter
	 */
	set coordA(value) {
		const newCoordinates = this.coordinates
		newCoordinates[0] = value
		this.coordinates = newCoordinates
	}

	/**
	 * 2nd coordinate in an arbitrary CRS
	 * @type {number}
	 * @example
	 * const url = new GeoURL("geo:60,30")
	 * console.log(url.coordB) // outputs "30"
	 */
	get coordB() {
		return this.coordinates[1]
	}
	/**
	 * Set the 2st coordinate
	 * @method
	 * @instance
	 * @memberof GeoURL
	 * @name set coordB
	 * @param {number} value
	 * @throws {TypeError} if the value isn't a finite number
	 * @example
	 * const url = new GeoURL("geo:60,30")
	 * url.coordB = 31
	 * console.log(url.toString()) // outputs "geo:60,31"
	 * @see {@link GeoURL#coordB} for the corresponding getter
	 */
	set coordB(value) {
		const newCoordinates = this.coordinates
		newCoordinates[1] = value
		this.coordinates = newCoordinates
	}

	/**
	 * 3rd coordinate in an arbitrary CRS, possibly undefined
	 * @type {number|undefined}
	 * @example
	 * const url = new GeoURL("geo:60,30")
	 * console.log(url.coordC) // outputs "undefined"
	 * @example
	 * const url = new GeoURL("geo:60,30,5")
	 * console.log(url.coordC) // outputs "5"
	 */
	get coordC() {
		return this.coordinates[2]
	}
	/**
	 * Set the 3rd coordinate
	 * @method
	 * @instance
	 * @memberof GeoURL
	 * @name set coordC
	 * @param {number|undefined} value
	 * @throws {TypeError} if the value is neither a finite number nor undefined
	 * @example
	 * const url = new GeoURL("geo:60,30")
	 * url.coordC = 5
	 * console.log(url.toString()) // outputs "geo:60,30,5"
	 * @example
	 * const url = new GeoURL("geo:60,30,5")
	 * url.coordC = undefined
	 * console.log(url.toString()) // outputs "geo:60,30"
	 * @see {@link GeoURL#coordC} for the corresponding getter
	 */
	set coordC(value) {
		const [a, b] = this.coordinates
		const newCoordinates = value != null ? [a, b, value] : [a, b]
		this.coordinates = newCoordinates
	}

	/**
	 * Uncertainty in meters
	 *
	 * Equals to undefined for missing u geo parameter.
	 * Setting to undefined deletes the parameter.
	 * Setting keeps the number up to a nanometer precision.
	 * @type {number|undefined}
	 * @see {@link https://datatracker.ietf.org/doc/html/rfc5870#section-3.4.3|RFC 5870} for parameter description
	 */
	get u() {
		return parseNumber(this.geoParams.get("u"))
	}
	/**
	 * Set uncertainty
	 * @method
	 * @instance
	 * @memberof GeoURL
	 * @name set u
	 * @param {number|undefined} value - new uncertainty value in meters or undefined to remove the uncertainty
	 * @see {@link GeoURL#u} for the corresponding getter
	 */
	set u(value) {
		if (value != null) {
			this.geoParams.set("u", formatU(value))
		} else {
			this.geoParams.delete("u")
		}
	}
	/**
	 * Uncertainty in meters
	 *
	 * A longer name for {@link GeoURL#u}
	 * @type {number|undefined}
	 */
	get uncertainty() {
		return this.u
	}
	set uncertainty(value) {
		this.u = value
	}

	_parseCoordinatesString(value) {
		const coordinates = value.split(",").map(parseNumber)
		this._validateCoordinates(coordinates)
		return coordinates
	}

	_validateCoordinates(coordinates) {
		if (coordinates.length < 2 || coordinates.length > 3) {
			throw new TypeError(`Invalid number of coordinates`)
		}
		if (!coordinates.every(coordinate => typeof coordinate == "number")) {
			throw new TypeError(`Invalid coordinate value`)
		}
	}
}