Prerequisites

In this tutorial, it is assumed that you are familiar with:

Goal

In this tutorial, we are going to add support for a custom vector format.

Most of the time, it suffices to use one of the many model decoders provided by the LuciadLightspeed API to read business data from an external source. For example, to decode SHP data, you can use the model decoders available in the LuciadLightspeed API.

However, sometimes you have data defined in a custom format for which LuciadLightspeed does not provide a default model decoder. In that case, you need to create the model and the decoder yourself.

To create a model decoder, you need to choose or define LuciadLightspeed classes for:

  • The domain objects (the model data)

  • The model reference

  • The model descriptor

  • The model

In this tutorial, we are going to perform all those steps.

Once we have the model decoder, we can visualize the data using the standard LuciadLightspeed API classes.

The custom file format

The file format we are going to support is a fictitious file format for waypoints, geographical locations used for aircraft navigation.

Each waypoint is represented by:

  • A line with the identifier of the waypoint

  • A line containing a set of coordinates for the waypoint, specifying its longitude, latitude and height.

The coordinates are expressed as double values, and are separated by a space.

There is an empty line between each waypoint.

For example:

WayPoint WP001
-94.63313085049567 40.01131142211313  5000

Waypoint WP002
-96.32255011830625 39.78181694038106  5000

WayPoint WP003
-101.42797384139817 38.90782811827928 5000

You can download the example file custom.cwp here.

Modeling the domain objects

We are going to use ILcdDataObject instances for our domain objects, because it offers us the benefit of re-using the standard LuciadLightspeed classes, when we are labeling the data on the map, for example.

Similar to what we did in the Introduction to the data modeling API tutorial, we create the TLcdDataModel and the TLcdDataType for our domain objects first:

Program: Creating the TLcdDataModel
public static final TLcdDataModel DATA_MODEL;
public static final TLcdDataType WAYPOINT_TYPE;

static {
  //Create the TLcdDataModel for our waypoints
  TLcdDataModelBuilder dataModelBuilder =
      new TLcdDataModelBuilder("http://www.luciad.com/tutorial/waypoints");

  //Create a TLcdDataType for the geometry
  TLcdDataTypeBuilder pointTypeBuilder =
      dataModelBuilder.typeBuilder("PointType")
                      .superType(TLcdShapeDataTypes.SHAPE_TYPE)
                      .primitive(true)
                      .instanceClass(TLcdLonLatHeightPoint.class);

  TLcdDataTypeBuilder wayPointTypeBuilder =
      dataModelBuilder.typeBuilder("WayPointType");
  wayPointTypeBuilder.addProperty("identifier", TLcdCoreDataTypes.STRING_TYPE);
  wayPointTypeBuilder.addProperty("location", pointTypeBuilder);

  TLcdDataModel dataModel = dataModelBuilder.createDataModel();
  //Specify which property holds the geometry
  TLcdDataType wayPointType = dataModel.getDeclaredType("WayPointType");
  wayPointType.addAnnotation(new TLcdHasGeometryAnnotation(wayPointType.getProperty("location")));

  DATA_MODEL = dataModel;
  WAYPOINT_TYPE = wayPointType;
}

For the geometry, we use TLcdLonLatHeightPoint instances. This is a point shape with a longitude, latitude and elevation, an exact match to our waypoints.

Creating the model decoder

Creating a model decoder comes down to implementing the ILcdModelDecoder interface.

We start by implementing the getDisplayName method. This method should return a human-readable name for the format that the model decoder can decode.

@Override
public String getDisplayName() {
  return "Way Points";
}

A model decoder should also indicate which sources it can handle. This check needs to be a fast one. A typical implementation checks the extension:

@Override
public boolean canDecodeSource(String aSourceName) {
  return aSourceName != null && aSourceName.endsWith(".cwp");
}

The last method to implement is the method that performs the actual decoding. In this method, we:

In code, this looks like:

