@loaders.gl/shapefile
Version:
Loader for the Shapefile Format
335 lines (302 loc) • 9.5 kB
text/typescript
// loaders.gl
// SPDX-License-Identifier: MIT
// Copyright (c) vis.gl contributors
// import type {Feature} from '@loaders.gl/gis';
import {LoaderContext, parseInBatchesFromContext, parseFromContext} from '@loaders.gl/loader-utils';
import {binaryToGeometry, transformGeoJsonCoords} from '@loaders.gl/gis';
import type {
BinaryGeometry,
Geometry,
ObjectRowTable,
ObjectRowTableBatch
} from '@loaders.gl/schema';
import {Proj4Projection} from '@math.gl/proj4';
import type {SHXOutput} from './parse-shx';
import type {SHPHeader} from './parse-shp-header';
import type {ShapefileLoaderOptions} from './types';
import {parseShx} from './parse-shx';
import {zipBatchIterators} from '../streaming/zip-batch-iterators';
import {SHPLoader} from '../../shp-loader';
import {DBFLoader} from '../../dbf-loader';
type Feature = any;
interface ShapefileOutput {
encoding?: string;
prj?: string;
shx?: SHXOutput;
header: SHPHeader;
data: object[];
}
/**
* Parsing of file in batches
*/
// eslint-disable-next-line max-statements, complexity
export async function* parseShapefileInBatches(
asyncIterator: AsyncIterable<ArrayBuffer> | Iterable<ArrayBuffer>,
options?: ShapefileLoaderOptions,
context?: LoaderContext
): AsyncIterable<ShapefileOutput> {
const {reproject = false, _targetCrs = 'WGS84'} = options?.gis || {};
const {shx, cpg, prj} = await loadShapefileSidecarFiles(options, context);
// parse geometries
const shapeIterable = await parseInBatchesFromContext(
asyncIterator,
SHPLoader,
options,
context!
);
const shapeIterator: AsyncIterator<any> =
shapeIterable[Symbol.asyncIterator]?.() || shapeIterable[Symbol.iterator]?.();
// parse properties
let propertyIterator: AsyncIterator<any> | null = null;
const dbfResponse = await context?.fetch(replaceExtension(context?.url || '', 'dbf'));
if (dbfResponse?.ok) {
const propertyIterable = await parseInBatchesFromContext(
dbfResponse,
DBFLoader,
{
...options,
dbf: {encoding: cpg || 'latin1'}
},
context!
);
propertyIterator =
propertyIterable[Symbol.asyncIterator]?.() || propertyIterable[Symbol.iterator]();
}
// When `options.metadata` is `true`, there's an extra initial `metadata`
// object before the iterator starts. zipBatchIterators expects to receive
// batches of Array objects, and will fail with non-iterable batches, so it's
// important to skip over the first batch.
let shapeHeader = (await shapeIterator.next()).value;
if (shapeHeader && shapeHeader.batchType === 'metadata') {
shapeHeader = (await shapeIterator.next()).value;
}
let dbfHeader: {batchType?: string} = {};
if (propertyIterator) {
dbfHeader = (await propertyIterator.next()).value;
if (dbfHeader && dbfHeader.batchType === 'metadata') {
dbfHeader = (await propertyIterator.next()).value;
}
}
const zippedIterator: AsyncIterator<ObjectRowTableBatch> = propertyIterator
? zipBatchIterators(shapeIterator, propertyIterator, 'object-row-table')
: shapeIterator;
const zippedBatchIterable: AsyncIterable<ObjectRowTableBatch> = {
[Symbol.asyncIterator]() {
return zippedIterator;
}
};
for await (const batch of zippedBatchIterable) {
let geometries: any;
let properties: any;
if (!propertyIterator) {
geometries = batch;
} else {
[geometries, properties] = batch.data;
}
const geojsonGeometries = parseGeometries(geometries);
let features = joinProperties(geojsonGeometries, properties);
if (reproject) {
// @ts-ignore
features = reprojectFeatures(features, prj, _targetCrs);
}
yield {
encoding: cpg,
prj,
shx,
header: shapeHeader,
data: features
};
}
}
/**
* Parse shapefile
*
* @param arrayBuffer
* @param options
* @param context
* @returns output of shapefile
*/
export async function parseShapefile(
arrayBuffer: ArrayBuffer,
options?: ShapefileLoaderOptions,
context?: LoaderContext
): Promise<ShapefileOutput> {
const {reproject = false, _targetCrs = 'WGS84'} = options?.gis || {};
const {shx, cpg, prj} = await loadShapefileSidecarFiles(options, context);
// parse geometries
const {header, geometries} = await parseFromContext(arrayBuffer, SHPLoader, options, context!); // {shp: shx}
const geojsonGeometries = parseGeometries(geometries);
// parse properties
let propertyTable: ObjectRowTable | undefined;
const dbfResponse = await context?.fetch(replaceExtension(context?.url!, 'dbf'));
if (dbfResponse?.ok) {
propertyTable = await parseFromContext(
dbfResponse as any,
DBFLoader,
{dbf: {shape: 'object-row-table', encoding: cpg || 'latin1'}},
context!
);
}
let features = joinProperties(geojsonGeometries, propertyTable?.data || []);
if (reproject) {
features = reprojectFeatures(features, prj, _targetCrs);
}
switch (options?.shapefile?.shape) {
case 'geojson-table':
return {
// @ts-expect-error
shape: 'geojson-table',
type: 'FeatureCollection',
encoding: cpg,
schema: propertyTable?.schema || {metadata: {}, fields: []},
prj,
shx,
header,
features
};
default:
return {
encoding: cpg,
prj,
shx,
header,
data: features
};
}
}
/**
* Parse geometries
*
* @param geometries
* @returns geometries as an array
*/
function parseGeometries(geometries: BinaryGeometry[]): Geometry[] {
const geojsonGeometries: any[] = [];
for (const geom of geometries) {
geojsonGeometries.push(binaryToGeometry(geom));
}
return geojsonGeometries;
}
/**
* Join properties and geometries into features
*
* @param geometries [description]
* @param properties [description]
* @return [description]
*/
function joinProperties(geometries: Geometry[], properties: object[]): Feature[] {
const features: Feature[] = [];
for (let i = 0; i < geometries.length; i++) {
const geometry = geometries[i];
const feature: Feature = {
type: 'Feature',
geometry,
// properties can be undefined if dbfResponse above was empty
properties: (properties && properties[i]) || {}
};
features.push(feature);
}
return features;
}
/**
* Reproject GeoJSON features to output CRS
*
* @param features parsed GeoJSON features
* @param sourceCrs source coordinate reference system
* @param targetCrs †arget coordinate reference system
* @return Reprojected Features
*/
function reprojectFeatures(features: Feature[], sourceCrs?: string, targetCrs?: string): Feature[] {
if (!sourceCrs && !targetCrs) {
return features;
}
const projection = new Proj4Projection({from: sourceCrs || 'WGS84', to: targetCrs || 'WGS84'});
return transformGeoJsonCoords(features, (coord) => projection.project(coord));
}
/**
*
* @param options
* @param context
* @returns Promise
*/
// eslint-disable-next-line max-statements
export async function loadShapefileSidecarFiles(
options?: ShapefileLoaderOptions,
context?: LoaderContext
): Promise<{
shx?: SHXOutput;
cpg?: string;
prj?: string;
}> {
// Attempt a parallel load of the small sidecar files
// @ts-ignore context must be defined
const {url, fetch} = context;
const shxPromise = fetch(replaceExtension(url, 'shx'));
const cpgPromise = fetch(replaceExtension(url, 'cpg'));
const prjPromise = fetch(replaceExtension(url, 'prj'));
await Promise.all([shxPromise, cpgPromise, prjPromise]);
let shx: SHXOutput | undefined;
let cpg: string | undefined;
let prj: string | undefined;
const shxResponse = await shxPromise;
if (shxResponse.ok) {
const arrayBuffer = await shxResponse.arrayBuffer();
shx = parseShx(arrayBuffer);
}
const cpgResponse = await cpgPromise;
if (cpgResponse.ok) {
cpg = await cpgResponse.text();
}
const prjResponse = await prjPromise;
if (prjResponse.ok) {
prj = await prjResponse.text();
}
return {
shx,
cpg,
prj
};
}
/**
* Replace the extension at the end of a path.
*
* Matches the case of new extension with the case of the original file extension,
* to increase the chance of finding files without firing off a request storm looking for various case combinations
*
* NOTE: Extensions can be both lower and uppercase
* per spec, extensions should be lower case, but that doesn't mean they always are. See:
* calvinmetcalf/shapefile-js#64, mapserver/mapserver#4712
* https://trac.osgeo.org/mapserver/ticket/166
*/
export function replaceExtension(url: string, newExtension: string): string {
const baseName = basename(url);
const extension = extname(url);
const isUpperCase = extension === extension.toUpperCase();
if (isUpperCase) {
newExtension = newExtension.toUpperCase();
}
return `${baseName}.${newExtension}`;
}
// NOTE - this gives the entire path minus extension (i.e. NOT same as path.basename)
/**
* @param url
* @returns string
*/
function basename(url: string): string {
const extIndex = url && url.lastIndexOf('.');
if (typeof extIndex === 'number') {
return extIndex >= 0 ? url.substr(0, extIndex) : '';
}
return extIndex;
}
/**
* @param url
* @returns string
*/
function extname(url: string): string {
const extIndex = url && url.lastIndexOf('.');
if (typeof extIndex === 'number') {
return extIndex >= 0 ? url.substr(extIndex + 1) : '';
}
return extIndex;
}