@brianfunk/areapi 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +143 -0
- package/data/bta.json +1 -0
- package/data/cma.json +1 -0
- package/data/ea.json +1 -0
- package/data/manifest.json +78 -0
- package/data/mea.json +1 -0
- package/data/mta.json +1 -0
- package/data/pea.json +1 -0
- package/data/reag.json +1 -0
- package/package.json +60 -0
- package/src/cli.js +77 -0
- package/src/format.js +66 -0
- package/src/index.d.ts +87 -0
- package/src/index.js +117 -0
- package/src/load.js +74 -0
- package/src/pip.js +77 -0
package/package.json
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@brianfunk/areapi",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Which FCC market area is this point in? CMA, BTA, MTA, EA, MEA, REAG, PEA lookup by latitude/longitude. Zero dependencies, no database.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./src/index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"types": "./src/index.d.ts",
|
|
10
|
+
"browser": "./src/index.js",
|
|
11
|
+
"default": "./src/index.js"
|
|
12
|
+
},
|
|
13
|
+
"./data/*": "./data/*"
|
|
14
|
+
},
|
|
15
|
+
"bin": {
|
|
16
|
+
"areapi": "./src/cli.js"
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"src/",
|
|
20
|
+
"data/*.json",
|
|
21
|
+
"README.md",
|
|
22
|
+
"LICENSE"
|
|
23
|
+
],
|
|
24
|
+
"engines": {
|
|
25
|
+
"node": ">=20"
|
|
26
|
+
},
|
|
27
|
+
"scripts": {
|
|
28
|
+
"test": "node --test",
|
|
29
|
+
"build:data": "node scripts/build-data.js",
|
|
30
|
+
"build:site": "node scripts/build-site.js",
|
|
31
|
+
"dev": "netlify dev"
|
|
32
|
+
},
|
|
33
|
+
"repository": {
|
|
34
|
+
"type": "git",
|
|
35
|
+
"url": "git+https://github.com/brianfunk/areapi.git"
|
|
36
|
+
},
|
|
37
|
+
"keywords": [
|
|
38
|
+
"fcc",
|
|
39
|
+
"cma",
|
|
40
|
+
"bta",
|
|
41
|
+
"mta",
|
|
42
|
+
"pea",
|
|
43
|
+
"market-area",
|
|
44
|
+
"geocoding",
|
|
45
|
+
"point-in-polygon",
|
|
46
|
+
"spectrum",
|
|
47
|
+
"wireless"
|
|
48
|
+
],
|
|
49
|
+
"author": "Brian Funk",
|
|
50
|
+
"license": "MIT",
|
|
51
|
+
"bugs": {
|
|
52
|
+
"url": "https://github.com/brianfunk/areapi/issues"
|
|
53
|
+
},
|
|
54
|
+
"homepage": "https://areapi.netlify.app",
|
|
55
|
+
"devDependencies": {
|
|
56
|
+
"esbuild": "^0.28.2",
|
|
57
|
+
"mapshaper": "^0.7.72"
|
|
58
|
+
},
|
|
59
|
+
"types": "./src/index.d.ts"
|
|
60
|
+
}
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* areapi CLI
|
|
4
|
+
*
|
|
5
|
+
* npx areapi 38.9907 -77.0261
|
|
6
|
+
* npx areapi 38.9907 -77.0261 --types cma,bta --format json
|
|
7
|
+
* npx areapi --types # list area types
|
|
8
|
+
*/
|
|
9
|
+
import { parseArgs } from 'node:util';
|
|
10
|
+
import { find, types, TYPES, TYPE_NAMES, AreapiError } from './index.js';
|
|
11
|
+
import { format } from './format.js';
|
|
12
|
+
|
|
13
|
+
const HELP = `areapi - which FCC market area is this point in?
|
|
14
|
+
|
|
15
|
+
Usage:
|
|
16
|
+
areapi <lat> <lon> [--types cma,bta,...] [--format table|json|xml]
|
|
17
|
+
areapi --list
|
|
18
|
+
|
|
19
|
+
Options:
|
|
20
|
+
-t, --types comma-separated area types (default: all of ${TYPES.join(',')})
|
|
21
|
+
-f, --format table (default on a terminal), json (default when piped), xml
|
|
22
|
+
-l, --list list area types with counts and vintages
|
|
23
|
+
-h, --help show this help
|
|
24
|
+
`;
|
|
25
|
+
|
|
26
|
+
// Negative coordinates look like flags to parseArgs; lift numeric args out first.
|
|
27
|
+
const argv = process.argv.slice(2);
|
|
28
|
+
const numeric = argv.filter((a) => /^-?\d+(\.\d+)?$/.test(a));
|
|
29
|
+
const rest = argv.filter((a) => !numeric.includes(a));
|
|
30
|
+
|
|
31
|
+
let args;
|
|
32
|
+
try {
|
|
33
|
+
args = parseArgs({
|
|
34
|
+
args: rest,
|
|
35
|
+
allowPositionals: true,
|
|
36
|
+
options: {
|
|
37
|
+
types: { type: 'string', short: 't' },
|
|
38
|
+
format: { type: 'string', short: 'f' },
|
|
39
|
+
list: { type: 'boolean', short: 'l' },
|
|
40
|
+
help: { type: 'boolean', short: 'h' },
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
} catch (err) {
|
|
44
|
+
fail(err.message);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const { values } = args;
|
|
48
|
+
const positionals = [...numeric, ...args.positionals];
|
|
49
|
+
|
|
50
|
+
if (values.help) {
|
|
51
|
+
process.stdout.write(HELP);
|
|
52
|
+
process.exit(0);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (values.list) {
|
|
56
|
+
const m = await types();
|
|
57
|
+
const rows = Object.entries(m.types).map(([k, t]) => `${k.padEnd(5)} ${String(t.count).padStart(4)} ${t.vintage} ${TYPE_NAMES[k]}`);
|
|
58
|
+
process.stdout.write(rows.join('\n') + '\n');
|
|
59
|
+
process.exit(0);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (positionals.length !== 2) fail('Expected <lat> <lon>.\n\n' + HELP);
|
|
63
|
+
|
|
64
|
+
try {
|
|
65
|
+
const result = await find({ lat: positionals[0], lon: positionals[1] }, { types: values.types });
|
|
66
|
+
const fmt = values.format || (process.stdout.isTTY ? 'table' : 'json');
|
|
67
|
+
const { body } = format(result, fmt);
|
|
68
|
+
process.stdout.write(body + '\n');
|
|
69
|
+
process.exit(result.status === 'OK' ? 0 : 3);
|
|
70
|
+
} catch (err) {
|
|
71
|
+
fail(err instanceof AreapiError ? err.message : err.stack);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function fail(msg) {
|
|
75
|
+
process.stderr.write(`areapi: ${msg}\n`);
|
|
76
|
+
process.exit(2);
|
|
77
|
+
}
|
package/src/format.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/** Output serializers shared by the CLI and the HTTP function. */
|
|
2
|
+
|
|
3
|
+
export const FORMATS = ['json', 'xml', 'jsonp', 'table'];
|
|
4
|
+
|
|
5
|
+
export function toJSON(result) {
|
|
6
|
+
return JSON.stringify(result);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function toJSONP(result, callback = 'callback') {
|
|
10
|
+
const cb = /^[A-Za-z_$][\w$.]*$/.test(callback) ? callback : 'callback';
|
|
11
|
+
return `/**/ typeof ${cb} === 'function' && ${cb}(${JSON.stringify(result)});`;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function toXML(result, root = 'response') {
|
|
15
|
+
return `<?xml version="1.0" encoding="UTF-8"?>\n${node(root, result)}`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function node(name, value) {
|
|
19
|
+
if (Array.isArray(value)) {
|
|
20
|
+
const item = singular(name);
|
|
21
|
+
return `<${name}>${value.map((v) => node(item, v)).join('')}</${name}>`;
|
|
22
|
+
}
|
|
23
|
+
if (value && typeof value === 'object') {
|
|
24
|
+
return `<${name}>${Object.entries(value)
|
|
25
|
+
.map(([k, v]) => node(k, v))
|
|
26
|
+
.join('')}</${name}>`;
|
|
27
|
+
}
|
|
28
|
+
if (value == null) return `<${name}/>`;
|
|
29
|
+
return `<${name}>${escape(String(value))}</${name}>`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function singular(name) {
|
|
33
|
+
if (name === 'areas') return 'area';
|
|
34
|
+
if (name === 'types') return 'type';
|
|
35
|
+
return name.endsWith('s') ? name.slice(0, -1) : 'item';
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function escape(s) {
|
|
39
|
+
return s.replace(/[<>&'"]/g, (c) => ({ '<': '<', '>': '>', '&': '&', "'": ''', '"': '"' })[c]);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function toTable(result) {
|
|
43
|
+
const lines = [];
|
|
44
|
+
lines.push(`point ${result.point.lat}, ${result.point.lon}`);
|
|
45
|
+
lines.push(`status ${result.status}`);
|
|
46
|
+
if (result.areas.length) {
|
|
47
|
+
const w = Math.max(...result.areas.map((a) => a.id.length), 2);
|
|
48
|
+
for (const a of result.areas) {
|
|
49
|
+
lines.push(`${a.type.toUpperCase().padEnd(5)} ${a.id.padStart(w)} ${a.name}`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return lines.join('\n');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function format(result, fmt = 'json', callback) {
|
|
56
|
+
switch ((fmt || 'json').toLowerCase()) {
|
|
57
|
+
case 'xml':
|
|
58
|
+
return { body: toXML(result), contentType: 'application/xml; charset=utf-8' };
|
|
59
|
+
case 'jsonp':
|
|
60
|
+
return { body: toJSONP(result, callback), contentType: 'application/javascript; charset=utf-8' };
|
|
61
|
+
case 'table':
|
|
62
|
+
return { body: toTable(result), contentType: 'text/plain; charset=utf-8' };
|
|
63
|
+
default:
|
|
64
|
+
return { body: toJSON(result), contentType: 'application/json; charset=utf-8' };
|
|
65
|
+
}
|
|
66
|
+
}
|
package/src/index.d.ts
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
export type AreaType = 'cma' | 'bta' | 'mta' | 'ea' | 'mea' | 'reag' | 'pea';
|
|
2
|
+
|
|
3
|
+
export const TYPES: readonly AreaType[];
|
|
4
|
+
export const TYPE_NAMES: Readonly<Record<AreaType, string>>;
|
|
5
|
+
|
|
6
|
+
export interface Point {
|
|
7
|
+
lat: number;
|
|
8
|
+
lon: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface PointInput {
|
|
12
|
+
lat: number | string;
|
|
13
|
+
lon: number | string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface Area {
|
|
17
|
+
type: AreaType;
|
|
18
|
+
/** Market number as a string, e.g. "8" */
|
|
19
|
+
id: string;
|
|
20
|
+
name: string;
|
|
21
|
+
vintage: string | null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface FindOptions {
|
|
25
|
+
/** Subset of area types, as an array or comma-separated string. Default: all. */
|
|
26
|
+
types?: AreaType | string | readonly (AreaType | string)[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface FindResult {
|
|
30
|
+
status: 'OK' | 'NONE';
|
|
31
|
+
point: Point;
|
|
32
|
+
types: AreaType[];
|
|
33
|
+
areas: Area[];
|
|
34
|
+
/** Milliseconds */
|
|
35
|
+
executionTime: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface AreaFeature {
|
|
39
|
+
type: 'Feature';
|
|
40
|
+
id: number;
|
|
41
|
+
bbox: [number, number, number, number];
|
|
42
|
+
properties: { id: string; name: string };
|
|
43
|
+
geometry: { type: 'Polygon'; coordinates: number[][][] } | { type: 'MultiPolygon'; coordinates: number[][][][] };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface Manifest {
|
|
47
|
+
generated: string;
|
|
48
|
+
simplify: string;
|
|
49
|
+
precision: string;
|
|
50
|
+
geometrySources: string[];
|
|
51
|
+
crosswalk: string;
|
|
52
|
+
names: string;
|
|
53
|
+
types: Record<AreaType, {
|
|
54
|
+
name: string;
|
|
55
|
+
vintage: string;
|
|
56
|
+
note: string;
|
|
57
|
+
count: number;
|
|
58
|
+
defined: number;
|
|
59
|
+
missing: { id: string; name: string }[];
|
|
60
|
+
bytes: number;
|
|
61
|
+
}>;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export class AreapiError extends Error {
|
|
65
|
+
name: 'AreapiError';
|
|
66
|
+
/** HTTP-style status, 400 for bad input, 404 for unknown routes. */
|
|
67
|
+
status: number;
|
|
68
|
+
constructor(message: string, status?: number);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Find every requested area type that contains the point. */
|
|
72
|
+
export function find(point: PointInput, opts?: FindOptions): Promise<FindResult>;
|
|
73
|
+
|
|
74
|
+
/** All features of one type containing the point (normally 0 or 1). */
|
|
75
|
+
export function findType(type: AreaType, point: Point): Promise<Area[]>;
|
|
76
|
+
|
|
77
|
+
/** GeoJSON Feature for one area, or null if the id is unknown. */
|
|
78
|
+
export function feature(type: AreaType | string, id: string | number): Promise<AreaFeature | null>;
|
|
79
|
+
|
|
80
|
+
/** The data manifest: counts, vintages, sources, missing ids. */
|
|
81
|
+
export function types(): Promise<Manifest>;
|
|
82
|
+
|
|
83
|
+
export function normalizeTypes(types?: FindOptions['types'] | null): AreaType[];
|
|
84
|
+
export function normalizePoint(point: PointInput): Point;
|
|
85
|
+
|
|
86
|
+
/** Browser only: base URL where <type>.json files are served from (default "data/"). */
|
|
87
|
+
export function setDataUrl(url: string): void;
|
package/src/index.js
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* areapi: which FCC market area is this point in?
|
|
3
|
+
*
|
|
4
|
+
* import { find } from 'areapi';
|
|
5
|
+
* const r = await find({ lat: 38.9907, lon: -77.0261 });
|
|
6
|
+
* r.areas -> [{ type: 'cma', id: '8', name: 'Washington, DC-MD-VA', ... }, ...]
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { inFeature } from './pip.js';
|
|
10
|
+
import { load, loadManifest, setDataUrl } from './load.js';
|
|
11
|
+
|
|
12
|
+
export { setDataUrl };
|
|
13
|
+
|
|
14
|
+
/** Area types in display order. Keep in sync with data/manifest.json. */
|
|
15
|
+
export const TYPES = ['cma', 'bta', 'mta', 'ea', 'mea', 'reag', 'pea'];
|
|
16
|
+
|
|
17
|
+
export const TYPE_NAMES = {
|
|
18
|
+
cma: 'Cellular Market Area',
|
|
19
|
+
bta: 'Basic Trading Area',
|
|
20
|
+
mta: 'Major Trading Area',
|
|
21
|
+
ea: 'Economic Area',
|
|
22
|
+
mea: 'Major Economic Area',
|
|
23
|
+
reag: 'Regional Economic Area Grouping',
|
|
24
|
+
pea: 'Partial Economic Area',
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export class AreapiError extends Error {
|
|
28
|
+
constructor(message, status = 400) {
|
|
29
|
+
super(message);
|
|
30
|
+
this.name = 'AreapiError';
|
|
31
|
+
this.status = status;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function normalizeTypes(types) {
|
|
36
|
+
if (types == null || types === '' || types === 'all') return [...TYPES];
|
|
37
|
+
const list = Array.isArray(types) ? types : String(types).split(',');
|
|
38
|
+
const out = [];
|
|
39
|
+
for (const raw of list) {
|
|
40
|
+
const t = String(raw).trim().toLowerCase();
|
|
41
|
+
if (!t) continue;
|
|
42
|
+
if (!TYPES.includes(t)) {
|
|
43
|
+
throw new AreapiError(`Unknown area type "${raw}". Valid types: ${TYPES.join(', ')}`);
|
|
44
|
+
}
|
|
45
|
+
if (!out.includes(t)) out.push(t);
|
|
46
|
+
}
|
|
47
|
+
if (out.length === 0) return [...TYPES];
|
|
48
|
+
return out;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function normalizePoint({ lat, lon }) {
|
|
52
|
+
const la = Number(lat);
|
|
53
|
+
const lo = Number(lon);
|
|
54
|
+
if (lat == null || lat === '' || Number.isNaN(la) || la < -90 || la > 90) {
|
|
55
|
+
throw new AreapiError(`Invalid latitude "${lat}". Must be a number from -90 to 90.`);
|
|
56
|
+
}
|
|
57
|
+
if (lon == null || lon === '' || Number.isNaN(lo) || lo < -180 || lo > 180) {
|
|
58
|
+
throw new AreapiError(`Invalid longitude "${lon}". Must be a number from -180 to 180.`);
|
|
59
|
+
}
|
|
60
|
+
return { lat: la, lon: lo };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** All features of one type that contain the point (normally 0 or 1). */
|
|
64
|
+
export async function findType(type, { lat, lon }) {
|
|
65
|
+
const data = await load(type);
|
|
66
|
+
const hits = [];
|
|
67
|
+
for (const f of data.features) {
|
|
68
|
+
if (inFeature(lon, lat, f)) {
|
|
69
|
+
hits.push({
|
|
70
|
+
type,
|
|
71
|
+
id: String(f.properties.id),
|
|
72
|
+
name: f.properties.name,
|
|
73
|
+
vintage: data.vintage ?? null,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return hits;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* @param {{lat: number|string, lon: number|string}} point
|
|
82
|
+
* @param {{types?: string|string[]}} [opts]
|
|
83
|
+
*/
|
|
84
|
+
export async function find(point, opts = {}) {
|
|
85
|
+
const start = now();
|
|
86
|
+
const p = normalizePoint(point);
|
|
87
|
+
const types = normalizeTypes(opts.types);
|
|
88
|
+
const perType = await Promise.all(types.map((t) => findType(t, p)));
|
|
89
|
+
const areas = perType.flat();
|
|
90
|
+
return {
|
|
91
|
+
status: areas.length ? 'OK' : 'NONE',
|
|
92
|
+
point: p,
|
|
93
|
+
types,
|
|
94
|
+
areas,
|
|
95
|
+
executionTime: round(now() - start),
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Geometry for one area, for drawing. */
|
|
100
|
+
export async function feature(type, id) {
|
|
101
|
+
const [t] = normalizeTypes(type);
|
|
102
|
+
const data = await load(t);
|
|
103
|
+
return data.features.find((f) => String(f.properties.id) === String(id)) ?? null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Manifest: vintages, counts, sources. */
|
|
107
|
+
export function types() {
|
|
108
|
+
return loadManifest();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function now() {
|
|
112
|
+
return typeof performance !== 'undefined' ? performance.now() : Date.now();
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function round(ms) {
|
|
116
|
+
return Math.round(ms * 1000) / 1000;
|
|
117
|
+
}
|
package/src/load.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Loads a market-area dataset by type. Works in Node (reads from the package's
|
|
3
|
+
* data/ directory) and in the browser (fetches from `dataUrl`, default "data/").
|
|
4
|
+
* Results are cached per type for the life of the process/page.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const cache = new Map();
|
|
8
|
+
const isBrowser = typeof window !== 'undefined' && typeof window.fetch === 'function';
|
|
9
|
+
|
|
10
|
+
let dataUrl = 'data/';
|
|
11
|
+
|
|
12
|
+
/** Browser only: where the <type>.json files live, relative or absolute. */
|
|
13
|
+
export function setDataUrl(url) {
|
|
14
|
+
dataUrl = url.endsWith('/') ? url : url + '/';
|
|
15
|
+
cache.clear();
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
let nodeDataDir = null;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Locate the data directory. Normally it sits next to src/ in the package, but
|
|
22
|
+
* bundlers (e.g. Netlify's esbuild) move this module, so fall back to the data/
|
|
23
|
+
* directory of the working directory or the Lambda task root.
|
|
24
|
+
*/
|
|
25
|
+
async function findDataDir() {
|
|
26
|
+
if (nodeDataDir) return nodeDataDir;
|
|
27
|
+
const { access } = await import('node:fs/promises');
|
|
28
|
+
const { fileURLToPath } = await import('node:url');
|
|
29
|
+
const path = await import('node:path');
|
|
30
|
+
const candidates = [
|
|
31
|
+
fileURLToPath(new URL('../data/', import.meta.url)),
|
|
32
|
+
path.join(process.cwd(), 'data'),
|
|
33
|
+
process.env.LAMBDA_TASK_ROOT && path.join(process.env.LAMBDA_TASK_ROOT, 'data'),
|
|
34
|
+
process.env.AREAPI_DATA_DIR,
|
|
35
|
+
].filter(Boolean);
|
|
36
|
+
for (const dir of candidates) {
|
|
37
|
+
try {
|
|
38
|
+
await access(path.join(dir, 'manifest.json'));
|
|
39
|
+
nodeDataDir = dir;
|
|
40
|
+
return dir;
|
|
41
|
+
} catch {
|
|
42
|
+
// try next
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
throw new Error(`areapi: data directory not found (tried ${candidates.join(', ')})`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function readNode(name) {
|
|
49
|
+
const { readFile } = await import('node:fs/promises');
|
|
50
|
+
const path = await import('node:path');
|
|
51
|
+
const dir = await findDataDir();
|
|
52
|
+
return JSON.parse(await readFile(path.join(dir, `${name}.json`), 'utf8'));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function readBrowser(name) {
|
|
56
|
+
const res = await fetch(`${dataUrl}${name}.json`);
|
|
57
|
+
if (!res.ok) throw new Error(`areapi: failed to load ${name}.json (${res.status})`);
|
|
58
|
+
return res.json();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function load(name) {
|
|
62
|
+
if (!cache.has(name)) {
|
|
63
|
+
const p = (isBrowser ? readBrowser(name) : readNode(name)).catch((err) => {
|
|
64
|
+
cache.delete(name);
|
|
65
|
+
throw err;
|
|
66
|
+
});
|
|
67
|
+
cache.set(name, p);
|
|
68
|
+
}
|
|
69
|
+
return cache.get(name);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function loadManifest() {
|
|
73
|
+
return load('manifest');
|
|
74
|
+
}
|
package/src/pip.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Point-in-polygon for GeoJSON geometries. Ray casting, no dependencies.
|
|
3
|
+
*
|
|
4
|
+
* Coordinates are [lon, lat] as in GeoJSON. A point on an edge is treated as
|
|
5
|
+
* inside, which is what you want for "which market am I in" (never "none"
|
|
6
|
+
* just because you stood exactly on a county line).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** @param {number[]} bbox [minX, minY, maxX, maxY] */
|
|
10
|
+
export function inBbox(x, y, bbox) {
|
|
11
|
+
return x >= bbox[0] && x <= bbox[2] && y >= bbox[1] && y <= bbox[3];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Is (x, y) inside a single linear ring? Even-odd rule. */
|
|
15
|
+
export function inRing(x, y, ring) {
|
|
16
|
+
let inside = false;
|
|
17
|
+
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
|
|
18
|
+
const [xi, yi] = ring[i];
|
|
19
|
+
const [xj, yj] = ring[j];
|
|
20
|
+
// Edge touch: treat as inside.
|
|
21
|
+
if (onSegment(x, y, xi, yi, xj, yj)) return true;
|
|
22
|
+
const crosses = (yi > y) !== (yj > y) && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi;
|
|
23
|
+
if (crosses) inside = !inside;
|
|
24
|
+
}
|
|
25
|
+
return inside;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function onSegment(px, py, ax, ay, bx, by) {
|
|
29
|
+
const cross = (bx - ax) * (py - ay) - (by - ay) * (px - ax);
|
|
30
|
+
if (Math.abs(cross) > 1e-12) return false;
|
|
31
|
+
return (
|
|
32
|
+
px >= Math.min(ax, bx) - 1e-12 &&
|
|
33
|
+
px <= Math.max(ax, bx) + 1e-12 &&
|
|
34
|
+
py >= Math.min(ay, by) - 1e-12 &&
|
|
35
|
+
py <= Math.max(ay, by) + 1e-12
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Polygon = [outerRing, ...holes]. Inside outer and not inside any hole. */
|
|
40
|
+
export function inPolygon(x, y, rings) {
|
|
41
|
+
if (!inRing(x, y, rings[0])) return false;
|
|
42
|
+
for (let i = 1; i < rings.length; i++) {
|
|
43
|
+
if (inRing(x, y, rings[i])) return false;
|
|
44
|
+
}
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Any GeoJSON Polygon or MultiPolygon geometry. */
|
|
49
|
+
export function inGeometry(x, y, geometry) {
|
|
50
|
+
if (!geometry) return false;
|
|
51
|
+
if (geometry.type === 'Polygon') return inPolygon(x, y, geometry.coordinates);
|
|
52
|
+
if (geometry.type === 'MultiPolygon') {
|
|
53
|
+
return geometry.coordinates.some((rings) => inPolygon(x, y, rings));
|
|
54
|
+
}
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Feature with optional precomputed bbox. */
|
|
59
|
+
export function inFeature(x, y, feature) {
|
|
60
|
+
if (feature.bbox && !inBbox(x, y, feature.bbox)) return false;
|
|
61
|
+
return inGeometry(x, y, feature.geometry);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Compute a [minX, minY, maxX, maxY] bbox for a Polygon/MultiPolygon. */
|
|
65
|
+
export function bboxOf(geometry) {
|
|
66
|
+
const b = [Infinity, Infinity, -Infinity, -Infinity];
|
|
67
|
+
const polys = geometry.type === 'Polygon' ? [geometry.coordinates] : geometry.coordinates;
|
|
68
|
+
for (const rings of polys) {
|
|
69
|
+
for (const [x, y] of rings[0]) {
|
|
70
|
+
if (x < b[0]) b[0] = x;
|
|
71
|
+
if (y < b[1]) b[1] = y;
|
|
72
|
+
if (x > b[2]) b[2] = x;
|
|
73
|
+
if (y > b[3]) b[3] = y;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return b;
|
|
77
|
+
}
|