UNPKG

geos.js

Version:

an easy-to-use JavaScript wrapper over WebAssembly build of GEOS

751 lines (687 loc) 26.3 kB
/** * @file * # Geosify - GeoJSON* to GEOS conversion overview * * _*extended GeoJSON: with M ordinate support and curved geometries_ * * The main idea behind this custom GeoJSON integration is to improve * the efficiency of importing data into Wasm by reducing unnecessary copies * and memory allocations. The Wasm-side implementation of this integration * operates directly on the memory of created coordinate sequences, bypassing * GEOS C-API. The entire process of importing data from GeoJSON consists of * the following steps: * * ## 1. [JS] measure and validate GeoJSON geometries * Measure how big the buffer needs to be to include all necessary information * about a given set of geometries. Verify the validity of provided geometries * to simplify the error handling done by Wasm. * * ## 2. [JS] encode data of GeoJSON geometries * Encode the basic information about the geometries. The resulting buffer * consists of three parts `D`, `S` and `F`: * - `D` - a sequence of `u32` numbers which are a numerical recipe for the * creation of a given set of geometries.\ * A single geometry is defined by a sequence of `u32` numbers, starting with * a header specifying the type of geometry and its dimension (whether it has * a Z/M coordinate). The next numbers depend on the type of geometry. In the case * of LineString it will be the number of its points, and in the case of Polygon * it will be the number of its rings and the length of each ring. This is somewhat * similar to WKB but stores the `u32` and `f64` values in separate continuous * buffers that keep the data properly aligned, which speeds up writing and reading. * - `S` - a sequence of `u32` numbers which are the pointers to * `GEOSCoordSequence` underlying data * - `F` - a sequence of `f64` numbers which are the concatenated coordinates * of all (Multi)Point geometries * <pre> * // example of a buffer describing MultiPolygon, Point and LineString * D meta (u32) 10 -> number of records in `D` part * S meta (u32) 4 -> number of records in `S` part * D (u32) 6 -> geometry header - MultiPolygon XY [<type>0110 <isEmpty>0 <hasZ>0 <hasM>0] * D (u32) └─ 2 -> number of sub-polygons * D (u32) ├─ 2 -> number of rings in the first sub-polygon * D (u32) │ ├─ 4 -> number of coordinates in the first sub-polygon exterior ring * D (u32) │ └─ 6 -> number of coordinates in the first sub-polygon first interior ring * D (u32) └─ 1 -> number of rings in the second sub-polygon * D (u32) └─ 5 -> number of coordinates in the second sub-polygon exterior ring * D (u32) 0 -> geometry header - Point XY [<type>0000 <isEmpty>0 <hasZ>0 <hasM>0] * D (u32) 33 -> geometry header - LineString XYZ [<type>0001 <isEmpty>0 <hasZ>1 <hasM>0] * D (u32) └─ 8 -> number of coordinates * S (u32) blank * S (u32) blank * S (u32) blank * S (u32) blank * F (f64) some x -> point x coordinate * F (f64) some y -> point y coordinate * </pre> * * ## 3. [Wasm] create blank instances of `GEOSCoordSequence` * `geosify_geomsCoords` initializes `GEOSCoordSequence` with the specified dimensions. * <pre> * // continuation of the above example, `geosify_geomsCoords` will modify `S` and partially `D` * D (u32) 6 * D (u32) └─ 2 * D (u32) ├─ 2 * D *(u32) │ ├─ *GEOSCoordSequence -> pointer to the GEOSCoordSequence XY*4 * D *(u32) │ └─ *GEOSCoordSequence -> CS XY*6 * D (u32) └─ 1 * D *(u32) └─ *GEOSCoordSequence -> CS XY*5 * D (u32) 0 * D (u32) 33 * D *(u32) └─ *GEOSCoordSequence -> CS XYZ*8 * S *(u32) *f64 -> pointer to the data of GEOSCoordSequence XY*4 * S *(u32) *f64 -> CS XY*6 * S *(u32) *f64 -> CS XY*5 * S *(u32) *f64 -> CS XYZ*8 * </pre> * * ## 4. [JS] populate blank `GEOSCoordSequence` with data of GeoJSON geometries directly * From the `S` part JS-side knows the addresses of the newly created coordinate * sequences in Wasm memory, and can set their state without additional copies. * * ## 5. [Wasm] create instances of `GEOSGeometry` * Based on the `D` part of the buffer and the already populated `GEOSCoordSequence`s, * `geosify_geoms` creates corresponding `GEOSGeometry` instances. * <pre> * // continuation of the above example, `D` part is replaced by a list of `GEOSGeometry` pointers * D *(u32) *GEOSGeometry -> pointer to the created GEOS MultiPolygon * D *(u32) *GEOSGeometry -> pointer to the created GEOS Point * D *(u32) *GEOSGeometry -> pointer to the created GEOS LineString * </pre> * * ## 6. [JS] retrieve pointers of `GEOSGeometry` */ declare const THIS_FILE: symbol; // to omit ^ @file doc from the bundle import type { LineString as GeoJSON_LineString, Position } from 'geojson'; import type { JSON_CircularString, JSON_CompoundCurve, JSON_Feature, JSON_Geometry } from '../geom/types/JSON.mjs'; import type { GEOSGeometry, Ptr } from '../core/types/WasmGEOS.mjs'; import { POINTER } from '../core/symbols.mjs'; import { CollectionElementsKeyMap, type CoordinateType, type Geometry, type GeometryExtras, GeometryRef, type GeometryType, GEOSGeometryTypeDecoder, GEOSGeomTypeIdMap } from '../geom/Geometry.mjs'; import { GEOSError } from '../core/GEOSError.mjs'; import { geos } from '../core/geos.mjs'; interface InputCoordsOptions { /** needed places in `F` for a single point */ L: (l: number | undefined) => number; /** encode geometry header */ H: (type: GeometryType, pt: Position | undefined) => number; /** encode point coordinates */ P: (pt: Position, F: Float64Array, f: number, l: number | undefined) => number; /** populate coordinate sequence */ C: (pts: Position[], F: Float64Array, f: number, l: number | undefined) => void; } const CoordsOptionsMap: Record<CoordinateType, InputCoordsOptions> = { XY: { L: () => 2, H: (t) => GEOSGeomTypeIdMap[ t ], P: (pt, F, f) => { F[ f++ ] = pt[ 0 ]; F[ f++ ] = pt[ 1 ]; return f; }, C: (pts, F, f) => { for (const pt of pts) { F[ f++ ] = pt[ 0 ]; F[ f++ ] = pt[ 1 ]; F[ f++ ] = NaN; } }, }, XYZ: { L: (l) => l! > 2 ? 3 : 2, H: (t, pt) => { const l = pt?.length; return GEOSGeomTypeIdMap[ t ] | (l! > 2 ? 32 : 0); }, P: (pt, F, f, l) => { F[ f++ ] = pt[ 0 ]; F[ f++ ] = pt[ 1 ]; if (l! > 2) { F[ f++ ] = pt[ 2 ]; } return f; }, C: (pts, F, f, l) => { const hasZ = l! > 2; for (const pt of pts) { F[ f++ ] = pt[ 0 ]; F[ f++ ] = pt[ 1 ]; F[ f++ ] = hasZ ? pt[ 2 ] : NaN; } }, }, XYZM: { L: (l) => l! > 2 ? l! > 3 ? 4 : 3 : 2, H: (t, pt) => { const l = pt?.length; return GEOSGeomTypeIdMap[ t ] | (l! > 2 ? 32 : 0) | (l! > 3 ? 64 : 0); }, P: (pt, F, f, l) => { F[ f++ ] = pt[ 0 ]; F[ f++ ] = pt[ 1 ]; if (l! > 2) { F[ f++ ] = pt[ 2 ]; if (l! > 3) { F[ f++ ] = pt[ 3 ]; } } return f; }, C: (pts, F, f, l) => { const hasZ = l! > 2; const hasM = l! > 3; for (const pt of pts) { F[ f++ ] = pt[ 0 ]; F[ f++ ] = pt[ 1 ]; F[ f++ ] = hasZ ? pt[ 2 ] : NaN; if (hasM) { F[ f++ ] = pt[ 3 ]; } } }, }, XYM: { L: (l) => l! > 2 ? 4 : 2, H: (t, pt) => { const l = pt?.length; return GEOSGeomTypeIdMap[ t ] | (l! > 2 ? 64 : 0); }, P: (pt, F, f, l) => { F[ f++ ] = pt[ 0 ]; F[ f++ ] = pt[ 1 ]; if (l! > 2) { F[ f++ ] = NaN; F[ f++ ] = pt[ 2 ]; } return f; }, C: (pts, F, f, l) => { const hasM = l! > 2; for (const pt of pts) { F[ f++ ] = pt[ 0 ]; F[ f++ ] = pt[ 1 ]; F[ f++ ] = NaN; if (hasM) { F[ f++ ] = pt[ 2 ]; } } }, }, }; /* **************************************** * 1) Measure and validate **************************************** */ export class InvalidGeoJSONError extends GEOSError { /** Invalid geometry */ geometry: unknown; /** More detailed error reason */ details?: string; /** @internal */ constructor(geom: JSON_Geometry, message: string, details?: string) { super(message); this.name = 'InvalidGeoJSONError'; this.details = details; this.geometry = geom; } } const ptsTooFewError = (geom: JSON_Geometry, limit: number, ptsLength: number, name: string): InvalidGeoJSONError => ( new InvalidGeoJSONError(geom, `${name} must have at leat ${limit} points`, `found ${ptsLength}`, ) ); const ptsDifferError = (geom: JSON_Geometry, a: Position, b: Position, ringOwner?: string): InvalidGeoJSONError => ( new InvalidGeoJSONError(geom, ringOwner ? `${ringOwner} ring must be closed` : `${geom.type} segments must be continuous`, `points [${a.join()}] and [${b.join()}] are not equal`, ) ); const wrongTypeError = (geom: JSON_Geometry, actual: number, allowed: number[], partName = 'component'): InvalidGeoJSONError => ( new InvalidGeoJSONError(geom, `${geom.type} ${partName} must be ${allowed.map(id => GEOSGeometryTypeDecoder[ id ]).join(', ').replace(/,( \w+)$/, ' or$1')}`, `"${GEOSGeometryTypeDecoder[ actual ]}" is not allowed`, ) ); interface GeosifyCounter { d: number; // number of records in `D` (geometry set data) s: number; // number of records in `S` (`GEOSCoordSequence` that need to be created) f: number; // number of records in `F` (embedded coordinates of (Multi)Points) } const ptsDiffer = (a: Position, b: Position): boolean | undefined => { if (a.length !== b.length) return true; for (let i = 0; i < a.length; i++) { if (a[ i ] !== b[ i ]) return true; } }; const validateLine = (geom: JSON_Geometry, pts: Position[]): void => { const ptsLength = pts.length; if (ptsLength === 1) { throw ptsTooFewError(geom, 2, ptsLength, 'LineString'); } }; const validatePoly = (geom: JSON_Geometry, ppts: Position[][]): void => { for (const pts of ppts) { const ptsLength = pts.length; if (ptsLength) { // could be empty if (ptsLength < 3) { throw ptsTooFewError(geom, 3, ptsLength, 'Polygon ring'); } if (ptsDiffer(pts[ 0 ], pts[ ptsLength - 1 ])) { throw ptsDifferError(geom, pts[ 0 ], pts[ ptsLength - 1 ], 'Polygon'); } } } }; const geosifyMeasureAndValidateGeom = (geom: JSON_Geometry, c: GeosifyCounter, o: InputCoordsOptions): number => { switch (geom?.type) { case 'Point': { const pt = geom.coordinates; const dim = o.L(pt.length); c.f += dim; c.d += 1; // [header] return 0; } case 'MultiPoint' : { const pts = geom.coordinates; const dim = o.L(pts[ 0 ]?.length); c.f += pts.length * dim; c.d += 2; // [header][numPoints] return 4; } case 'LineString': { validateLine(geom, geom.coordinates); c.s += 1; // [cs->data] c.d += 2; // [header][cs->size/ptr] return 1; } case 'Polygon': { const ppts = geom.coordinates; const pptsLength = ppts.length; validatePoly(geom, ppts); c.s += pptsLength; // [R1:cs->data]…[RN:cs->data] c.d += 2 + pptsLength; // [header][numRings] [R1:cs->size/ptr]…[RN:cs->size/ptr] return 3; } case 'MultiLineString': { const ppts = geom.coordinates; const pptsLength = ppts.length; for (const pts of ppts) { validateLine(geom, pts); } c.s += pptsLength; // [L1:cs->data]…[LN:cs->data] c.d += 2 + pptsLength; // [header][numLines] [L1:cs->size/ptr]…[LN:cs->size/ptr] return 5; } case 'MultiPolygon': { const pppts = geom.coordinates; c.d += 2 + pppts.length; // [header][numPolygons] [P1:numRings]…[PN:numRings] for (const ppts of pppts) { const pptsLength = ppts.length; validatePoly(geom, ppts); c.s += pptsLength; // [R1:cs->data]…[RN:cs->data] c.d += pptsLength; // [R1:cs->size/ptr]…[RN:cs->size/ptr] } return 6; } case 'GeometryCollection': { const geoms = geom.geometries; for (const g of geoms) { geosifyMeasureAndValidateGeom(g, c, o); } c.d += 2; // [header][numGeometries] return 7; } case 'CircularString': { const ptsLength = geom.coordinates.length; if (ptsLength) { // could be empty if (ptsLength < 3) { throw new InvalidGeoJSONError(geom, `${geom.type} must have at least one circular arc defined by 3 points`); } if (!(ptsLength % 2)) { throw new InvalidGeoJSONError(geom, `${geom.type} must have and odd number of points`); } } c.s += 1; // [cs->data] c.d += 2; // [header][cs->size/ptr] return 8; } case 'CompoundCurve': { const segments = geom.segments; if (segments.length) { let last: Position | undefined; for (const segment of segments) { const t = geosifyMeasureAndValidateGeom(segment, c, o); if (t !== 1 && t !== 8) { throw wrongTypeError(geom, t, [ 1, 8 ], 'segment'); } const pts = segment.coordinates; if (!pts.length) { throw new InvalidGeoJSONError(geom, `${geom.type} cannot contain empty segments`); } if (last && ptsDiffer(last, pts[ 0 ])) { throw ptsDifferError(geom, last, pts[ 0 ]); } last = pts[ pts.length - 1 ]; } } c.d += 2; // [header][numGeometries] return 9; } case 'CurvePolygon': { const rings = geom.rings; if (rings.length) { for (const ring of rings) { let pts: Position[], first: Position, last: Position; const t = geosifyMeasureAndValidateGeom(ring, c, o); if (t === 1 || t === 8) { pts = (ring as GeoJSON_LineString | JSON_CircularString).coordinates; first = pts[ 0 ]; last = pts[ pts.length - 1 ]; } else if (t === 9) { const segments = (ring as JSON_CompoundCurve).segments; first = segments[ 0 ].coordinates[ 0 ]; pts = segments[ segments.length - 1 ].coordinates; last = pts[ pts.length - 1 ]; } else { throw wrongTypeError(geom, t, [ 1, 8, 9 ], 'ring'); } if (first && last && ptsDiffer(first, last)) { // allow for empty rings like with standard polygons throw ptsDifferError(geom, first, last, geom.type); } // TODO each ring must have at least N points } } c.d += 2; // [header][numGeometries] return 10; } case 'MultiCurve': { const geoms = geom.curves; for (const g of geoms) { const t = geosifyMeasureAndValidateGeom(g, c, o); if (t !== 1 && t !== 8 && t !== 9) { throw wrongTypeError(geom, t, [ 1, 8, 9 ]); } } c.d += 2; // [header][numGeometries] return 11; } case 'MultiSurface': { const geoms = geom.surfaces; for (const g of geoms) { const t = geosifyMeasureAndValidateGeom(g, c, o); if (t !== 3 && t !== 10) { throw wrongTypeError(geom, t, [ 3, 10 ]); } } c.d += 2; // [header][numGeometries] return 12; } } throw new InvalidGeoJSONError(geom, 'Invalid geometry'); }; /* **************************************** * 2) Encode metadata **************************************** */ interface GeosifyEncodeState { B: Uint32Array; d: number; // `D` iterator F: Float64Array; f: number; // `F` iterator } const geosifyEncodeGeom = (geom: JSON_Geometry, s: GeosifyEncodeState, o: InputCoordsOptions): void => { const { B, F } = s; let { d, f } = s; // geom header = typeId | (+isEmpty << 4) | (+hasZ << 5) | (+hasM << 6); const type = geom.type; switch (type) { case 'Point': { const pt = geom.coordinates; if (pt.length) { B[ d++ ] = o.H(type, pt); f = o.P(pt, F, f, pt.length); } else { B[ d++ ] = 16; // 0 | (1 << 4) | (0 << 5) | (0 << 6) } break; } case 'MultiPoint': { const pts = geom.coordinates; const dim = pts[ 0 ]?.length; B[ d++ ] = o.H(type, pts[ 0 ]); B[ d++ ] = pts.length; for (const pt of pts) { f = o.P(pt, F, f, dim); } break; } case 'LineString': case 'CircularString': { const pts = geom.coordinates; B[ d++ ] = o.H(type, pts[ 0 ]); B[ d++ ] = pts.length; break; } case 'Polygon': case 'MultiLineString': { const ppts = geom.coordinates; B[ d++ ] = o.H(type, ppts[ 0 ]?.[ 0 ]); B[ d++ ] = ppts.length; for (const pts of ppts) { B[ d++ ] = pts.length; } break; } case 'MultiPolygon': { const pppts = geom.coordinates; B[ d++ ] = o.H(type, pppts[ 0 ]?.[ 0 ]?.[ 0 ]); B[ d++ ] = pppts.length; for (const ppts of pppts) { B[ d++ ] = ppts.length; for (const pts of ppts) { B[ d++ ] = pts.length; } } break; } case 'GeometryCollection': case 'CompoundCurve': case 'CurvePolygon': case 'MultiCurve': case 'MultiSurface': { const geoms = (geom as any)[ CollectionElementsKeyMap[ type ] ] as JSON_Geometry[]; B[ s.d++ ] = GEOSGeomTypeIdMap[ type ]; B[ s.d++ ] = geoms.length; for (const g of geoms) { geosifyEncodeGeom(g, s, o); } return; } } s.f = f; s.d = d; }; /* **************************************** * 4) Populate coordinate sequences **************************************** */ interface GeosifyPopulateState { B: Uint32Array; s: number; // `S` iterator F: Float64Array; } const geosifyPopulateGeom = (geom: JSON_Geometry, s: GeosifyPopulateState, o: InputCoordsOptions): void => { const { B, F } = s; switch (geom.type) { // Point & MultiPoint - skip case 'LineString': case 'CircularString': { const pts = geom.coordinates; o.C(pts, F, B[ s.s++ ], pts[ 0 ]?.length); break; } case 'Polygon': case 'MultiLineString': { const ppts = geom.coordinates; const dim = ppts[ 0 ]?.[ 0 ]?.length; for (const pts of ppts) { o.C(pts, F, B[ s.s++ ], dim); } break; } case 'MultiPolygon': { const pppts = geom.coordinates; const dim = pppts[ 0 ]?.[ 0 ]?.[ 0 ]?.length; for (const ppts of pppts) { for (const pts of ppts) { o.C(pts, F, B[ s.s++ ], dim); } } break; } case 'GeometryCollection': case 'CompoundCurve': case 'CurvePolygon': case 'MultiCurve': case 'MultiSurface': { const geoms = (geom as any)[ CollectionElementsKeyMap[ geom.type ] ] as JSON_Geometry[]; for (const g of geoms) { geosifyPopulateGeom(g, s, o); } } } }; /** * Creates a {@link Geometry} from GeoJSON geometry object. * * @param geojson - GeoJSON geometry object * @param layout - Input geometry coordinate layout * @param extras - Optional geometry extras * @returns A new geometry * @throws {InvalidGeoJSONError} on invalid GeoJSON geometry * * @example * const pt = geosifyGeometry({ * type: 'Point', * coordinates: [ 1, 1 ], * }, 'XYZM'); * const line = geosifyGeometry({ * type: 'LineString', * coordinates: [ [ 0, 0 ], [ 1, 1 ] ], * }, 'XYZM'); * const collection = geosifyGeometry({ * type: 'GeometryCollection', * geometries: [ * { type: 'Point', coordinates: [ 1, 1 ] }, * { type: 'LineString', coordinates: [ [ 0, 0 ], [ 1, 1 ] ] }, * ], * }, 'XYZM'); * pt.type; // 'Point' * line.type; // 'LineString' * collection.type; // 'GeometryCollection' */ export function geosifyGeometry<P>(geojson: JSON_Geometry, layout?: CoordinateType, extras?: GeometryExtras<P>): Geometry<P> { const o = CoordsOptionsMap[ layout || 'XYZM' ]; const c: GeosifyCounter = { d: 0, s: 0, f: 0 }; geosifyMeasureAndValidateGeom(geojson, c, o); const buff = geos.buffByL4(3 + c.d + c.s + c.f * 2); try { let B = geos.U32; let d = buff.i4, s: number, f: number; B[ d++ ] = c.d; B[ d++ ] = c.s; s = d + c.d; f = Math.ceil((s + c.s) / 2); const es: GeosifyEncodeState = { B, d, F: geos.F64, f }; geosifyEncodeGeom(geojson, es, o); if (c.s) { geos.geosify_geomsCoords(buff[ POINTER ]); const ps: GeosifyPopulateState = { B: geos.U32, s, F: geos.F64 }; geosifyPopulateGeom(geojson, ps, o); } geos.geosify_geoms(buff[ POINTER ]); B = geos.U32; return new GeometryRef( B[ d ] as Ptr<GEOSGeometry>, geojson.type, extras, ) as Geometry<P>; } finally { buff.freeIfTmp(); } } /** * Creates an array of {@link GeometryRef} from an array of GeoJSON feature objects. * * @param geojsons - Array of GeoJSON feature objects * @param layout - Input geometry coordinate layout * @returns An array of new geometries * @throws {InvalidGeoJSONError} on GeoJSON feature without geometry * @throws {InvalidGeoJSONError} on invalid GeoJSON geometry * * @example * const [ pt, line, collection ] = geosifyFeatures([ * { * type: 'Feature', * geometry: { type: 'Point', coordinates: [ 1, 1 ] }, * properties: null, * }, * { * type: 'Feature', * geometry: { type: 'LineString', coordinates: [ [ 0, 0 ], [ 1, 1 ] ] }, * properties: null, * }, * { * type: 'Feature', * geometry: { * type: 'GeometryCollection', * geometries: [ * { type: 'Point', coordinates: [ 1, 1 ] }, * { type: 'LineString', coordinates: [ [ 0, 0 ], [ 1, 1 ] ] }, * ], * }, * properties: null, * }, * ], 'XYZM'); * pt.type; // 'Point' * line.type; // 'LineString' * collection.type; // 'GeometryCollection' */ export function geosifyFeatures<P>(geojsons: JSON_Feature<JSON_Geometry, P>[], layout?: CoordinateType): Geometry<P>[] { const o = CoordsOptionsMap[ layout || 'XYZM' ]; const c: GeosifyCounter = { d: 0, s: 0, f: 0 }; for (const geom of geojsons) { geosifyMeasureAndValidateGeom(geom.geometry, c, o); } const buff = geos.buffByL4(3 + c.d + c.s + c.f * 2); try { let B = geos.U32; let d = buff.i4, s: number, f: number; B[ d++ ] = c.d; B[ d++ ] = c.s; s = d + c.d; f = Math.ceil((s + c.s) / 2); const es: GeosifyEncodeState = { B, d, F: geos.F64, f }; for (const geom of geojsons) { geosifyEncodeGeom(geom.geometry, es, o); } if (c.s) { geos.geosify_geomsCoords(buff[ POINTER ]); const ps: GeosifyPopulateState = { B: geos.U32, s, F: geos.F64 }; for (const geom of geojsons) { geosifyPopulateGeom(geom.geometry, ps, o); } } geos.geosify_geoms(buff[ POINTER ]); B = geos.U32; const geometriesLength = geojsons.length; const geosGeometries = Array<Geometry<P>>(geometriesLength); for (let i = 0; i < geometriesLength; i++) { const feature = geojsons[ i ]; geosGeometries[ i ] = new GeometryRef( B[ d++ ] as Ptr<GEOSGeometry>, feature.geometry.type, feature, ) as Geometry<P>; } return geosGeometries; } finally { buff.freeIfTmp(); } }