placemark / togeojson

convert KML, TCX, and GPX to GeoJSON, without the fuss
https://placemark.github.io/togeojson/
BSD 2-Clause "Simplified" License
400 stars 67 forks source link

GPX metadataType #111

Open Raruto opened 1 year ago

Raruto commented 1 year ago

Hi Tom,

over years I come back to ask myself this question: Is it valid to have a properties element in an geoJSON featureCollection?

So first of all here's just a friendly reminder:

RFC 7946 - 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

{
  "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.

GPX 1.1 Schema Documentation - metadataType

<xsd:complexType name="metadataType">
  <xsd:sequence>
    <-- elements must appear in this order -->
    <xsd:element name="name" type="[xsd](https://www.topografix.com/GPX/1/1/#ns_xsd):string" minOccurs="0"/>
    <xsd:element name="desc" type="[xsd](https://www.topografix.com/GPX/1/1/#ns_xsd):string" minOccurs="0"/>
    <xsd:element name="author" type="[personType](https://www.topografix.com/GPX/1/1/#type_personType)" minOccurs="0"/>
    <xsd:element name="copyright" type="[copyrightType](https://www.topografix.com/GPX/1/1/#type_copyrightType)" minOccurs="0"/>
    <xsd:element name="link" type="[linkType](https://www.topografix.com/GPX/1/1/#type_linkType)" minOccurs="0" maxOccurs="unbounded"/>
    <xsd:element name="time" type="[xsd](https://www.topografix.com/GPX/1/1/#ns_xsd):dateTime" minOccurs="0"/>
    <xsd:element name="keywords" type="[xsd](https://www.topografix.com/GPX/1/1/#ns_xsd):string" minOccurs="0"/>
   <xsd:element name="bounds" type="[boundsType](https://www.topografix.com/GPX/1/1/#type_boundsType)" minOccurs="0"/>
   <xsd:element name="extensions" type="[extensionsType](https://www.topografix.com/GPX/1/1/#type_extensionsType)" minOccurs="0"/>
  </xsd:sequence>
</xsd:complexType>

Motivation

Mainly, make it easier to access the root gpx file <name>.

Right now, others can achieve this more or less by doing like so:

// Ref: https://github.com/Raruto/leaflet-elevation/blob/e0c68cba9a71d140e4c5a4179c51ae34588b7327/src/control.js#L965-L973

let xml  = (new DOMParser()).parseFromString(data, "text/xml");
let type = xml.documentElement.tagName.toLowerCase(); // "kml" or "gpx"
let name = xml.getElementsByTagName('name');
if (xml.getElementsByTagName('parsererror').length) {
  throw 'Invalid XML';
}
if (!(type in toGeoJSON)) {
  type = xml.documentElement.tagName == "TrainingCenterDatabase" ? 'tcx' : 'gpx';
}
let geojson  = toGeoJSON[type](xml);
geojson.name = name.length > 0 ? (Array.from(name).find(tag => tag.parentElement.tagName == "trk") ?? name[0]).textContent : '';

Draft implementation

Essentially, augmenting the FeatureCollection interface by providing a new property: metadata

lib/gpx.ts#L181-L197

import { extractMetadata } from "./gpx/metadata";

...

export function gpx(node: Document): FeatureCollection {
  return {
    type: "FeatureCollection",
    features: Array.from(gpxGen(node)),
    metadata: extractMetadata(node),
  };
}

lib/gpx/metadata.ts

Almost the same as: lib/gpx/properties.ts#L1-L36

NB some functions parameters types invoked in here must be double-checked (ref: Element and Document interfaces)

import { $, getMulti, nodeVal } from "../shared";

export function extractMetadata(node: Document) {
  const properties = getMulti(node, [
    "name",
    "desc",
    // "author",
    // "copyright",
    "time",
    "keywords",
    // bounds
  ]);

  const extensions = Array.from(
    node.getElementsByTagNameNS(
      "http://www.garmin.com/xmlschemas/GpxExtensions/v3",
      "*"
    )
  );
  for (const child of extensions) {
    if (child.parentNode?.parentNode === node) {
      properties[child.tagName.replace(":", "_")] = nodeVal(child);
    }
  }

  const links = $(node, "link");
  if (links.length) {
    properties.links = links.map((link) =>
      Object.assign(
        { href: link.getAttribute("href") },
        getMulti(link, ["text", "type"])
      )
    );
  }

  return properties;
}

lib/index.d.ts

I'm not very knowledgeable about typescript, anyway I think it could result in something like this:

// Based on https://stackoverflow.com/questions/42262565/how-to-augment-typescript-interface-in-d-ts

import * as geojson from 'geojson';

declare module 'geojson' {
  namespace GeoJSON {
    export interface FeatureCollection<G extends geojson.Geometry | null = geojson.Geometry, P = geojson.GeoJsonProperties> extends geojson.GeoJsonObject {
      metadata?: {
        name?: string;
        desc?: string;
        author?: {
          name?: string;
          email?: string;
          link?: {
            href: string;
            text?: string;
            type?: string;
          };
        };
        copyright?: {
          author?: string;
          year?: string;
          license?: string;
        };
        time?: string;
        keywords?: string;
        bounds?: [number, number, number, number]
        extensions?: any;
      };
    }
  }
}

I'm really sorry, but at the moment I can't say how much additional re-work might be needed to integrate it in the generation of current dist/index.d.ts file.

Hoping that somehow these notes can be useful

👋 Raruto

tmcw commented 1 year ago

Personally I think I'd prefer a separate method, like toGeoJSON.gpxMetadata() -> metadata object rather than putting properties on FeatureCollection. I still think that putting properties on a FeatureCollection is technically allowed but practically dangerous: as soon as you send that GeoJSON through a transformation step like Turf.js, the extra properties will be removed. They won't be accessible via most GeoJSON editors or through map libraries.

I think it just makes more sense to have a separate process to get metadata, and if people strongly want to combine that metadata with their FeatureCollection object, they can do so with { ...featureCollection, ...metadata }.

Raruto commented 1 year ago

Based on: https://gis.stackexchange.com/a/415412

You can insert a Polygon feature without any coordinates and set your properties as you like:

{
 "type": "FeatureCollection",
  "features": [
    {
      "type": "Feature",
      "geometry": {
        "type": "Polygon",
        "coordinates": [          
        ]
      },
      "properties": {
        "description": "This is the geometry for..."
      }
    },
    {
      // Other features
    }
  ]
}

Maybe something like this would be even more easier to store and therefore portable (also when it comes to snapshot testing):

{
  "type": "FeatureCollection",
  "features": [
    {
      "type": "Feature",
      "geometry": {
        "type": "Polygon",
        "coordinates": [ ]     // eventually populated by https://www.topografix.com/GPX/1/1/#type_boundsType
      },
      "properties": {
        "_gpxType": "metadata" // everything else related to https://www.topografix.com/GPX/1/1/#type_metadataType
        "name": "...",
        "desc": "...",
        "author": {
            "name": "...",
            "email": "",
            "link": {
               "href": "...",
               "text": "...",
               "type": "...",
            }
        },
        "copyright": {
          "author": "...",
          "year": "...",
          "license": "..."
        },
        "time": "...",
        "keywords": "...",
        "extensions": ??
    },
    {
      // Other features
    }
  ]
}

Not really sure if these property names make sense:

but that's just to give you a quick idea.

Related info:

👋 Raruto