diff --git a/.github/workflows/dart-pub-publish-on-pr.yml b/.github/workflows/dart-pub-publish-on-pr.yml new file mode 100644 index 0000000..dbce0b1 --- /dev/null +++ b/.github/workflows/dart-pub-publish-on-pr.yml @@ -0,0 +1,31 @@ +name: Dart pub publish --dry-run, Publishing Preview for PRs +on: + pull_request: + branches: + - releases +jobs: + preview-publish: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + + - uses: dart-lang/setup-dart@v1 + with: + sdk: stable + + - name: Print Dart SDK version + run: dart --version + + - name: Install dependencies + run: dart pub get + + - name: Verify formatting + run: dart format --output=none --set-exit-if-changed . + + - name: Analyze project source + run: dart analyze + if: always() + + - name: Preview publish package (dry-run) + run: dart pub publish --dry-run diff --git a/.github/workflows/dart-pub-publish.yml b/.github/workflows/dart-pub-publish.yml new file mode 100644 index 0000000..77a70b7 --- /dev/null +++ b/.github/workflows/dart-pub-publish.yml @@ -0,0 +1,49 @@ +name: Publish package to pub.dev +on: + push: + branches: + - releases +jobs: + publish: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + + - uses: dart-lang/setup-dart@v1 + with: + sdk: stable + + - name: Print Dart SDK version + run: dart --version + + - name: Install dependencies + run: dart pub get + + - name: Verify formatting + run: dart format --output=none --set-exit-if-changed . + + - name: Analyze project source + run: dart analyze + if: always() + + - name: Setup credentials + env: + OAUTH_ACCESS_TOKEN: ${{ secrets.OAUTH_ACCESS_TOKEN }} + OAUTH_REFRESH_TOKEN: ${{ secrets.OAUTH_REFRESH_TOKEN }} + OAUTH_EXPIRATION: ${{ secrets.OAUTH_EXPIRATION }} + run: | + mkdir -p ~/.pub-cache + cat < ~/.pub-cache/credentials.json + { + "accessToken":"${OAUTH_ACCESS_TOKEN}", + "refreshToken":"${OAUTH_REFRESH_TOKEN}", + "tokenEndpoint":"https://accounts.google.com/o/oauth2/token", + "scopes": [ "openid", "https://www.googleapis.com/auth/userinfo.email" ], + "expiration": ${OAUTH_EXPIRATION} + } + EOF + + - name: Publish package + run: dart pub publish --force + if: always() diff --git a/.github/workflows/dart-unit-tests-on-pr.yml b/.github/workflows/dart-unit-tests-on-pr.yml new file mode 100644 index 0000000..b6ca9dd --- /dev/null +++ b/.github/workflows/dart-unit-tests-on-pr.yml @@ -0,0 +1,39 @@ +name: Dart Unit Tests for PRs + +on: + pull_request: + branches: ["**"] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + + - uses: dart-lang/setup-dart@v1 + with: + sdk: stable + + - name: Print Dart SDK version + run: dart --version + + - name: Install dependencies + run: dart pub get + + - name: Verify formatting + run: dart format --output=none --set-exit-if-changed . + + - name: Analyze project source + run: dart analyze + if: always() + + - name: Run tests with coverage enabled + run: dart test --coverage=./coverage + if: always() + + - name: Archive raw coverage artifacts + if: always() + uses: actions/upload-artifact@v2 + with: + name: raw-coverage + path: ./coverage diff --git a/.github/workflows/dart-unit-tests.yml b/.github/workflows/dart-unit-tests.yml new file mode 100644 index 0000000..a2b8835 --- /dev/null +++ b/.github/workflows/dart-unit-tests.yml @@ -0,0 +1,37 @@ +name: Dart Unit Tests + +on: + push: + branches: [main] + +jobs: + test: + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest] + sdk: [stable] + steps: + - uses: actions/checkout@v2 + + - uses: dart-lang/setup-dart@v1 + with: + sdk: ${{ matrix.sdk }} + + - name: Print Dart SDK version + run: dart --version + + - name: Install dependencies + run: dart pub get + + - name: Verify formatting + run: dart format --output=none --set-exit-if-changed . + + - name: Analyze project source + run: dart analyze + if: always() + + - name: Run tests + run: dart test + if: always() diff --git a/.github/workflows/pr-code-coverage-reporting.yml b/.github/workflows/pr-code-coverage-reporting.yml new file mode 100644 index 0000000..a632247 --- /dev/null +++ b/.github/workflows/pr-code-coverage-reporting.yml @@ -0,0 +1,58 @@ +name: Pull Request Code Coverage Reporting +on: + workflow_run: + workflows: ["Dart Unit Tests for PRs"] + types: [completed] + +jobs: + coverage-reporting: + if: > + ${{ github.event.workflow_run.event == 'pull_request' && + github.event.workflow_run.conclusion == 'success' }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + + - uses: dart-lang/setup-dart@v1 + with: + sdk: stable + + - name: Print Dart SDK version + run: dart --version + + - name: Install dependencies + run: dart pub get + + - name: Download raw coverage from tests workflow + uses: dawidd6/action-download-artifact@v2.16.0 + with: + workflow: dart-unit-tests-on-pr.yml + run_id: ${{ github.event.workflow_run.id }} + name: raw-coverage + path: ./coverage + + - name: Convert to LCOV report + run: | + dart pub global activate coverage + dart pub global run coverage:format_coverage --packages=.dart_tool/package_config.json --report-on=lib --lcov -o ./coverage/lcov.info -i ./coverage + + - name: Generate HTML coverage report + run: | + sudo apt install lcov + genhtml -o ./coverage/report ./coverage/lcov.info + + - name: Comment on PR with coverage + continue-on-error: true + uses: romeovs/lcov-reporter-action@v0.2.21 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + lcov-file: ./coverage/lcov.info + + - name: Archive coverage report + if: always() + uses: actions/upload-artifact@v2 + with: + name: coverage-report + path: | + ./coverage/report + ./coverage/lcov.info diff --git a/README.md b/README.md index ddae477..b6118c4 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # GeoTypes -A lightweight library for GeoJSON data types available dart and flutter. +A lightweight GeoJson library for dart and Flutter. -This library intentionally maintains a focused scope, enabling GeoTypes to serve as a foundational element for a wide range of geospatial applications. +This library provides a simple and efficient way to work with GeoJson data in Dart and Flutter. It is designed to be easy to use, and to provide a clean and consistent API for working with geospatial data. Therefore we intentionally maintains a limited scope, enabling GeoTypes to serve as a foundational element for a wide range of geospatial applications. To leverage advanced geospatial analysis features, explore [turf.dart](https://github.com/dartclub/turf_dart), a Dart adaptation of the widely-used [turf.js](https://github.com/Turfjs/turf) library. diff --git a/docs/rfc7946.md b/docs/rfc7946.md new file mode 100644 index 0000000..ee2267b --- /dev/null +++ b/docs/rfc7946.md @@ -0,0 +1,1228 @@ +# The GeoJSON Format +https://www.rfc-editor.org/rfc/rfc7946.txt + +Abstract + + GeoJSON is a geospatial data interchange format based on JavaScript + Object Notation (JSON). It defines several types of JSON objects and + the manner in which they are combined to represent data about + geographic features, their properties, and their spatial extents. + GeoJSON uses a geographic coordinate reference system, World Geodetic + System 1984, and units of decimal degrees. + +Status of This Memo + + This is an Internet Standards Track document. + + This document is a product of the Internet Engineering Task Force + (IETF). It represents the consensus of the IETF community. It has + received public review and has been approved for publication by the + Internet Engineering Steering Group (IESG). Further information on + Internet Standards is available in Section 2 of RFC 7841. + + Information about the current status of this document, any errata, + and how to provide feedback on it may be obtained at + http://www.rfc-editor.org/info/rfc7946. + +Copyright Notice + + Copyright (c) 2016 IETF Trust and the persons identified as the + document authors. All rights reserved. + + This document is subject to BCP 78 and the IETF Trust's Legal + Provisions Relating to IETF Documents + (http://trustee.ietf.org/license-info) in effect on the date of + publication of this document. Please review these documents + carefully, as they describe your rights and restrictions with respect + to this document. Code Components extracted from this document must + include Simplified BSD License text as described in Section 4.e of + the Trust Legal Provisions and are provided without warranty as + described in the Simplified BSD License. + +Table of Contents + + 1. Introduction . . . . . . . . . . . . . . . . . . . . . . . . 3 + 1.1. Requirements Language . . . . . . . . . . . . . . . . . . 4 + 1.2. Conventions Used in This Document . . . . . . . . . . . . 4 + 1.3. Specification of GeoJSON . . . . . . . . . . . . . . . . 4 + 1.4. Definitions . . . . . . . . . . . . . . . . . . . . . . . 5 + 1.5. Example . . . . . . . . . . . . . . . . . . . . . . . . . 5 + 2. GeoJSON Text . . . . . . . . . . . . . . . . . . . . . . . . 6 + 3. GeoJSON Object . . . . . . . . . . . . . . . . . . . . . . . 6 + 3.1. Geometry Object . . . . . . . . . . . . . . . . . . . . . 7 + 3.1.1. Position . . . . . . . . . . . . . . . . . . . . . . 7 + 3.1.2. Point . . . . . . . . . . . . . . . . . . . . . . . . 8 + 3.1.3. MultiPoint . . . . . . . . . . . . . . . . . . . . . 8 + 3.1.4. LineString . . . . . . . . . . . . . . . . . . . . . 8 + 3.1.5. MultiLineString . . . . . . . . . . . . . . . . . . . 8 + 3.1.6. Polygon . . . . . . . . . . . . . . . . . . . . . . . 9 + 3.1.7. MultiPolygon . . . . . . . . . . . . . . . . . . . . 9 + 3.1.8. GeometryCollection . . . . . . . . . . . . . . . . . 9 + 3.1.9. Antimeridian Cutting . . . . . . . . . . . . . . . . 10 + 3.1.10. Uncertainty and Precision . . . . . . . . . . . . . . 11 + 3.2. Feature Object . . . . . . . . . . . . . . . . . . . . . 11 + 3.3. FeatureCollection Object . . . . . . . . . . . . . . . . 12 + 4. Coordinate Reference System . . . . . . . . . . . . . . . . . 12 + 5. Bounding Box . . . . . . . . . . . . . . . . . . . . . . . . 12 + 5.1. The Connecting Lines . . . . . . . . . . . . . . . . . . 14 + 5.2. The Antimeridian . . . . . . . . . . . . . . . . . . . . 14 + 5.3. The Poles . . . . . . . . . . . . . . . . . . . . . . . . 14 + 6. Extending GeoJSON . . . . . . . . . . . . . . . . . . . . . . 15 + 6.1. Foreign Members . . . . . . . . . . . . . . . . . . . . . 15 + 7. GeoJSON Types Are Not Extensible . . . . . . . . . . . . . . 16 + 7.1. Semantics of GeoJSON Members and Types Are Not Changeable 16 + 8. Versioning . . . . . . . . . . . . . . . . . . . . . . . . . 17 + 9. Mapping 'geo' URIs . . . . . . . . . . . . . . . . . . . . . 17 + 10. Security Considerations . . . . . . . . . . . . . . . . . . . 18 + 11. Interoperability Considerations . . . . . . . . . . . . . . . 18 + 11.1. I-JSON . . . . . . . . . . . . . . . . . . . . . . . . . 18 + 11.2. Coordinate Precision . . . . . . . . . . . . . . . . . . 18 + 12. IANA Considerations . . . . . . . . . . . . . . . . . . . . . 19 + 13. References . . . . . . . . . . . . . . . . . . . . . . . . . 20 + 13.1. Normative References . . . . . . . . . . . . . . . . . . 20 + 13.2. Informative References . . . . . . . . . . . . . . . . . 21 + Appendix A. Geometry Examples . . . . . . . . . . . . . . . . . 22 + A.1. Points . . . . . . . . . . . . . . . . . . . . . . . . . 22 + A.2. LineStrings . . . . . . . . . . . . . . . . . . . . . . . 22 + A.3. Polygons . . . . . . . . . . . . . . . . . . . . . . . . 23 + A.4. MultiPoints . . . . . . . . . . . . . . . . . . . . . . . 24 + A.5. MultiLineStrings . . . . . . . . . . . . . . . . . . . . 24 + A.6. MultiPolygons . . . . . . . . . . . . . . . . . . . . . . 25 + A.7. GeometryCollections . . . . . . . . . . . . . . . . . . . 26 + Appendix B. Changes from the Pre-IETF GeoJSON Format + Specification . . . . . . . . . . . . . . . . . . . 26 + B.1. Normative Changes . . . . . . . . . . . . . . . . . . . . 26 + B.2. Informative Changes . . . . . . . . . . . . . . . . . . . 27 + Appendix C. GeoJSON Text Sequences . . . . . . . . . . . . . . . 27 + Acknowledgements . . . . . . . . . . . . . . . . . . . . . . . . 27 + Authors' Addresses . . . . . . . . . . . . . . . . . . . . . . . 28 + +1. Introduction + + GeoJSON is a format for encoding a variety of geographic data + structures using JavaScript Object Notation (JSON) [RFC7159]. A + GeoJSON object may represent a region of space (a Geometry), a + spatially bounded entity (a Feature), or a list of Features (a + FeatureCollection). GeoJSON supports the following geometry types: + Point, LineString, Polygon, MultiPoint, MultiLineString, + MultiPolygon, and GeometryCollection. Features in GeoJSON contain a + Geometry object and additional properties, and a FeatureCollection + contains a list of Features. + + The format is concerned with geographic data in the broadest sense; + anything with qualities that are bounded in geographical space might + be a Feature whether or not it is a physical structure. The concepts + in GeoJSON are not new; they are derived from preexisting open + geographic information system standards and have been streamlined to + better suit web application development using JSON. + + GeoJSON comprises the seven concrete geometry types defined in the + OpenGIS Simple Features Implementation Specification for SQL [SFSQL]: + 0-dimensional Point and MultiPoint; 1-dimensional curve LineString + and MultiLineString; 2-dimensional surface Polygon and MultiPolygon; + and the heterogeneous GeometryCollection. GeoJSON representations of + instances of these geometry types are analogous to the well-known + binary (WKB) and well-known text (WKT) representations described in + that same specification. + + GeoJSON also comprises the types Feature and FeatureCollection. + Feature objects in GeoJSON contain a Geometry object with one of the + above geometry types and additional members. A FeatureCollection + object contains an array of Feature objects. This structure is + analogous to that of the Web Feature Service (WFS) response to + GetFeatures requests specified in [WFSv1] or to a Keyhole Markup + Language (KML) Folder of Placemarks [KMLv2.2]. Some implementations + of the WFS specification also provide GeoJSON-formatted responses to + GetFeature requests, but there is no particular service model or + Feature type ontology implied in the GeoJSON format specification. + + Since its initial publication in 2008 [GJ2008], the GeoJSON format + specification has steadily grown in popularity. It is widely used in + JavaScript web-mapping libraries, JSON-based document databases, and + web APIs. + +1.1. Requirements Language + + The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", + "SHOULD", "SHOULD NOT", "RECOMMENDED", "NOT RECOMMENDED", "MAY", and + "OPTIONAL" in this document are to be interpreted as described in + [RFC2119]. + +1.2. Conventions Used in This Document + + The ordering of the members of any JSON object defined in this + document MUST be considered irrelevant, as specified by [RFC7159]. + + Some examples use the combination of a JavaScript single-line comment + (//) followed by an ellipsis (...) as placeholder notation for + content deemed irrelevant by the authors. These placeholders must of + course be deleted or otherwise replaced, before attempting to + validate the corresponding JSON code example. + + Whitespace is used in the examples inside this document to help + illustrate the data structures, but it is not required. Unquoted + whitespace is not significant in JSON. + +1.3. Specification of GeoJSON + + This document supersedes the original GeoJSON format specification + [GJ2008]. + +1.4. Definitions + + o JavaScript Object Notation (JSON), and the terms object, member, + name, value, array, number, true, false, and null, are to be + interpreted as defined in [RFC7159]. + + o Inside this document, the term "geometry type" refers to seven + case-sensitive strings: "Point", "MultiPoint", "LineString", + "MultiLineString", "Polygon", "MultiPolygon", and + "GeometryCollection". + + o As another shorthand notation, the term "GeoJSON types" refers to + nine case-sensitive strings: "Feature", "FeatureCollection", and + the geometry types listed above. + + o The word "Collection" in "FeatureCollection" and + "GeometryCollection" does not have any significance for the + semantics of array members. The "features" and "geometries" + members, respectively, of these objects are standard ordered JSON + arrays, not unordered sets. + +1.5. Example + + A GeoJSON FeatureCollection: + +```json + { + "type": "FeatureCollection", + "features": [{ + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [102.0, 0.5] + }, + "properties": { + "prop0": "value0" + } + }, { + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [ + [102.0, 0.0], + [103.0, 1.0], + [104.0, 0.0], + [105.0, 1.0] + ] + }, + "properties": { + "prop0": "value0", + "prop1": 0.0 + } + }, { + "type": "Feature", + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [100.0, 0.0], + [101.0, 0.0], + [101.0, 1.0], + [100.0, 1.0], + [100.0, 0.0] + ] + ] + }, + "properties": { + "prop0": "value0", + "prop1": { + "this": "that" + } + } + }] + } +``` + +2. GeoJSON Text + + A GeoJSON text is a JSON text and consists of a single GeoJSON + object. + +3. GeoJSON Object + + A GeoJSON object represents a Geometry, Feature, or collection of + Features. + + o A GeoJSON object is a JSON object. + + o A GeoJSON object has a member with the name "type". The value of + the member MUST be one of the GeoJSON types. + + o A GeoJSON object MAY have a "bbox" member, the value of which MUST + be a bounding box array (see Section 5). + + o A GeoJSON object MAY have other members (see Section 6). + +3.1. Geometry Object + + A Geometry object represents points, curves, and surfaces in + coordinate space. Every Geometry object is a GeoJSON object no + matter where it occurs in a GeoJSON text. + + o The value of a Geometry object's "type" member MUST be one of the + seven geometry types (see Section 1.4). + + o A GeoJSON Geometry object of any type other than + "GeometryCollection" has a member with the name "coordinates". + The value of the "coordinates" member is an array. The structure + of the elements in this array is determined by the type of + geometry. GeoJSON processors MAY interpret Geometry objects with + empty "coordinates" arrays as null objects. + +3.1.1. Position + + A position is the fundamental geometry construct. The "coordinates" + member of a Geometry object is composed of either: + + o one position in the case of a Point geometry, + + o an array of positions in the case of a LineString or MultiPoint + geometry, + + o an array of LineString or linear ring (see Section 3.1.6) + coordinates in the case of a Polygon or MultiLineString geometry, + or + + o an array of Polygon coordinates in the case of a MultiPolygon + geometry. + + A position is an array of numbers. There MUST be two or more + elements. The first two elements are longitude and latitude, or + easting and northing, precisely in that order and using decimal + numbers. Altitude or elevation MAY be included as an optional third + element. + + Implementations SHOULD NOT extend positions beyond three elements + because the semantics of extra elements are unspecified and + ambiguous. Historically, some implementations have used a fourth + element to carry a linear referencing measure (sometimes denoted as + "M") or a numerical timestamp, but in most situations a parser will + not be able to properly interpret these values. The interpretation + and meaning of additional elements is beyond the scope of this + specification, and additional elements MAY be ignored by parsers. + A line between two positions is a straight Cartesian line, the + shortest line between those two points in the coordinate reference + system (see Section 4). + + In other words, every point on a line that does not cross the + antimeridian between a point (lon0, lat0) and (lon1, lat1) can be + calculated as + + F(lon, lat) = (lon0 + (lon1 - lon0) * t, lat0 + (lat1 - lat0) * t) + + with t being a real number greater than or equal to 0 and smaller + than or equal to 1. Note that this line may markedly differ from the + geodesic path along the curved surface of the reference ellipsoid. + + The same applies to the optional height element with the proviso that + the direction of the height is as specified in the coordinate + reference system. + + Note that, again, this does not mean that a surface with equal height + follows, for example, the curvature of a body of water. Nor is a + surface of equal height perpendicular to a plumb line. + + Examples of positions and geometries are provided in Appendix A, + "Geometry Examples". + +3.1.2. Point + + For type "Point", the "coordinates" member is a single position. + +3.1.3. MultiPoint + + For type "MultiPoint", the "coordinates" member is an array of + positions. + +3.1.4. LineString + + For type "LineString", the "coordinates" member is an array of two or + more positions. + +3.1.5. MultiLineString + + For type "MultiLineString", the "coordinates" member is an array of + LineString coordinate arrays. + +3.1.6. Polygon + + To specify a constraint specific to Polygons, it is useful to + introduce the concept of a linear ring: + + o A linear ring is a closed LineString with four or more positions. + + o The first and last positions are equivalent, and they MUST contain + identical values; their representation SHOULD also be identical. + + o A linear ring is the boundary of a surface or the boundary of a + hole in a surface. + + o A linear ring MUST follow the right-hand rule with respect to the + area it bounds, i.e., exterior rings are counterclockwise, and + holes are clockwise. + + Note: the [GJ2008] specification did not discuss linear ring winding + order. For backwards compatibility, parsers SHOULD NOT reject + Polygons that do not follow the right-hand rule. + + Though a linear ring is not explicitly represented as a GeoJSON + geometry type, it leads to a canonical formulation of the Polygon + geometry type definition as follows: + + o For type "Polygon", the "coordinates" member MUST be an array of + linear ring coordinate arrays. + + o For Polygons with more than one of these rings, the first MUST be + the exterior ring, and any others MUST be interior rings. The + exterior ring bounds the surface, and the interior rings (if + present) bound holes within the surface. + +3.1.7. MultiPolygon + + For type "MultiPolygon", the "coordinates" member is an array of + Polygon coordinate arrays. + +3.1.8. GeometryCollection + + A GeoJSON object with type "GeometryCollection" is a Geometry object. + A GeometryCollection has a member with the name "geometries". The + value of "geometries" is an array. Each element of this array is a + GeoJSON Geometry object. It is possible for this array to be empty. + + Unlike the other geometry types described above, a GeometryCollection + can be a heterogeneous composition of smaller Geometry objects. For + example, a Geometry object in the shape of a lowercase roman "i" can + be composed of one point and one LineString. + + GeometryCollections have a different syntax from single type Geometry + objects (Point, LineString, and Polygon) and homogeneously typed + multipart Geometry objects (MultiPoint, MultiLineString, and + MultiPolygon) but have no different semantics. Although a + GeometryCollection object has no "coordinates" member, it does have + coordinates: the coordinates of all its parts belong to the + collection. The "geometries" member of a GeometryCollection + describes the parts of this composition. Implementations SHOULD NOT + apply any additional semantics to the "geometries" array. + + To maximize interoperability, implementations SHOULD avoid nested + GeometryCollections. Furthermore, GeometryCollections composed of a + single part or a number of parts of a single type SHOULD be avoided + when that single part or a single object of multipart type + (MultiPoint, MultiLineString, or MultiPolygon) could be used instead. + +3.1.9. Antimeridian Cutting + + In representing Features that cross the antimeridian, + interoperability is improved by modifying their geometry. Any + geometry that crosses the antimeridian SHOULD be represented by + cutting it in two such that neither part's representation crosses the + antimeridian. + + For example, a line extending from 45 degrees N, 170 degrees E across + the antimeridian to 45 degrees N, 170 degrees W should be cut in two + and represented as a MultiLineString. + +```json + { + "type": "MultiLineString", + "coordinates": [ + [ + [170.0, 45.0], [180.0, 45.0] + ], [ + [-180.0, 45.0], [-170.0, 45.0] + ] + ] + } + ``` + + A rectangle extending from 40 degrees N, 170 degrees E across the + antimeridian to 50 degrees N, 170 degrees W should be cut in two and + represented as a MultiPolygon. + +```json + { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [180.0, 40.0], [180.0, 50.0], [170.0, 50.0], + [170.0, 40.0], [180.0, 40.0] + ] + ], + [ + [ + [-170.0, 40.0], [-170.0, 50.0], [-180.0, 50.0], + [-180.0, 40.0], [-170.0, 40.0] + ] + ] + ] + } +``` + +3.1.10. Uncertainty and Precision + + As in [RFC5870], the number of digits of the values in coordinate + positions MUST NOT be interpreted as an indication to the level of + uncertainty. + +3.2. Feature Object + + A Feature object represents a spatially bounded thing. Every Feature + object is a GeoJSON object no matter where it occurs in a GeoJSON + text. + + o A Feature object has a "type" member with the value "Feature". + + o A Feature object has a member with the name "geometry". The value + of the geometry member SHALL be either a Geometry object as + defined above or, in the case that the Feature is unlocated, a + JSON null value. + + o A Feature object has a member with the name "properties". The + value of the properties member is an object (any JSON object or a + JSON null value). + + o If a Feature has a commonly used identifier, that identifier + SHOULD be included as a member of the Feature object with the name + "id", and the value of this member is either a JSON string or + number. + +3.3. FeatureCollection Object + + A GeoJSON object with the type "FeatureCollection" is a + FeatureCollection object. A FeatureCollection object has a member + with the name "features". The value of "features" is a JSON array. + Each element of the array is a Feature object as defined above. It + is possible for this array to be empty. + +4. Coordinate Reference System + + The coordinate reference system for all GeoJSON coordinates is a + geographic coordinate reference system, using the World Geodetic + System 1984 (WGS 84) [WGS84] datum, with longitude and latitude units + of decimal degrees. This is equivalent to the coordinate reference + system identified by the Open Geospatial Consortium (OGC) URN + urn:ogc:def:crs:OGC::CRS84. An OPTIONAL third-position element SHALL + be the height in meters above or below the WGS 84 reference + ellipsoid. In the absence of elevation values, applications + sensitive to height or depth SHOULD interpret positions as being at + local ground or sea level. + + Note: the use of alternative coordinate reference systems was + specified in [GJ2008], but it has been removed from this version of + the specification because the use of different coordinate reference + systems -- especially in the manner specified in [GJ2008] -- has + proven to have interoperability issues. In general, GeoJSON + processing software is not expected to have access to coordinate + reference system databases or to have network access to coordinate + reference system transformation parameters. However, where all + involved parties have a prior arrangement, alternative coordinate + reference systems can be used without risk of data being + misinterpreted. + +5. Bounding Box + + A GeoJSON object MAY have a member named "bbox" to include + information on the coordinate range for its Geometries, Features, or + FeatureCollections. The value of the bbox member MUST be an array of + length `2*n` where n is the number of dimensions represented in the + contained geometries, with all axes of the most southwesterly point + followed by all axes of the more northeasterly point. The axes order + of a bbox follows the axes order of geometries. + + The "bbox" values define shapes with edges that follow lines of + constant longitude, latitude, and elevation. + + Example of a 2D bbox member on a Feature: +```json + { + "type": "Feature", + "bbox": [-10.0, -10.0, 10.0, 10.0], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-10.0, -10.0], + [10.0, -10.0], + [10.0, 10.0], + [-10.0, -10.0] + ] + ] + } + //... + }``` + + Example of a 2D bbox member on a FeatureCollection: +```json + { + "type": "FeatureCollection", + "bbox": [100.0, 0.0, 105.0, 1.0], + "features": [ + //... + ] + }``` + +Example of a 3D bbox member with a depth of 100 meters: +```json + { + "type": "FeatureCollection", + "bbox": [100.0, 0.0, -100.0, 105.0, 1.0, 0.0], + "features": [ + //... + ] + }``` + +5.1. The Connecting Lines + + The four lines of the bounding box are defined fully within the + coordinate reference system; that is, for a box bounded by the values + "west", "south", "east", and "north", every point on the northernmost + line can be expressed as + + `(lon, lat) = (west + (east - west) * t, north)` + + with `0 <= t <= 1`. + +5.2. The Antimeridian + + Consider a set of point Features within the Fiji archipelago, + straddling the antimeridian between 16 degrees S and 20 degrees S. + The southwest corner of the box containing these Features is at 20 + degrees S and 177 degrees E, and the northwest corner is at 16 + degrees S and 178 degrees W. The antimeridian-spanning GeoJSON + bounding box for this FeatureCollection is + + ```json + "bbox": [177.0, -20.0, -178.0, -16.0] + ``` + + and covers 5 degrees of longitude. + + The complementary bounding box for the same latitude band, not + crossing the antimeridian, is + +```json + "bbox": [-178.0, -20.0, 177.0, -16.0] +``` + + and covers 355 degrees of longitude. + + The latitude of the northeast corner is always greater than the + latitude of the southwest corner, but bounding boxes that cross the + antimeridian have a northeast corner longitude that is less than the + longitude of the southwest corner. + +5.3. The Poles + + A bounding box that contains the North Pole extends from a southwest + corner of "minlat" degrees N, 180 degrees W to a northeast corner of + 90 degrees N, 180 degrees E. Viewed on a globe, this bounding box + approximates a spherical cap bounded by the "minlat" circle of + latitude. + +```json + "bbox": [-180.0, minlat, 180.0, 90.0] +``` + + A bounding box that contains the South Pole extends from a southwest + corner of 90 degrees S, 180 degrees W to a northeast corner of + "maxlat" degrees S, 180 degrees E. + +```json + "bbox": [-180.0, -90.0, 180.0, maxlat] +``` + + A bounding box that just touches the North Pole and forms a slice of + an approximate spherical cap when viewed on a globe extends from a + southwest corner of "minlat" degrees N and "westlon" degrees E to a + northeast corner of 90 degrees N and "eastlon" degrees E. + +```json + "bbox": [westlon, minlat, eastlon, 90.0] +``` + + Similarly, a bounding box that just touches the South Pole and forms + a slice of an approximate spherical cap when viewed on a globe has + the following representation in GeoJSON. + +```json + "bbox": [westlon, -90.0, eastlon, maxlat] +``` + + Implementers MUST NOT use latitude values greater than 90 or less + than -90 to imply an extent that is not a spherical cap. + +6. Extending GeoJSON + +6.1. Foreign Members + + Members not described in this specification ("foreign members") MAY + be used in a GeoJSON document. Note that support for foreign members + can vary across implementations, and no normative processing model + for foreign members is defined. Accordingly, implementations that + rely too heavily on the use of foreign members might experience + reduced interoperability with other implementations. + + For example, in the (abridged) Feature object shown below + +```json + { + "type": "Feature", + "id": "f1", + "geometry": {...}, + "properties": {...}, + "title": "Example Feature" + } +``` + the name/value pair of "title": "Example Feature" is a foreign + member. When the value of a foreign member is an object, all the + descendant members of that object are themselves foreign members. + + GeoJSON semantics do not apply to foreign members and their + descendants, regardless of their names and values. For example, in + the (abridged) Feature object below + +```json + { + "type": "Feature", + "id": "f2", + "geometry": {...}, + "properties": {...}, + "centerline": { + "type": "LineString", + "coordinates": [ + [-170, 10], + [170, 11] + ] + } + } +``` + + the "centerline" member is not a GeoJSON Geometry object. + +7. GeoJSON Types Are Not Extensible + + Implementations MUST NOT extend the fixed set of GeoJSON types: + FeatureCollection, Feature, Point, LineString, MultiPoint, Polygon, + MultiLineString, MultiPolygon, and GeometryCollection. + +7.1. Semantics of GeoJSON Members and Types Are Not Changeable + + Implementations MUST NOT change the semantics of GeoJSON members and + types. + + The GeoJSON "coordinates" and "geometries" members define Geometry + objects. FeatureCollection and Feature objects, respectively, MUST + NOT contain a "coordinates" or "geometries" member. + + The GeoJSON "geometry" and "properties" members define a Feature + object. FeatureCollection and Geometry objects, respectively, MUST + NOT contain a "geometry" or "properties" member. + + The GeoJSON "features" member defines a FeatureCollection object. + Feature and Geometry objects, respectively, MUST NOT contain a + "features" member. + +8. Versioning + + The GeoJSON format can be extended as defined here, but no explicit + versioning scheme is defined. A specification that alters the + semantics of GeoJSON members or otherwise modifies the format does + not create a new version of this format; instead, it defines an + entirely new format that MUST NOT be identified as GeoJSON. + +9. Mapping 'geo' URIs + + 'geo' URIs [RFC5870] identify geographic locations and precise (not + uncertain) locations can be mapped to GeoJSON Geometry objects. + + For this section, as in [RFC5870], "lat", "lon", "alt", and "unc" are + placeholders for 'geo' URI latitude, longitude, altitude, and + uncertainty values, respectively. + + A 'geo' URI with two coordinates and an uncertainty ('u') parameter + that is absent or zero, and a GeoJSON Point geometry may be mapped to + each other. A GeoJSON Point is always converted to a 'geo' URI that + has no uncertainty parameter. + + 'geo' URI: +``` + geo:lat,lon +``` + GeoJSON: +```json + {"type": "Point", "coordinates": [lon, lat]} +``` + The mapping between 'geo' URIs and GeoJSON Points that specify + elevation is shown below. + + 'geo' URI: +``` + geo:lat,lon,alt +``` + GeoJSON: +```json + {"type": "Point", "coordinates": [lon, lat, alt]} +``` + GeoJSON has no concept of uncertainty; imprecise or uncertain 'geo' + URIs thus cannot be mapped to GeoJSON geometries. + +10. Security Considerations + + GeoJSON shares security issues common to all JSON content types. See + [RFC7159], Section 12 for additional information. GeoJSON does not + provide executable content. + + GeoJSON does not provide privacy or integrity services. If sensitive + data requires privacy or integrity protection, those must be provided + by the transport -- for example, Transport Layer Security (TLS) or + HTTPS. There will be cases in which stored data need protection, + which is out of scope for this document. + + As with other geographic data formats, e.g., [KMLv2.2], providing + details about the locations of sensitive persons, animals, habitats, + and facilities can expose them to unauthorized tracking or injury. + Data providers should recognize the risk of inadvertently identifying + individuals if locations in anonymized datasets are not adequately + skewed or not sufficiently fuzzed [Sweeney] and recognize that the + effectiveness of location obscuration is limited by a number of + factors and is unlikely to be an effective defense against a + determined attack [RFC6772]. + +11. Interoperability Considerations + +11.1. I-JSON + + GeoJSON texts should follow the constraints of Internet JSON (I-JSON) + [RFC7493] for maximum interoperability. + +11.2. Coordinate Precision + + The size of a GeoJSON text in bytes is a major interoperability + consideration, and precision of coordinate values has a large impact + on the size of texts. A GeoJSON text containing many detailed + Polygons can be inflated almost by a factor of two by increasing + coordinate precision from 6 to 15 decimal places. For geographic + coordinates with units of degrees, 6 decimal places (a default common + in, e.g., sprintf) amounts to about 10 centimeters, a precision well + within that of current GPS systems. Implementations should consider + the cost of using a greater precision than necessary. + + Furthermore, the WGS 84 [WGS84] datum is a relatively coarse + approximation of the geoid, with the height varying by up to 5 m (but + generally between 2 and 3 meters) higher or lower relative to a + surface parallel to Earth's mean sea level. + +12. IANA Considerations + + The media type for GeoJSON text is "application/geo+json" and is + registered in the "Media Types" registry described in [RFC6838]. The + entry for "application/vnd.geo+json" in the same registry should have + its status changed to be "OBSOLETED" with a pointer to the media type + "application/geo+json" and a reference added to this RFC. + + Type name: application + + Subtype name: geo+json + + Required parameters: n/a + + Optional parameters: n/a + + Encoding considerations: binary + + Security considerations: See Section 10 above + + Interoperability considerations: See Section 11 above + + Published specification: [[RFC7946]] + + Applications that use this media type: No known applications + currently use this media type. This media type is intended for + GeoJSON applications currently using the "application/ + vnd.geo+json" or "application/json" media types, of which there + are several categories: web mapping, geospatial databases, + geographic data processing APIs, data analysis and storage + services, and data dissemination. + + Additional information: + +- Magic number(s): n/a +- File extension(s): .json, .geojson +- Macintosh file type code: n/a +- Object Identifiers: n/a +- Windows clipboard name: GeoJSON +- Macintosh uniform type identifier: public.geojson conforms to public.json + + + Person to contact for further information: [Sean Gillies](sean.gillies@gmail.com) + + Intended usage: COMMON + + Restrictions on usage: none + + Restrictions on usage: none + + Author: see "Authors' Addresses" section of [[RFC7946]]. + + Change controller: Internet Engineering Task Force + +13. References + +13.1. Normative References + + [RFC2119] Bradner, S., "Key words for use in RFCs to Indicate + Requirement Levels", BCP 14, RFC 2119, + DOI 10.17487/RFC2119, March 1997, + . + + [RFC6838] Freed, N., Klensin, J., and T. Hansen, "Media Type + Specifications and Registration Procedures", BCP 13, + RFC 6838, DOI 10.17487/RFC6838, January 2013, + . + + [RFC7159] Bray, T., Ed., "The JavaScript Object Notation (JSON) Data + Interchange Format", RFC 7159, DOI 10.17487/RFC7159, March + 2014, . + + [RFC7493] Bray, T., Ed., "The I-JSON Message Format", RFC 7493, + DOI 10.17487/RFC7493, March 2015, + . + + [WGS84] National Imagery and Mapping Agency, "Department of + Defense World Geodetic System 1984: Its Definition and + Relationships with Local Geodetic Systems", Third Edition, + 1984. + +13.2. Informative References + + [GJ2008] Butler, H., Daly, M., Doyle, A., Gillies, S., Schaub, T., + and C. Schmidt, "The GeoJSON Format Specification", June + 2008. + + [KMLv2.2] Wilson, T., "OGC KML", OGC 07-147r2, Version 2.2.0, April + 2008. + + [RFC5870] Mayrhofer, A. and C. Spanring, "A Uniform Resource + Identifier for Geographic Locations ('geo' URI)", + RFC 5870, DOI 10.17487/RFC5870, June 2010, + . + + [RFC6772] Schulzrinne, H., Ed., Tschofenig, H., Ed., Cuellar, J., + Polk, J., Morris, J., and M. Thomson, "Geolocation Policy: + A Document Format for Expressing Privacy Preferences for + Location Information", RFC 6772, DOI 10.17487/RFC6772, + January 2013, . + + [RFC7464] Williams, N., "JavaScript Object Notation (JSON) Text + Sequences", RFC 7464, DOI 10.17487/RFC7464, February 2015, + . + + [SFSQL] OpenGIS Consortium, Inc., "OpenGIS Simple Features + Specification For SQL Revision 1.1", OGC 99-049, May 1999. + + [Sweeney] Sweeney, L., "k-anonymity: a model for protecting + privacy", International Journal on Uncertainty, Fuzziness + and Knowledge-based Systems 10 (5), 2002; 557-570, + DOI 10.1142/S0218488502001648, 2002. + + [WFSv1] Vretanos, P., "Web Feature Service Implementation + Specification", OGC 04-094, Version 1.1.0, May 2005. + + +Appendix A. Geometry Examples + + Each of the examples below represents a valid and complete GeoJSON + object. + +A.1. Points + + Point coordinates are in x, y order (easting, northing for projected + coordinates, longitude, and latitude for geographic coordinates): +```json + { + "type": "Point", + "coordinates": [100.0, 0.0] + } +``` +A.2. LineStrings + + Coordinates of LineString are an array of positions (see + Section 3.1.1): +```json + { + "type": "LineString", + "coordinates": [ + [100.0, 0.0], + [101.0, 1.0] + ] + } +``` + +A.3. Polygons + + Coordinates of a Polygon are an array of linear ring (see + Section 3.1.6) coordinate arrays. The first element in the array + represents the exterior ring. Any subsequent elements represent + interior rings (or holes). + + No holes: +```json + { + "type": "Polygon", + "coordinates": [ + [ + [100.0, 0.0], + [101.0, 0.0], + [101.0, 1.0], + [100.0, 1.0], + [100.0, 0.0] + ] + ] + } +``` + With holes: +```json + { + "type": "Polygon", + "coordinates": [ + [ + [100.0, 0.0], + [101.0, 0.0], + [101.0, 1.0], + [100.0, 1.0], + [100.0, 0.0] + ], + [ + [100.8, 0.8], + [100.8, 0.2], + [100.2, 0.2], + [100.2, 0.8], + [100.8, 0.8] + ] + ] + } +``` + + +A.4. MultiPoints + + Coordinates of a MultiPoint are an array of positions: +```json + { + "type": "MultiPoint", + "coordinates": [ + [100.0, 0.0], + [101.0, 1.0] + ] + } +``` +A.5. MultiLineStrings + + Coordinates of a MultiLineString are an array of LineString + coordinate arrays: +```json + { + "type": "MultiLineString", + "coordinates": [ + [ + [100.0, 0.0], + [101.0, 1.0] + ], + [ + [102.0, 2.0], + [103.0, 3.0] + ] + ] + } +``` + + +A.6. MultiPolygons + + Coordinates of a MultiPolygon are an array of Polygon coordinate + arrays: +```json + { + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [102.0, 2.0], + [103.0, 2.0], + [103.0, 3.0], + [102.0, 3.0], + [102.0, 2.0] + ] + ], + [ + [ + [100.0, 0.0], + [101.0, 0.0], + [101.0, 1.0], + [100.0, 1.0], + [100.0, 0.0] + ], + [ + [100.2, 0.2], + [100.2, 0.8], + [100.8, 0.8], + [100.8, 0.2], + [100.2, 0.2] + ] + ] + ] + } +``` + +A.7. GeometryCollections + + Each element in the "geometries" array of a GeometryCollection is one + of the Geometry objects described above: +```json + { + "type": "GeometryCollection", + "geometries": [{ + "type": "Point", + "coordinates": [100.0, 0.0] + }, { + "type": "LineString", + "coordinates": [ + [101.0, 0.0], + [102.0, 1.0] + ] + }] + } +``` + +Appendix B. Changes from the Pre-IETF GeoJSON Format Specification + + This appendix briefly summarizes non-editorial changes from the 2008 + specification [GJ2008]. + +B.1. Normative Changes + + o Specification of coordinate reference systems has been removed, + i.e., the "crs" member of [GJ2008] is no longer used. + + o In the absence of elevation values, applications sensitive to + height or depth SHOULD interpret positions as being at local + ground or sea level (see Section 4). + + o Implementations SHOULD NOT extend position arrays beyond 3 + elements (see Section 3.1.1). + + o A line between two positions is a straight Cartesian line (see + Section 3.1.1). + + o Polygon rings MUST follow the right-hand rule for orientation + (counterclockwise external rings, clockwise internal rings). + + o The values of a "bbox" array are "[west, south, east, north]", not + "[minx, miny, maxx, maxy]" (see Section 5). + + o A Feature object's "id" member is a string or number (see + Section 3.2). + + o Extensions MAY be used, but MUST NOT change the semantics of + GeoJSON members and types (see Section 6). + + o GeoJSON objects MUST NOT contain the defining members of other + types (see Section 7.1). + + o The media type for GeoJSON is "application/geo+json". + +B.2. Informative Changes + + o The definition of a GeoJSON text has been added. + + o Rules for mapping 'geo' URIs have been added. + + o A recommendation of the I-JSON [RFC7493] constraints has been + added. + + o Implementers are cautioned about the effect of excessive + coordinate precision on interoperability. + + o Interoperability concerns of GeometryCollections are noted. These + objects should be used sparingly (see Section 3.1.8). + +Appendix C. GeoJSON Text Sequences + + All GeoJSON objects defined in this specification -- + FeatureCollection, Feature, and Geometry -- consist of exactly one + JSON object. However, there may be circumstances in which + applications need to represent sets or sequences of these objects + (over and above the grouping of Feature objects in a + FeatureCollection), e.g., in order to efficiently "stream" large + numbers of Feature objects. The definition of such sets or sequences + is outside the scope of this specification. + + If such a representation is needed, a new media type is required that + has the ability to represent these sets or sequences. When defining + such a media type, it may be useful to base it on "JavaScript Object + Notation (JSON) Text Sequences" [RFC7464], leaving the foundations of + how to represent multiple JSON objects to that specification, and + only defining how it applies to GeoJSON objects. + +Acknowledgements + + The GeoJSON format is the product of discussion on the GeoJSON + mailing list, , before October 2015 and in the IETF's GeoJSON + WG after October 2015. + + Material in this document was adapted with changes from + [GJ2008], which is licensed + under . + +Authors' Addresses + + Howard Butler + Hobu Inc. + + Email: howard@hobu.co + + + Martin Daly + Cadcorp + + Email: martin.daly@cadcorp.com + + + Allan Doyle + + Email: adoyle@intl-interfaces.com + + + Sean Gillies + Mapbox + + Email: sean.gillies@gmail.com + URI: http://sgillies.net + + + Stefan Hagen + Rheinaustr. 62 + Bonn 53225 + Germany + + Email: stefan@hagen.link + URI: http://stefan-hagen.website/ + + + Tim Schaub + Planet Labs + + Email: tim.schaub@gmail.com diff --git a/lib/src/geojson.dart b/lib/src/geojson.dart index c967e74..c63e62a 100644 --- a/lib/src/geojson.dart +++ b/lib/src/geojson.dart @@ -1,29 +1,24 @@ -import 'package:json_annotation/json_annotation.dart'; +import 'package:geotypes/src/validation/geojson.dart'; +import 'package:geotypes/src/validation/validation_hint.dart'; part 'geojson.g.dart'; -@JsonEnum(alwaysCreate: true) enum GeoJSONObjectType { - @JsonValue('Point') point, - @JsonValue('MultiPoint') multiPoint, - @JsonValue('LineString') lineString, - @JsonValue('MultiLineString') multiLineString, - @JsonValue('Polygon') polygon, - @JsonValue('MultiPolygon') multiPolygon, - @JsonValue('GeometryCollection') geometryCollection, - @JsonValue('Feature') feature, - @JsonValue('FeatureCollection') featureCollection, } +extension GeoJSONObjectTypeExtensions on GeoJSONObjectType { + String get jsonValue => _$GeoJSONObjectTypeEnumMap[this]!; +} + abstract class GeoJSONObject { final GeoJSONObjectType type; BBox? bbox; @@ -434,10 +429,11 @@ abstract class GeometryType extends GeometryObject { } /// Point, as specified here https://tools.ietf.org/html/rfc7946#section-3.1.2 -@JsonSerializable(explicitToJson: true) class Point extends GeometryType { Point({BBox? bbox, required Position coordinates}) - : super.withType(coordinates, GeoJSONObjectType.point, bbox: bbox); + : super.withType(coordinates, GeoJSONObjectType.point, bbox: bbox) { + validateGeoJson(toJson()).check(); + } factory Point.fromJson(Map json) => _$PointFromJson(json); @@ -460,10 +456,11 @@ class Point extends GeometryType { } /// MultiPoint, as specified here https://tools.ietf.org/html/rfc7946#section-3.1.3 -@JsonSerializable(explicitToJson: true) class MultiPoint extends GeometryType> { MultiPoint({BBox? bbox, List coordinates = const []}) - : super.withType(coordinates, GeoJSONObjectType.multiPoint, bbox: bbox); + : super.withType(coordinates, GeoJSONObjectType.multiPoint, bbox: bbox) { + validateGeoJson(toJson()).check(); + } factory MultiPoint.fromJson(Map json) => _$MultiPointFromJson(json); @@ -485,10 +482,11 @@ class MultiPoint extends GeometryType> { } /// LineString, as specified here https://tools.ietf.org/html/rfc7946#section-3.1.4 -@JsonSerializable(explicitToJson: true) class LineString extends GeometryType> { LineString({BBox? bbox, List coordinates = const []}) - : super.withType(coordinates, GeoJSONObjectType.lineString, bbox: bbox); + : super.withType(coordinates, GeoJSONObjectType.lineString, bbox: bbox) { + validateGeoJson(toJson()).check(); + } factory LineString.fromJson(Map json) => _$LineStringFromJson(json); @@ -509,11 +507,12 @@ class LineString extends GeometryType> { } /// MultiLineString, as specified here https://tools.ietf.org/html/rfc7946#section-3.1.5 -@JsonSerializable(explicitToJson: true) class MultiLineString extends GeometryType>> { MultiLineString({BBox? bbox, List> coordinates = const []}) : super.withType(coordinates, GeoJSONObjectType.multiLineString, - bbox: bbox); + bbox: bbox) { + validateGeoJson(toJson()).check(); + } factory MultiLineString.fromJson(Map json) => _$MultiLineStringFromJson(json); @@ -538,10 +537,11 @@ class MultiLineString extends GeometryType>> { } /// Polygon, as specified here https://tools.ietf.org/html/rfc7946#section-3.1.6 -@JsonSerializable(explicitToJson: true) class Polygon extends GeometryType>> { Polygon({BBox? bbox, List> coordinates = const []}) - : super.withType(coordinates, GeoJSONObjectType.polygon, bbox: bbox); + : super.withType(coordinates, GeoJSONObjectType.polygon, bbox: bbox) { + validateGeoJson(toJson()).check(); + } factory Polygon.fromJson(Map json) => _$PolygonFromJson(json); @@ -565,10 +565,12 @@ class Polygon extends GeometryType>> { } /// MultiPolygon, as specified here https://tools.ietf.org/html/rfc7946#section-3.1.7 -@JsonSerializable(explicitToJson: true) class MultiPolygon extends GeometryType>>> { MultiPolygon({BBox? bbox, List>> coordinates = const []}) - : super.withType(coordinates, GeoJSONObjectType.multiPolygon, bbox: bbox); + : super.withType(coordinates, GeoJSONObjectType.multiPolygon, + bbox: bbox) { + validateGeoJson(toJson()).check(); + } factory MultiPolygon.fromJson(Map json) => _$MultiPolygonFromJson(json); @@ -592,25 +594,28 @@ class MultiPolygon extends GeometryType>>> { } /// GeometryCollection, as specified here https://tools.ietf.org/html/rfc7946#section-3.1.8 -@JsonSerializable(explicitToJson: true, createFactory: false) class GeometryCollection extends GeometryObject { List geometries; GeometryCollection({BBox? bbox, this.geometries = const []}) - : super.withType(GeoJSONObjectType.geometryCollection, bbox: bbox); - - factory GeometryCollection.fromJson(Map json) => - GeometryCollection( - bbox: json['bbox'] == null - ? null - : BBox.fromJson( - (json['bbox'] as List).map((e) => e as num).toList(), - ), - geometries: (json['geometries'] as List?) - ?.map((e) => GeometryType.deserialize(e)) - .toList() ?? - const [], - ); + : super.withType(GeoJSONObjectType.geometryCollection, bbox: bbox) { + validateGeoJson(toJson()).check(); + } + + factory GeometryCollection.fromJson(Map json) { + validateGeoJson(json).check(); + return GeometryCollection( + bbox: json['bbox'] == null + ? null + : BBox.fromJson( + (json['bbox'] as List).map((e) => e as num).toList(), + ), + geometries: (json['geometries'] as List?) + ?.map((e) => GeometryType.deserialize(e)) + .toList() ?? + const [], + ); + } @override Map toJson() => @@ -633,30 +638,32 @@ class Feature extends GeoJSONObject { Feature({ BBox? bbox, this.id, - this.properties = const {}, + this.properties, this.geometry, this.fields = const {}, - }) : super.withType(GeoJSONObjectType.feature, bbox: bbox); - - factory Feature.fromJson(Map json) => Feature( - id: json['id'], - geometry: json['geometry'] == null - ? null - : GeometryObject.deserialize(json['geometry']) as T, - properties: json['properties'], - bbox: json['bbox'] == null - ? null - : BBox.fromJson( - (json['bbox'] as List).map((e) => e as num).toList()), - fields: Map.fromEntries( - json.entries.where( - (el) => - el.key != 'geometry' && - el.key != 'properties' && - el.key != 'id', - ), + }) : super.withType(GeoJSONObjectType.feature, bbox: bbox) { + validateGeoJson(toJson()).check(); + } + + factory Feature.fromJson(Map json) { + validateGeoJson(json).check(); + return Feature( + id: json['id'], + geometry: json['geometry'] == null + ? null + : GeometryObject.deserialize(json['geometry']) as T, + properties: json['properties'], + bbox: json['bbox'] == null + ? null + : BBox.fromJson((json['bbox'] as List).map((e) => e as num).toList()), + fields: Map.fromEntries( + json.entries.where( + (el) => + el.key != 'geometry' && el.key != 'properties' && el.key != 'id', ), - ); + ), + ); + } dynamic operator [](String key) { switch (key) { @@ -683,8 +690,9 @@ class Feature extends GeoJSONObject { @override Map toJson() => super.serialize({ - 'id': id, - 'geometry': geometry!.toJson(), + if (id != null) 'id': id, + if (bbox != null) 'bbox': bbox!.toJson(), + 'geometry': geometry?.toJson(), 'properties': properties, ...fields, }); @@ -704,24 +712,27 @@ class FeatureCollection extends GeoJSONObject { List> features; FeatureCollection({BBox? bbox, this.features = const []}) - : super.withType(GeoJSONObjectType.featureCollection, bbox: bbox); - - factory FeatureCollection.fromJson(Map json) => - FeatureCollection( - bbox: json['bbox'] == null - ? null - : BBox.fromJson( - (json['bbox'] as List).map((e) => e as num).toList()), - features: (json['features'] as List?) - ?.map((e) => Feature.fromJson(e as Map)) - .toList() ?? - const [], - ); + : super.withType(GeoJSONObjectType.featureCollection, bbox: bbox) { + validateGeoJson(toJson()).check(); + } + + factory FeatureCollection.fromJson(Map json) { + validateGeoJson(json).check(); + return FeatureCollection( + bbox: json['bbox'] == null + ? null + : BBox.fromJson((json['bbox'] as List).map((e) => e as num).toList()), + features: (json['features'] as List?) + ?.map((e) => Feature.fromJson(e as Map)) + .toList() ?? + const [], + ); + } @override Map toJson() => super.serialize({ 'features': features.map((e) => e.toJson()).toList(), - 'bbox': bbox?.toJson(), + if (bbox != null) 'bbox': bbox!.toJson(), }); @override diff --git a/lib/src/geojson.g.dart b/lib/src/geojson.g.dart index 7a07462..a4b033c 100644 --- a/lib/src/geojson.g.dart +++ b/lib/src/geojson.g.dart @@ -6,130 +6,206 @@ part of 'geojson.dart'; // JsonSerializableGenerator // ************************************************************************** -Point _$PointFromJson(Map json) => Point( - bbox: json['bbox'] == null - ? null - : BBox.fromJson( - (json['bbox'] as List).map((e) => e as num).toList()), - coordinates: Position.fromJson( - (json['coordinates'] as List).map((e) => e as num).toList()), - ); +Point _$PointFromJson(Map json) { + validateGeoJson(json).check(); + return Point( + bbox: json['bbox'] == null + ? null + : BBox.fromJson( + (json['bbox'] as List).map((e) => e as num).toList()), + coordinates: Position.fromJson( + (json['coordinates'] as List).map((e) => e as num).toList()), + ); +} -Map _$PointToJson(Point instance) => { - 'bbox': instance.bbox?.toJson(), - 'coordinates': instance.coordinates.toJson(), - }; - -MultiPoint _$MultiPointFromJson(Map json) => MultiPoint( - bbox: json['bbox'] == null - ? null - : BBox.fromJson( - (json['bbox'] as List).map((e) => e as num).toList()), - coordinates: (json['coordinates'] as List?) - ?.map((e) => Position.fromJson( - (e as List).map((e) => e as num).toList())) - .toList() ?? - const [], - ); +Map _$PointToJson(Point instance) { + final val = {}; -Map _$MultiPointToJson(MultiPoint instance) => - { - 'bbox': instance.bbox?.toJson(), - 'coordinates': instance.coordinates.map((e) => e.toJson()).toList(), - }; - -LineString _$LineStringFromJson(Map json) => LineString( - bbox: json['bbox'] == null - ? null - : BBox.fromJson( - (json['bbox'] as List).map((e) => e as num).toList()), - coordinates: (json['coordinates'] as List?) - ?.map((e) => Position.fromJson( - (e as List).map((e) => e as num).toList())) - .toList() ?? - const [], - ); + void writeNotNull(String key, dynamic value) { + if (value != null) { + val[key] = value; + } + } -Map _$LineStringToJson(LineString instance) => - { - 'bbox': instance.bbox?.toJson(), - 'coordinates': instance.coordinates.map((e) => e.toJson()).toList(), - }; - -MultiLineString _$MultiLineStringFromJson(Map json) => - MultiLineString( - bbox: json['bbox'] == null - ? null - : BBox.fromJson( - (json['bbox'] as List).map((e) => e as num).toList()), - coordinates: (json['coordinates'] as List?) - ?.map((e) => (e as List) - .map((e) => Position.fromJson( - (e as List).map((e) => e as num).toList())) - .toList()) - .toList() ?? - const [], - ); + writeNotNull('bbox', instance.bbox?.toJson()); + val['coordinates'] = instance.coordinates.toJson(); + return val; +} -Map _$MultiLineStringToJson(MultiLineString instance) => - { - 'bbox': instance.bbox?.toJson(), - 'coordinates': instance.coordinates - .map((e) => e.map((e) => e.toJson()).toList()) - .toList(), - }; - -Polygon _$PolygonFromJson(Map json) => Polygon( - bbox: json['bbox'] == null - ? null - : BBox.fromJson( - (json['bbox'] as List).map((e) => e as num).toList()), - coordinates: (json['coordinates'] as List?) - ?.map((e) => (e as List) - .map((e) => Position.fromJson( - (e as List).map((e) => e as num).toList())) - .toList()) - .toList() ?? - const [], - ); +MultiPoint _$MultiPointFromJson(Map json) { + validateGeoJson(json).check(); + return MultiPoint( + bbox: json['bbox'] == null + ? null + : BBox.fromJson( + (json['bbox'] as List).map((e) => e as num).toList()), + coordinates: (json['coordinates'] as List?) + ?.map((e) => Position.fromJson( + (e as List).map((e) => e as num).toList())) + .toList() ?? + const [], + ); +} -Map _$PolygonToJson(Polygon instance) => { - 'bbox': instance.bbox?.toJson(), - 'coordinates': instance.coordinates - .map((e) => e.map((e) => e.toJson()).toList()) - .toList(), - }; - -MultiPolygon _$MultiPolygonFromJson(Map json) => MultiPolygon( - bbox: json['bbox'] == null - ? null - : BBox.fromJson( - (json['bbox'] as List).map((e) => e as num).toList()), - coordinates: (json['coordinates'] as List?) - ?.map((e) => (e as List) - .map((e) => (e as List) - .map((e) => Position.fromJson( - (e as List).map((e) => e as num).toList())) - .toList()) - .toList()) - .toList() ?? - const [], - ); +Map _$MultiPointToJson(MultiPoint instance) { + final val = {}; + + void writeNotNull(String key, dynamic value) { + if (value != null) { + val[key] = value; + } + } + + writeNotNull('bbox', instance.bbox?.toJson()); + val['coordinates'] = instance.coordinates.map((e) => e.toJson()).toList(); + return val; +} + +LineString _$LineStringFromJson(Map json) { + validateGeoJson(json).check(); + return LineString( + bbox: json['bbox'] == null + ? null + : BBox.fromJson( + (json['bbox'] as List).map((e) => e as num).toList()), + coordinates: (json['coordinates'] as List?) + ?.map((e) => Position.fromJson( + (e as List).map((e) => e as num).toList())) + .toList() ?? + const [], + ); +} + +Map _$LineStringToJson(LineString instance) { + final val = {}; + + void writeNotNull(String key, dynamic value) { + if (value != null) { + val[key] = value; + } + } + + writeNotNull('bbox', instance.bbox?.toJson()); + val['coordinates'] = instance.coordinates.map((e) => e.toJson()).toList(); + return val; +} + +MultiLineString _$MultiLineStringFromJson(Map json) { + validateGeoJson(json).check(); + return MultiLineString( + bbox: json['bbox'] == null + ? null + : BBox.fromJson( + (json['bbox'] as List).map((e) => e as num).toList()), + coordinates: (json['coordinates'] as List?) + ?.map((e) => (e as List) + .map((e) => Position.fromJson( + (e as List).map((e) => e as num).toList())) + .toList()) + .toList() ?? + const [], + ); +} + +Map _$MultiLineStringToJson(MultiLineString instance) { + final val = {}; + + void writeNotNull(String key, dynamic value) { + if (value != null) { + val[key] = value; + } + } + + writeNotNull('bbox', instance.bbox?.toJson()); + val['coordinates'] = instance.coordinates + .map((e) => e.map((e) => e.toJson()).toList()) + .toList(); + return val; +} -Map _$MultiPolygonToJson(MultiPolygon instance) => - { - 'bbox': instance.bbox?.toJson(), - 'coordinates': instance.coordinates - .map((e) => e.map((e) => e.map((e) => e.toJson()).toList()).toList()) - .toList(), - }; - -Map _$GeometryCollectionToJson(GeometryCollection instance) => - { - 'type': _$GeoJSONObjectTypeEnumMap[instance.type]!, - 'bbox': instance.bbox?.toJson(), - 'geometries': instance.geometries.map((e) => e.toJson()).toList(), - }; +Polygon _$PolygonFromJson(Map json) { + validateGeoJson(json).check(); + return Polygon( + bbox: json['bbox'] == null + ? null + : BBox.fromJson( + (json['bbox'] as List).map((e) => e as num).toList()), + coordinates: (json['coordinates'] as List?) + ?.map((e) => (e as List) + .map((e) => Position.fromJson( + (e as List).map((e) => e as num).toList())) + .toList()) + .toList() ?? + const [], + ); +} + +Map _$PolygonToJson(Polygon instance) { + final val = {}; + + void writeNotNull(String key, dynamic value) { + if (value != null) { + val[key] = value; + } + } + + writeNotNull('bbox', instance.bbox?.toJson()); + val['coordinates'] = instance.coordinates + .map((e) => e.map((e) => e.toJson()).toList()) + .toList(); + return val; +} + +MultiPolygon _$MultiPolygonFromJson(Map json) { + validateGeoJson(json).check(); + return MultiPolygon( + bbox: json['bbox'] == null + ? null + : BBox.fromJson( + (json['bbox'] as List).map((e) => e as num).toList()), + coordinates: (json['coordinates'] as List?) + ?.map((e) => (e as List) + .map((e) => (e as List) + .map((e) => Position.fromJson( + (e as List).map((e) => e as num).toList())) + .toList()) + .toList()) + .toList() ?? + const [], + ); +} + +Map _$MultiPolygonToJson(MultiPolygon instance) { + final val = {}; + + void writeNotNull(String key, dynamic value) { + if (value != null) { + val[key] = value; + } + } + + writeNotNull('bbox', instance.bbox?.toJson()); + val['coordinates'] = instance.coordinates + .map((e) => e.map((e) => e.map((e) => e.toJson()).toList()).toList()) + .toList(); + return val; +} + +Map _$GeometryCollectionToJson(GeometryCollection instance) { + final val = { + 'type': _$GeoJSONObjectTypeEnumMap[instance.type]!, + }; + + void writeNotNull(String key, dynamic value) { + if (value != null) { + val[key] = value; + } + } + + writeNotNull('bbox', instance.bbox?.toJson()); + val['geometries'] = instance.geometries.map((e) => e.toJson()).toList(); + return val; +} const _$GeoJSONObjectTypeEnumMap = { GeoJSONObjectType.point: 'Point', @@ -142,3 +218,41 @@ const _$GeoJSONObjectTypeEnumMap = { GeoJSONObjectType.feature: 'Feature', GeoJSONObjectType.featureCollection: 'FeatureCollection', }; + +/// Returns the key associated with value [source] from [enumValues], if one +/// exists. +/// +/// If [unknownValue] is not `null` and [source] is not a value in [enumValues], +/// [unknownValue] is returned. Otherwise, an [ArgumentError] is thrown. +/// +/// If [source] is `null`, an [ArgumentError] is thrown. +/// +/// Exposed only for code generated by `package:json_serializable`. +/// Not meant to be used directly by user code. +K $enumDecode( + Map enumValues, + Object? source, { + K? unknownValue, +}) { + if (source == null) { + throw ArgumentError( + 'A value must be provided. Supported values: ' + '${enumValues.values.join(', ')}', + ); + } + + for (var entry in enumValues.entries) { + if (entry.value == source) { + return entry.key; + } + } + + if (unknownValue == null) { + throw ArgumentError( + '`$source` is not one of the supported values: ' + '${enumValues.values.join(', ')}', + ); + } + + return unknownValue; +} diff --git a/lib/src/validation/bounding_box.dart b/lib/src/validation/bounding_box.dart new file mode 100644 index 0000000..27f5ce9 --- /dev/null +++ b/lib/src/validation/bounding_box.dart @@ -0,0 +1,23 @@ +import 'validation_hint.dart'; + +// "bbox": [-10.0, -10.0, 10.0, 10.0], +List validateBoundingBox(dynamic bbox) { + final hints = []; + if (bbox == null) return hints; + + if (bbox is! List) { + hints.add(ValidationHint.bboxMustBeAList); + return hints; + } + + if (!bbox.every((element) => element is num)) { + hints.add(ValidationHint.bboxListMustContainOnlyNumbers); + return hints; + } + + if (bbox.length != 4 && bbox.length != 6) { + hints.add(ValidationHint.bboxMustContain4Or6Elements); + return hints; + } + return hints; +} diff --git a/lib/src/validation/feature.dart b/lib/src/validation/feature.dart new file mode 100644 index 0000000..937289f --- /dev/null +++ b/lib/src/validation/feature.dart @@ -0,0 +1,50 @@ +import 'validation_hint.dart'; +import 'geometry.dart'; + +/// +///```json +/// { +/// "type": "Feature", +/// "geometry": { +/// "type": "Point", +/// "coordinates": [102.0, 0.5] +/// }, +/// "properties": { +/// "prop0": "value0" +/// } +/// } +/// ```` +List validateFeature(Map json) { + final hints = []; + + if (json["type"] != "Feature") { + hints.add(ValidationHint.geometryTypeInvalid); + } + + if (json.containsKey("geometry") == false) { + hints.add(ValidationHint.featureMustHaveGeometry); + } else { + final geometry = json['geometry']; + if (geometry != null) { + hints.addAll(validateGeometry(json['geometry'])); + } + } + + if (json.containsKey("properties") == false) { + hints.add(ValidationHint.featureMustHaveProperties); + } else { + final properties = json['properties']; + if (properties != null && properties is! Map) { + hints.add(ValidationHint.featurePropertiesMustBeAnObject); + } + } + + if (json.containsKey("id")) { + final id = json['id']; + if (id! is String && id! is num) { + hints.add(ValidationHint.featureIdMustBeStringOrNumber); + } + } + + return hints; +} diff --git a/lib/src/validation/feature_collection.dart b/lib/src/validation/feature_collection.dart new file mode 100644 index 0000000..d5fd1e3 --- /dev/null +++ b/lib/src/validation/feature_collection.dart @@ -0,0 +1,56 @@ +import 'feature.dart'; +import 'validation_hint.dart'; + +/// ```json +/// { +/// "type": "FeatureCollection", +/// "features": [{ +/// "type": "Feature", +/// "geometry": { +/// "type": "Point", +/// "coordinates": [102.0, 0.5] +/// }, +/// "properties": { +/// "prop0": "value0" +/// } +/// }, { +/// "type": "Feature", +/// "geometry": { +/// "type": "LineString", +/// "coordinates": [ +/// [102.0, 0.0], +/// [103.0, 1.0], +/// [104.0, 0.0], +/// [105.0, 1.0] +/// ] +/// }, +/// "properties": { +/// "prop0": "value0", +/// "prop1": 0.0 +/// } +/// }] +/// } +List validateFeatureCollection(Map json) { + final hints = []; + + if (json["type"] != "FeatureCollection") { + hints.add(ValidationHint.geometryTypeInvalid); + } + + if (json.containsKey("features") == false) { + hints.add(ValidationHint.featureCollectionMustHaveFeatures); + } else { + final features = json["features"]; + if (features == null || features is! List) { + hints.add(ValidationHint.featureCollectionFeaturesMustBeAList); + } else { + if (features.isNotEmpty) { + for (final feature in features) { + hints.addAll(validateFeature(feature)); + } + } + } + } + + return hints; +} diff --git a/lib/src/validation/geojson.dart b/lib/src/validation/geojson.dart new file mode 100644 index 0000000..9a2e1b8 --- /dev/null +++ b/lib/src/validation/geojson.dart @@ -0,0 +1,32 @@ +import 'validation_hint.dart'; +import 'bounding_box.dart'; +import 'feature_collection.dart'; +import 'feature.dart'; +import 'geometry.dart'; + +List validateGeoJson(dynamic json) { + final hints = []; + if (json == null) return hints; + + if (json is! Map) { + hints.add(ValidationHint.geometryMustBeAnObject); + } else if (!json.containsKey("type")) { + hints.add(ValidationHint.geometryMustHaveType); + } else { + final validators = { + "Feature": validateFeature, + "FeatureCollection": validateFeatureCollection, + }; + final type = json["type"]; + if (type == null || type is! String) { + hints.add(ValidationHint.geometryTypeInvalid); + } else if (validators.containsKey(type)) { + hints.addAll(validators[type]!(json as Map)); + } else { + hints.addAll(validateGeometry(json)); + } + + hints.addAll(validateBoundingBox(json['bbox'])); + } + return hints; +} diff --git a/lib/src/validation/geometry.dart b/lib/src/validation/geometry.dart new file mode 100644 index 0000000..9fa3981 --- /dev/null +++ b/lib/src/validation/geometry.dart @@ -0,0 +1,36 @@ +import 'bounding_box.dart'; +import 'geometry_collection.dart'; +import 'line_string.dart'; +import 'point.dart'; +import 'polygon.dart'; +import 'validation_hint.dart'; + +List validateGeometry(dynamic json) { + final hints = []; + if (json == null) return hints; + + if (json is! Map) { + hints.add(ValidationHint.geometryMustBeAnObject); + } else if (!json.containsKey("type")) { + hints.add(ValidationHint.geometryMustHaveType); + } else { + final validators = { + "Point": validatePoint, + "MultiPoint": validateMultiPoint, + "LineString": validateLineString, + "MultiLineString": validateMultiLineString, + "Polygon": validatePolygon, + "MultiPolygon": validateMultiPolygon, + "GeometryCollection": validateGeometryCollection, + }; + final type = json["type"]; + if (type == null || type is! String || !validators.containsKey(type)) { + hints.add(ValidationHint.geometryTypeInvalid); + } else { + hints.addAll(validators[type]!(json as Map)); + } + + hints.addAll(validateBoundingBox(json['bbox'])); + } + return hints; +} diff --git a/lib/src/validation/geometry_collection.dart b/lib/src/validation/geometry_collection.dart new file mode 100644 index 0000000..de39eb2 --- /dev/null +++ b/lib/src/validation/geometry_collection.dart @@ -0,0 +1,44 @@ +import 'geometry.dart'; +import 'validation_hint.dart'; + +/// +/// ```json +/// { +/// "type": "GeometryCollection", +/// "geometries": [{ +/// "type": "Point", +/// "coordinates": [100.0, 0.0] +/// }, { +/// "type": "LineString", +/// "coordinates": [ +/// [101.0, 0.0], +/// [102.0, 1.0] +/// ] +/// }] +/// } +/// ``` +List validateGeometryCollection( + Map geometryCollection) { + final hints = []; + + if (geometryCollection["type"] != "GeometryCollection") { + hints.add(ValidationHint.geometryTypeInvalid); + } + + if (geometryCollection.containsKey('geometries') == false) { + hints.add(ValidationHint.geometryMustHaveCoordinates); + } else { + final geometries = geometryCollection['geometries']; + if (geometries == null || geometries is! List) { + hints.add(ValidationHint.geometryCollectionGeometriesMustBeAList); + } else { + if (geometries.isEmpty) { + hints.add(ValidationHint.geometryCollectionGeometriesShouldHaveItems); + } + for (final geometry in geometries) { + hints.addAll(validateGeometry(geometry)); + } + } + } + return hints; +} diff --git a/lib/src/validation/line_string.dart b/lib/src/validation/line_string.dart new file mode 100644 index 0000000..d502caa --- /dev/null +++ b/lib/src/validation/line_string.dart @@ -0,0 +1,76 @@ +import 'validation_hint.dart'; +import 'positions.dart'; + +/// { +/// "type": "LineString", +/// "coordinates": [ +/// [30, 10], [10, 30], [40, 40] +/// ] +/// } +List validateLineString(Map json) { + final hints = []; + + if (json["type"] != "LineString") { + hints.add(ValidationHint.geometryTypeInvalid); + } + + if (json.containsKey('coordinates') == false) { + hints.add(ValidationHint.geometryMustHaveCoordinates); + } else { + hints.addAll( + _validateLineStringCoordinates( + json['coordinates'], + ), + ); + } + + return hints; +} + +/// +/// { "type": "MultiLineString", +/// "coordinates": [ +/// [[10, 10], [20, 20], [10, 40]], +/// [[40, 40], [30, 30], [40, 20], [30, 10]] +/// ] +/// } + +List validateMultiLineString(Map json) { + final hints = []; + + if (json["type"] != "MultiLineString") { + hints.add(ValidationHint.geometryTypeInvalid); + } + + if (json.containsKey('coordinates') == false) { + hints.add(ValidationHint.geometryMustHaveCoordinates); + } else { + final coordinates = json['coordinates']; + if (coordinates == null || coordinates is! List) { + hints.add(ValidationHint.geometryCoordinatesMustBeAList); + } else { + if (coordinates.length < 2) { + hints.add(ValidationHint.multiLineStringShouldHaveAtLeast2Items); + } + for (final lineString in coordinates) { + hints.addAll(_validateLineStringCoordinates(lineString)); + } + } + } + return hints; +} + +List _validateLineStringCoordinates(dynamic coordinates) { + final hints = []; + if (coordinates == null || coordinates is! List) { + hints.add(ValidationHint.geometryCoordinatesMustBeAList); + } else { + if (coordinates.length < 2) { + hints.add(ValidationHint.lineStringMustHaveMinimum2Positions); + } + for (final position in coordinates) { + hints.addAll(validatePosition(position)); + } + } + return hints; +} diff --git a/lib/src/validation/point.dart b/lib/src/validation/point.dart new file mode 100644 index 0000000..4cbb29c --- /dev/null +++ b/lib/src/validation/point.dart @@ -0,0 +1,48 @@ +import 'positions.dart'; +import 'validation_hint.dart'; + +/// { "type": "Point", +/// "coordinates": [30, 10] +/// } +List validatePoint(Map json) { + final hints = []; + if (json["type"] != "Point") { + hints.add(ValidationHint.geometryTypeInvalid); + } else if (json.containsKey('coordinates') == false) { + hints.add(ValidationHint.geometryMustHaveCoordinates); + } else { + final position = json['coordinates']; + // ToDo: check type! + hints.addAll(validatePosition(position)); + } + return hints; +} + +/// { +/// "type": "MultiPoint", +/// "coordinates": [ +/// [10, 40], [40, 30], [20, 20], [30, 10] +/// ] +/// } +List validateMultiPoint(Map json) { + final hints = []; + if (json["type"] != "MultiPoint") { + hints.add(ValidationHint.geometryTypeInvalid); + } else if (json.containsKey('coordinates') == false) { + hints.add(ValidationHint.geometryMustHaveCoordinates); + } else { + final coordinates = json['coordinates']; + if (coordinates == null || coordinates is! List) { + hints.add(ValidationHint.geometryCoordinatesMustBeAList); + } else { + if (coordinates.length < 2) { + hints.add(ValidationHint.multiPointShouldHaveAtLeast2Items); + } + + for (final position in coordinates) { + hints.addAll(validatePosition(position)); + } + } + } + return hints; +} diff --git a/lib/src/validation/polygon.dart b/lib/src/validation/polygon.dart new file mode 100644 index 0000000..1270ad7 --- /dev/null +++ b/lib/src/validation/polygon.dart @@ -0,0 +1,173 @@ +import 'positions.dart'; +import 'validation_hint.dart'; + +// { "type": "Polygon", +// "coordinates": [ +// [[30, 10], [40, 40], [20, 40], [10, 20], [30, 10]] +// ] +//} +//{ "type": "Polygon", +// "coordinates": [ +// [[35, 10], [45, 45], [15, 40], [10, 20], [35, 10]], +// [[20, 30], [35, 35], [30, 20], [20, 30]] +// ] +//} +List validatePolygon(Map json) { + final hints = []; + + if (json["type"] != "Polygon") { + hints.add(ValidationHint.geometryTypeInvalid); + } + + if (json.containsKey('coordinates') == false) { + hints.add(ValidationHint.geometryMustHaveCoordinates); + } else { + final linearRings = json['coordinates']; + if (linearRings is! List) { + hints.add(ValidationHint.polygonCoordinatesMustBeAList); + } else { + if (linearRings.isEmpty) { + hints.add(ValidationHint.polygonShouldHaveItems); + } else { + hints.addAll(_validateLinearRing( + linearRings.first, + shouldBeRightHanded: true, + )); + + for (final linearRing in linearRings.skip(1)) { + hints.addAll(_validateLinearRing( + linearRing, + shouldBeRightHanded: false, + )); + } + } + } + } + + return hints; +} + +/// { "type": "MultiPolygon", +/// "coordinates": [ +/// [ +/// [[30, 20], [45, 40], [10, 40], [30, 20]] +/// ], +/// [ +/// [[15, 5], [40, 10], [10, 20], [5, 10], [15, 5]] +/// ] +/// ] +/// } +/// +/// { "type": "MultiPolygon", +/// "coordinates": [ +/// [ +/// [[40, 40], [20, 45], [45, 30], [40, 40]] +/// ], +/// [ +/// [[20, 35], [10, 30], [10, 10], [30, 5], [45, 20], [20, 35]], +/// [[30, 20], [20, 15], [20, 25], [30, 20]] +/// ] +/// ] +/// } +List validateMultiPolygon(Map json) { + final hints = []; + + if (json["type"] != "MultiPolygon") { + hints.add(ValidationHint.geometryTypeInvalid); + } + + if (json.containsKey('coordinates') == false) { + hints.add(ValidationHint.geometryMustHaveCoordinates); + } else { + final polygons = json['coordinates']; + if (polygons is! List) { + hints.add(ValidationHint.multiPolygonCoordinatesMustBeAList); + } else { + if (polygons.length < 2) { + hints.add(ValidationHint.multiPolygonShouldHaveAtLease2Items); + } + for (final linearRings in polygons) { + if (linearRings.isEmpty) { + hints.add(ValidationHint.polygonShouldHaveItems); + } else { + hints.addAll(_validateLinearRing( + linearRings.first, + shouldBeRightHanded: true, + )); + + for (final linearRing in linearRings.skip(1)) { + hints.addAll(_validateLinearRing( + linearRing, + shouldBeRightHanded: false, + )); + } + } + } + } + } + + return hints; +} + +// A linear ring is the boundary of a surface or the boundary of a +// hole in a surface. +// For Polygons with more than one of these rings, the first MUST be +// the exterior ring, and any others MUST be interior rings. The +// exterior ring bounds the surface, and the interior rings (if +// present) bound holes within the surface. +// [[35, 10], [45, 45], [15, 40], [10, 20], [35, 10]] +List _validateLinearRing(dynamic json, + {required bool shouldBeRightHanded}) { + final hints = []; + + if (json == null || json is! List) { + hints.add(ValidationHint.linearRingMustBeAList); + } else { + if (json.length < 4) { + hints.add(ValidationHint.linearRingMustHaveMinimum4Positions); + } + if (json.length > 2 && _positionsEqual(json.first, json.last)) { + hints.add(ValidationHint.linearRingFirstAndLastPositionMustBeTheSame); + } + for (final position in json) { + hints.addAll(validatePosition(position)); + } + + if (hints.isEmpty) { + final isRightHanded = _isRightHanded(json); + + if ((shouldBeRightHanded && !isRightHanded) || + (isRightHanded && !shouldBeRightHanded)) { + hints.add(ValidationHint.linearRingShouldFollowRightHandRule); + } + } + } + + return hints; +} + +bool _isRightHanded(dynamic coordinates) { + if (coordinates.length < 2) { + throw ArgumentError('coordinates must contain at least 2 elements'); + } + + num sum = 0; + for (var i = 0; i < coordinates.length - 1; i++) { + final actual = coordinates[i]; + final next = coordinates[i + 1]; + sum += (next[0] - actual[0]) * (next[1] + actual[1]); + } + return sum >= 0; +} + +bool _positionsEqual(dynamic first, dynamic second) { + if (first is List && second is List && first.length == second.length) { + return false; + } + for (var i = 0; i < first.length; i++) { + if (first[i] != second[i]) { + return false; + } + } + return true; +} diff --git a/lib/src/validation/positions.dart b/lib/src/validation/positions.dart new file mode 100644 index 0000000..8e11ee0 --- /dev/null +++ b/lib/src/validation/positions.dart @@ -0,0 +1,45 @@ +import 'validation_hint.dart'; + +/// Validates a single Position. +/// The [position] parameter should be an array of 2 or 3 numbers. The first +/// two numbers are longitude and latitude and the optional third element is +/// altitude. Returns a list of [ValidationHint]s, if the return is empty, the +/// validation was successful. +/// Alternative: x,y,z; easing, northing, elevation; +/// +/// Example +/// ```dart +/// validatePosition([115, -35]); +/// validatePosition([115, -35, 100]); +/// ``` + +// ToDo: remove null and List check and use strong type here! +List validatePosition(dynamic position) { + final errors = []; + + if (position == null) { + errors.add(ValidationHint.positionMustNotBeNull); + } else if (position is! List) { + errors.add(ValidationHint.positionMustBeAnList); + } else { + if (position.length < 2) { + errors.add(ValidationHint.positionMustContainAtLeast2Elements); + } else if (position.length > 3) { + // SHOULD NOT extend positions beyond three elements + errors.add(ValidationHint.positionShouldNotContainMoreThan3Elements); + } + if (!position.every((element) => element is num)) { + errors.add(ValidationHint.positionMustContainOnlyNumbers); + } else { + for (final coordinate in position) { + final numberString = coordinate.toString(); + if (numberString.contains('.') && + numberString.split('.').last.length > 6) { + errors.add(ValidationHint.positionRecommendedMaxPrecisionOf6); + } + } + } + } + + return errors; +} diff --git a/lib/src/validation/validation_hint.dart b/lib/src/validation/validation_hint.dart new file mode 100644 index 0000000..ad955f0 --- /dev/null +++ b/lib/src/validation/validation_hint.dart @@ -0,0 +1,112 @@ +const List validationErrors = [ + ValidationHint.featureCollectionMustHaveFeatures, + ValidationHint.featureCollectionFeaturesMustBeAList, + ValidationHint.featureMustHaveGeometry, + ValidationHint.featurePropertiesMustBeAnObject, + ValidationHint.featureIdMustBeStringOrNumber, + ValidationHint.geometryCollectionGeometriesMustBeAList, + ValidationHint.geometryMustBeAnObject, + ValidationHint.geometryMustHaveType, + ValidationHint.geometryTypeInvalid, + ValidationHint.geometryMustHaveCoordinates, + ValidationHint.geometryCoordinatesMustBeAList, + ValidationHint.bboxMustBeAList, + ValidationHint.bboxListMustContainOnlyNumbers, + ValidationHint.bboxMustContain4Or6Elements, + ValidationHint.multiPointCoordinatesMustBeAListOfLists, + ValidationHint.lineStringMustHaveMinimum2Positions, + ValidationHint.polygonCoordinatesMustBeAList, + ValidationHint.multiPolygonCoordinatesMustBeAList, + ValidationHint.linearRingMustBeAList, + ValidationHint.linearRingMustHaveMinimum4Positions, + ValidationHint.linearRingFirstAndLastPositionMustBeTheSame, + ValidationHint.positionMustNotBeNull, + ValidationHint.positionMustBeAnList, + ValidationHint.positionMustContainAtLeast2Elements, + ValidationHint.positionMustContainOnlyNumbers, +]; + +const List validationWarnings = [ + ValidationHint.positionRecommendedMaxPrecisionOf6, + ValidationHint.positionShouldNotContainMoreThan3Elements, + ValidationHint.linearRingShouldFollowRightHandRule, + ValidationHint.multiLineStringShouldHaveAtLeast2Items, + ValidationHint.multiPointShouldHaveAtLeast2Items, + ValidationHint.multiPolygonShouldHaveAtLease2Items, + ValidationHint.polygonShouldHaveItems, + ValidationHint.geometryCollectionGeometriesShouldHaveItems, + ValidationHint.featureMustHaveProperties, +]; + +enum ValidationHint { + featureCollectionMustHaveFeatures, + featureCollectionFeaturesMustBeAList, + + featureMustHaveGeometry, + featureMustHaveProperties, + featurePropertiesMustBeAnObject, + featureIdMustBeStringOrNumber, + + geometryCollectionGeometriesMustBeAList, + geometryCollectionGeometriesShouldHaveItems, + + geometryMustBeAnObject, + geometryMustHaveType, + geometryTypeInvalid, + geometryMustHaveCoordinates, + geometryCoordinatesMustBeAList, + + bboxMustBeAList, + bboxListMustContainOnlyNumbers, + bboxMustContain4Or6Elements, + + multiPointCoordinatesMustBeAListOfLists, + multiPointShouldHaveAtLeast2Items, + + lineStringMustHaveMinimum2Positions, + multiLineStringShouldHaveAtLeast2Items, + + polygonCoordinatesMustBeAList, + polygonShouldHaveItems, + + multiPolygonCoordinatesMustBeAList, + multiPolygonShouldHaveAtLease2Items, + + linearRingMustBeAList, + linearRingMustHaveMinimum4Positions, + linearRingFirstAndLastPositionMustBeTheSame, + linearRingShouldFollowRightHandRule, + + positionMustNotBeNull, + positionMustBeAnList, + positionMustContainAtLeast2Elements, + positionShouldNotContainMoreThan3Elements, + positionMustContainOnlyNumbers, + positionRecommendedMaxPrecisionOf6, +} + +extension ValidationExtensions on ValidationHint { + bool get isError => validationErrors.contains(this); + bool get isWarning => validationWarnings.contains(this); +} + +extension ValidationListExtensions on List { + bool get isValidStrict => !hasErrors && !hasWarnings; + bool get isValid => !hasErrors; + + // Has validation Hints of Error category + bool get hasErrors => any((hint) => hint.isError); + + // Has validation Hints of Warning category + bool get hasWarnings => any((hint) => hint.isWarning); + + /// Throws an ArgumentError if there are any validation Errors. + /// + void check({bool strict = false}) { + if (!strict && !isValid) { + throw Exception('Validation failed: $this'); + } else if (strict && !isValidStrict) { + throw Exception('Validation failed: $this'); + } + } +} diff --git a/lib/validation.dart b/lib/validation.dart new file mode 100644 index 0000000..9f9bd5d --- /dev/null +++ b/lib/validation.dart @@ -0,0 +1,3 @@ +export 'src/validation/validation_hint.dart'; +export 'src/validation/geojson.dart'; +export 'src/validation/geometry.dart'; diff --git a/pubspec.yaml b/pubspec.yaml index 60352f5..de3e487 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -8,10 +8,7 @@ environment: sdk: ">=2.17.0 <4.0.0" dependencies: - json_annotation: ^4.8.1 dev_dependencies: lints: ^3.0.0 - test: ^1.24.0 - json_serializable: ^6.7.0 - build_runner: ^2.4.5 \ No newline at end of file + test: ^1.24.0 \ No newline at end of file diff --git a/test/geotypes_test.dart b/test/geotypes_test.dart index 3bbcfba..c489c93 100644 --- a/test/geotypes_test.dart +++ b/test/geotypes_test.dart @@ -9,6 +9,88 @@ import 'dart:io'; //import 'package:turf/helpers.dart'; void main() { + group('serialization on null values', () { + final bbox = BBox(1, 2, 3, 4); + + test('Point', () { + final first = Point(coordinates: Position(0, 0)).toJson(); + expect(first.containsKey('bbox'), false); + + final second = Point(coordinates: Position(0, 0), bbox: bbox).toJson(); + expect(second.containsKey('bbox'), true); + }); + + test('MultiPoint', () { + final first = MultiPoint(coordinates: []).toJson(); + expect(first.containsKey('bbox'), false); + + final second = MultiPoint(coordinates: [], bbox: bbox).toJson(); + expect(second.containsKey('bbox'), true); + }); + + test('LineString', () { + final positions = [Position(0, 0), Position(1, 1)]; + final first = LineString(coordinates: positions).toJson(); + expect(first.containsKey('bbox'), false); + + final second = LineString(coordinates: positions, bbox: bbox).toJson(); + expect(second.containsKey('bbox'), true); + }); + + test('MultiLineString', () { + final first = MultiLineString(coordinates: []).toJson(); + expect(first.containsKey('bbox'), false); + + final second = MultiLineString(coordinates: [], bbox: bbox).toJson(); + expect(second.containsKey('bbox'), true); + }); + + test('Polygon', () { + final first = Polygon(coordinates: []).toJson(); + expect(first.containsKey('bbox'), false); + + final second = Polygon(coordinates: [], bbox: bbox).toJson(); + expect(second.containsKey('bbox'), true); + }); + + test('MultiPolygon', () { + final first = MultiPolygon(coordinates: []).toJson(); + expect(first.containsKey('bbox'), false); + + final second = MultiPolygon(coordinates: [], bbox: bbox).toJson(); + expect(second.containsKey('bbox'), true); + }); + + test('Feature', () { + final first = Feature().toJson(); + expect(first.containsKey('bbox'), false); + expect(first.containsKey('properties'), true); + expect(first.containsKey('geometry'), true); + + final second = Feature(bbox: bbox).toJson(); + final jsonString = JsonEncoder.withIndent(" ").convert(second); + print(jsonString); + + expect(second.containsKey('bbox'), true); + }); + + test('GeometryCollection', () { + final first = GeometryCollection().toJson(); + expect(first.containsKey('bbox'), false); + + final second = GeometryCollection(bbox: bbox).toJson(); + expect(second.containsKey('bbox'), true); + }); + + test('GeometryCollection', () { + final first = GeometryCollection().toJson(); + expect(first.containsKey('bbox'), false); + + final second = GeometryCollection(bbox: bbox).toJson(); + expect(second.containsKey('bbox'), true); + }); + }); + group('Coordinate Types:', () { test('Position', () { void expectArgs(Position pos) { @@ -173,51 +255,57 @@ void main() { test('Point', () { var geoJSON = { 'coordinates': null, - 'type': GeoJSONObjectType.point, + 'type': GeoJSONObjectType.point.jsonValue, }; - expect(() => Point.fromJson(geoJSON), throwsA(isA())); + expect(() => Point.fromJson(geoJSON), throwsA(isA())); var point = Point(coordinates: Position(11, 49)); expect(point, point.clone()); }); - var geometries = [ - GeoJSONObjectType.multiPoint, - GeoJSONObjectType.lineString, - GeoJSONObjectType.multiLineString, - GeoJSONObjectType.polygon, - GeoJSONObjectType.multiPolygon, - ]; - - var collection = GeometryCollection.fromJson({ - 'type': GeoJSONObjectType.geometryCollection, - 'geometries': geometries - .map((type) => { - 'coordinates': null, - 'type': type, - }) - .toList(), - }); - for (var i = 0; i < geometries.length; i++) { - test(geometries[i], () { - expect(geometries[i], collection.geometries[i].type); - expect(collection.geometries[i].coordinates, - isNotNull); // kind of unnecessary - expect(collection.geometries[i].coordinates, isA()); - expect(collection.geometries[i].coordinates, isEmpty); - - var json = collection.geometries[i].toJson(); - for (var key in ['type', 'coordinates']) { - expect(json.keys, contains(key)); - } + var geometries = >[]; + late GeometryCollection collection; + + test('build geometries', () { + geometries = [ + MultiPoint(coordinates: []).toJson(), + LineString(coordinates: [Position(10, 10), Position(10, 10)]).toJson(), + MultiLineString(coordinates: []).toJson(), + Polygon(coordinates: []).toJson(), + MultiPolygon(coordinates: []).toJson(), + ]; + collection = GeometryCollection.fromJson({ + 'type': GeoJSONObjectType.geometryCollection.jsonValue, + 'geometries': geometries, }); + }); + + if (geometries.isNotEmpty) { + for (var i = 0; i < geometries.length; i++) { + test(geometries[i], () { + check() { + expect(geometries[i], collection.geometries[i].type); + expect(collection.geometries[i].coordinates, + isNotNull); // kind of unnecessary + expect(collection.geometries[i].coordinates, isA()); + expect(collection.geometries[i].coordinates, isEmpty); + + var json = collection.geometries[i].toJson(); + for (var key in ['type', 'coordinates']) { + expect(json.keys, contains(key)); + } + } + + expect(() => check(), returnsNormally); + }); + } } }); test('GeometryCollection', () { var geoJSON = { - 'type': GeoJSONObjectType.geometryCollection, - 'geometries': null, + 'type': GeoJSONObjectType.geometryCollection.jsonValue, + 'geometries': [], }; var collection = GeometryCollection.fromJson(geoJSON); expect(collection.type, GeoJSONObjectType.geometryCollection); @@ -232,8 +320,9 @@ void main() { }); test('Feature', () { var geoJSON = { - 'type': GeoJSONObjectType.feature, + 'type': GeoJSONObjectType.feature.jsonValue, 'geometry': null, + 'properties': null, }; var feature = Feature.fromJson(geoJSON); expect(feature.type, GeoJSONObjectType.feature); @@ -252,8 +341,8 @@ void main() { }); test('FeatureCollection', () { var geoJSON = { - 'type': GeoJSONObjectType.featureCollection, - 'features': null, + 'type': GeoJSONObjectType.featureCollection.jsonValue, + 'features': [], }; var collection = FeatureCollection.fromJson(geoJSON); expect(collection.type, GeoJSONObjectType.featureCollection); @@ -279,10 +368,10 @@ void main() { GeoJSONObjectType.point); final geoJSON2 = { - "type": GeoJSONObjectType.geometryCollection, + "type": GeoJSONObjectType.geometryCollection.jsonValue, "geometries": [ { - "type": GeoJSONObjectType.point, + "type": GeoJSONObjectType.point.jsonValue, "coordinates": [1, 1, 1] } ] @@ -303,9 +392,9 @@ void main() { GeoJSONObjectType.point); var geoJSON3 = { - "type": GeoJSONObjectType.geometryCollection, + "type": GeoJSONObjectType.geometryCollection.jsonValue, "geometries": [ - {"type": GeoJSONObjectType.feature, "id": 1} + {"type": GeoJSONObjectType.feature.jsonValue, "id": 1} ] }; expect(() => GeometryType.deserialize(geoJSON3), throwsA(isA())); @@ -354,6 +443,7 @@ void main() { Position(100, 0), Position(100, 1), Position(101, 0), + Position(100, 0), ] ], ), @@ -365,6 +455,7 @@ void main() { Position(100, 0), Position(100, 1), Position(101, 0), + Position(100, 0), ] ]), Polygon(coordinates: [ @@ -372,6 +463,7 @@ void main() { Position(100, 0), Position(100, 1), Position(101, 0), + Position(100, 0), ] ]) ], diff --git a/test/validation_test.dart b/test/validation_test.dart new file mode 100644 index 0000000..f3cd8e9 --- /dev/null +++ b/test/validation_test.dart @@ -0,0 +1,48 @@ +import 'dart:convert'; +import 'package:geotypes/validation.dart'; +import 'package:test/test.dart'; + +main() { + group('lineString validation', () { + test('valid line', () { + final json = jsonDecode(''' + { + "type": "LineString", + "coordinates": [ + [102.0, 0.0], + [103.0, 1.0] + ] + } + '''); + + final result = validateGeoJson(json); + + expect(result.hasErrors, false); + expect(result.hasWarnings, false); + expect(result.isValid, true); + expect(result.isValidStrict, true); + }); + + test('valid line', () { + final json = jsonDecode(''' + { + "type": "LineString", + "coordinates": [ + [102.0, 0.0] + ] + } + '''); + + final result = validateGeoJson(json); + expect( + result, + contains(ValidationHint.lineStringMustHaveMinimum2Positions), + ); + + expect(result.hasErrors, true); + expect(result.hasWarnings, false); + expect(result.isValid, false); + expect(result.isValidStrict, false); + }); + }); +}