Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
163 changes: 163 additions & 0 deletions modules/editable-layers/src/edit-modes/coordinate-system.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
// deck.gl-community
// SPDX-License-Identifier: MIT
// Copyright (c) vis.gl contributors

import turfBearing from '@turf/bearing';
import turfDistance from '@turf/distance';
import turfDestination from '@turf/destination';
import turfMidpoint from '@turf/midpoint';
import {point} from '@turf/helpers';
import {COORDINATE_SYSTEM} from '@deck.gl/core';

import {Position} from '../utils/geojson-types';

/**
* Abstraction layer for coordinate system math used by edit modes.
*
* Allows edit modes to be used with geographic (WGS84) or Cartesian (screen/local)
* coordinate systems without changing the mode logic.
*
* Naming note: this interface is intentionally distinct from deck.gl's own
* `CoordinateSystem` type (a numeric enum from `@deck.gl/core`), which describes how
* positions are projected for *rendering*. `EditModeCoordinateSystem` describes how edit
* modes should perform *geometric math* on those positions.
*
* - GeoCoordinateSystem: wraps turf.js — uses geodesic math, assumes WGS84 lon/lat.
* - CartesianCoordinateSystem: uses Euclidean math — suitable for OrthographicView or pixel space.
*/
export interface EditModeCoordinateSystem {
/**
* Returns the distance between two positions.
* For GeoCoordinateSystem the unit is kilometers; for CartesianCoordinateSystem it is the
* native coordinate unit (consistent with destination()).
*/
distance(a: Position, b: Position): number;

/**
* Returns the bearing from position `a` to position `b`.
* Uses compass convention: 0° = north, clockwise, range [-180, 180].
*/
bearing(a: Position, b: Position): number;

/**
* Returns a new position reached by traveling `distance` units in the given `bearing`
* direction from `origin`.
*/
destination(origin: Position, distance: number, bearing: number): Position;

/**
* Returns the midpoint between two positions.
*/
midpoint(a: Position, b: Position): Position;
}

/**
* Geographic coordinate system using turf.js (WGS84 lon/lat, geodesic math).
* This is the default and preserves the existing behavior of all edit modes.
*/
export class GeoCoordinateSystem implements EditModeCoordinateSystem {
distance(a: Position, b: Position): number {
return turfDistance(point(a), point(b));
}

bearing(a: Position, b: Position): number {
return turfBearing(point(a), point(b));
}

destination(origin: Position, distance: number, bearing: number): Position {
return turfDestination(point(origin), distance, bearing).geometry.coordinates;
}

midpoint(a: Position, b: Position): Position {
return turfMidpoint(point(a), point(b)).geometry.coordinates;
}
}

/**
* Cartesian (Euclidean) coordinate system for non-geographic use cases such as
* OrthographicView or pixel/local coordinates.
*
* Bearing follows the same compass convention as the geographic system so that
* destination() and bearing() are consistent with each other:
* 0° = +Y axis, 90° = +X axis (clockwise from north/up).
*/
export class CartesianCoordinateSystem implements EditModeCoordinateSystem {
distance(a: Position, b: Position): number {
const dx = b[0] - a[0];
const dy = b[1] - a[1];
return Math.sqrt(dx * dx + dy * dy);
}

bearing(a: Position, b: Position): number {
const dx = b[0] - a[0];
const dy = b[1] - a[1];
// atan2(dx, dy): angle from the +Y axis, clockwise — matches compass bearing convention
const angle = Math.atan2(dx, dy) * (180 / Math.PI);
// Normalize to [-180, 180] to match turf's bearing range
return angle > 180 ? angle - 360 : angle <= -180 ? angle + 360 : angle;
}

destination(origin: Position, distance: number, bearing: number): Position {
const bearingRad = bearing * (Math.PI / 180);
return [
origin[0] + distance * Math.sin(bearingRad),
origin[1] + distance * Math.cos(bearingRad)
] as Position;
}

midpoint(a: Position, b: Position): Position {
return [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2] as Position;
}
}

