@bitbaum/geo-kit 0.0.0-stage → 0.1.1

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.
@@ -0,0 +1,270 @@
1
+ import { GeographyError } from "./errors.js";
2
+ import { fetchGeographyBytes, publicDataUrl } from "./http.js";
3
+ import { intersectsGeographyBounds, isWgs84Bounds } from "./spatial.js";
4
+ import { isISODate, isPlainObject, periodContains, validateGeographyManifest, validateGeometry } from "./validation.js";
5
+ export function selectGeographyResources(manifest, request) {
6
+ const manifestProblems = validateGeographyManifest(manifest);
7
+ if (manifestProblems.length) {
8
+ throw new GeographyError("invalid_manifest", manifestProblems.slice(0, 8).join("; "));
9
+ }
10
+ if (!isISODate(request.asOf))
11
+ throw new GeographyError("invalid_manifest", "asOf must be a real ISO date");
12
+ if (request.bbox !== undefined && !isWgs84Bounds(request.bbox)) {
13
+ throw new GeographyError("invalid_manifest", "bbox must contain WGS84 bounds in west/south/east/north order");
14
+ }
15
+ const geographyIds = new Set(request.geographyIds ?? []);
16
+ const resourceIds = new Set(request.resourceIds ?? []);
17
+ if (geographyIds.size === 0 && resourceIds.size === 0) {
18
+ throw new GeographyError("selection_required", "request at least one geographyId or resourceId");
19
+ }
20
+ const kindKeys = request.kindKeys ? new Set(request.kindKeys) : null;
21
+ const levelKeys = request.levelKeys ? new Set(request.levelKeys) : null;
22
+ if (request.zoom !== undefined && (!Number.isFinite(request.zoom) || request.zoom < 0 || request.zoom > 30)) {
23
+ throw new GeographyError("invalid_manifest", "zoom must be between 0 and 30");
24
+ }
25
+ const selected = manifest.resources.filter((resource) => {
26
+ if (resourceIds.size > 0 && !resourceIds.has(resource.id))
27
+ return false;
28
+ if (geographyIds.size > 0 && !geographyIds.has(resource.geographyId))
29
+ return false;
30
+ if (kindKeys && !kindKeys.has(resource.kindKey))
31
+ return false;
32
+ if (levelKeys && !levelKeys.has(resource.levelKey))
33
+ return false;
34
+ if (request.bbox && resource.bbox && !intersectsGeographyBounds(request.bbox, resource.bbox))
35
+ return false;
36
+ if (!periodContains(resource.validFrom, resource.validTo, request.asOf))
37
+ return false;
38
+ if (request.viewpointKey === undefined && resource.viewpointKey !== null)
39
+ return false;
40
+ if (request.viewpointKey !== undefined && request.viewpointKey !== "all" &&
41
+ resource.viewpointKey !== null && resource.viewpointKey !== request.viewpointKey)
42
+ return false;
43
+ if (request.zoom !== undefined &&
44
+ ((resource.minZoom !== undefined && request.zoom < resource.minZoom) ||
45
+ (resource.maxZoom !== undefined && request.zoom >= resource.maxZoom)))
46
+ return false;
47
+ return true;
48
+ });
49
+ const maxResources = request.maxResources ?? 128;
50
+ const maxBytes = request.maxBytes ?? 32 * 1024 * 1024;
51
+ if (!Number.isSafeInteger(maxResources) || maxResources < 1 || !Number.isSafeInteger(maxBytes) || maxBytes < 1) {
52
+ throw new GeographyError("invalid_manifest", "selection limits must be positive safe integers");
53
+ }
54
+ if (selected.length > maxResources) {
55
+ throw new GeographyError("selection_limit", `selection has ${selected.length} resources; limit is ${maxResources}`);
56
+ }
57
+ const total = selected.reduce((sum, resource) => sum + resource.byteSize, 0);
58
+ if (!Number.isSafeInteger(total) || total > maxBytes) {
59
+ throw new GeographyError("selection_limit", `selection is ${total} bytes; limit is ${maxBytes}`);
60
+ }
61
+ return selected.sort((a, b) => lexical(a.geographyId, b.geographyId) ||
62
+ lexical(a.kindKey, b.kindKey) ||
63
+ lexical(a.levelKey, b.levelKey) ||
64
+ lexical(a.id, b.id));
65
+ }
66
+ function lexical(a, b) {
67
+ return a < b ? -1 : a > b ? 1 : 0;
68
+ }
69
+ /** Verify the declared digest, exact decoded size, JSON shape, and feature count. */
70
+ export async function verifyGeographyResource(resource, bytes) {
71
+ if (bytes.byteLength !== resource.byteSize) {
72
+ throw new GeographyError("invalid_resource", `resource ${resource.id} has ${bytes.byteLength} bytes; expected ${resource.byteSize}`);
73
+ }
74
+ const digest = await sha256Hex(bytes);
75
+ if (digest !== resource.sha256.toLowerCase()) {
76
+ throw new GeographyError("invalid_resource", `resource ${resource.id} failed its SHA-256 check`);
77
+ }
78
+ let decoded;
79
+ try {
80
+ decoded = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
81
+ }
82
+ catch {
83
+ throw new GeographyError("invalid_resource", `resource ${resource.id} is not valid UTF-8 JSON`);
84
+ }
85
+ const problems = resource.format === "geojson"
86
+ ? validateFeatureCollection(decoded)
87
+ : validateTopology(decoded);
88
+ if (problems.length) {
89
+ throw new GeographyError("invalid_resource", `resource ${resource.id}: ${problems.slice(0, 8).join("; ")}`);
90
+ }
91
+ const actualCount = resource.format === "geojson"
92
+ ? decoded.features.length
93
+ : countTopoFeatures(decoded);
94
+ if (actualCount !== resource.featureCount) {
95
+ throw new GeographyError("invalid_resource", `resource ${resource.id} has ${actualCount} features; expected ${resource.featureCount}`);
96
+ }
97
+ return decoded;
98
+ }
99
+ function validateFeatureCollection(value) {
100
+ if (!isPlainObject(value) || value.type !== "FeatureCollection" || !Array.isArray(value.features)) {
101
+ return ["expected a GeoJSON FeatureCollection"];
102
+ }
103
+ const problems = [];
104
+ for (const [i, feature] of value.features.entries()) {
105
+ if (!isPlainObject(feature) || feature.type !== "Feature" || !("geometry" in feature)) {
106
+ problems.push(`features[${i}] is not a GeoJSON Feature`);
107
+ }
108
+ else if (feature.geometry === null) {
109
+ problems.push(`features[${i}] has no geometry`);
110
+ }
111
+ else {
112
+ problems.push(...validateGeometry(feature.geometry, 2_000_000).map((problem) => `features[${i}].${problem}`));
113
+ }
114
+ if (isPlainObject(feature) && feature.properties !== null && !isPlainObject(feature.properties)) {
115
+ problems.push(`features[${i}].properties must be an object or null`);
116
+ }
117
+ if (problems.length >= 40)
118
+ break;
119
+ }
120
+ return problems;
121
+ }
122
+ function validateTopology(value) {
123
+ if (!isPlainObject(value) || value.type !== "Topology" || !Array.isArray(value.arcs) || !isPlainObject(value.objects)) {
124
+ return ["expected a TopoJSON Topology with arcs and objects"];
125
+ }
126
+ const topology = value;
127
+ const problems = [];
128
+ const transform = topology.transform;
129
+ const hasValidTransform = transform === undefined || (!isPlainObject(transform) || !Array.isArray(transform.scale) || !Array.isArray(transform.translate) ||
130
+ transform.scale.length !== 2 || transform.translate.length !== 2 ||
131
+ ![...transform.scale, ...transform.translate].every(Number.isFinite)) === false;
132
+ if (!hasValidTransform) {
133
+ problems.push("transform must contain finite two-dimensional scale and translate values");
134
+ }
135
+ for (const [i, rawArc] of topology.arcs.entries()) {
136
+ if (!Array.isArray(rawArc) || rawArc.length < 2) {
137
+ problems.push(`arcs[${i}] needs at least two positions`);
138
+ continue;
139
+ }
140
+ let x = 0;
141
+ let y = 0;
142
+ for (const [j, rawPosition] of rawArc.entries()) {
143
+ if (!Array.isArray(rawPosition) || rawPosition.length < 2 || !rawPosition.every(Number.isFinite)) {
144
+ problems.push(`arcs[${i}][${j}] must contain finite coordinates`);
145
+ break;
146
+ }
147
+ if (transform && hasValidTransform) {
148
+ x += rawPosition[0];
149
+ y += rawPosition[1];
150
+ const lon = x * transform.scale[0] + transform.translate[0];
151
+ const lat = y * transform.scale[1] + transform.translate[1];
152
+ if (lon < -180 || lon > 180 || lat < -90 || lat > 90) {
153
+ problems.push(`arcs[${i}][${j}] decodes outside WGS84 bounds`);
154
+ break;
155
+ }
156
+ }
157
+ else if (rawPosition[0] < -180 || rawPosition[0] > 180 || rawPosition[1] < -90 || rawPosition[1] > 90) {
158
+ problems.push(`arcs[${i}][${j}] is outside WGS84 bounds`);
159
+ break;
160
+ }
161
+ }
162
+ if (problems.length >= 40)
163
+ break;
164
+ }
165
+ const checkReferences = (geometry, path) => {
166
+ if (!isPlainObject(geometry)) {
167
+ problems.push(`${path} must be a geometry object`);
168
+ return;
169
+ }
170
+ if (geometry.type === "GeometryCollection") {
171
+ if (!Array.isArray(geometry.geometries))
172
+ problems.push(`${path}.geometries must be an array`);
173
+ else
174
+ geometry.geometries.forEach((child, i) => checkReferences(child, `${path}.geometries[${i}]`));
175
+ return;
176
+ }
177
+ if (["LineString", "MultiLineString", "Polygon", "MultiPolygon"].includes(String(geometry.type))) {
178
+ const references = [];
179
+ const flatten = (part) => {
180
+ if (typeof part === "number")
181
+ references.push(part);
182
+ else if (Array.isArray(part))
183
+ part.forEach(flatten);
184
+ };
185
+ flatten(geometry.arcs);
186
+ if (references.length === 0)
187
+ problems.push(`${path}.arcs must contain arc references`);
188
+ for (const ref of references) {
189
+ const index = ref >= 0 ? ref : ~ref;
190
+ if (!Number.isSafeInteger(ref) || index < 0 || index >= topology.arcs.length) {
191
+ problems.push(`${path} references a missing arc`);
192
+ break;
193
+ }
194
+ }
195
+ return;
196
+ }
197
+ if (["Point", "MultiPoint"].includes(String(geometry.type))) {
198
+ problems.push(...validateGeometry({ type: geometry.type, coordinates: geometry.coordinates }, 2_000_000).map((problem) => `${path}.${problem}`));
199
+ return;
200
+ }
201
+ problems.push(`${path}.type is unsupported`);
202
+ };
203
+ for (const [key, geometry] of Object.entries(topology.objects))
204
+ checkReferences(geometry, `objects.${key}`);
205
+ return problems.slice(0, 40);
206
+ }
207
+ function countTopoFeatures(topology) {
208
+ const count = (geometry) => {
209
+ if (!isPlainObject(geometry))
210
+ return 0;
211
+ if (geometry.type === "GeometryCollection" && Array.isArray(geometry.geometries)) {
212
+ return geometry.geometries.reduce((sum, child) => sum + count(child), 0);
213
+ }
214
+ return 1;
215
+ };
216
+ return Object.values(topology.objects).reduce((sum, object) => sum + count(object), 0);
217
+ }
218
+ export async function sha256Hex(bytes) {
219
+ if (!globalThis.crypto?.subtle)
220
+ throw new GeographyError("invalid_resource", "Web Crypto SHA-256 is unavailable");
221
+ const digest = await globalThis.crypto.subtle.digest("SHA-256", Uint8Array.from(bytes).buffer);
222
+ return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
223
+ }
224
+ /** Fetches selected public data without cookies; data resources may not redirect off-origin. */
225
+ export async function loadGeographyResources(manifest, request, options) {
226
+ options.signal?.throwIfAborted();
227
+ const problems = validateGeographyManifest(manifest, { licensePolicy: options.licensePolicy });
228
+ if (problems.length)
229
+ throw new GeographyError("invalid_manifest", problems.slice(0, 8).join("; "));
230
+ const selected = selectGeographyResources(manifest, request);
231
+ const maxConcurrent = options.maxConcurrent ?? 4;
232
+ if (!Number.isSafeInteger(maxConcurrent) || maxConcurrent < 1 || maxConcurrent > 16) {
233
+ throw new GeographyError("invalid_manifest", "maxConcurrent must be between 1 and 16");
234
+ }
235
+ publicDataUrl("/", options.baseUrl);
236
+ const results = new Array(selected.length);
237
+ let cursor = 0;
238
+ const worker = async () => {
239
+ while (true) {
240
+ options.signal?.throwIfAborted();
241
+ const index = cursor++;
242
+ if (index >= selected.length)
243
+ return;
244
+ const resource = selected[index];
245
+ let bytes = await options.cache?.get(resource.sha256) ?? null;
246
+ options.signal?.throwIfAborted();
247
+ const fromCache = bytes !== null;
248
+ if (bytes)
249
+ bytes = bytes.slice();
250
+ if (!bytes) {
251
+ bytes = await fetchGeographyBytes(resource.href, resource.byteSize, options, `resource ${resource.id}`);
252
+ }
253
+ const data = await verifyGeographyResource(resource, bytes);
254
+ options.signal?.throwIfAborted();
255
+ if (!fromCache)
256
+ await options.cache?.put(resource.sha256, bytes.slice());
257
+ results[index] = { resource, data, bytes };
258
+ }
259
+ };
260
+ await Promise.all(Array.from({ length: Math.min(maxConcurrent, selected.length) }, () => worker()));
261
+ return results;
262
+ }
263
+ /** Build an opaque, versioned geometry reference suitable for Solon's `areas.geometry_ref`. */
264
+ export function makeGeometryRef(input) {
265
+ const parts = [input.sourceId, input.datasetVersion, input.resourceId, String(input.featureId)];
266
+ if (parts.some((part) => !part || part.length > 256)) {
267
+ throw new GeographyError("invalid_resource", "geometry reference components must be non-empty and at most 256 characters");
268
+ }
269
+ return `geo:v1:${parts.map(encodeURIComponent).join(":")}`;
270
+ }
@@ -0,0 +1,4 @@
1
+ import type { GeographyBounds } from "./types.ts";
2
+ export declare function isWgs84Bounds(value: unknown): value is GeographyBounds;
3
+ /** Inclusive intersections: touching edges and either spelling of the date line overlap. */
4
+ export declare function intersectsGeographyBounds(a: GeographyBounds, b: GeographyBounds): boolean;
@@ -0,0 +1,21 @@
1
+ export function isWgs84Bounds(value) {
2
+ if (!Array.isArray(value) || value.length !== 4 || !value.every(Number.isFinite))
3
+ return false;
4
+ const [west, south, east, north] = value;
5
+ return west >= -180 && west <= 180 && east >= -180 && east <= 180 &&
6
+ south >= -90 && north <= 90 && south <= north;
7
+ }
8
+ /** Inclusive intersections: touching edges and either spelling of the date line overlap. */
9
+ export function intersectsGeographyBounds(a, b) {
10
+ if (a[1] > b[3] || b[1] > a[3])
11
+ return false;
12
+ const longitudeIntervals = ([west, , east]) => {
13
+ const intervals = west <= east ? [[west, east]] : [[west, 180], [-180, east]];
14
+ if (west === -180)
15
+ intervals.push([180, 180]);
16
+ if (east === 180)
17
+ intervals.push([-180, -180]);
18
+ return intervals;
19
+ };
20
+ return longitudeIntervals(a).some(([west, east]) => longitudeIntervals(b).some(([otherWest, otherEast]) => west <= otherEast && otherWest <= east));
21
+ }
@@ -0,0 +1,216 @@
1
+ /**
2
+ * @bitbaum/geo-kit
3
+ *
4
+ * Country-neutral wire contracts and client-side validation for public
5
+ * geography services. It does not own geography records, source policy,
6
+ * account data, or political conclusions. Solon owns public facts and their
7
+ * provenance; consumers ask for only the layers, dates, and viewpoints they
8
+ * need.
9
+ */
10
+ export declare const GEOGRAPHY_MANIFEST_VERSION: 1;
11
+ export type Position = readonly [longitude: number, latitude: number, ...extra: number[]];
12
+ export type LinearRing = readonly Position[];
13
+ export type BoundaryGeometry = {
14
+ type: "Polygon";
15
+ coordinates: readonly LinearRing[];
16
+ } | {
17
+ type: "MultiPolygon";
18
+ coordinates: readonly (readonly LinearRing[])[];
19
+ };
20
+ export type GeoJSONGeometry = BoundaryGeometry | {
21
+ type: "Point";
22
+ coordinates: Position;
23
+ } | {
24
+ type: "MultiPoint";
25
+ coordinates: readonly Position[];
26
+ } | {
27
+ type: "LineString";
28
+ coordinates: readonly Position[];
29
+ } | {
30
+ type: "MultiLineString";
31
+ coordinates: readonly (readonly Position[])[];
32
+ } | {
33
+ type: "GeometryCollection";
34
+ geometries: readonly GeoJSONGeometry[];
35
+ };
36
+ export type ResourceFormat = "geojson" | "topojson";
37
+ export type ISODate = string;
38
+ /** West > east represents a box crossing the antimeridian. */
39
+ export type GeographyBounds = readonly [west: number, south: number, east: number, north: number];
40
+ export interface GeographySource {
41
+ id: string;
42
+ publisher: string;
43
+ dataset: string;
44
+ url: string;
45
+ licenseSPDX: string;
46
+ retrievedAt: string;
47
+ attribution?: string;
48
+ }
49
+ /** A single immutable geometry file in a manifest. */
50
+ export interface GeographyResource {
51
+ id: string;
52
+ sourceIds: readonly string[];
53
+ /** An open key such as `administrative`, `economic`, `ecological`, or `custom`. */
54
+ kindKey: string;
55
+ /** Stable identifier for the geographic system or scope. */
56
+ geographyId: string;
57
+ /** Data-defined tier, such as `country`, `admin1`, or `municipality`. */
58
+ levelKey: string;
59
+ format: ResourceFormat;
60
+ /** Same-origin, root-relative URL. Redirects are rejected by the loader. */
61
+ href: string;
62
+ sha256: string;
63
+ byteSize: number;
64
+ featureCount: number;
65
+ validFrom: ISODate | null;
66
+ /** Exclusive upper bound. Null means no known end. */
67
+ validTo: ISODate | null;
68
+ /** Null means viewpoint-neutral source geometry. */
69
+ viewpointKey: string | null;
70
+ bbox?: GeographyBounds;
71
+ minZoom?: number;
72
+ /** Exclusive upper bound; null means no known maximum. */
73
+ maxZoom?: number;
74
+ }
75
+ export interface GeographyManifest {
76
+ schemaVersion: typeof GEOGRAPHY_MANIFEST_VERSION;
77
+ datasetVersion: string;
78
+ generatedAt: string;
79
+ sources: readonly GeographySource[];
80
+ viewpoints: readonly GeographyViewpoint[];
81
+ resources: readonly GeographyResource[];
82
+ }
83
+ /** A named account of the perspective represented by one or more resources. */
84
+ export interface GeographyViewpoint {
85
+ key: string;
86
+ label: string;
87
+ description: string;
88
+ sourceIds: readonly string[];
89
+ }
90
+ export interface LicenseRule {
91
+ /** License policy is owned by the application / governing organization. */
92
+ spdx: string;
93
+ requiresAttribution: boolean;
94
+ }
95
+ export interface ManifestValidationOptions {
96
+ licensePolicy?: readonly LicenseRule[];
97
+ maxResources?: number;
98
+ maxByteSize?: number;
99
+ }
100
+ export interface ResourceSelection {
101
+ /** At least one of these selectors is required to prevent accidental full loads. */
102
+ geographyIds?: readonly string[];
103
+ resourceIds?: readonly string[];
104
+ kindKeys?: readonly string[];
105
+ levelKeys?: readonly string[];
106
+ /** Required: callers choose the time explicitly; the library never reads the clock. */
107
+ asOf: ISODate;
108
+ /** Default selects only neutral geometry; an explicit key adds that viewpoint. `all` is opt-in. */
109
+ viewpointKey?: string | "all";
110
+ zoom?: number;
111
+ /** Only intersecting resources; resources without bounds remain eligible. */
112
+ bbox?: GeographyBounds;
113
+ maxBytes?: number;
114
+ maxResources?: number;
115
+ }
116
+ export type GeometryResourceData = GeoJSONFeatureCollection | Topology;
117
+ export interface GeoJSONFeature {
118
+ type: "Feature";
119
+ id?: string | number;
120
+ geometry: GeoJSONGeometry | null;
121
+ properties: Record<string, unknown> | null;
122
+ [key: string]: unknown;
123
+ }
124
+ export interface GeoJSONFeatureCollection {
125
+ type: "FeatureCollection";
126
+ features: GeoJSONFeature[];
127
+ [key: string]: unknown;
128
+ }
129
+ export interface Topology {
130
+ type: "Topology";
131
+ arcs: number[][][];
132
+ objects: Record<string, unknown>;
133
+ transform?: {
134
+ scale: [number, number];
135
+ translate: [number, number];
136
+ };
137
+ [key: string]: unknown;
138
+ }
139
+ export interface LoadedResource {
140
+ resource: GeographyResource;
141
+ data: GeometryResourceData;
142
+ bytes: Uint8Array;
143
+ }
144
+ export interface ResourceCache {
145
+ get(sha256: string): Promise<Uint8Array | null>;
146
+ put(sha256: string, bytes: Uint8Array): Promise<void>;
147
+ }
148
+ export interface LoadResourcesOptions {
149
+ baseUrl: string;
150
+ signal?: AbortSignal;
151
+ maxConcurrent?: number;
152
+ cache?: ResourceCache;
153
+ licensePolicy?: readonly LicenseRule[];
154
+ fetcher?: typeof fetch;
155
+ }
156
+ export interface LoadManifestOptions extends ManifestValidationOptions {
157
+ baseUrl: string;
158
+ signal?: AbortSignal;
159
+ fetcher?: typeof fetch;
160
+ /** Limit on decoded manifest bytes, enforced while streaming. Default: 64 KiB. */
161
+ maxBytes?: number;
162
+ }
163
+ export type AreaAssertionKind = "claims" | "administers" | "proposes";
164
+ /** Mirrors Solon's public `area_assertions` contract; it reports claims, not truth. */
165
+ export interface AreaAssertion {
166
+ areaId: string;
167
+ jurisdictionId: string;
168
+ assertion: AreaAssertionKind;
169
+ assertedById: string | null;
170
+ sourceId: string;
171
+ validFrom: ISODate | null;
172
+ validTo: ISODate | null;
173
+ }
174
+ export interface AreaPoliticalSummary {
175
+ claimants: readonly string[];
176
+ administrators: readonly string[];
177
+ proposers: readonly string[];
178
+ disputed: boolean;
179
+ unclaimed: boolean;
180
+ }
181
+ export interface BoundaryDraft {
182
+ /** Client-generated draft id; this is not a published geography id. */
183
+ id: string;
184
+ proposalId: string;
185
+ title: string;
186
+ geographyId: string;
187
+ kindKey: string;
188
+ geometry: BoundaryGeometry;
189
+ validFrom: ISODate | null;
190
+ validTo: ISODate | null;
191
+ source: {
192
+ publisher: string;
193
+ dataset: string;
194
+ licenseSPDX: string;
195
+ attribution?: string;
196
+ url?: string;
197
+ };
198
+ }
199
+ export interface BoundaryDraftValidationOptions {
200
+ licensePolicy: readonly LicenseRule[];
201
+ /** Full topology checks belong to a robust geometry engine supplied by the app. */
202
+ validateTopology?: (geometry: BoundaryGeometry) => {
203
+ valid: boolean;
204
+ problems?: readonly string[];
205
+ };
206
+ /** Must resolve to an approved proposal under the app's own governance workflow. */
207
+ proposalApproved?: (proposalId: string) => boolean;
208
+ maxVertices?: number;
209
+ maxBytes?: number;
210
+ }
211
+ export interface BoundaryDraftValidation {
212
+ problems: string[];
213
+ publishable: boolean;
214
+ vertexCount: number;
215
+ byteSize: number;
216
+ }
package/dist/types.js ADDED
@@ -0,0 +1,10 @@
1
+ /**
2
+ * @bitbaum/geo-kit
3
+ *
4
+ * Country-neutral wire contracts and client-side validation for public
5
+ * geography services. It does not own geography records, source policy,
6
+ * account data, or political conclusions. Solon owns public facts and their
7
+ * provenance; consumers ask for only the layers, dates, and viewpoints they
8
+ * need.
9
+ */
10
+ export const GEOGRAPHY_MANIFEST_VERSION = 1;
@@ -0,0 +1,17 @@
1
+ import type { ISODate, Position, ManifestValidationOptions } from "./types.ts";
2
+ export declare function isStableKey(value: unknown): value is string;
3
+ export declare function isISODate(value: unknown): value is ISODate;
4
+ export declare function isPlainObject(value: unknown): value is Record<string, unknown>;
5
+ export declare function validPeriod(from: unknown, to: unknown): boolean;
6
+ export declare function validUrl(value: unknown, protocols: readonly string[]): value is string;
7
+ export declare function safeResourceHref(value: unknown): value is string;
8
+ /** Validate a manifest without fetching anything. Licenses and their legal policy are supplied by the owner. */
9
+ export declare function validateGeographyManifest(value: unknown, options?: ManifestValidationOptions): string[];
10
+ export declare function periodContains(from: ISODate | null, to: ISODate | null, date: ISODate): boolean;
11
+ /** Resolve current/historical claims as recorded. No default viewpoint or legal status is inferred. */
12
+ /** Prevent malformed API records or unsourced claims from silently changing a map's dispute styling. */
13
+ export declare function validateAreaAssertions(value: unknown): string[];
14
+ /** Validate a GeoJSON geometry's coordinates and structural rules. */
15
+ export declare function validateGeometry(value: unknown, maxPositions?: number): string[];
16
+ /** True when a coordinate tuple is a finite WGS84 position (lon, lat, optional altitude). */
17
+ export declare function isWgs84Position(value: unknown): value is Position;