-
Notifications
You must be signed in to change notification settings - Fork 2.2k
[WIP] CARTO: Add TrajectoryTileLayer #9757
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from 2 commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,72 @@ | ||
// deck.gl | ||
// SPDX-License-Identifier: MIT | ||
// Copyright (c) vis.gl contributors | ||
|
||
import type {ProcessedGeometry} from './trajectory-utils'; | ||
|
||
// Helper functions for speed calculation | ||
function deg2rad(deg: number): number { | ||
return deg * (Math.PI / 180); | ||
} | ||
|
||
function distanceBetweenPoints([lon1, lat1, lon2, lat2]: number[]): number { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What are the units of the return value of |
||
const R = 6371000; // Radius of the earth in m | ||
const dLat = deg2rad(lat2 - lat1); | ||
const dLon = deg2rad(lon2 - lon1); | ||
const a = | ||
Math.sin(dLat / 2) * Math.sin(dLat / 2) + | ||
Math.cos(deg2rad(lat1)) * Math.cos(deg2rad(lat2)) * Math.sin(dLon / 2) * Math.sin(dLon / 2); | ||
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); | ||
} | ||
|
||
/** | ||
* Calculate speeds and write to numericProps attribute for trajectory geometry | ||
*/ | ||
export function autocomputeSpeed(geometry: ProcessedGeometry): void { | ||
const {positions, attributes} = geometry; | ||
const n = positions.value.length / positions.size; | ||
geometry.numericProps.speed = {value: new Float32Array(n), size: 1}; | ||
|
||
// Calculate speed and write to numericProps | ||
let previousSpeed = 0; | ||
|
||
for (let i = 0; i < n; i++) { | ||
let speed = 0; | ||
|
||
if (i < n - 1) { | ||
const start = i === n - 1 ? i - 1 : i; | ||
const step = positions.value.subarray( | ||
positions.size * start, | ||
positions.size * start + 2 * positions.size | ||
); | ||
let lat1: number = 0; | ||
let lat2: number = 0; | ||
let lon1: number = 0; | ||
let lon2: number = 0; | ||
|
||
if (positions.size === 2) { | ||
[lon1, lat1, lon2, lat2] = step; | ||
} else if (positions.size === 3) { | ||
[lon1, lat1, , lon2, lat2] = step; // skip altitude values | ||
} | ||
|
||
const deltaP = distanceBetweenPoints([lon1, lat1, lon2, lat2]); | ||
const [t1, t2] = attributes.getTimestamps.value.subarray(start, start + 2); | ||
const deltaT = t2 - t1; | ||
speed = deltaT > 0 ? deltaP / deltaT : previousSpeed; | ||
|
||
if (deltaT === 0) { | ||
speed = previousSpeed; | ||
} | ||
|
||
previousSpeed = speed; | ||
} | ||
|
||
if (speed === 0) { | ||
speed = 100; // fallback speed | ||
} | ||
|
||
// Write speed to numericProps | ||
geometry.numericProps.speed.value[i] = speed; | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,198 @@ | ||
// deck.gl | ||
// SPDX-License-Identifier: MIT | ||
// Copyright (c) vis.gl contributors | ||
|
||
import {CompositeLayerProps, DefaultProps} from '@deck.gl/core'; | ||
import {_Tile2DHeader, TripsLayer, TripsLayerProps} from '@deck.gl/geo-layers'; | ||
import {GeoJsonLayer} from '@deck.gl/layers'; | ||
|
||
import type {TilejsonResult} from '@carto/api-client'; | ||
import VectorTileLayer, {VectorTileLayerProps} from './vector-tile-layer'; | ||
import {transformTrajectoryData, type TileBounds, normalizeTimestamp} from './trajectory-utils'; | ||
import {autocomputeSpeed} from './trajectory-speed-utils'; | ||
import {createBinaryProxy, createEmptyBinary} from '../utils'; | ||
|
||
const defaultProps: DefaultProps<TrajectoryTileLayerProps> = { | ||
...TripsLayer.defaultProps, | ||
data: VectorTileLayer.defaultProps.data, | ||
autocomputeSpeed: false, | ||
renderMode: 'trips', | ||
currentTime: 0, | ||
trailLength: 1000 | ||
}; | ||
|
||
/** All properties supported by TrajectoryTileLayer. */ | ||
export type TrajectoryTileLayerProps<FeaturePropertiesT = unknown> = _TrajectoryTileLayerProps & | ||
Omit<TripsLayerProps<FeaturePropertiesT>, 'data'> & | ||
Pick< | ||
VectorTileLayerProps<FeaturePropertiesT>, | ||
'getFillColor' | 'getLineColor' | 'uniqueIdProperty' | ||
>; | ||
|
||
/** Properties added by TrajectoryTileLayer. */ | ||
type _TrajectoryTileLayerProps = { | ||
data: null | TilejsonResult | Promise<TilejsonResult>; | ||
|
||
/** | ||
* Set to true to automatically compute speed for each vertex and store in properties.speed | ||
*/ | ||
autocomputeSpeed?: boolean; | ||
|
||
/** | ||
* Rendering mode for trajectories. | ||
* - 'paths': Static path rendering | ||
* - 'trips': Animated trip rendering with time controls | ||
*/ | ||
renderMode?: 'paths' | 'trips'; | ||
Comment on lines
+41
to
+46
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Optional: I slightly prefer your |
||
}; | ||
|
||
/** | ||
* Helper function to wrap `getFillColor` accessor into a `getLineColor` accessor | ||
* which will invoke `getFillColor` for each vertex in the line | ||
* @param getFillColor | ||
* @returns | ||
*/ | ||
function getLineColorFromFillColor(getFillColor: TrajectoryTileLayerProps['getFillColor']) { | ||
return (d: any, info: any) => { | ||
const {index, data, target} = info; | ||
const startIndex = data.startIndices[index]; | ||
const endIndex = data.startIndices[index + 1]; | ||
const nVertices = endIndex - startIndex; | ||
const colors = new Array(nVertices).fill(0).map((_, i) => { | ||
const vertexIndex = startIndex + i; | ||
const properties = createBinaryProxy(data, vertexIndex); | ||
const vertex = {properties} as any; | ||
return typeof getFillColor === 'function' | ||
? getFillColor(vertex, {index: vertexIndex, data, target}) | ||
: getFillColor; | ||
}); | ||
|
||
return colors; | ||
}; | ||
} | ||
|
||
// @ts-ignore | ||
export default class TrajectoryTileLayer< | ||
FeaturePropertiesT = any, | ||
ExtraProps extends {} = {} | ||
> extends VectorTileLayer<FeaturePropertiesT, Required<_TrajectoryTileLayerProps> & ExtraProps> { | ||
static layerName = 'TrajectoryTileLayer'; | ||
static defaultProps = defaultProps; | ||
|
||
state!: VectorTileLayer['state'] & { | ||
trajectoryIdColumn: string; | ||
timestampColumn: string; | ||
minTime: number; | ||
maxTime: number; | ||
}; | ||
|
||
constructor(...propObjects: TrajectoryTileLayerProps<FeaturePropertiesT>[]) { | ||
// @ts-ignore | ||
super(...propObjects); | ||
} | ||
|
||
updateState(parameters) { | ||
super.updateState(parameters); | ||
if (parameters.props.data && parameters.props.data.widgetSource) { | ||
const dataSourceProps = parameters.props.data.widgetSource.props; | ||
const {trajectoryIdColumn, timestampColumn} = dataSourceProps; | ||
|
||
if (!trajectoryIdColumn) { | ||
throw new Error( | ||
'TrajectoryTileLayer: trajectoryIdColumn is required in data source configuration' | ||
); | ||
} | ||
if (!timestampColumn) { | ||
throw new Error( | ||
'TrajectoryTileLayer: timestampColumn is required in data source configuration' | ||
); | ||
} | ||
Comment on lines
+100
to
+109
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Optional - sliiightly more concise/readable with an assert() here maybe? |
||
|
||
this.setState({trajectoryIdColumn, timestampColumn}); | ||
} | ||
|
||
// Read timestampRange from the data source (tilejson) | ||
if (parameters.props.data && parameters.props.data.timestampRange) { | ||
const {min, max} = parameters.props.data.timestampRange; | ||
const minTime = normalizeTimestamp(min); | ||
const maxTime = normalizeTimestamp(max); | ||
this.setState({minTime, maxTime}); | ||
} | ||
} | ||
|
||
async getTileData(tile) { | ||
const data = await super.getTileData(tile); | ||
if (!data || !data.points) return data; | ||
|
||
// Get tile bounds from the tile object | ||
const tileBounds = tile.bbox as TileBounds; | ||
const {minTime, maxTime} = this.state; | ||
|
||
const lines = transformTrajectoryData( | ||
data.points, | ||
this.state.trajectoryIdColumn, | ||
this.state.timestampColumn, | ||
tileBounds, | ||
{min: minTime, max: maxTime} | ||
); | ||
|
||
if (!lines) return null; | ||
if (this.props.autocomputeSpeed) { | ||
autocomputeSpeed(lines); | ||
} | ||
return {...createEmptyBinary(), lines}; | ||
} | ||
|
||
renderSubLayers( | ||
props: TrajectoryTileLayerProps & { | ||
id: string; | ||
data: any; | ||
_offset: number; | ||
tile: _Tile2DHeader; | ||
_subLayerProps: CompositeLayerProps['_subLayerProps']; | ||
} | ||
): GeoJsonLayer | GeoJsonLayer[] | null { | ||
if (props.data === null) { | ||
return null; | ||
} | ||
|
||
// This may not be as efficient as just rendering a PathLayer, but it allows to | ||
// switch between the render modes without reloading data | ||
const showTrips = props.renderMode === 'trips'; | ||
|
||
// Normalize currentTime to match the normalized timestamps in the data | ||
const normalizedCurrentTime = props.currentTime! - this.state.minTime; | ||
const {minTime, maxTime} = this.state; | ||
const totalTimeSpan = maxTime - minTime; | ||
|
||
const layerProps = { | ||
getWidth: props.getWidth, | ||
widthUnits: props.widthUnits || 'pixels', | ||
lineJointRounded: props.jointRounded !== undefined ? props.jointRounded : true, | ||
capRounded: props.capRounded !== undefined ? props.capRounded : true, | ||
_pathType: props._pathType || 'open' | ||
}; | ||
|
||
const getLineColor = props.getFillColor | ||
? getLineColorFromFillColor(props.getFillColor) | ||
: props.getLineColor; | ||
|
||
const modifiedProps = { | ||
...props, | ||
getLineColor, | ||
_subLayerProps: { | ||
...props._subLayerProps, | ||
linestrings: { | ||
type: TripsLayer, | ||
currentTime: showTrips ? normalizedCurrentTime : totalTimeSpan, | ||
trailLength: showTrips ? props.trailLength : totalTimeSpan, | ||
parameters: {depthTest: false}, | ||
...layerProps, | ||
...props._subLayerProps?.linestrings | ||
} | ||
} | ||
}; | ||
|
||
return super.renderSubLayers(modifiedProps as any); | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Optional: There's
toRadians
in@math.gl/core
, but it doesn't seem to be used elsewhere in the codebase so no strong preference here.