@Override
public ILcdModel decode(String aSourceName) throws IOException {
  //Sanity check to see whether we can decode the data
  if (!canDecodeSource(aSourceName)) {
    throw new IOException("Cannot decode " + aSourceName);
  }

  //Create an empty model
  TLcd2DBoundsIndexedModel model = createEmptyModel();

  //With the correct reference
  //The reference of the model expresses how the coordinates of the
  //shapes should be interpreted
  model.setModelReference(createModelReference());

  //And a ILcdDataModelDescriptor to expose the data model
  ILcdDataModelDescriptor modelDescriptor = createModelDescriptor(aSourceName);
  model.setModelDescriptor(modelDescriptor);

  //Read the file and create the domain objects (=ILcdDataObjects of the WayPointType)
  List<ILcdDataObject> wayPoints = createWayPoints(aSourceName);

  //Add them to the model
  model.addElements(new Vector<>(wayPoints), ILcdModel.NO_EVENT);

  //Return the created model
  return model;
}

We use a TLcd2DBoundsIndexedModel as the model class. This model implementation provides a spatial index, allowing for fast queries of the data.

private TLcd2DBoundsIndexedModel createEmptyModel() {
  return new TLcd2DBoundsIndexedModel();
}

For the model reference, we use a WGS84 reference.

private ILcdModelReference createModelReference() {
  return new TLcdGeodeticReference();//WGS-84 reference
}

For the ILcdDataModelDescriptor, we use the default implementation of the interface: TLcdDataModelDescriptor.

private ILcdDataModelDescriptor createModelDescriptor(String aSourceName) {
  return new TLcdDataModelDescriptor(aSourceName,
                                     "CWP", //Typename: identifier of the format
                                     "Way Points",//Display name for the model
                                     DATA_MODEL,
                                     Collections.singleton(WAYPOINT_TYPE),//The type(s) of our model elements
                                     DATA_MODEL.getTypes());//All types used in the data model
}

The constructor needs to pass in the TLcdDataModel as well as the types of the domain objects. In our case, the model contains only waypoints, so we pass in a collection with a single element WAYPOINT_TYPE.

The last argument is a collection of all types used in the data model. This includes the WAYPOINT_TYPE, but also the TLcdCoreDataTypes.STRING_TYPE used for the identifier property of the waypoints.

Finally, we need to implement the reading of the file, and the creation of the ILcdDataObject instances.

private List<ILcdDataObject> createWayPoints(String aSourceName) throws IOException {
  List<ILcdDataObject> result = new ArrayList<>();

  //Use TLcdInputStreamFactory to create an InputStream for aSourceName
  //This allows to read files on the class path, absolute file paths, files embedded in a jar, ...
  TLcdInputStreamFactory isf = new TLcdInputStreamFactory();
  try (InputStream is = isf.createInputStream(aSourceName);
       BufferedReader reader = new BufferedReader(new InputStreamReader(is))) {

    String line;
    while ((line = reader.readLine()) != null) {
      //Skip empty lines
      if (line.trim().isEmpty()) {
        continue;
      }

      // The first non-empty line contains the way point name.
      String wayPointIdentifier = line;
      //The next line is the coordinates line
      String coordinatesLine = reader.readLine();

      if (coordinatesLine == null) {
        throw new IOException("Unexpected end of file encountered.");
      }

      String[] coordinates = coordinatesLine.split("\\s+");//split the line based on whitespace
      if (coordinates.length != 3) {
        throw new IOException("Expected <lon lat height>, but found " + coordinatesLine);
      }

      //Parse the coordinates and the elevation
      try {
        double lon = Double.parseDouble(coordinates[0]);
        double lat = Double.parseDouble(coordinates[1]);
        double height = Double.parseDouble(coordinates[2]);
        if (lon < -180 || lon > 180 || lat < -90 || lat > 90) {
          throw new NumberFormatException("The longitude and latitude must be in the interval " +
                                          "[-180, 180] and [-90, 90], respectively");
        }
        if (height < 0) {
          throw new NumberFormatException("The altitude of the way point must be positive");
        }
        //Create the waypoint and set the properties
        ILcdDataObject wayPoint = WAYPOINT_TYPE.newInstance();
        wayPoint.setValue("identifier", wayPointIdentifier);
        wayPoint.setValue("location", new TLcdLonLatHeightPoint(lon, lat, height));

        result.add(wayPoint);
      } catch (NumberFormatException ex) {
        throw new IOException(ex);
      }
    }
  }
  return result;
}

