terriajs
Version:
Geospatial data visualization platform.
635 lines (566 loc) • 20.8 kB
text/typescript
import i18next from "i18next";
import {
computed,
makeObservable,
observable,
override,
reaction,
runInAction
} from "mobx";
import Cartesian3 from "terriajs-cesium/Source/Core/Cartesian3";
import Cartographic from "terriajs-cesium/Source/Core/Cartographic";
import Color from "terriajs-cesium/Source/Core/Color";
import createGuid from "terriajs-cesium/Source/Core/createGuid";
import Ellipsoid from "terriajs-cesium/Source/Core/Ellipsoid";
import JulianDate from "terriajs-cesium/Source/Core/JulianDate";
import PolygonHierarchy from "terriajs-cesium/Source/Core/PolygonHierarchy";
import Rectangle from "terriajs-cesium/Source/Core/Rectangle";
import CallbackProperty from "terriajs-cesium/Source/DataSources/CallbackProperty";
import ConstantPositionProperty from "terriajs-cesium/Source/DataSources/ConstantPositionProperty";
import CustomDataSource from "terriajs-cesium/Source/DataSources/CustomDataSource";
import DataSource from "terriajs-cesium/Source/DataSources/DataSource";
import Entity from "terriajs-cesium/Source/DataSources/Entity";
import PolylineGlowMaterialProperty from "terriajs-cesium/Source/DataSources/PolylineGlowMaterialProperty";
import filterOutUndefined from "../Core/filterOutUndefined";
import isDefined from "../Core/isDefined";
import DragPoints from "../Map/DragPoints/DragPoints";
import MappableMixin from "../ModelMixins/MappableMixin";
import ViewState from "../ReactViewModels/ViewState";
import MappableTraits from "../Traits/TraitsClasses/MappableTraits";
import CreateModel from "./Definition/CreateModel";
import MapInteractionMode from "./MapInteractionMode";
import Terria from "./Terria";
interface OnDrawingCompleteParams {
points: Cartesian3[];
rectangle?: Rectangle;
}
interface Options {
terria: Terria;
messageHeader?: string | (() => string);
allowPolygon?: boolean;
drawRectangle?: boolean;
onMakeDialogMessage?: () => string;
buttonText?: string;
onPointClicked?: (dataSource: DataSource) => void;
onPointMoved?: (dataSource: DataSource) => void;
onDrawingComplete?: (params: OnDrawingCompleteParams) => void;
onCleanUp?: () => void;
invisible?: boolean;
}
export default class UserDrawing extends MappableMixin(
CreateModel(MappableTraits)
) {
private readonly messageHeader: string | (() => string);
private readonly allowPolygon: boolean;
private readonly onMakeDialogMessage?: () => string;
private readonly buttonText?: string;
private readonly onPointClicked?: (dataSource: CustomDataSource) => void;
private readonly onPointMoved?: (dataSource: CustomDataSource) => void;
private readonly onDrawingComplete?: (
params: OnDrawingCompleteParams
) => void;
private readonly onCleanUp?: () => void;
private readonly invisible?: boolean;
// helper for dragging points around
private dragHelper?: DragPoints;
pointEntities: CustomDataSource;
otherEntities: CustomDataSource;
polygon?: Entity;
private inDrawMode: boolean;
closeLoop: boolean;
private disposePickedFeatureSubscription?: () => void;
private drawRectangle: boolean;
private mousePointEntity?: Entity;
constructor(options: Options) {
super(createGuid(), options.terria);
makeObservable(this);
/**
* Text that appears at the top of the dialog when drawmode is active.
*/
this.messageHeader =
options.messageHeader ?? i18next.t("models.userDrawing.messageHeader");
/**
* If true, user can click on first point to close the line, turning it into a polygon.
*/
this.allowPolygon = options.allowPolygon ?? true;
/**
* Callback that occurs when the dialog is redrawn, to add additional information to dialog.
*/
this.onMakeDialogMessage = options.onMakeDialogMessage;
this.buttonText = options.buttonText;
/**
* Callback that occurs when point is clicked (may be added or removed). Function takes a CustomDataSource which is
* a list of PointEntities.
*/
this.onPointClicked = options.onPointClicked;
/**
* Callback that occurs when point is moved. Function takes a CustomDataSource which is a list of PointEntities.
*/
this.onPointMoved = options.onPointMoved;
/**
* Callback that occurs when a drawing is complete. This is called when the
* user has clicked done button and the shape has at least 1 point.
* The callback function will receive the points in the shape and a rectangle
* if `drawRectangle` was set to `true`.
*/
this.onDrawingComplete = options.onDrawingComplete;
/**
* Callback that occurs on clean up, i.e. when drawing is done or cancelled.
*/
this.onCleanUp = options.onCleanUp;
/**
* Storage for points that will be drawn
*/
this.pointEntities = new CustomDataSource("Points");
/**
* Storage for line that connects the points, and polygon if the first and last point are the same
*/
this.otherEntities = new CustomDataSource("Lines and polygons");
/**
* Whether to interpret user clicks as drawing
*/
this.inDrawMode = false;
/**
* Whether the first and last point in the user drawing are the same
*/
this.closeLoop = false;
this.drawRectangle = options.drawRectangle ?? false;
this.invisible = options.invisible;
}
protected forceLoadMapItems(): Promise<void> {
return Promise.resolve();
}
get mapItems() {
// Don't show points if drawing rectangle
return this.drawRectangle
? [this.otherEntities]
: [this.pointEntities, this.otherEntities];
}
get svgPoint() {
/**
* SVG element for point drawn when user clicks.
* http://stackoverflow.com/questions/24869733/how-to-draw-custom-dynamic-billboards-in-cesium-js
*/
const svgDataDeclare = "data:image/svg+xml,";
const svgPrefix =
'<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" width="20px" height="20px" xml:space="preserve">';
const svgCircle =
'<circle cx="10" cy="10" r="5" stroke="rgb(0,170,215)" stroke-width="4" fill="white" /> ';
const svgSuffix = "</svg>";
const svgString = svgPrefix + svgCircle + svgSuffix;
// create the cesium entity
return svgDataDeclare + svgString;
}
get cesiumRectangle(): Rectangle | undefined {
return this.getRectangleForShape();
}
enterDrawMode(): void {
// Create and setup a new dragHelper
this.dragHelper = new DragPoints(this.terria, (customDataSource) => {
if (typeof this.onPointMoved === "function") {
this.onPointMoved(customDataSource);
}
this.prepareToAddNewPoint();
});
this.dragHelper.setUp();
// If we have finished a polygon, don't allow more points to be drawn. In future, perhaps support multiple polygons.
if (this.inDrawMode || this.closeLoop) {
// Do nothing
return;
}
runInAction(() => {
this.inDrawMode = true;
});
if (isDefined(this.terria.cesium)) {
this.terria.cesium.cesiumWidget.canvas.setAttribute(
"style",
"cursor: crosshair"
);
} else if (isDefined(this.terria.leaflet)) {
const container = document.getElementById("cesiumContainer");
if (container !== null) {
container.setAttribute("style", "cursor: crosshair");
}
}
// Cancel any feature picking already in progress and disable feature info requests.
runInAction(() => {
this.terria.pickedFeatures = undefined;
this.terria.allowFeatureInfoRequests = false;
});
const that = this;
// Rectangle will show up once user has a point.
if (this.drawRectangle) {
this.mousePointEntity = new Entity({
id: "mousePoint",
position: undefined
});
const rectangle = {
name: "Rectangle",
id: "rectangle",
rectangle: {
coordinates: new CallbackProperty(
((time: JulianDate | undefined) => {
if (
!isDefined(time) ||
this.pointEntities.entities.values.length < 1
)
return;
const point1 =
this.pointEntities.entities.values[0].position &&
(this.pointEntities.entities.values[0].position.getValue(
time
) as Cartesian3);
if (!point1) {
return;
}
const point2 =
(this.pointEntities.entities.values?.[1]?.position?.getValue(
time
) as Cartesian3) ||
this.mousePointEntity?.position?.getValue(time);
return (
point1 &&
point2 &&
Rectangle.fromCartographicArray([
Cartographic.fromCartesian(point1),
Cartographic.fromCartesian(point2)
])
);
}).bind(this),
false
),
material: new Color(1.0, 1.0, 1.0, 0.5)
}
};
this.otherEntities.entities.add(rectangle as any);
} else {
// Line will show up once user has drawn some points. Vertices of line are user points.
this.otherEntities.entities.add({
name: "Line",
polyline: {
positions: new CallbackProperty(function () {
const pos = that.getPointsForShape();
if (isDefined(pos) && that.closeLoop) {
pos.push(pos[0]);
}
return pos;
}, false),
material: new PolylineGlowMaterialProperty({
color: new Color(0.0, 0.0, 0.0, 0.1),
glowPower: 0.25
} as any),
width: 20
} as any
} as any);
}
this.terria.overlays.add(this);
// Listen for user clicks on map
const pickPointMode = this.addMapInteractionMode();
this.disposePickedFeatureSubscription = reaction(
() => pickPointMode.pickedFeatures,
async (pickedFeatures, _previousValue, reaction) => {
if (isDefined(pickedFeatures)) {
if (isDefined(pickedFeatures.allFeaturesAvailablePromise)) {
await pickedFeatures.allFeaturesAvailablePromise;
}
if (isDefined(pickedFeatures.pickPosition)) {
const pickedPoint = pickedFeatures.pickPosition;
this.addPointToPointEntities("First Point", pickedPoint);
reaction.dispose();
this.prepareToAddNewPoint();
}
}
}
);
}
/**
* Add new point to list of pointEntities
*/
private addPointToPointEntities(name: string, position: Cartesian3) {
const pointEntity = new Entity({
name: name,
position: new ConstantPositionProperty(position),
billboard: {
image: this.svgPoint,
eyeOffset: new Cartesian3(0.0, 0.0, -50.0)
} as any
});
// Remove the existing points if we are in drawRectangle mode and the user
// has picked a 3rd point. This lets the user draw new rectangle that
// replaces the current one.
if (this.drawRectangle && this.pointEntities.entities.values.length === 2) {
this.pointEntities.entities.removeAll();
}
this.pointEntities.entities.add(pointEntity);
this.dragHelper?.updateDraggableObjects(this.pointEntities);
if (isDefined(this.onPointClicked)) {
this.onPointClicked(this.pointEntities);
}
}
endDrawing(): void {
this.dragHelper?.destroy();
if (this.disposePickedFeatureSubscription) {
this.disposePickedFeatureSubscription();
}
runInAction(() => {
this.terria.mapInteractionModeStack.pop();
this.cleanUp();
});
}
/**
* Updates the MapInteractionModeStack with a listener for a new point.
*/
private addMapInteractionMode() {
const pickPointMode = new MapInteractionMode({
message: this.getDialogMessage(),
buttonText: this.getButtonText(),
onCancel: () => {
runInAction(() => {
if (this.onDrawingComplete) {
const isDrawingComplete =
this.pointEntities.entities.values.length >= 2;
const points = this.getPointsForShape();
if (isDrawingComplete && points) {
this.onDrawingComplete({
points: filterOutUndefined(points),
rectangle: this.getRectangleForShape()
});
}
}
});
this.endDrawing();
},
onEnable: (viewState: ViewState) => {
runInAction(() => (viewState.explorerPanelIsVisible = false));
if (this.drawRectangle && this.mousePointEntity) {
const scratchPosition = new Cartesian3();
this.mousePointEntity.position = new CallbackProperty(() => {
const cartographicMouseCoords =
this.terria.currentViewer.mouseCoords.cartographic;
let mousePosition = undefined;
if (cartographicMouseCoords) {
mousePosition = Ellipsoid.WGS84.cartographicToCartesian(
cartographicMouseCoords,
scratchPosition
);
}
return mousePosition;
}, false) as any;
}
},
invisible: this.invisible
});
runInAction(() => {
this.terria.mapInteractionModeStack.push(pickPointMode);
});
return pickPointMode;
}
/**
* Called after a point has been added, prepares to add and draw another point, as well as updating the dialog.
*/
private prepareToAddNewPoint() {
runInAction(() => {
this.terria.mapInteractionModeStack.pop();
});
const pickPointMode = this.addMapInteractionMode();
this.disposePickedFeatureSubscription = reaction(
() => pickPointMode.pickedFeatures,
async (pickedFeatures, _previousValue, reaction) => {
if (isDefined(pickedFeatures)) {
if (isDefined(pickedFeatures.allFeaturesAvailablePromise)) {
await pickedFeatures.allFeaturesAvailablePromise;
}
if (isDefined(pickedFeatures.pickPosition)) {
const pickedPoint = pickedFeatures.pickPosition;
// If existing point was picked, _clickedExistingPoint handles that, and returns true.
// getDragCount helps us determine if the point was actually dragged rather than clicked. If it was
// dragged, we shouldn't treat it as a clicked-existing-point scenario.
if (
this.dragHelper &&
this.dragHelper.getDragCount() < 10 &&
!this.clickedExistingPoint(pickedFeatures.features)
) {
// No existing point was picked, so add a new point
this.addPointToPointEntities("Another Point", pickedPoint);
} else {
this.dragHelper?.resetDragCount();
}
reaction.dispose();
if (this.inDrawMode) {
this.prepareToAddNewPoint();
}
}
}
}
);
}
/**
* Find out if user clicked an existing point and handle appropriately.
*/
private clickedExistingPoint(features: Entity[]) {
let userClickedExistingPoint = false;
if (features.length < 1) {
return userClickedExistingPoint;
}
const that = this;
features.forEach((feature) => {
let index = -1;
for (let i = 0; i < this.pointEntities.entities.values.length; i++) {
const pointFeature = this.pointEntities.entities.values[i];
if (pointFeature.id === feature.id) {
index = i;
break;
}
}
if (index === -1) {
// Probably a layer or feature that has nothing to do with what we're drawing.
return;
} else if (index === 0 && !this.closeLoop && this.allowPolygon) {
// Index is zero if it's the first point, meaning we have a closed shape
this.polygon = this.otherEntities.entities.add({
name: "User polygon",
polygon: {
hierarchy: new CallbackProperty(function () {
return new PolygonHierarchy(that.getPointsForShape());
}, false),
material: new Color(0.0, 0.666, 0.843, 0.25),
outlineColor: new Color(1.0, 1.0, 1.0, 1.0),
perPositionHeight: true as any
} as any
} as any) as Entity;
this.closeLoop = true;
// A point has not been added, but conceptually it has because the first point is now also the last point.
if (typeof that.onPointClicked === "function") {
that.onPointClicked(that.pointEntities);
}
userClickedExistingPoint = true;
return;
} else {
// User clicked on a point that's not the end of the loop. Remove it.
this.pointEntities.entities.removeById(feature.id);
// If it gets down to 2 points, it should stop acting like a polygon.
if (this.pointEntities.entities.values.length < 2 && this.closeLoop) {
this.closeLoop = false;
if (this.polygon) {
this.otherEntities.entities.remove(this.polygon);
}
}
// Also let client of UserDrawing know if a point has been removed.
if (typeof that.onPointClicked === "function") {
that.onPointClicked(that.pointEntities);
}
userClickedExistingPoint = true;
return;
}
});
return userClickedExistingPoint;
}
/**
* User has finished or cancelled; restore initial state.
*/
cleanUp(): void {
this.terria.overlays.remove(this);
this.pointEntities = new CustomDataSource("Points");
this.otherEntities = new CustomDataSource("Lines and polygons");
this.terria.allowFeatureInfoRequests = true;
runInAction(() => {
this.inDrawMode = false;
});
this.closeLoop = false;
// Return cursor to original state
if (isDefined(this.terria.cesium)) {
this.terria.cesium.cesiumWidget.canvas.setAttribute(
"style",
"cursor: auto"
);
} else if (isDefined(this.terria.leaflet)) {
const container = document.getElementById("cesiumContainer");
if (container !== null) {
container.setAttribute("style", "cursor: auto");
}
}
// Allow client to clean up too
if (typeof this.onCleanUp === "function") {
this.onCleanUp();
}
}
/**
* Create the HTML message in the dialog box.
* Example:
*
* Measuring Tool
* 373.45 km
* Click to add another point
*/
getDialogMessage(): string {
let message =
"<strong>" +
(typeof this.messageHeader === "function"
? this.messageHeader()
: this.messageHeader) +
"</strong></br>";
const innerMessage = isDefined(this.onMakeDialogMessage)
? this.onMakeDialogMessage()
: "";
if (innerMessage !== "") {
message += innerMessage + "</br>";
}
if (this.drawRectangle && this.pointEntities.entities.values.length >= 2) {
message +=
"<i>" + i18next.t("models.userDrawing.clickToRedrawRectangle") + "</i>";
} else if (this.pointEntities.entities.values.length > 0) {
message +=
"<i>" + i18next.t("models.userDrawing.clickToAddAnotherPoint") + "</i>";
} else {
message +=
"<i>" + i18next.t("models.userDrawing.clickToAddFirstPoint") + "</i>";
}
// htmlToReactParser will fail if html doesn't have only one root element.
return "<div>" + message + "</div>";
}
/**
* Figure out the text for the dialog button.
*/
getButtonText(): string {
return (
this.buttonText ??
(this.pointEntities.entities.values.length >= 2
? i18next.t("models.userDrawing.btnDone")
: i18next.t("models.userDrawing.btnCancel"))
);
}
/**
* Return a list of the coords for the user drawing
*/
getPointsForShape(): Cartesian3[] | undefined {
if (isDefined(this.pointEntities.entities)) {
const pos = [];
for (let i = 0; i < this.pointEntities.entities.values.length; i++) {
const obj = this.pointEntities.entities.values[i];
if (isDefined(obj.position)) {
const position = obj.position.getValue(
this.terria.timelineClock.currentTime
);
if (position !== undefined) {
pos.push(position);
}
}
}
return pos;
}
}
getRectangleForShape(): Rectangle | undefined {
if (!this.drawRectangle) {
return undefined;
}
if (this.pointEntities.entities.values.length < 2) {
return undefined;
}
const rectangle = this.otherEntities.entities
.getById("rectangle")
?.rectangle?.coordinates?.getValue(this.terria.timelineClock.currentTime);
return rectangle;
}
}