UNPKG

terriajs

Version:

Geospatial data visualization platform.

530 lines (476 loc) 17.4 kB
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); } @computed 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; } @observable 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()]; }); @computed 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(); } @computed get mapItems(): DataSource[] { this._dataSource.show = this.show; return [this.dataSource]; } @computed private get _cesiumUpAxis() { if (this.model.upAxis === undefined) { return Axis.Y; } return Axis.fromName(this.model.upAxis); } @computed private get _cesiumForwardAxis() { if (this.model.forwardAxis === undefined) { return Axis.Z; } return Axis.fromName(this.model.forwardAxis); } @computed 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); } @computed 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; }