Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions examples/world.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
/**
* A clickable world map: `bun examples/world.ts`
*
* Click a country to select it, hover to see what is under the cursor, and
* press z to zoom to the selection or r to go back to the whole globe.
*
* The map is the canvas drawing polylines in degrees, which is all a map is
* once the canvas works in the caller's own coordinates. The clicking is the
* other direction: the cell under the cursor is turned back into a longitude
* and latitude and tested against the outlines, so the answer is the country
* actually there rather than whichever bounding box happened to be first.
*/
import { createApp, countryBounds, findCountry, WORLD_X, WORLD_Y } from "@profullstack/hqtui";
import type { CountryOutline, Bounds } from "@profullstack/hqtui";

let selected: CountryOutline | undefined;
let hovered: CountryOutline | undefined;
let window: { x: Bounds; y: Bounds } = { x: WORLD_X, y: WORLD_Y };

const app = await createApp({ title: "hqtui · world" });

app.render(({ ui, theme }) => {
const shown = hovered ?? selected;
const zoomed = window.x !== WORLD_X;

ui.panel(
{
title: "World",
subtitle: shown ? shown.name : "click a country",
footer: zoomed ? "z zoom · r reset · q quit" : "z zoom to selection · q quit",
},
(p) => {
p.worldMap({
...window,
color: theme.border,
highlight: [selected?.iso || selected?.name || "", hovered?.iso || hovered?.name || ""],
highlightColor: theme.accent,
onSelect: (country) => {
selected = country;
app.invalidate();
},
onHover: (country) => {
if (country?.name !== hovered?.name) {
hovered = country;
app.invalidate();
}
},
});

p.row({ size: 1, gap: 2 }, (row) => {
row.keyValues(
[
{ label: "Selected", value: selected?.name ?? "—" },
{ label: "ISO", value: selected?.iso || "—" },
],
{ spread: false },
);
});
},
);
});

app.on("key", (key) => {
if (key.name === "z" && selected) {
window = countryBounds(selected);
app.invalidate();
}
if (key.name === "r") {
window = { x: WORLD_X, y: WORLD_Y };
app.invalidate();
}
});

// Somewhere to start from, so the first frame is not an empty selection.
selected = findCountry("Japan");

