terriajs
Version:
Geospatial data visualization platform.
530 lines (476 loc) • 17.4 kB
text/typescript
import { get as _get } from "lodash-es";
import {
computed,
IReactionDisposer,
makeObservable,
observable,
runInAction
} from "mobx";
import { createTransformer, ITransformer } from "mobx-utils";
import Pbf from "pbf";
import Cartesian3 from "terriajs-cesium/Source/Core/Cartesian3";
import Color from "terriajs-cesium/Source/Core/Color";
import HeadingPitchRoll from "terriajs-cesium/Source/Core/HeadingPitchRoll";
import JulianDate from "terriajs-cesium/Source/Core/JulianDate";
import NearFarScalar from "terriajs-cesium/Source/Core/NearFarScalar";
import Transforms from "terriajs-cesium/Source/Core/Transforms";
import BillboardGraphics from "terriajs-cesium/Source/DataSources/BillboardGraphics";
import ConstantPositionProperty from "terriajs-cesium/Source/DataSources/ConstantPositionProperty";
import ConstantProperty from "terriajs-cesium/Source/DataSources/ConstantProperty";
import DataSource from "terriajs-cesium/Source/DataSources/CustomDataSource";
import Entity from "terriajs-cesium/Source/DataSources/Entity";
import ModelGraphics from "terriajs-cesium/Source/DataSources/ModelGraphics";
import PointGraphics from "terriajs-cesium/Source/DataSources/PointGraphics";
import PropertyBag from "terriajs-cesium/Source/DataSources/PropertyBag";
import Axis from "terriajs-cesium/Source/Scene/Axis";
import ColorBlendMode from "terriajs-cesium/Source/Scene/ColorBlendMode";
import HeightReference from "terriajs-cesium/Source/Scene/HeightReference";
import ShadowMode from "terriajs-cesium/Source/Scene/ShadowMode";
import isDefined from "../../../Core/isDefined";
import loadArrayBuffer from "../../../Core/loadArrayBuffer";
import TerriaError from "../../../Core/TerriaError";
import AutoRefreshingMixin from "../../../ModelMixins/AutoRefreshingMixin";
import CatalogMemberMixin from "../../../ModelMixins/CatalogMemberMixin";
import MappableMixin from "../../../ModelMixins/MappableMixin";
import UrlMixin from "../../../ModelMixins/UrlMixin";
import GtfsCatalogItemTraits from "../../../Traits/TraitsClasses/GtfsCatalogItemTraits";
import { RectangleTraits } from "../../../Traits/TraitsClasses/MappableTraits";
import CreateModel from "../../Definition/CreateModel";
import createStratumInstance from "../../Definition/createStratumInstance";
import LoadableStratum from "../../Definition/LoadableStratum";
import { BaseModel } from "../../Definition/Model";
import StratumOrder from "../../Definition/StratumOrder";
import Terria from "../../Terria";
import proxyCatalogItemUrl from "../proxyCatalogItemUrl";
import {
FeedEntity,
FeedMessage,
FeedMessageReader
} from "./GtfsRealtimeProtoBufReaders";
import prettyPrintGtfsEntityField from "./prettyPrintGtfsEntityField";
import VehicleData from "./VehicleData";
interface RectangleExtent {
east: number;
south: number;
west: number;
north: number;
}
class GtfsStratum extends LoadableStratum(GtfsCatalogItemTraits) {
static stratumName = "gtfs";
constructor(private readonly _item: GtfsCatalogItem) {
super();
makeObservable(this);
}
duplicateLoadableStratum(newModel: BaseModel): this {
return new GtfsStratum(newModel as GtfsCatalogItem) as this;
}
static async load(item: GtfsCatalogItem) {
return new GtfsStratum(item);
}
get rectangle() {
return createStratumInstance(RectangleTraits, this._item._bbox);
}
}
StratumOrder.addLoadStratum(GtfsStratum.stratumName);
/**
* For displaying realtime transport data. See [here](https://developers.google.com/transit/gtfs-realtime/reference/)
* for the spec.
*/
export default class GtfsCatalogItem extends UrlMixin(
AutoRefreshingMixin(
MappableMixin(CatalogMemberMixin(CreateModel(GtfsCatalogItemTraits)))
)
) {
disposer: IReactionDisposer | undefined;
_bbox: RectangleExtent = {
west: Infinity,
south: Infinity,
east: -Infinity,
north: -Infinity
};
/**
* Always use the getter to read this. This is a cache for a computed property.
*
* We cache it because recreating it reactively is computationally expensive, so we modify it reactively instead.
*/
protected _dataSource: DataSource = new DataSource("billboard");
protected static readonly FEATURE_INFO_TEMPLATE_FIELDS: string[] = [
"route_short_name",
"occupancy_status#str",
"speed#km",
"speed",
"bearing"
];
static get type() {
return "gtfs";
}
get type() {
return GtfsCatalogItem.type;
}
protected gtfsFeedEntities: FeedEntity[] = [];
protected convertManyFeedEntitiesToBillboardData: ITransformer<
FeedEntity[],
VehicleData[]
> = createTransformer((feedEntities: FeedEntity[]) => {
// Sometimes the feed can contain many records for the same vehicle
// so we'll only display the newest record.
// Although technically the timestamp property is optional, if none is
// present we'll show the record.
const vehicleMap = new Map();
for (let i = 0; i < feedEntities.length; ++i) {
const entity: FeedEntity = feedEntities[i];
const item: VehicleData = this.convertFeedEntityToBillboardData(entity);
if (item && item.position && item.featureInfo) {
const vehicleInfo = item.featureInfo.get("entity").vehicle.vehicle;
if (vehicleMap.has(vehicleInfo.id) && vehicleInfo.timestamp) {
const existingRecord = vehicleMap.get(vehicleInfo.id);
if (existingRecord.timestamp < vehicleInfo.timestamp) {
vehicleMap.set(vehicleInfo.id, item);
}
} else {
vehicleMap.set(vehicleInfo.id, item);
}
}
}
return [...vehicleMap.values()];
});
protected get dataSource(): DataSource {
this._dataSource.entities.suspendEvents();
// Convert the GTFS protobuf into a more useful shape
const vehicleData: VehicleData[] =
this.convertManyFeedEntitiesToBillboardData(this.gtfsFeedEntities);
for (const data of vehicleData) {
if (data.sourceId === undefined) {
continue;
}
const entity: Entity = this._dataSource.entities.getOrCreateEntity(
data.sourceId
);
if (!entity.model) {
if (this._coloredModels) {
const gtfsEntity: FeedEntity = data.featureInfo?.get("entity");
const value = _get(
gtfsEntity,
this.model.colorModelsByProperty.property!
);
if (value !== undefined) {
const index =
this.model.colorModelsByProperty.colorGroups.findIndex(
(colorGroup) =>
colorGroup.regExp !== undefined &&
new RegExp(colorGroup.regExp).test(value)
);
if (index !== -1) {
entity.model = this._coloredModels[index];
}
entity.point = undefined;
} else {
entity.model = this._model;
}
} else if (this._model) {
entity.model = this._model;
}
}
if (
this.model !== undefined &&
this.model !== null &&
data.orientation !== undefined &&
data.orientation !== null
) {
entity.orientation = new ConstantProperty(data.orientation);
}
if (
data.position !== undefined &&
(!entity.position ||
entity.position.getValue(new JulianDate()) !== data.position)
) {
entity.position = new ConstantPositionProperty(data.position);
}
// If we're using a billboard
if (data.billboard !== null && data.billboard !== undefined) {
if (entity.billboard === null || entity.billboard === undefined) {
entity.billboard = data.billboard;
}
if (data.billboard.color) {
data.billboard.color.getValue(new JulianDate()).alpha = this.opacity;
}
if (
!entity.billboard.color ||
!entity.billboard.color.equals(data.billboard.color)
) {
entity.billboard.color = data.billboard.color;
}
}
// If we're using a point
if (data.point !== null && data.point !== undefined) {
if (entity.point === null || entity.point === undefined) {
entity.point = data.point;
}
if (data.point.color) {
data.point.color.getValue(new JulianDate()).alpha = this.opacity;
}
if (
!entity.point.color ||
!entity.point.color.equals(data.point.color)
) {
entity.point.color = data.point.color;
}
}
if (data.featureInfo !== undefined && data.featureInfo !== null) {
entity.properties = new PropertyBag();
for (const key of data.featureInfo.keys()) {
entity.properties.addProperty(key, data.featureInfo.get(key));
}
}
}
// remove entities that no longer exist
if (this._dataSource.entities.values.length > vehicleData.length) {
const idSet = new Set(vehicleData.map((val) => val.sourceId));
this._dataSource.entities.values
.filter((entity) => !idSet.has(entity.id))
.forEach((entity) => this._dataSource.entities.remove(entity));
}
this._dataSource.entities.resumeEvents();
return this._dataSource;
}
refreshData() {
this.forceLoadMapItems();
}
get mapItems(): DataSource[] {
this._dataSource.show = this.show;
return [this.dataSource];
}
private get _cesiumUpAxis() {
if (this.model.upAxis === undefined) {
return Axis.Y;
}
return Axis.fromName(this.model.upAxis);
}
private get _cesiumForwardAxis() {
if (this.model.forwardAxis === undefined) {
return Axis.Z;
}
return Axis.fromName(this.model.forwardAxis);
}
private get _model() {
if (this.model.url === undefined) {
return undefined;
}
const options = {
uri: new ConstantProperty(this.model.url),
upAxis: new ConstantProperty(this._cesiumUpAxis),
forwardAxis: new ConstantProperty(this._cesiumForwardAxis),
scale: new ConstantProperty(this.model.scale ?? 1),
heightReference: new ConstantProperty(HeightReference.RELATIVE_TO_GROUND),
distanceDisplayCondition: new ConstantProperty({
near: 0.0,
far: this.model.maximumDistance
}),
maximumScale: new ConstantProperty(this.model.maximumScale),
minimumPixelSize: new ConstantProperty(this.model.minimumPixelSize ?? 0),
shadows: ShadowMode.DISABLED
};
return new ModelGraphics(options);
}
private get _coloredModels() {
const colorGroups = this.model?.colorModelsByProperty?.colorGroups;
const model = this._model;
if (
!isDefined(model) ||
!isDefined(this.model?.colorModelsByProperty?.property) ||
!isDefined(colorGroups) ||
colorGroups.length === 0
) {
return undefined;
}
return colorGroups.map(({ color }) => {
const coloredModel = model.clone();
coloredModel.color = new ConstantProperty(
Color.fromCssColorString(color ?? "white")
);
coloredModel.colorBlendMode = new ConstantProperty(ColorBlendMode.MIX);
coloredModel.colorBlendAmount = new ConstantProperty(0.7);
return coloredModel;
});
}
constructor(
id: string | undefined,
terria: Terria,
sourceReference?: BaseModel
) {
super(id, terria, sourceReference);
makeObservable(this);
}
protected forceLoadMetadata(): Promise<void> {
return Promise.resolve();
}
protected forceLoadMapItems(): Promise<void> {
if (this.strata.get(GtfsStratum.stratumName) === undefined) {
GtfsStratum.load(this).then((stratum) => {
runInAction(() => {
this.strata.set(GtfsStratum.stratumName, stratum);
});
});
}
const promise: Promise<void> = this.retrieveData()
.then((data: FeedMessage) => {
runInAction(() => {
if (data.entity !== undefined && data.entity !== null) {
this.gtfsFeedEntities = data.entity;
this.terria.currentViewer.notifyRepaintRequired();
}
});
})
.catch((_e: Error) => {
throw new TerriaError({
title: `Could not load ${this.nameInCatalog}.`,
sender: this,
message: `There was an error loading the data for ${this.nameInCatalog}.`
});
});
return promise;
}
protected retrieveData(): Promise<FeedMessage> {
// These headers work for the Transport for NSW APIs. Presumably, other services will require different headers.
const headers: any = {
"Content-Type": "application/x-google-protobuf;charset=UTF-8",
"Cache-Control": "no-cache"
};
if (this.headers !== undefined) {
this.headers.forEach(({ name, value }) => {
if (name !== undefined && value !== undefined) headers[name] = value;
});
}
if (this.url !== null && this.url !== undefined) {
return loadArrayBuffer(proxyCatalogItemUrl(this, this.url), headers).then(
(arr: ArrayBuffer) => {
const pbfBuffer = new Pbf(new Uint8Array(arr));
return new FeedMessageReader().read(pbfBuffer);
}
);
} else {
return Promise.reject();
}
}
protected convertFeedEntityToBillboardData(entity: FeedEntity): VehicleData {
if (entity.id === undefined) {
return {};
}
let position = undefined;
let orientation = undefined;
const featureInfo: Map<string, any> = new Map();
if (
entity.vehicle !== null &&
entity.vehicle !== undefined &&
entity.vehicle.position !== null &&
entity.vehicle.position !== undefined &&
entity.vehicle.position.latitude !== null &&
entity.vehicle.position.latitude !== undefined &&
entity.vehicle.position.longitude !== null &&
entity.vehicle.position.longitude !== undefined &&
entity.vehicle.position.bearing !== null &&
entity.vehicle.position.bearing !== undefined
) {
updateBbox(
entity.vehicle.position.latitude,
entity.vehicle.position.longitude,
this._bbox
);
position = Cartesian3.fromDegrees(
entity.vehicle.position.longitude,
entity.vehicle.position.latitude
);
orientation = Transforms.headingPitchRollQuaternion(
position,
HeadingPitchRoll.fromDegrees(
entity.vehicle.position.bearing - 90.0,
0.0,
0.0
)
);
}
// Add the values that the feature info template gets populated with
for (const field of GtfsCatalogItem.FEATURE_INFO_TEMPLATE_FIELDS) {
featureInfo.set(field, prettyPrintGtfsEntityField(field, entity));
}
featureInfo.set("entity", entity);
let billboard;
let point;
if (this.image !== undefined && this.image !== null) {
billboard = new BillboardGraphics({
image: new ConstantProperty(this.image),
heightReference: new ConstantProperty(
HeightReference.RELATIVE_TO_GROUND
),
scaleByDistance:
this.scaleImageByDistance.nearValue ===
this.scaleImageByDistance.farValue
? undefined
: new ConstantProperty(
new NearFarScalar(
this.scaleImageByDistance.near,
this.scaleImageByDistance.nearValue,
this.scaleImageByDistance.far,
this.scaleImageByDistance.farValue
)
),
scale:
this.scaleImageByDistance.nearValue ===
this.scaleImageByDistance.farValue &&
this.scaleImageByDistance.nearValue !== 1.0
? new ConstantProperty(this.scaleImageByDistance.nearValue)
: undefined,
color: new ConstantProperty(new Color(1.0, 1.0, 1.0, this.opacity))
});
} else {
point = new PointGraphics({
color: new ConstantProperty(Color.CYAN),
outlineWidth: new ConstantProperty(1),
outlineColor: new ConstantProperty(Color.WHITE),
scaleByDistance:
this.scaleImageByDistance.nearValue ===
this.scaleImageByDistance.farValue
? undefined
: new ConstantProperty(
new NearFarScalar(
this.scaleImageByDistance.near,
this.scaleImageByDistance.nearValue,
this.scaleImageByDistance.far,
this.scaleImageByDistance.farValue
)
),
pixelSize:
this.scaleImageByDistance.nearValue ===
this.scaleImageByDistance.farValue &&
this.scaleImageByDistance.nearValue !== 1.0
? new ConstantProperty(32 * this.scaleImageByDistance.nearValue)
: new ConstantProperty(32),
heightReference: new ConstantProperty(
HeightReference.RELATIVE_TO_GROUND
)
});
}
return {
sourceId: entity.id,
position: position,
orientation: orientation,
featureInfo: featureInfo,
billboard: billboard,
point: point
};
}
}
function updateBbox(lat: number, lon: number, rectangle: RectangleExtent) {
if (lon < rectangle.west) rectangle.west = lon;
if (lat < rectangle.south) rectangle.south = lat;
if (lon > rectangle.east) rectangle.east = lon;
if (lat > rectangle.north) rectangle.north = lat;
}