/**
* Default geographic coordinate system instance.
* Used by edit modes when no coordinate system is provided.
*/
export const geoCoordinateSystem = new GeoCoordinateSystem();

/**
* Default Cartesian coordinate system instance.
* Use this with OrthographicView or any non-geographic coordinate system.
*/
export const cartesianCoordinateSystem = new CartesianCoordinateSystem();

/**
* Returns the provided coordinate system, or `geoCoordinateSystem` as the default.
* Use this helper inside edit modes to avoid null checks scattered throughout the code.
*/
export function getEditModeCoordinateSystem(
coordinateSystem?: EditModeCoordinateSystem
): EditModeCoordinateSystem {
return coordinateSystem ?? geoCoordinateSystem;
}

/**
* Maps a deck.gl `COORDINATE_SYSTEM` constant to the appropriate `EditModeCoordinateSystem`
* implementation for edit-mode geometric math.
*
* This allows the `EditableGeoJsonLayer` to automatically derive the correct math from
* its own `coordinateSystem` prop without requiring consumers to configure it separately.
*
* | deck.gl constant | Math used |
* |------------------------------------|-----------------------|
* | `COORDINATE_SYSTEM.LNGLAT` | GeoCoordinateSystem |
* | `COORDINATE_SYSTEM.DEFAULT` | GeoCoordinateSystem |
* | `COORDINATE_SYSTEM.CARTESIAN` | CartesianCoordinateSystem |
* | `COORDINATE_SYSTEM.METER_OFFSETS` | CartesianCoordinateSystem |
* | `COORDINATE_SYSTEM.LNGLAT_OFFSETS` | GeoCoordinateSystem |
*/
export function fromDeckCoordinateSystem(
deckCoordSystem: number | undefined
): EditModeCoordinateSystem {
switch (deckCoordSystem) {
case COORDINATE_SYSTEM.CARTESIAN:
case COORDINATE_SYSTEM.METER_OFFSETS:
return cartesianCoordinateSystem;
case COORDINATE_SYSTEM.LNGLAT:
case COORDINATE_SYSTEM.LNGLAT_OFFSETS:
case COORDINATE_SYSTEM.DEFAULT:
default:
return geoCoordinateSystem;
}
}
24 changes: 13 additions & 11 deletions modules/editable-layers/src/edit-modes/measure-distance-mode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,6 @@
// SPDX-License-Identifier: MIT
// Copyright (c) vis.gl contributors

