diff --git a/modules/carto/package.json b/modules/carto/package.json index 1276b363340..0a2f015b1d2 100644 --- a/modules/carto/package.json +++ b/modules/carto/package.json @@ -42,7 +42,7 @@ "prepublishOnly": "npm run build-bundle && npm run build-bundle -- --env=dev" }, "dependencies": { - "@carto/api-client": "^0.5.6", + "@carto/api-client": "^0.5.15", "@loaders.gl/compression": "^4.2.0", "@loaders.gl/gis": "^4.2.0", "@loaders.gl/loader-utils": "^4.2.0", diff --git a/modules/carto/src/index.ts b/modules/carto/src/index.ts index 8e680e60c71..85f28b01a5a 100644 --- a/modules/carto/src/index.ts +++ b/modules/carto/src/index.ts @@ -9,6 +9,7 @@ import {default as HeatmapTileLayer} from './layers/heatmap-tile-layer'; import {default as PointLabelLayer} from './layers/point-label-layer'; import {default as QuadbinTileLayer} from './layers/quadbin-tile-layer'; import {default as RasterTileLayer} from './layers/raster-tile-layer'; +import {default as TrajectoryTileLayer} from './layers/trajectory-tile-layer'; import {default as VectorTileLayer} from './layers/vector-tile-layer'; // Exports for playground/bindings @@ -19,6 +20,7 @@ const CARTO_LAYERS = { PointLabelLayer, QuadbinTileLayer, RasterTileLayer, + TrajectoryTileLayer, VectorTileLayer }; export { @@ -29,6 +31,7 @@ export { PointLabelLayer, QuadbinTileLayer, RasterTileLayer, + TrajectoryTileLayer, VectorTileLayer }; @@ -47,6 +50,7 @@ export type {QuadbinTileLayerProps} from './layers/quadbin-tile-layer'; export type {RasterLayerProps} from './layers/raster-layer'; export type {RasterTileLayerProps} from './layers/raster-tile-layer'; export type {SpatialIndexTileLayerProps} from './layers/spatial-index-tile-layer'; +export type {TrajectoryTileLayerProps} from './layers/trajectory-tile-layer'; export type {VectorTileLayerProps} from './layers/vector-tile-layer'; // Helpers @@ -63,6 +67,7 @@ export {default as colorCategories} from './style/color-categories-style'; export {default as colorContinuous} from './style/color-continuous-style'; export {fetchMap, LayerFactory} from './api/fetch-map'; export {fetchBasemapProps} from './api/basemap'; +export {normalizeTimestamp} from './layers/trajectory-utils'; export type { FetchMapOptions, FetchMapResult, diff --git a/modules/carto/src/layers/trajectory-speed-utils.ts b/modules/carto/src/layers/trajectory-speed-utils.ts new file mode 100644 index 00000000000..45e8e7ebc0d --- /dev/null +++ b/modules/carto/src/layers/trajectory-speed-utils.ts @@ -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 { + 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; + } +} diff --git a/modules/carto/src/layers/trajectory-tile-layer.ts b/modules/carto/src/layers/trajectory-tile-layer.ts new file mode 100644 index 00000000000..bc982218541 --- /dev/null +++ b/modules/carto/src/layers/trajectory-tile-layer.ts @@ -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 = { + ...TripsLayer.defaultProps, + data: VectorTileLayer.defaultProps.data, + autocomputeSpeed: false, + renderMode: 'trips', + currentTime: 0, + trailLength: 1000 +}; + +/** All properties supported by TrajectoryTileLayer. */ +export type TrajectoryTileLayerProps = _TrajectoryTileLayerProps & + Omit, 'data'> & + Pick< + VectorTileLayerProps, + 'getFillColor' | 'getLineColor' | 'uniqueIdProperty' + >; + +/** Properties added by TrajectoryTileLayer. */ +type _TrajectoryTileLayerProps = { + data: null | TilejsonResult | Promise; + + /** + * 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'; +}; + +/** + * 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 & ExtraProps> { + static layerName = 'TrajectoryTileLayer'; + static defaultProps = defaultProps; + + state!: VectorTileLayer['state'] & { + trajectoryIdColumn: string; + timestampColumn: string; + minTime: number; + maxTime: number; + }; + + constructor(...propObjects: TrajectoryTileLayerProps[]) { + // @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' + ); + } + + 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); + } +} diff --git a/modules/carto/src/layers/trajectory-utils.ts b/modules/carto/src/layers/trajectory-utils.ts new file mode 100644 index 00000000000..981a011fe20 --- /dev/null +++ b/modules/carto/src/layers/trajectory-utils.ts @@ -0,0 +1,298 @@ +// deck.gl +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +import {BinaryLineFeature} from '@loaders.gl/schema'; +import {NumericProps} from './schema/spatialjson-utils'; + +/** + * Normalize timestamp to Unix seconds format + * @param timestamp - Either a Unix timestamp (number) or ISO string + * @returns Unix timestamp in seconds + */ +export function normalizeTimestamp(timestamp: number | string): number { + if (typeof timestamp === 'string') { + // Convert ISO timestamp string to Unix time in seconds + return new Date(timestamp).getTime() / 1000; + } + // Already a number, return as-is + return timestamp; +} + +export interface TileBounds { + west: number; + south: number; + east: number; + north: number; +} + +export interface TrajectoryPoint { + index: number; + timestamp: number; + position: [number, number]; + trajectoryId: string | number; +} + +export type ProcessedGeometry = BinaryLineFeature & { + attributes: { + getColor?: {value: Uint8Array; size: number; normalized: boolean}; + getTimestamps: {value: Float32Array; size: number}; + }; +}; + +function isPointInTileBounds(bounds: TileBounds, point?: TrajectoryPoint): boolean { + if (!point) return false; + const [lon, lat] = point.position; + return lon >= bounds.west && lon <= bounds.east && lat >= bounds.south && lat <= bounds.north; +} + +/** + * Extract trajectory and timestamp data from geometry, handling different data formats + */ +export function extractTrajectoryData( + geometry: any, + trajectoryIdColumn: string, + timestampColumn: string, + timestampRange?: {min: number | string; max: number | string} +): {tripIds: {value: any[]}; timestamps: {value: number[]}; properties?: any[]} { + const {numericProps, properties} = geometry; + + // Handle trajectory IDs + const tripIds = numericProps?.[trajectoryIdColumn] || { + value: properties?.map((p: any) => p[trajectoryIdColumn]) || [] + }; + + // Calculate timestamp normalization offset if timestampRange is provided + let timestampOffset = 0; + if (timestampRange) { + timestampOffset = normalizeTimestamp(timestampRange.min); + } + + // Handle timestamps - convert ISO strings to Unix time if needed and normalize + const timestamps = numericProps?.[timestampColumn] || { + value: + properties?.map((p: any) => { + const timestamp = p[timestampColumn]; + const unixTime = normalizeTimestamp(timestamp); + // Normalize timestamp to avoid 32-bit float precision issues + return unixTime - timestampOffset; + }) || [] + }; + + // Also normalize numeric timestamps if they exist + if (numericProps?.[timestampColumn] && timestampOffset > 0) { + timestamps.value = timestamps.value.map((t: number) => t - timestampOffset); + } + + return {tripIds, timestamps, properties}; +} + +/** + * Group trajectory points by trajectory ID and sort by timestamp + */ +export function groupAndSortTrajectoryPoints( + geometry: any, + tripIds: {value: any[]}, + timestamps: {value: number[]} +): Map { + const {positions} = geometry; + const n = geometry.featureIds?.value?.length || 0; + const trajectoryGroups = new Map(); + + for (let i = 0; i < n; i++) { + const trajectoryId = tripIds.value[i]; + const timestamp = timestamps.value[i]; + const position = positions.value.subarray(positions.size * i, positions.size * i + 2); + + if (!trajectoryGroups.has(trajectoryId)) { + trajectoryGroups.set(trajectoryId, []); + } + + trajectoryGroups.get(trajectoryId).push({ + index: i, + timestamp, + position, + trajectoryId + }); + } + + // Sort each trajectory by timestamp + for (const points of trajectoryGroups.values()) { + points.sort((a, b) => a.timestamp - b.timestamp); + } + + return trajectoryGroups; +} + +/** + * Filter trajectory points by tile bounds and create line segments + */ +export function createTrajectorySegments( + trajectoryGroups: Map, + tileBounds: TileBounds +): {validPoints: TrajectoryPoint[]; pathIndices: number[]} { + const pathIndices: number[] = []; + const validPoints: TrajectoryPoint[] = []; + let currentIndex = 0; + + for (const points of trajectoryGroups.values()) { + let trajectoryEntered = false; + for (let i = 0; i < points.length; i++) { + const point = points[i]; + const nextPoint = points[i + 1]; + const pointInBounds = isPointInTileBounds(tileBounds, point); + const nextPointInBounds = isPointInTileBounds(tileBounds, nextPoint); + + // Point is valid if it is in bounds or the next point is + if (pointInBounds || nextPointInBounds) { + if (!trajectoryEntered || !pointInBounds) { + // Start new segment, either due to: + // - New trajectory starting + // - Entering bounds + // Don't need to do anything for segment end, only start is marked + pathIndices.push(currentIndex); + trajectoryEntered = true; + } + + // Add actual point to positions array + validPoints.push(point); + currentIndex++; + } + } + } + + // Close last segment + pathIndices.push(currentIndex); + return {validPoints, pathIndices}; +} + +/** + * Rebuild geometry with filtered trajectory points + */ +export function rebuildGeometry( + originalGeometry: any, + validPoints: TrajectoryPoint[], + pathIndices: number[] +): ProcessedGeometry { + const {positions, numericProps, properties} = originalGeometry; + const newN = validPoints.length; + + if (newN === 0) { + throw new Error('No valid trajectory points after filtering'); + } + + const newPositions = new Float64Array(newN * positions.size); + const newProperties: BinaryLineFeature['properties'] = []; + const newTimestamps = {value: new Float32Array(newN), size: 1}; + + for (let i = 0; i < newN; i++) { + const point = validPoints[i]; + + // Copy point data to arrays + newPositions.set(point.position, positions.size * i); + newTimestamps.value[i] = point.timestamp; + } + + const size = 2; + const newNumericProps: NumericProps = {}; + const numericPropKeys = Object.keys(numericProps); + for (const prop of numericPropKeys) { + const propSize = numericProps[prop].size || 1; + newNumericProps[prop] = {value: new Float32Array(newN), size: propSize}; + } + + // Properties are per-line. + const newFeatureIds = new Uint16Array(newN); + let dst = 0; + for (let i = 0; i < pathIndices.length - 1; i++) { + const startIndex = pathIndices[i]; + const endIndex = pathIndices[i + 1]; + const point = validPoints[startIndex]; + const srcIndex = point.index; + const numVertices = endIndex - startIndex; + + const trajectoryId = point.trajectoryId; + newProperties[i] = {trajectoryId, ...properties[srcIndex]}; + + for (let j = startIndex; j < endIndex; j++) { + newFeatureIds[j] = i; // Each vertex is marked with featureId of the line + } + + for (const prop of numericPropKeys) { + const {value: originalValue} = originalGeometry.numericProps[prop]; + const {value, size: propSize} = newNumericProps[prop]; + const dataSlice = originalValue.subarray( + srcIndex * propSize, + (srcIndex + numVertices) * propSize + ); + value.set(dataSlice, dst * propSize); + } + dst += numVertices; + } + + return { + positions: {value: newPositions, size}, + properties: newProperties, + numericProps: newNumericProps, + featureIds: {value: newFeatureIds, size: 1}, + globalFeatureIds: {value: newFeatureIds, size: 1}, + pathIndices: {value: new Uint16Array(pathIndices), size: 1}, + type: 'LineString', + attributes: { + // getColor: {value: new Uint8Array(4 * newN), size: 4, normalized: true}, + getTimestamps: newTimestamps + } + }; +} + +/** + * Main function to transform trajectory data with tile boundary awareness + */ +export function transformTrajectoryData( + geometry: any, + trajectoryIdColumn: string, + timestampColumn: string, + tileBounds: TileBounds, + timestampRange?: {min: number | string; max: number | string} +): ProcessedGeometry | null { + if (!geometry || !geometry.positions || (!geometry.numericProps && !geometry.properties)) { + throw new Error('Invalid geometry: missing required properties'); + } + + const n = geometry.featureIds?.value?.length || 0; + if (n === 0) { + throw new Error('No features in geometry'); + } + + // Step 1: Extract trajectory and timestamp data + const {tripIds, timestamps} = extractTrajectoryData( + geometry, + trajectoryIdColumn, + timestampColumn, + timestampRange + ); + + if (!tripIds.value.length || !timestamps.value.length) { + throw new Error('No trajectory or timestamp data found'); + } + + // Check timestamp precision if no timestampRange provided (auto-normalization needed) + if (!timestampRange) { + throw new Error('Invalid geometry: missing timestampRange'); + } + + // Step 2: Group and sort trajectory points + const trajectoryGroups = groupAndSortTrajectoryPoints(geometry, tripIds, timestamps); + + // Step 3: Create trajectory segments with tile boundary awareness + const {validPoints, pathIndices} = createTrajectorySegments(trajectoryGroups, tileBounds); + + if (validPoints.length === 0) { + return null; + } + + // Step 4: Rebuild geometry with filtered points + const processedGeometry = rebuildGeometry(geometry, validPoints, pathIndices); + + return processedGeometry; +} diff --git a/modules/carto/src/layers/vector-tile-layer.ts b/modules/carto/src/layers/vector-tile-layer.ts index d2295df498c..53a759f5da4 100644 --- a/modules/carto/src/layers/vector-tile-layer.ts +++ b/modules/carto/src/layers/vector-tile-layer.ts @@ -180,7 +180,6 @@ export default class VectorTileLayer< const subLayerProps = { ...props, - data: {...props.data, tileBbox}, autoHighlight: false, // Do not perform clipping on points (#9059) _subLayerProps: { diff --git a/modules/geo-layers/src/trips-layer/trips-layer.ts b/modules/geo-layers/src/trips-layer/trips-layer.ts index 0eee4cb6ca0..d2a7ba427c5 100644 --- a/modules/geo-layers/src/trips-layer/trips-layer.ts +++ b/modules/geo-layers/src/trips-layer/trips-layer.ts @@ -64,17 +64,24 @@ vTime = instanceTimestamps + (instanceNextTimestamps - instanceTimestamps) * vPa 'fs:#decl': `\ in float vTime; `, - // Drop the segments outside of the time window + // Drop the segments outside of the time window, unless highlighted 'fs:#main-start': `\ -if(vTime > trips.currentTime || (trips.fadeTrail && (vTime < trips.currentTime - trips.trailLength))) { +bool isHighlighted = bool(picking_vRGBcolor_Avalid.a); +if(!isHighlighted && (vTime > trips.currentTime || (trips.fadeTrail && (vTime < trips.currentTime - trips.trailLength)))) { discard; } `, - // Fade the color (currentTime - 100%, end of trail - 0%) + // Fade the color (currentTime - 100%, end of trail - 0%), unless highlighted 'fs:DECKGL_FILTER_COLOR': `\ -if(trips.fadeTrail) { +bool isHighlighted = bool(picking_vRGBcolor_Avalid.a); +if(!isHighlighted && trips.fadeTrail) { color.a *= 1.0 - (trips.currentTime - vTime) / trips.trailLength; } + +bool isTrailVisible = trips.currentTime - trips.trailLength < vTime && vTime < trips.currentTime; +if (bool(picking.isActive) && !isTrailVisible) { + discard; +} ` }; shaders.modules = [...shaders.modules, tripsUniforms]; diff --git a/test/apps/carto-trajectories/README.md b/test/apps/carto-trajectories/README.md new file mode 100644 index 00000000000..be8410aa426 --- /dev/null +++ b/test/apps/carto-trajectories/README.md @@ -0,0 +1,25 @@ +Example usage of the `QuadbinHeatmapTileLayer` + +### Usage + +Copy the content of this folder to your project. + +```bash +# install dependencies +npm install +# or +yarn +# bundle and serve the app with vite +npm start +``` + +### Data format + +Sample data is stored in [deck.gl Example Data](https://github.com/visgl/deck.gl-data/tree/master/examples/scatterplot), showing the population distribution in Manhattan. [Source](http://www.census.gov) + +To use your own data, check out +the [documentation of ScatterplotLayer](../../../docs/api-reference/layers/scatterplot-layer.md). + +### Basemap + +The basemap in this example is provided by [CARTO free basemap service](https://carto.com/basemaps). To use an alternative base map solution, visit [this guide](https://deck.gl/docs/get-started/using-with-map#using-other-basemap-services) diff --git a/test/apps/carto-trajectories/app.tsx b/test/apps/carto-trajectories/app.tsx new file mode 100644 index 00000000000..b6b06c71e53 --- /dev/null +++ b/test/apps/carto-trajectories/app.tsx @@ -0,0 +1,386 @@ +/* global: document */ +import React, {useEffect, useState, useRef} from 'react'; +import {createRoot} from 'react-dom/client'; +import {Map, Source, useControl, useMap} from 'react-map-gl'; +import {MapboxOverlay} from '@deck.gl/mapbox'; + +import {DeckProps} from '@deck.gl/core'; +import type {TrajectoryTableSourceResponse, TrajectoryQuerySourceResponse} from '@carto/api-client'; +import {TrajectoryTileLayer, VectorTileLayer, colorContinuous} from '@deck.gl/carto'; +import {normalizeTimestamp} from '@deck.gl/carto/layers/trajectory-utils'; + +import RangeInput from './range-input'; +import {DATASETS, DATASET_NAMES, LAYER, PALETTES, MAP_STYLES} from './config'; + +const MAPBOX_TOKEN = process.env.MapboxAccessToken; // eslint-disable-line + +function DeckGLOverlay(props: DeckProps) { + const overlay = useControl(() => new MapboxOverlay(props)); + overlay.setProps(props); + return null; +} + +export default function App() { + const initialDatasetName = DATASET_NAMES[0]; + const initialDataset = DATASETS[initialDatasetName]; + const [selectedDataset, setSelectedDataset] = useState(initialDatasetName); + const [layerType, setLayerType] = useState(initialDataset.layerType); + const [mapStyle, setMapStyle] = useState(initialDataset.mapStyle); + const [palette, setPalette] = useState(initialDataset.palette); + const [currentTime, setCurrentTime] = useState(0); + const [animationSpeed, setAnimationSpeed] = useState(100); + const [trailLength, setTrailLength] = useState(1000); + const [lineWidth, setLineWidth] = useState(initialDataset.lineWidth); + const [timeRange, setTimeRange] = useState([0, 1000]); + const [timestampRange, setTimestampRange] = useState(null); + + function setDataset(datasetName: string) { + const dataset = DATASETS[datasetName]; + setSelectedDataset(datasetName); + setLayerType(dataset.layerType); + setLineWidth(dataset.lineWidth); + setMapStyle(dataset.mapStyle); + setPalette(dataset.palette); + // Reset time controls to defaults - they'll be updated when data loads + setCurrentTime(0); + setTimeRange([0, 1000]); + setTimestampRange(null); + } + + const mapRef = useRef(null); + const currentDataset = DATASETS[selectedDataset]; + const [dataSource, setDataSource] = useState< + TrajectoryTableSourceResponse | TrajectoryQuerySourceResponse | null + >(null); + + // Handle trajectory click to jump to start time + function handleTrajectoryClick(info: any) { + if (!info.object || !dataSource?.widgetSource) return; + + const {trajectoryIdColumn, timestampColumn} = dataSource.widgetSource.props; + const trajectoryId = info.object.properties[trajectoryIdColumn]; + + if (!trajectoryId) return; + + // Fetch the time range for this specific trajectory and jump to its start + dataSource.widgetSource + .getRange({ + column: timestampColumn, + filters: {[trajectoryIdColumn]: {in: {values: [trajectoryId]}}} + }) + .then(timeRange => { + if (timeRange?.min) { + const startTime = normalizeTimestamp(timeRange.min); + setCurrentTime(startTime); + } + }) + .catch(error => { + console.error('Error fetching trajectory time range:', error); + }); + } + + useEffect(() => { + currentDataset.dataSource.then(result => { + setDataSource(result); + setTimestampRange(result.timestampRange); + + if (result.timestampRange) { + // Handle both Unix timestamps (numbers) and ISO strings + const {min, max} = result.timestampRange; + const minTime = normalizeTimestamp(min); + const maxTime = normalizeTimestamp(max); + + const duration = maxTime - minTime; + + console.log('Dataset duration:', duration, 'seconds'); + console.log('Duration in days:', Math.round((duration / 86400) * 100) / 100); + + // Set time range + setTimeRange([minTime, maxTime]); + setCurrentTime(minTime); + + // Calculate animation speed for 15-second full animation + const targetAnimationDuration = 15; // seconds + const calculatedSpeed = duration / targetAnimationDuration; + + // Cap the animation speed to reasonable bounds + const maxReasonableSpeed = 50000; // Max speed to prevent UI issues + const minReasonableSpeed = 10; // Min speed for very short datasets + const clampedSpeed = Math.max( + minReasonableSpeed, + Math.min(maxReasonableSpeed, calculatedSpeed) + ); + + console.log('Dataset duration:', duration, 'seconds'); + console.log('Raw calculated speed:', calculatedSpeed); + console.log('Clamped animation speed:', clampedSpeed); + setAnimationSpeed(Math.round(clampedSpeed)); + + // Set trail length to 2-5% of full duration for better visibility + const trailPercentage = 0.03; // 3% default + const calculatedTrailLength = Math.round(duration * trailPercentage); + console.log( + 'Calculated trail length:', + calculatedTrailLength, + 'seconds =', + formatTrailLength(calculatedTrailLength) + ); + setTrailLength(calculatedTrailLength); + } + + if (mapRef.current) { + const {latitude, longitude, bearing, pitch, zoom} = currentDataset.viewState; + mapRef.current.flyTo({ + bearing, + center: [longitude, latitude], + pitch, + zoom + }); + } + }); + }, [selectedDataset]); + + if (!dataSource) return null; + + const initialViewState = currentDataset.viewState; + + const [layer] = layerType.split(' '); + const showTrips = layer === 'Trips'; + const showPoints = layer === 'Point'; + const styleName = mapStyle; + const trajectoryIdColumn = dataSource.widgetSource.props.trajectoryIdColumn; + + const layers = showPoints + ? [ + new VectorTileLayer({ + id: 'points', + data: dataSource, + pointRadiusMinPixels: lineWidth, + pointRadiusMaxPixels: lineWidth, + getFillColor: colorContinuous({ + attr: trajectoryIdColumn, + domain: [0, 100, 200, 300, 400, 500, 600, 700, 800, 900, 1000], + colors: palette + }), + updateTriggers: {getFillColor: [palette]} + }) + ] + : [ + new TrajectoryTileLayer({ + id: 'trajectories', + data: dataSource, + uniqueIdProperty: 'trajectoryId', // TODO internalize this + + renderMode: showTrips ? 'trips' : 'paths', + + // Color entire line by trajectoryId, will be invoked once per line + getLineColor: colorContinuous({ + attr: trajectoryIdColumn, + domain: [0, 100, 200, 300, 400, 500, 600, 700, 800, 900, 1000], + colors: palette + }), + + // Color each vertex by speed (requires autocomputeSpeed: true) + autocomputeSpeed: true, + getFillColor: colorContinuous({ + attr: 'speed', + domain: [0, 5, 10, 15, 20, 25], // speed in m/s + colors: palette + }), + updateTriggers: { + getLineColor: [palette], + getFillColor: [palette] + }, + + // Animation and interaction props + currentTime, + trailLength, + getWidth: lineWidth, + onClick: handleTrajectoryClick, + pickable: true, + autoHighlight: true, + highlightColor: [255, 122, 44, 255] + }) + ]; + + function onAfterRender() { + const isLoading = layers.every(layer => layer.isLoaded); + const loadingElement = document.getElementById('loading-indicator'); + if (loadingElement) { + loadingElement.style.transition = isLoading ? 'opacity 1s' : ''; + loadingElement.style.opacity = `${isLoading ? 0 : 1}`; + } + } + + return ( + <> + + + + + + + + + {showTrips && ( + formatLabel(x, 'seconds', timestampRange)} + /> + )} + {showTrips && ( + formatLabel(x, 'speed')} + /> + )} + {showTrips && ( + formatLabel(x, 'trail')} + /> + )} + formatLabel(x, 'pixels')} + /> + {showTrips && ( +
+ 💡 Click any trajectory to jump to its start time +
+ )} + + ); +} + +export function renderToDOM(container: HTMLElement) { + const root = createRoot(container); + root.render(); +} + +function formatTimestamp(timestamp: number, originalRange: any): string { + if (!originalRange) return Math.round(timestamp).toString(); + + // Check if original timestamps were ISO strings + if (typeof originalRange.min === 'string') { + // Convert Unix timestamp back to readable date + return new Date(timestamp * 1000).toLocaleString(); + } else { + // Format Unix timestamp as relative time or date + return new Date(timestamp * 1000).toLocaleString(); + } +} + +function formatTrailLength(seconds: number): string { + if (seconds < 60) { + return `${Math.round(seconds)}s`; + } else if (seconds < 3600) { + return `${Math.round(seconds / 60)}m`; + } else if (seconds < 86400) { + return `${Math.round(seconds / 3600)}h`; + } else { + return `${Math.round(seconds / 86400)}d`; + } +} + +function formatAnimationSpeed(speed: number): string { + if (speed >= 10000) { + return `${(speed / 1000).toFixed(0)}K`; + } else if (speed >= 1000) { + return `${(speed / 1000).toFixed(1)}K`; + } else { + return Math.round(speed).toString(); + } +} + +function formatLabel(n: number, label: string, timestampRange?: any) { + if (label === 'seconds' && timestampRange) { + return formatTimestamp(n, timestampRange); + } + if (label === 'trail') { + return formatTrailLength(n); + } + if (label === 'speed') { + return formatAnimationSpeed(n); + } + return `${label ? label + ': ' : ''}` + Math.round(n); +} + +function ObjectSelect({title, obj, value, onSelect, top = 0}) { + const keys = Array.isArray(obj) ? obj : Object.values(obj); + return ( + <> + +

+ + ); +} diff --git a/test/apps/carto-trajectories/config.ts b/test/apps/carto-trajectories/config.ts new file mode 100644 index 00000000000..07b4a4ea81d --- /dev/null +++ b/test/apps/carto-trajectories/config.ts @@ -0,0 +1,123 @@ +import {trajectoryQuerySource, trajectoryTableSource} from '@carto/api-client'; + +// CARTO API configuration +const API_CONFIG = { + apiBaseUrl: 'https://gcp-us-east1-19.dev.api.carto.com', + accessToken: 'XXX', // Replace with your CARTO access token + connectionName: 'carto_dw' +}; + +export const DATASETS = { + 'NYC Taxi Trips (Query)': { + name: 'NYC Taxi Trips (Query)', + dataSource: trajectoryQuerySource({ + ...API_CONFIG, + sqlQuery: + 'SELECT geometry as geom, timestamp as ts, ride_id as trajectoryId FROM `cartodb-on-gcp-frontend-team.felix.taxi-trips`', + trajectoryIdColumn: 'trajectoryId', + timestampColumn: 'ts' + }), + viewState: { + latitude: 40.7128, + longitude: -74.006, + zoom: 10, + bearing: 0, + pitch: 0 + }, + layerType: 'Trips 2D', + lineWidth: 2, + mapStyle: 'dark-v11', + palette: 'Sunset' + }, + 'NYC Taxi Trips (Table)': { + name: 'NYC Taxi Trips (Table)', + dataSource: trajectoryTableSource({ + ...API_CONFIG, + tableName: 'cartodb-on-gcp-frontend-team.felix.taxi-trips', + spatialDataColumn: 'geometry', + trajectoryIdColumn: 'ride_id', + timestampColumn: 'timestamp' + }), + viewState: { + latitude: 40.7128, + longitude: -74.006, + zoom: 10, + bearing: 0, + pitch: 0 + }, + layerType: 'Trips 2D', + lineWidth: 2, + mapStyle: 'outdoors-v12', + palette: 'ArmyRose' + }, + 'Atlanta Vehicle Trajectories': { + name: 'Atlanta Vehicle Trajectories', + dataSource: trajectoryQuerySource({ + ...API_CONFIG, + sqlQuery: + 'SELECT * FROM `carto-demo-data.demo_tables.citytrek_14k_vehicle_trajectories` WHERE timestamp >= "2017-11-01" AND timestamp < "2017-11-05"', + trajectoryIdColumn: 'trip_id', + timestampColumn: 'timestamp' + }), + viewState: { + latitude: 33.749, + longitude: -84.388, + zoom: 10, + bearing: 0, + pitch: 0 + }, + layerType: 'Trips 2D', + lineWidth: 1.5, + mapStyle: 'dark-v11', + palette: 'Vivid' + }, + 'Memphis Vehicle Trajectories (Query)': { + name: 'Memphis Vehicle Trajectories (Query)', + dataSource: trajectoryQuerySource({ + ...API_CONFIG, + sqlQuery: + 'SELECT * FROM `carto-demo-data.demo_tables.citytrek_14k_vehicle_trajectories` LIMIT 100000', + trajectoryIdColumn: 'trip_id', + timestampColumn: 'timestamp' + }), + viewState: { + latitude: 35.1495, + longitude: -90.049, + zoom: 10, + bearing: 0, + pitch: 0 + }, + layerType: 'Trips 2D', + lineWidth: 1.5, + mapStyle: 'satellite-streets-v12', + palette: 'Temps' + } +}; + +// Available layer rendering modes +export const LAYER = { + Path: 'Path 2D', + Trips: 'Trips 2D', + Points: 'Point 2D' +}; + +export const PALETTES = { + ArmyRose: 'ArmyRose', + DarkMint: 'DarkMint', + Emrld: 'Emrld', + OrYel: 'OrYel', + Peach: 'Peach', + Prism: 'Prism', + Sunset: 'Sunset', + Temps: 'Temps', + Tropic: 'Tropic', + Vivid: 'Vivid' +}; + +export const MAP_STYLES = { + dark: 'dark-v11', + outdoors: 'outdoors-v12', + satellite: 'satellite-streets-v12', + navigationNight: 'navigation-night-v1' +}; +export const DATASET_NAMES = Object.keys(DATASETS); diff --git a/test/apps/carto-trajectories/index.html b/test/apps/carto-trajectories/index.html new file mode 100644 index 00000000000..7d690791b43 --- /dev/null +++ b/test/apps/carto-trajectories/index.html @@ -0,0 +1,45 @@ + + + + + deck.gl Example + + + + +
+
+
+ + + + diff --git a/test/apps/carto-trajectories/package.json b/test/apps/carto-trajectories/package.json new file mode 100644 index 00000000000..bd1ea5e3960 --- /dev/null +++ b/test/apps/carto-trajectories/package.json @@ -0,0 +1,26 @@ +{ + "scripts": { + "start": "vite --open", + "start-local": "vite --config ../vite.config.local.mjs" + }, + "dependencies": { + "@material-ui/core": "^4.10.2", + "@material-ui/icons": "^4.10.2", + "react-map-gl": "^7.1.0", + "mapbox-gl": "^3.8.0", + "react": "^18.0.0", + "react-dom": "^18.0.0", + "@carto/api-client": "^0.5.15", + "@deck.gl/carto": "^9.2.0-alpha.2", + "@deck.gl/core": "^9.2.0-alpha.2", + "@deck.gl/geo-layers": "^9.2.0-alpha.2", + "@deck.gl/layers": "^9.2.0-alpha.2", + "@deck.gl/mesh-layers": "^9.2.0-alpha.2" + }, + "devDependencies": { + "@types/react-dom": "^18.2.20", + "typescript": "^4.6.0", + "vite": "^4.0.0" + } +} + diff --git a/test/apps/carto-trajectories/range-input.jsx b/test/apps/carto-trajectories/range-input.jsx new file mode 100644 index 00000000000..880c3049e5b --- /dev/null +++ b/test/apps/carto-trajectories/range-input.jsx @@ -0,0 +1,94 @@ +/* global requestAnimationFrame, cancelAnimationFrame */ +import React, {useEffect, useState} from 'react'; +import {styled, withStyles} from '@material-ui/core/styles'; +import Slider from '@material-ui/core/Slider'; +import Button from '@material-ui/core/IconButton'; +import PlayIcon from '@material-ui/icons/PlayArrow'; +import PauseIcon from '@material-ui/icons/Pause'; + +const PositionContainer = styled('div')({ + position: 'absolute', + zIndex: 1, + bottom: '40px', + width: '100%', + display: 'flex', + justifyContent: 'center', + alignItems: 'center' +}); + +const COLOR = '#f5f1d8'; + +const SliderInput = withStyles({ + root: { + marginLeft: 12, + width: '40%', + color: COLOR + }, + valueLabel: { + '& span': { + whiteSpace: 'nowrap', + background: 'none', + color: COLOR + } + } +})(Slider); + +export default function RangeInput({ + min, + max, + value, + animationSpeed = 0, + onChange, + formatLabel, + bottom = 40, + name = '' +}) { + const [isPlaying, setIsPlaying] = useState(animationSpeed > 0); + const [animation] = useState({}); + + // prettier-ignore + useEffect(() => { + return () => animation.id && cancelAnimationFrame(animation.id); + }, [animation]); + + if (isPlaying && !animation.id) { + const t = performance.now(); + const deltaT = animation.lastT ? t - animation.lastT : 1 / 60; + animation.lastT = t; + + let nextValue = value + animationSpeed * (deltaT / 1000); + if (nextValue >= max) { + nextValue = min; + } + animation.id = requestAnimationFrame(() => { + animation.id = 0; + onChange(nextValue); + }); + } else { + animation.lastT = 0; + } + + const isAnimationEnabled = animationSpeed > 0; + return ( + + {isAnimationEnabled && ( + + )} + + onChange(newValue)} + valueLabelDisplay="on" + valueLabelFormat={formatLabel} + /> + + ); +} diff --git a/test/modules/carto/index.ts b/test/modules/carto/index.ts index e10b06c00d3..68b9bd4c218 100644 --- a/test/modules/carto/index.ts +++ b/test/modules/carto/index.ts @@ -18,6 +18,7 @@ import './layers/quadbin-layer.spec'; import './layers/quadbin-tile-layer.spec'; import './layers/quadbin-tileset-2d.spec'; import './layers/vector-tile-layer.spec'; +import './layers/trajectory-utils.spec'; import './layers/schema/carto-properties-tile-loader.spec'; import './layers/schema/carto-raster-tile-loader.spec'; import './layers/schema/carto-raster-tile.spec'; diff --git a/test/modules/carto/layers/trajectory-utils.spec.ts b/test/modules/carto/layers/trajectory-utils.spec.ts new file mode 100644 index 00000000000..7463cc9b44a --- /dev/null +++ b/test/modules/carto/layers/trajectory-utils.spec.ts @@ -0,0 +1,349 @@ +// deck.gl +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +import test from 'tape-promise/tape'; +import { + extractTrajectoryData, + groupAndSortTrajectoryPoints, + createTrajectorySegments, + rebuildGeometry, + applyTrajectoryColors, + transformTrajectoryData, + type TileBounds, + type TrajectoryPoint +} from '@deck.gl/carto/layers/trajectory-utils'; + +// Helper function to create mock geometry +function createMockGeometry( + trajectoryIds: string[], + timestamps: number[], + positions: number[] +): any { + return { + featureIds: { + value: new Array(trajectoryIds.length).fill(0).map((_, i) => i), + size: 1 + }, + positions: { + value: new Float32Array(positions), + size: 2 + }, + properties: trajectoryIds.map((id, i) => ({ + trip_id: id, + timestamp: new Date(timestamps[i] * 1000).toISOString() + })), + numericProps: {} + }; +} + +// Helper function to create mock geometry with numericProps +function createMockGeometryNumeric( + trajectoryIds: string[], + timestamps: number[], + positions: number[] +): any { + return { + featureIds: { + value: new Array(trajectoryIds.length).fill(0).map((_, i) => i), + size: 1 + }, + positions: { + value: new Float32Array(positions), + size: 2 + }, + numericProps: { + trip_id: {value: trajectoryIds, size: 1}, + timestamp: {value: timestamps, size: 1} + }, + properties: undefined + }; +} + +test('extractTrajectoryData - properties format', t => { + const geometry = createMockGeometry( + ['trip1', 'trip1', 'trip2'], + [1000, 2000, 1500], + [0, 0, 1, 1, 2, 2] + ); + + const result = extractTrajectoryData(geometry, 'trip_id', 'timestamp'); + + t.ok(result.tripIds, 'returns tripIds'); + t.ok(result.timestamps, 'returns timestamps'); + t.deepEqual(result.tripIds.value, ['trip1', 'trip1', 'trip2'], 'extracts correct trip IDs'); + t.deepEqual(result.timestamps.value, [1000, 2000, 1500], 'converts timestamps correctly'); + t.end(); +}); + +test('extractTrajectoryData - numericProps format', t => { + const geometry = createMockGeometryNumeric( + ['trip1', 'trip1', 'trip2'], + [1000, 2000, 1500], + [0, 0, 1, 1, 2, 2] + ); + + const result = extractTrajectoryData(geometry, 'trip_id', 'timestamp'); + + t.deepEqual(result.tripIds.value, ['trip1', 'trip1', 'trip2'], 'extracts correct trip IDs'); + t.deepEqual(result.timestamps.value, [1000, 2000, 1500], 'extracts correct timestamps'); + t.end(); +}); + +test('extractTrajectoryData - timestamp normalization', t => { + // Test with Unix timestamps that would need normalization + const baseTimestamp = 1640995200; // 2022-01-01 00:00:00 UTC + const timestamps = [baseTimestamp, baseTimestamp + 3600, baseTimestamp + 1800]; + const geometry = createMockGeometry(['trip1', 'trip1', 'trip2'], timestamps, [0, 0, 1, 1, 2, 2]); + + const timestampRange = { + min: baseTimestamp, + max: baseTimestamp + 3600 + }; + + const result = extractTrajectoryData(geometry, 'trip_id', 'timestamp', timestampRange); + + t.deepEqual(result.timestamps.value, [0, 3600, 1800], 'normalizes timestamps relative to min'); + t.end(); +}); + +test('extractTrajectoryData - ISO string normalization', t => { + const geometry = createMockGeometry( + ['trip1', 'trip1'], + ['2022-01-01T00:00:00Z', '2022-01-01T01:00:00Z'], + [0, 0, 1, 1] + ); + + const timestampRange = { + min: '2022-01-01T00:00:00Z', + max: '2022-01-01T01:00:00Z' + }; + + const result = extractTrajectoryData(geometry, 'trip_id', 'timestamp', timestampRange); + + t.equal(result.timestamps.value[0], 0, 'first timestamp normalized to 0'); + t.equal(result.timestamps.value[1], 3600, 'second timestamp normalized to 3600 (1 hour)'); + t.end(); +}); + +test('groupAndSortTrajectoryPoints', t => { + const geometry = createMockGeometry( + ['trip1', 'trip2', 'trip1'], + [2000, 1500, 1000], + [0, 0, 1, 1, 2, 2] + ); + const {tripIds, timestamps} = extractTrajectoryData(geometry, 'trip_id', 'timestamp'); + + const groups = groupAndSortTrajectoryPoints(geometry, tripIds, timestamps); + + t.equal(groups.size, 2, 'creates correct number of groups'); + t.ok(groups.has('trip1'), 'has trip1 group'); + t.ok(groups.has('trip2'), 'has trip2 group'); + + const trip1Points = groups.get('trip1') as TrajectoryPoint[]; + t.equal(trip1Points.length, 2, 'trip1 has correct number of points'); + t.equal(trip1Points[0].timestamp, 1000, 'trip1 points sorted by timestamp - first'); + t.equal(trip1Points[1].timestamp, 2000, 'trip1 points sorted by timestamp - second'); + + t.end(); +}); + +test('createTrajectorySegments - no tile bounds', t => { + const mockPoints: TrajectoryPoint[] = [ + {index: 0, timestamp: 1000, lon: 0, lat: 0, position: [0, 0], trajectoryId: 'trip1'}, + {index: 1, timestamp: 2000, lon: 1, lat: 1, position: [1, 1], trajectoryId: 'trip1'}, + {index: 2, timestamp: 1500, lon: 2, lat: 2, position: [2, 2], trajectoryId: 'trip2'} + ]; + const groups = new Map([ + ['trip1', mockPoints.slice(0, 2)], + ['trip2', mockPoints.slice(2)] + ]); + + const result = createTrajectorySegments(groups); + + t.equal(result.validPoints.length, 3, 'includes all points when no bounds'); + t.equal(result.pathIndices.length, 2, 'creates path indices for each trajectory'); + t.deepEqual(result.pathIndices, [0, 2], 'correct path indices'); + t.end(); +}); + +test('createTrajectorySegments - with tile bounds', t => { + const mockPoints: TrajectoryPoint[] = [ + {index: 0, timestamp: 1000, lon: 0, lat: 0, position: [0, 0], trajectoryId: 'trip1'}, + {index: 1, timestamp: 2000, lon: 5, lat: 5, position: [5, 5], trajectoryId: 'trip1'}, // outside bounds + {index: 2, timestamp: 1500, lon: 1, lat: 1, position: [1, 1], trajectoryId: 'trip2'} + ]; + const groups = new Map([ + ['trip1', mockPoints.slice(0, 2)], + ['trip2', mockPoints.slice(2)] + ]); + + const tileBounds: TileBounds = {west: -1, south: -1, east: 2, north: 2}; + const result = createTrajectorySegments(groups, tileBounds); + + t.equal(result.validPoints.length, 2, 'filters out points outside bounds'); + t.equal(result.validPoints[0].lon, 0, 'keeps first point in bounds'); + t.equal(result.validPoints[1].lon, 1, 'keeps second point in bounds'); + t.end(); +}); + +test('rebuildGeometry', t => { + const originalGeometry = createMockGeometry(['trip1', 'trip1'], [1000, 2000], [0, 0, 1, 1]); + const validPoints: TrajectoryPoint[] = [ + {index: 0, timestamp: 1000, lon: 0, lat: 0, position: [0, 0], trajectoryId: 'trip1'}, + {index: 1, timestamp: 2000, lon: 1, lat: 1, position: [1, 1], trajectoryId: 'trip1'} + ]; + const pathIndices = [0]; + const tripIds = {value: ['trip1', 'trip1']}; + + const result = rebuildGeometry(originalGeometry, validPoints, pathIndices, tripIds, false); + + t.equal(result.type, 'LineString', 'sets correct geometry type'); + t.equal(result.positions.value.length, 4, 'creates correct positions array'); + t.equal(result.positions.size, 2, 'sets correct position size'); + t.equal(result.pathIndices.value.length, 2, 'creates path indices with end marker'); + t.deepEqual(Array.from(result.pathIndices.value), [0, 2], 'correct path indices values'); + t.ok(result.attributes.getColor, 'creates color attribute'); + t.ok(result.attributes.getTimestamps, 'creates timestamps attribute'); + t.ok(result.globalFeatureIds, 'creates globalFeatureIds'); + t.equal(result.globalFeatureIds.value.length, 2, 'globalFeatureIds has correct length'); + t.end(); +}); + +test('rebuildGeometry - with altitude', t => { + const originalGeometry = { + ...createMockGeometry(['trip1', 'trip1'], [1000, 2000], [0, 0, 100, 1, 1, 200]), + positions: { + value: new Float32Array([0, 0, 100, 1, 1, 200]), + size: 3 + } + }; + const validPoints: TrajectoryPoint[] = [ + {index: 0, timestamp: 1000, lon: 0, lat: 0, position: [0, 0], trajectoryId: 'trip1'}, + {index: 1, timestamp: 2000, lon: 1, lat: 1, position: [1, 1], trajectoryId: 'trip1'} + ]; + const pathIndices = [0]; + const tripIds = {value: ['trip1', 'trip1']}; + + const result = rebuildGeometry(originalGeometry, validPoints, pathIndices, tripIds, true); + + t.equal(result.positions.size, 3, 'sets correct position size for 3D'); + t.equal(result.positions.value.length, 6, 'creates correct 3D positions array'); + t.end(); +}); + +test('rebuildGeometry - with existing globalFeatureIds', t => { + const originalGeometry = { + ...createMockGeometry(['trip1', 'trip1'], [1000, 2000], [0, 0, 1, 1]), + globalFeatureIds: { + value: [100, 101], + size: 1 + } + }; + const validPoints: TrajectoryPoint[] = [ + {index: 0, timestamp: 1000, lon: 0, lat: 0, position: [0, 0], trajectoryId: 'trip1'}, + {index: 1, timestamp: 2000, lon: 1, lat: 1, position: [1, 1], trajectoryId: 'trip1'} + ]; + const pathIndices = [0]; + const tripIds = {value: ['trip1', 'trip1']}; + + const result = rebuildGeometry(originalGeometry, validPoints, pathIndices, tripIds, false); + + t.deepEqual(result.globalFeatureIds.value, [100, 101], 'preserves original globalFeatureIds'); + t.end(); +}); + +test('applyTrajectoryColors', t => { + const mockGeometry = { + positions: {value: new Float64Array([0, 0, 1, 1, 2, 2]), size: 2}, + attributes: { + getColor: {value: new Uint8Array(12), size: 4, normalized: true}, + getTimestamps: {value: new Float32Array([1000, 2000, 3000]), size: 1} + } + }; + + const result = applyTrajectoryColors(mockGeometry, 'Sunset'); + + t.ok(result.attributes.getColor, 'preserves color attribute'); + t.notEqual(result.attributes.getColor.value[0], 0, 'applies colors to first point'); + t.notEqual(result.attributes.getColor.value[4], 0, 'applies colors to second point'); + t.end(); +}); + +test('transformTrajectoryData - complete workflow', t => { + const geometry = createMockGeometry( + ['trip1', 'trip1', 'trip2'], + [2000, 1000, 1500], + [0, 0, 1, 1, 2, 2] + ); + + const result = transformTrajectoryData(geometry, 'Sunset', false, 'trip_id', 'timestamp'); + + t.ok(result, 'returns result'); + t.equal(result.type, 'LineString', 'correct geometry type'); + t.ok(result.positions, 'has positions'); + t.ok(result.pathIndices, 'has path indices'); + t.ok(result.attributes.getColor, 'has color attributes'); + t.ok(result.attributes.getTimestamps, 'has timestamp attributes'); + + // Check that points are sorted by timestamp within trajectories + const trip1Timestamps = [ + result.attributes.getTimestamps.value[0], + result.attributes.getTimestamps.value[1] + ]; + t.ok(trip1Timestamps[0] <= trip1Timestamps[1], 'timestamps are sorted within trajectory'); + + t.end(); +}); + +test('transformTrajectoryData - with tile bounds', t => { + const geometry = createMockGeometry( + ['trip1', 'trip1', 'trip1'], + [1000, 2000, 3000], + [0, 0, 5, 5, 1, 1] // middle point outside bounds + ); + + const tileBounds: TileBounds = {west: -1, south: -1, east: 2, north: 2}; + + const result = transformTrajectoryData( + geometry, + 'Sunset', + false, + 'trip_id', + 'timestamp', + tileBounds + ); + + // Should filter out the middle point that's outside bounds + t.equal(result.positions.value.length, 4, 'filters out points outside tile bounds'); + t.end(); +}); + +test('transformTrajectoryData - error handling', t => { + t.throws( + () => transformTrajectoryData(null, 'Sunset', false, 'trip_id', 'timestamp'), + /Invalid geometry/, + 'throws error for null geometry' + ); + + t.throws( + () => transformTrajectoryData({}, 'Sunset', false, 'trip_id', 'timestamp'), + /Invalid geometry/, + 'throws error for empty geometry' + ); + + const emptyGeometry = { + featureIds: {value: [], size: 1}, + positions: {value: new Float32Array([]), size: 2}, + properties: [], + numericProps: {} + }; + + t.throws( + () => transformTrajectoryData(emptyGeometry, 'Sunset', false, 'trip_id', 'timestamp'), + /No features/, + 'throws error for empty features' + ); + + t.end(); +}); diff --git a/yarn.lock b/yarn.lock index e6869683c2f..ae9044d6667 100644 --- a/yarn.lock +++ b/yarn.lock @@ -44,10 +44,10 @@ resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39" integrity sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw== -"@carto/api-client@^0.5.6": - version "0.5.6" - resolved "https://registry.yarnpkg.com/@carto/api-client/-/api-client-0.5.6.tgz#9e0714cd977770039f1693c9c55708be7d59e97b" - integrity sha512-sq6tzIK5P59Qk7zPNpjP0OkqIvZ1UqGKffLEOJtjQLWIPjRxm+EXUqdeOccj9uV0IGfyA5Dp/6a+H0vPDU/QeQ== +"@carto/api-client@^0.5.15": + version "0.5.15" + resolved "https://registry.yarnpkg.com/@carto/api-client/-/api-client-0.5.15.tgz#cb49ec2e9b5f3d4620167a3918801bdc4c0e8f3c" + integrity sha512-x6WWlHx+hpq0Xmnege61nzBB3hGmI9MGm12pG5BGzGkMXa+ZU5GszEB4Urf24o5TdpLCPsupMR+VHsFmVlxmjw== dependencies: "@loaders.gl/schema" "^4.3.3" "@types/geojson" "^7946.0.16"