Avatar

mxdvl

Digital toolmaker Currently client-side @guardian
6 public vals
Joined March 5, 2023
1
2
3
export function sleep(milliseconds = 3000) {
return new Promise(resolve => setTimeout(resolve, milliseconds));
}

Turn an array of coordinates in the format [longitude, latitude] into an array of GeoJSON features as line strings.

An optional distance threshold between subsequent points decides when distinct trips are created. Defaults to 120m.

Example on Observable

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
import { city } from "https://esm.town/v/mxdvl/cities";
import { haversine_distance } from "https://esm.town/v/mxdvl/haversine_distance";
type Coordinates = readonly [number, number];
type GeoJSONFeature = {
type: "Feature";
properties: { from: string; to: string | undefined };
geometry: { type: "LineString"; coordinates: readonly Coordinates[] };
};
async function to_line_string(
coordinates: readonly Coordinates[],
en_route = false,
): Promise<Readonly<GeoJSONFeature>> {
console.log({ coordinates });
const [from, to] = await Promise.all([
city(coordinates.at(0)),
en_route ? undefined : city(coordinates.at(-1)),
]);
console.log({ from, to });
return {
type: "Feature",
properties: { from, to },
geometry: {
type: "LineString",
coordinates,
},
};
}
export async function* trips(
coordinates: readonly Coordinates[],
distance_threshold = 120,
): AsyncGenerator<GeoJSONFeature> {
let trip = [];
let last_position = coordinates.at(0);
if (!last_position) return;
for (const position of coordinates) {
const distance = haversine_distance(last_position, position, 6371e3);
if (distance > distance_threshold) { trip.push(position); } else if (trip.length > 1) {
yield to_line_string(trip);
trip = [];
} else {
trip = [position];
}
last_position = position;
}
console.log(trip);
if (trip.length > 1) yield to_line_string(trip, true);
}

Round numbers with arbitrary decimal precision

1
2
3
4
/** round with abitrary decimal precision */
export function round(number: number, precision = 5) {
return Math.round(number * Math.pow(10, precision)) / Math.pow(10, precision);
}

Retrieve a city for a given coordinates in the format [longitude, latitude].

An OPENCAGE_API_KEY variable is necessary and can be obtained for free on their website.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
import { round } from "https://esm.town/v/mxdvl/round";
import { sqlite } from "https://esm.town/v/std/sqlite";
import { array, number, object, optional, parse, string } from "npm:valibot";
await sqlite.execute(`create table if not exists cities(
key text unique,
value text
)`);
function two_decimals(number: number) {
return round(number, 2);
}
const schema = object({
results: array(object({
components: object({
_normalized_city: optional(string()),
}),
})),
});
async function add_city(coordinates: Coordinates): Promise<string> {
const searchParams = new URLSearchParams({
q: [
coordinates[1],
coordinates[0],
].map(two_decimals).join(","),
language: "native",
key: Deno.env.get("OPENCAGE_API_KEY"),
});
const { results: [first] } = await fetch(`https://api.opencagedata.com/geocode/v1/json?${searchParams}`)
.then(response => response.json())
.then(json => parse(schema, json));
const city = first.components._normalized_city;
if (city === undefined) return;
const key = coordinates.map(two_decimals).join(",");
await sqlite.execute({
sql: `insert into cities(key, value) values (:key, :value)`,
args: { key, value: city },
});
return city;
}
type Coordinates = readonly [number, number];
export async function city(coordinates: Coordinates): Promise<string> {
const key = coordinates.map(two_decimals).join(",");
const query = await sqlite.execute({
sql: `select key, value from cities where key = :key`,
args: { key },
});
const city = query.rows[0]?.[1] ?? add_city(coordinates);
return city;
}
export default async function(request: Request): Promise<Response> {
const searchParams = new URL(request.url).searchParams;
if (searchParams.get("list")) {
const query = await sqlite.execute(`select key, value from cities`);
return Response.json(query.rows);
}
const latitude = Number(searchParams.get("latitude"));
const longitude = Number(searchParams.get("longitude"));
if (latitude === 0 && longitude === 0) return Response.json({ error: "Invalid coordinates", latitude, longitude });
return Response.json({
name: await city([longitude, latitude]),
latitude,
longitude,
});
}

Implementation of the Haversine formula for calculating the great circle distance between two points on a sphere.

If no radius is provided, the average Earth’s radius is used with a value of 6371km.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const degrees_to_radians = Math.PI / 180;
const sin = (degrees: number) => Math.sin(degrees * degrees_to_radians);
const cos = (degrees: number) => Math.cos(degrees * degrees_to_radians);
/** Compatible with GeoJSON */
type Coordinates = readonly [longitude: number, latitude: number];
/**
* Calculate the haversine distance between two points on a sphere.
* The coordinates take the form [longitude, latitude] in degrees.
* An default radius is the average Earth’s radius of 6371km.
*
* @see {https://en.wikipedia.org/wiki/Haversine_formula}
*/
export const haversine_distance = ([λ1, φ1]: Coordinates, [λ2, φ2]: Coordinates, radius = 6371) =>
2 * radius * Math.asin(Math.sqrt(sin((φ2 - φ1) / 2) ** 2 + cos(φ1) * cos(φ2) * sin((λ2 - λ1) / 2) ** 2));
Next