The model decoder can now be used to read waypoint files. The full code of the model decoder is available at the end of this tutorial.

Strictly speaking, there is no need to implement the ILcdModelDecoder interface if you want to create an ILcdModel of your custom format. You could also create the ILcdModel directly without using or needing an ILcdModelDecoder.

Visualizing the data

The ILcdModel created by the ILcdModelDecoder is a regular model with vector data. As such, it can be visualized and styled just like any other vector data model, SHP, GeoPackage, or GeoJSON models for instance.

See the Introduction to styling vector data tutorial for an introduction to visualizing and styling vector data on a Lightspeed map, or the Introduction to styling vector data tutorial for an introduction to visualizing and styling vector data on a GXY map.

Optional: Registering the model decoder as service

In your application, you typically want:

  • The same code path for opening waypoint files and files of any of the standard formats like SHP and GeoJSON. This makes your application code easier to write and maintain.

  • The same UI for the end-user of your application to load data. The end user should not know which data formats are custom and which are supported by the LuciadLightspeed API out-of-the-box.

Therefore, it is recommended to register your model decoder as a service. You register a decoder as a service by adding an annotation to the class:

@LcdService(service = ILcdModelDecoder.class)
public final class WayPointsModelDecoder implements ILcdModelDecoder {

Now, when you need to decode data, you can use a composite model decoder instance that collects all registered model decoders, and can decode formats like SHP or GeoJSON, as well as your custom waypoint format.

ILcdModelDecoder decoder =
    new TLcdCompositeModelDecoder(TLcdServiceLoader.getInstance(ILcdModelDecoder.class));

Full code

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Vector;

import com.luciad.datamodel.ILcdDataObject;
import com.luciad.datamodel.TLcdCoreDataTypes;
import com.luciad.datamodel.TLcdDataModel;
import com.luciad.datamodel.TLcdDataModelBuilder;
import com.luciad.datamodel.TLcdDataType;
import com.luciad.datamodel.TLcdDataTypeBuilder;
import com.luciad.io.TLcdInputStreamFactory;
import com.luciad.model.ILcdDataModelDescriptor;
import com.luciad.model.ILcdModel;
import com.luciad.model.ILcdModelDecoder;
import com.luciad.model.ILcdModelReference;
import com.luciad.model.TLcd2DBoundsIndexedModel;
import com.luciad.model.TLcdDataModelDescriptor;
import com.luciad.reference.TLcdGeodeticReference;
import com.luciad.shape.TLcdShapeDataTypes;
import com.luciad.shape.shape3D.TLcdLonLatHeightPoint;
import com.luciad.util.TLcdHasGeometryAnnotation;
import com.luciad.util.service.LcdService;

@LcdService(service = ILcdModelDecoder.class)
public final class WayPointsModelDecoder implements ILcdModelDecoder {
  public static final TLcdDataModel DATA_MODEL;
  public static final TLcdDataType WAYPOINT_TYPE;

  static {
    //Create the TLcdDataModel for our waypoints
    TLcdDataModelBuilder dataModelBuilder =
        new TLcdDataModelBuilder("http://www.luciad.com/tutorial/waypoints");

    //Create a TLcdDataType for the geometry
    TLcdDataTypeBuilder pointTypeBuilder =
        dataModelBuilder.typeBuilder("PointType")
                        .superType(TLcdShapeDataTypes.SHAPE_TYPE)
                        .primitive(true)
                        .instanceClass(TLcdLonLatHeightPoint.class);

    TLcdDataTypeBuilder wayPointTypeBuilder =
        dataModelBuilder.typeBuilder("WayPointType");
    wayPointTypeBuilder.addProperty("identifier", TLcdCoreDataTypes.STRING_TYPE);
    wayPointTypeBuilder.addProperty("location", pointTypeBuilder);

    TLcdDataModel dataModel = dataModelBuilder.createDataModel();
    //Specify which property holds the geometry
    TLcdDataType wayPointType = dataModel.getDeclaredType("WayPointType");
    wayPointType.addAnnotation(new TLcdHasGeometryAnnotation(wayPointType.getProperty("location")));

    DATA_MODEL = dataModel;
    WAYPOINT_TYPE = wayPointType;
  }