import turfDistance from '@turf/distance';
import turfMidpoint from '@turf/midpoint';
import {FeatureCollection} from '../utils/geojson-types';
import {
ClickEvent,
Expand All @@ -15,15 +13,16 @@ import {
} from './types';
import {getPickedEditHandle} from './utils';
import {GeoJsonEditMode} from './geojson-edit-mode';
import {getEditModeCoordinateSystem} from './coordinate-system';

export class MeasureDistanceMode extends GeoJsonEditMode {
_isMeasuringSessionFinished = false;
_currentTooltips: Tooltip[] = [];
_currentDistance = 0;

_calculateDistanceForTooltip = ({positionA, positionB, modeConfig}) => {
const {turfOptions, measurementCallback} = modeConfig || {};
const distance = turfDistance(positionA, positionB, turfOptions);
_calculateDistanceForTooltip = ({positionA, positionB, modeConfig, coordinateSystem}) => {
const {measurementCallback} = modeConfig || {};
const distance = getEditModeCoordinateSystem(coordinateSystem).distance(positionA, positionB);

if (measurementCallback) {
measurementCallback(distance);
Expand All @@ -50,6 +49,7 @@ export class MeasureDistanceMode extends GeoJsonEditMode {
handleClick(event: ClickEvent, props: ModeProps<FeatureCollection>) {
const {modeConfig, data, onEdit} = props;
const {centerTooltipsOnLine = false} = modeConfig || {};
const coordSys = getEditModeCoordinateSystem(props.coordinateSystem);

// restart measuring session
if (this._isMeasuringSessionFinished) {
Expand Down Expand Up @@ -83,14 +83,15 @@ export class MeasureDistanceMode extends GeoJsonEditMode {
this._currentDistance += this._calculateDistanceForTooltip({
positionA: clickSequence[clickSequence.length - 2],
positionB: clickSequence[clickSequence.length - 1],
modeConfig
modeConfig,
coordinateSystem: coordSys
});

const tooltipPosition = centerTooltipsOnLine
? turfMidpoint(
? coordSys.midpoint(
clickSequence[clickSequence.length - 2],
clickSequence[clickSequence.length - 1]
).geometry.coordinates
)
: event.mapCoords;

this._currentTooltips.push({
Expand Down Expand Up @@ -192,17 +193,18 @@ export class MeasureDistanceMode extends GeoJsonEditMode {
const {lastPointerMoveEvent, modeConfig} = props;
const {centerTooltipsOnLine = false} = modeConfig || {};
const positions = this.getClickSequence();
const coordSys = getEditModeCoordinateSystem(props.coordinateSystem);

if (positions.length > 0 && lastPointerMoveEvent && !this._isMeasuringSessionFinished) {
const distance = this._calculateDistanceForTooltip({
positionA: positions[positions.length - 1],
positionB: lastPointerMoveEvent.mapCoords,
modeConfig: props.modeConfig
modeConfig: props.modeConfig,
coordinateSystem: coordSys
});

const tooltipPosition = centerTooltipsOnLine
? turfMidpoint(positions[positions.length - 1], lastPointerMoveEvent.mapCoords).geometry
.coordinates
? coordSys.midpoint(positions[positions.length - 1], lastPointerMoveEvent.mapCoords)
: lastPointerMoveEvent.mapCoords;

return [
Expand Down
13 changes: 5 additions & 8 deletions modules/editable-layers/src/edit-modes/translate-mode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,7 @@
// SPDX-License-Identifier: MIT
// Copyright (c) vis.gl contributors

import turfBearing from '@turf/bearing';
import turfDistance from '@turf/distance';
import clone from '@turf/clone';
import {point} from '@turf/helpers';
import {WebMercatorViewport} from '@math.gl/web-mercator';
import {
FeatureCollection,
Expand All @@ -24,6 +21,7 @@ import {mapCoords} from './utils';
import {translateFromCenter} from '../utils/translate-from-center';
import {GeoJsonEditMode, GeoJsonEditAction} from './geojson-edit-mode';
import {ImmutableFeatureCollection} from './immutable-feature-collection';
import {getEditModeCoordinateSystem} from './coordinate-system';

export class TranslateMode extends GeoJsonEditMode {
_geometryBeforeTranslate: SimpleFeatureCollection | null | undefined;
Expand Down Expand Up @@ -142,14 +140,13 @@ export class TranslateMode extends GeoJsonEditMode {
}
}
} else {
const p1 = point(startDragPoint);
const p2 = point(currentPoint);
const coordinateSystem = getEditModeCoordinateSystem(props.coordinateSystem);

const distanceMoved = turfDistance(p1, p2);
const direction = turfBearing(p1, p2);
const distanceMoved = coordinateSystem.distance(startDragPoint, currentPoint);
const direction = coordinateSystem.bearing(startDragPoint, currentPoint);

const movedFeatures = this._geometryBeforeTranslate.features.map((feature) =>
translateFromCenter(clone(feature), distanceMoved, direction)
translateFromCenter(clone(feature), distanceMoved, direction, coordinateSystem)
);

for (let i = 0; i < selectedIndexes.length; i++) {
Expand Down
4 changes: 4 additions & 0 deletions modules/editable-layers/src/edit-modes/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
// Copyright (c) vis.gl contributors

import {Position, Point, SimpleGeometry, Feature} from '../utils/geojson-types';
import type {EditModeCoordinateSystem} from './coordinate-system';

export type ScreenCoordinates = [number, number];

Expand Down Expand Up @@ -135,6 +136,9 @@ export type ModeProps<TData> = {
// The last pointer move event that occurred
lastPointerMoveEvent: PointerMoveEvent;

// Coordinate system to use for geometric calculations (defaults to GeoCoordinateSystem)
coordinateSystem?: EditModeCoordinateSystem;

// Callback used to notify applications of an edit action
onEdit: (editAction: EditAction<TData>) => void;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
DoubleClickEvent,
ModeProps
} from '../edit-modes/types';
import {fromDeckCoordinateSystem} from '../edit-modes/coordinate-system';

import {ViewMode} from '../edit-modes/view-mode';
import {TranslateMode} from '../edit-modes/translate-mode';
Expand Down Expand Up @@ -438,6 +439,10 @@ export class EditableGeoJsonLayer extends EditableLayer<
selectedIndexes: props.selectedFeatureIndexes,
lastPointerMoveEvent: this.state.lastPointerMoveEvent,
cursor: this.state.cursor,
// Derive edit-mode math from deck.gl's coordinateSystem layer prop.
// This ensures that when the layer is configured for Cartesian or other
// non-geographic rendering, edit modes automatically use the correct geometry math.
coordinateSystem: fromDeckCoordinateSystem(this.props.coordinateSystem),
onEdit: (editAction) => {
// Force a re-render
// This supports double-click where we need to ensure that there's a re-render between the two clicks
Expand Down
9 changes: 9 additions & 0 deletions modules/editable-layers/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,15 @@ export type {EditMode} from './edit-modes/edit-mode';
export type {GeoJsonEditModeType} from './edit-modes/geojson-edit-mode';
export type {GeoJsonEditModeConstructor} from './edit-modes/geojson-edit-mode';

export type {EditModeCoordinateSystem} from './edit-modes/coordinate-system';
export {GeoCoordinateSystem, CartesianCoordinateSystem} from './edit-modes/coordinate-system';
export {
geoCoordinateSystem,
cartesianCoordinateSystem,
getEditModeCoordinateSystem,
fromDeckCoordinateSystem
} from './edit-modes/coordinate-system';

export type {EditableGeoJsonLayerProps} from './editable-layers/editable-geojson-layer';
export type {SelectionLayerProps} from './editable-layers/selection-layer';

Expand Down
28 changes: 13 additions & 15 deletions modules/editable-layers/src/utils/translate-from-center.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,29 +3,27 @@
// Copyright (c) vis.gl contributors

import turfCenter from '@turf/center';
import turfRhumbBearing from '@turf/rhumb-bearing';
import turfRhumbDistance from '@turf/rhumb-distance';
import turfRhumbDestination from '@turf/rhumb-destination';
import {mapCoords} from '../edit-modes/utils';
import {geoCoordinateSystem} from '../edit-modes/coordinate-system';
import type {EditModeCoordinateSystem} from '../edit-modes/coordinate-system';
import type {SimpleFeature} from './geojson-types';

// This function takes feature's center, moves it,
// and builds new feature around it keeping the proportions
export function translateFromCenter(feature: SimpleFeature, distance: number, direction: number) {
const initialCenterPoint = turfCenter(feature);
export function translateFromCenter(
feature: SimpleFeature,
distance: number,
direction: number,
coordinateSystem: EditModeCoordinateSystem = geoCoordinateSystem
) {
const initialCenter = turfCenter(feature).geometry.coordinates;

const movedCenterPoint = turfRhumbDestination(initialCenterPoint, distance, direction);
const movedCenter = coordinateSystem.destination(initialCenter, distance, direction);

const movedCoordinates = mapCoords(feature.geometry.coordinates, (coordinate) => {
const rhumbDistance = turfRhumbDistance(initialCenterPoint.geometry.coordinates, coordinate);
const rhumbDirection = turfRhumbBearing(initialCenterPoint.geometry.coordinates, coordinate);

const movedPosition = turfRhumbDestination(
movedCenterPoint.geometry.coordinates,
rhumbDistance,
rhumbDirection
).geometry.coordinates;
return movedPosition;
const dist = coordinateSystem.distance(initialCenter, coordinate);
const dir = coordinateSystem.bearing(initialCenter, coordinate);
return coordinateSystem.destination(movedCenter, dist, dir);
});

feature.geometry.coordinates = movedCoordinates;
Expand Down
Loading
Loading