await app.start();
280 changes: 280 additions & 0 deletions packages/hqtui/scripts/generate-world.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,280 @@
/**
* Generates `src/graphics/world-data.ts` from Natural Earth's country polygons.
*
* bun packages/hqtui/scripts/generate-world.ts
*
* The source is Natural Earth 1:110m Admin 0 countries, which is public domain
* <https://www.naturalearthdata.com/about/terms-of-use/>. It is fetched rather
* than committed: the raw file is 800KB of GeoJSON and this only needs running
* when the borders change, which is roughly never.
*
* The output is committed. Regenerating it should be a no-op unless the
* tolerance below changes.
*/
import { writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";

const SOURCE =
"https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_110m_admin_0_countries.geojson";

/**
* Douglas-Peucker tolerance, in degrees.
*
* A world map across an eighty-column terminal is about 160 Braille pixels
* wide, so one pixel is a bit over two degrees. At one degree the simplified
* outline is already finer than anything a terminal can show, and it keeps 171
* of the 177 countries; the six it drops are island states smaller than a pixel,
* which could be neither seen nor clicked.
*/
const TOLERANCE = 1.0;

/** A country whose bounding box is smaller than this cannot be drawn or hit. */
const MIN_SPAN = 1.5;

type Point = [number, number];

function perpendicular(p: Point, a: Point, b: Point): number {
const [px, py] = p;
const [ax, ay] = a;
const [bx, by] = b;
const dx = bx - ax;
const dy = by - ay;
if (dx === 0 && dy === 0) return Math.hypot(px - ax, py - ay);
const t = Math.max(0, Math.min(1, ((px - ax) * dx + (py - ay) * dy) / (dx * dx + dy * dy)));
return Math.hypot(px - (ax + t * dx), py - (ay + t * dy));
}

/** Douglas-Peucker, iterative so a long coastline cannot blow the stack. */
function simplify(points: Point[], tolerance: number): Point[] {
if (points.length < 3) return points;
const keep = new Array<boolean>(points.length).fill(false);
keep[0] = true;
keep[points.length - 1] = true;
const stack: [number, number][] = [[0, points.length - 1]];
while (stack.length > 0) {
const [lo, hi] = stack.pop() as [number, number];
let worst = 0;
let at = -1;
for (let i = lo + 1; i < hi; i++) {
const d = perpendicular(points[i], points[lo], points[hi]);
if (d > worst) {
worst = d;
at = i;
}
}
if (worst > tolerance && at !== -1) {
keep[at] = true;
stack.push([lo, at], [at, hi]);
}
}
return points.filter((_, i) => keep[i]);
}

interface Feature {
properties: Record<string, unknown>;
geometry: { type: string; coordinates: number[][][] | number[][][][] };
}

const response = await fetch(SOURCE);
if (!response.ok) throw new Error(`${SOURCE} responded ${response.status}`);
const collection = (await response.json()) as { features: Feature[] };

const countries: { name: string; iso: string; rings: Point[][] }[] = [];
for (const feature of collection.features) {
const props = feature.properties;
const name = String(props.NAME ?? props.NAME_LONG ?? "");
const iso = String(props.ISO_A2_EH ?? props.ISO_A2 ?? "");
if (!name) continue;

const geometry = feature.geometry;
const polygons = (geometry.type === "MultiPolygon"
? geometry.coordinates
: [geometry.coordinates]) as number[][][][];

const rings: Point[][] = [];
for (const polygon of polygons) {
// Only the outer ring. A lake inside a country is not a border at this
// resolution, and carrying the holes doubles the data for nothing.
const ring = polygon[0].map(([x, y]) => [x, y] as Point);
const xs = ring.map((p) => p[0]);
const ys = ring.map((p) => p[1]);
if (Math.max(...xs) - Math.min(...xs) < MIN_SPAN &&
Math.max(...ys) - Math.min(...ys) < MIN_SPAN) continue;
const cut = simplify(ring, TOLERANCE);
if (cut.length >= 3) {
rings.push(cut.map(([x, y]) => [Math.round(x * 10) / 10, Math.round(y * 10) / 10] as Point));
}
}
if (rings.length > 0) countries.push({ name, iso, rings });
}

countries.sort((a, b) => a.name.localeCompare(b.name));

const points = countries.reduce((n, c) => n + c.rings.reduce((m, r) => m + r.length, 0), 0);
const here = dirname(fileURLToPath(import.meta.url));
const repo = join(here, "..", "..", "..");

/** The same provenance note at the top of every generated file. */
function banner(comment: string): string {
const line = (text: string) => (text ? `${comment} ${text}` : comment.trimEnd());
return [
line("Country outlines, flattened for a terminal."),
line(""),
line("Generated by packages/hqtui/scripts/generate-world.ts from Natural Earth's"),
line("1:110m Admin 0 countries, which is public domain. Do not edit by hand."),
line(""),
line("Each ring is longitude and latitude interleaved -- lon, lat, lon, lat --"),
line(`rather than a list of pairs, because at ${points} points the nested form`),
line("costs a container per coordinate for no gain. A country has more than one"),
line("ring when it is more than one landmass."),
line(""),
line(`${countries.length} countries, ${points} points, simplified at ${TOLERANCE} degrees.`),
].join("\n");
}

const flat = (ring: Point[]) => ring.flatMap(([x, y]) => [x, y]);
/** A float literal every language reads the same way, including whole numbers. */
const real = (v: number) => (Number.isInteger(v) ? `${v}.0` : `${v}`);
const quoted = (v: string) => JSON.stringify(v);

const write = (relative: string, text: string) => {
const target = join(repo, relative);
writeFileSync(target, text);
console.log(` ${relative}`);
};

// ------------------------------------------------------------------ TypeScript
write(
"packages/hqtui/src/graphics/world-data.ts",
`/**\n${banner(" *")}\n */\n\n` +
`export interface CountryOutline {\n name: string;\n` +
` /** ISO 3166-1 alpha-2, where Natural Earth has one. */\n iso: string;\n` +
` /** Longitude and latitude, interleaved. */\n rings: number[][];\n}\n\n` +
`export const WORLD_COUNTRIES: readonly CountryOutline[] = [\n` +
countries
.map((c) =>
` {\n name: ${quoted(c.name)},\n iso: ${quoted(c.iso)},\n rings: [\n` +
c.rings.map((r) => ` [${flat(r).join(",")}],`).join("\n") +
`\n ],\n },`)
.join("\n") +
`\n];\n`,
);

// ------------------------------------------------------------------------ Rust
write(
"ports/rust/src/graphics/world_data.rs",
`${banner("//!")}\n\npub struct CountryOutline {\n pub name: &'static str,\n` +
` /// ISO 3166-1 alpha-2, where Natural Earth has one.\n pub iso: &'static str,\n` +
` /// Longitude and latitude, interleaved.\n pub rings: &'static [&'static [f64]],\n}\n\n` +
`pub static WORLD_COUNTRIES: &[CountryOutline] = &[\n` +
countries
.map((c) =>
` CountryOutline {\n name: ${quoted(c.name)},\n iso: ${quoted(c.iso)},\n` +
` rings: &[\n` +
c.rings.map((r) => ` &[${flat(r).map(real).join(",")}],`).join("\n") +
`\n ],\n },`)
.join("\n") +
`\n];\n`,
);

// -------------------------------------------------------------------------- Go
write(
"ports/go/world_data.go",
`package hqtui\n\n${banner("//")}\n\ntype CountryOutline struct {\n\tName string\n` +
`\t// ISO 3166-1 alpha-2, where Natural Earth has one.\n\tISO string\n` +
`\t// Rings are longitude and latitude, interleaved.\n\tRings [][]float64\n}\n\n` +
`var WorldCountries = []CountryOutline{\n` +
countries
.map((c) =>
`\t{\n\t\tName: ${quoted(c.name)},\n\t\tISO: ${quoted(c.iso)},\n\t\tRings: [][]float64{\n` +
c.rings.map((r) => `\t\t\t{${flat(r).join(",")}},`).join("\n") +
`\n\t\t},\n\t},`)
.join("\n") +
`\n}\n`,
);

// ---------------------------------------------------------------------- Python
write(
"ports/python/hqtui/graphics/world_data.py",
`"""\n${banner("")}\n"""\n\nfrom __future__ import annotations\n\n` +
`from dataclasses import dataclass\n\n\n` +
`@dataclass(frozen=True, slots=True)\nclass CountryOutline:\n name: str\n` +
` #: ISO 3166-1 alpha-2, where Natural Earth has one.\n iso: str\n` +
` #: Longitude and latitude, interleaved.\n rings: tuple[tuple[float, ...], ...]\n\n\n` +
`WORLD_COUNTRIES: tuple[CountryOutline, ...] = (\n` +
countries
.map((c) =>
` CountryOutline(\n ${quoted(c.name)},\n ${quoted(c.iso)},\n (\n` +
c.rings.map((r) => ` (${flat(r).join(",")}),`).join("\n") +
`\n ),\n ),`)
.join("\n") +
`\n)\n`,
);

// ------------------------------------------------------------------------- Zig
write(
"ports/zig/src/graphics/world_data.zig",
`${banner("//!")}\n\npub const CountryOutline = struct {\n name: []const u8,\n` +
` /// ISO 3166-1 alpha-2, where Natural Earth has one.\n iso: []const u8,\n` +
` /// Longitude and latitude, interleaved.\n rings: []const []const f64,\n};\n\n` +
`pub const WORLD_COUNTRIES = [_]CountryOutline{\n` +
countries
.map((c) =>
` .{\n .name = ${quoted(c.name)},\n .iso = ${quoted(c.iso)},\n` +
` .rings = &.{\n` +
c.rings.map((r) => ` &.{${flat(r).map(real).join(",")}},`).join("\n") +
`\n },\n },`)
.join("\n") +
`\n};\n`,
);

// ------------------------------------------------------------------------- C++
// A flat coordinate pool with index tables, rather than nested initialiser
// lists: the nested form is the same data but minutes of compile time, and this
// is the one language where that bites.
const pool: number[] = [];
const ringSpans: [number, number][] = [];
const countrySpans: [number, number][] = [];
for (const c of countries) {
const first = ringSpans.length;
for (const ring of c.rings) {
const at = pool.length;
pool.push(...flat(ring));
ringSpans.push([at, pool.length - at]);
}
countrySpans.push([first, ringSpans.length - first]);
}
const chunk = (values: string[], per: number) => {
const lines: string[] = [];
for (let i = 0; i < values.length; i += per) lines.push(` ${values.slice(i, i + per).join(",")},`);
return lines.join("\n");
};
write(
"ports/cpp/src/world_data.cpp",
`${banner("///")}\n#include <hqtui/widgets.hpp>\n\nnamespace hqtui {\nnamespace {\n\n` +
`const double POOL[] = {\n${chunk(pool.map(real), 12)}\n};\n\n` +
`struct Span {\n int at, length;\n};\n\n` +
`const Span RINGS[] = {\n${chunk(ringSpans.map(([a, n]) => `{${a},${n}}`), 8)}\n};\n\n` +
`const Span COUNTRIES[] = {\n${chunk(countrySpans.map(([a, n]) => `{${a},${n}}`), 8)}\n};\n\n` +
`const char *const NAMES[] = {\n${chunk(countries.map((c) => quoted(c.name)), 4)}\n};\n\n` +
`const char *const ISO[] = {\n${chunk(countries.map((c) => quoted(c.iso)), 12)}\n};\n\n` +
`} // namespace\n\n` +
`/// Built once, on first use: the pool above is plain static data, and this\n` +
`/// turns it into the shape the rest of the code wants without a static\n` +
`/// initialiser that has to run before main.\n` +
`const std::vector<CountryOutline> &world_countries() {\n` +
` static const std::vector<CountryOutline> countries = [] {\n` +
` std::vector<CountryOutline> out;\n` +
` out.reserve(${countries.length});\n` +
` for (std::size_t i = 0; i < ${countries.length}; i++) {\n` +
` CountryOutline c;\n c.name = NAMES[i];\n c.iso = ISO[i];\n` +
` const Span &cs = COUNTRIES[i];\n` +
` for (int r = 0; r < cs.length; r++) {\n` +
` const Span &rs = RINGS[cs.at + r];\n` +
` c.rings.emplace_back(POOL + rs.at, POOL + rs.at + rs.length);\n` +
` }\n out.push_back(std::move(c));\n }\n return out;\n }();\n` +
` return countries;\n}\n\n} // namespace hqtui\n`,
);

console.log(`${countries.length} countries, ${points} points`);
Loading