  @Override
  public String getDisplayName() {
    return "Way Points";
  }

  @Override
  public boolean canDecodeSource(String aSourceName) {
    return aSourceName != null && aSourceName.endsWith(".cwp");
  }

  @Override
  public ILcdModel decode(String aSourceName) throws IOException {
    //Sanity check to see whether we can decode the data
    if (!canDecodeSource(aSourceName)) {
      throw new IOException("Cannot decode " + aSourceName);
    }

    //Create an empty model
    TLcd2DBoundsIndexedModel model = createEmptyModel();

    //With the correct reference
    //The reference of the model expresses how the coordinates of the
    //shapes should be interpreted
    model.setModelReference(createModelReference());

    //And a ILcdDataModelDescriptor to expose the data model
    ILcdDataModelDescriptor modelDescriptor = createModelDescriptor(aSourceName);
    model.setModelDescriptor(modelDescriptor);

    //Read the file and create the domain objects (=ILcdDataObjects of the WayPointType)
    List<ILcdDataObject> wayPoints = createWayPoints(aSourceName);

    //Add them to the model
    model.addElements(new Vector<>(wayPoints), ILcdModel.NO_EVENT);

    //Return the created model
    return model;
  }

  private TLcd2DBoundsIndexedModel createEmptyModel() {
    return new TLcd2DBoundsIndexedModel();
  }

  private ILcdModelReference createModelReference() {
    return new TLcdGeodeticReference();//WGS-84 reference
  }

  private ILcdDataModelDescriptor createModelDescriptor(String aSourceName) {
    return new TLcdDataModelDescriptor(aSourceName,
                                       "CWP", //Typename: identifier of the format
                                       "Way Points",//Display name for the model
                                       DATA_MODEL,
                                       Collections.singleton(WAYPOINT_TYPE),//The type(s) of our model elements
                                       DATA_MODEL.getTypes());//All types used in the data model
  }

  private List<ILcdDataObject> createWayPoints(String aSourceName) throws IOException {
    List<ILcdDataObject> result = new ArrayList<>();

    //Use TLcdInputStreamFactory to create an InputStream for aSourceName
    //This allows to read files on the class path, absolute file paths, files embedded in a jar, ...
    TLcdInputStreamFactory isf = new TLcdInputStreamFactory();
    try (InputStream is = isf.createInputStream(aSourceName);
         BufferedReader reader = new BufferedReader(new InputStreamReader(is))) {

      String line;
      while ((line = reader.readLine()) != null) {
        //Skip empty lines
        if (line.trim().isEmpty()) {
          continue;
        }

        // The first non-empty line contains the way point name.
        String wayPointIdentifier = line;
        //The next line is the coordinates line
        String coordinatesLine = reader.readLine();

        if (coordinatesLine == null) {
          throw new IOException("Unexpected end of file encountered.");
        }

        String[] coordinates = coordinatesLine.split("\\s+");//split the line based on whitespace
        if (coordinates.length != 3) {
          throw new IOException("Expected <lon lat height>, but found " + coordinatesLine);
        }

        //Parse the coordinates and the elevation
        try {
          double lon = Double.parseDouble(coordinates[0]);
          double lat = Double.parseDouble(coordinates[1]);
          double height = Double.parseDouble(coordinates[2]);
          if (lon < -180 || lon > 180 || lat < -90 || lat > 90) {
            throw new NumberFormatException("The longitude and latitude must be in the interval " +
                                            "[-180, 180] and [-90, 90], respectively");
          }
          if (height < 0) {
            throw new NumberFormatException("The altitude of the way point must be positive");
          }
          //Create the waypoint and set the properties
          ILcdDataObject wayPoint = WAYPOINT_TYPE.newInstance();
          wayPoint.setValue("identifier", wayPointIdentifier);
          wayPoint.setValue("location", new TLcdLonLatHeightPoint(lon, lat, height));

          result.add(wayPoint);
        } catch (NumberFormatException ex) {
          throw new IOException(ex);
        }
      }
    }
    return result;
  }
}