diff --git a/docs/pages/Configure/Tabs/Panels/Panels_Tab.md b/docs/pages/Configure/Tabs/Panels/Panels_Tab.md
index 676b7079..0ba289ce 100644
--- a/docs/pages/Configure/Tabs/Panels/Panels_Tab.md
+++ b/docs/pages/Configure/Tabs/Panels/Panels_Tab.md
@@ -31,9 +31,13 @@ The Viewer sits to the left of the main Map. It can be expanded by clicking the
"type": "document"
},
{
- "url": "Layers/GPR/Data/GPR_radargram.jpg",
- "type": "radargram"
- },
+ "url": "Layers/GPR/Data/GPR_radargram.jpg",
+ "type": "radargram",
+ "mode": "026",
+ "topElev": -2535,
+ "depth": 10.99,
+ "length": 23.44
+ },
{
"name": "Model Example",
"url": "Data/models/example_model.obj",
@@ -75,7 +79,12 @@ Current only .pdf files are supported here.
### Radargram
-Renders as a standard image. The Curtain Tool will find this type and use it.
+Renders as a standard image. The Curtain Tool will find this type and use it. Should be set only for LineString geometries.
+
+- `mode`: Any string to show in a dropdown in the Curtain Tool to identify and switch between radargrams in case there are many.
+- `topElev`: Top elevation in meters that correspond to the top pixel of the radargram image. Radargrams should be generated to be terrain aligned.
+- `depth`: Depth of radargram image in meters.
+- `length`: Width of radargram image in meters.
### Model
diff --git a/docs/pages/Tools/Curtain/Curtain.md b/docs/pages/Tools/Curtain/Curtain.md
new file mode 100644
index 00000000..db6ef4f7
--- /dev/null
+++ b/docs/pages/Tools/Curtain/Curtain.md
@@ -0,0 +1,91 @@
+---
+layout: page
+title: Curtain
+permalink: /tools/curtain
+parent: Tools
+---
+
+# Curtain
+
+Vertical imagery aligned under terrain for visualizing data from ground penetrating radar.
+
+### Raw Variables
+
+The following is an example of the Sites Tool's Tool Tab configuration:
+
+```javascript
+{
+ "withCredentials": false
+}
+```
+
+- `withCredentials`: An array of objects required to add sites.
+
+### Configuring Features
+
+To setup curtains, add objects of type "radargram" to a LineString feature's `properties.images` array and enable the Viewer Panel.
+
+```json
+{
+ "type": "Feature",
+ "properties": {
+ "fromPoint": "A",
+ "toPoint": "B",
+ "length": 23.4476738253,
+ "day": 1,
+ "images": [
+ {
+ "url": "Layers/rfax/026_profile_noaxis.png",
+ "type": "radargram",
+ "mode": "026",
+ "topElev": -2535,
+ "depth": 10.99,
+ "length": 23.44
+ },
+ {
+ "url": "Layers/rfax/056_profile_noaxis.png",
+ "type": "radargram",
+ "mode": "056",
+ "topElev": -2535,
+ "depth": 12.01,
+ "length": 23.44
+ },
+ {
+ "url": "Layers/rfax/078_profile_noaxis.png",
+ "type": "radargram",
+ "mode": "078",
+ "topElev": -2535,
+ "depth": 12.74,
+ "length": 23.44
+ },
+ {
+ "url": "Layers/rfax/214_profile_noaxis.png",
+ "type": "radargram",
+ "mode": "214",
+ "topElev": -2535,
+ "depth": 19.74,
+ "length": 23.44
+ },
+ {
+ "url": "Layers/rfax/240_profile_noaxis.png",
+ "type": "radargram",
+ "mode": "240",
+ "topElev": -2535,
+ "depth": 17.41,
+ "length": 23.44
+ }
+ ]
+ },
+ "geometry": {
+ "type": "LineString",
+ "coordinates": [
+ ...
+ ]
+ }
+}
+```
+
+- `mode`: Any string to show in a dropdown in the Curtain Tool to identify and switch between radargrams in case there are many.
+- `topElev`: Top elevation in meters that correspond to the top pixel of the radargram image. Radargrams should be generated to be terrain aligned.
+- `depth`: Depth of radargram image in meters.
+- `length`: Width of radargram image in meters. May use LineString geometry's length.
diff --git a/src/essence/Tools/Curtain/CurtainTool.css b/src/essence/Tools/Curtain/CurtainTool.css
new file mode 100644
index 00000000..43e636a1
--- /dev/null
+++ b/src/essence/Tools/Curtain/CurtainTool.css
@@ -0,0 +1,221 @@
+.CurtainTool {
+ width: 100%;
+ height: 100%;
+ display: flex;
+ background: var(--color-k);
+ color: var(--color-f);
+ box-shadow: inset 2px 0px 10px 0px rgba(0, 0, 0, 0.2);
+}
+.CurtainTool #curtainTop,
+.CurtainTool #curtainIcons {
+ display: flex;
+ justify-content: space-between;
+}
+.CurtainTool #curtainTop {
+ border-bottom: 1px solid var(--color-i);
+}
+.CurtainTool #curtainIcons {
+}
+.CurtainTool #curtainIcons > div {
+ width: 30px;
+ height: 30px;
+ line-height: 30px;
+ margin: 2px 0px 2px 2px;
+ text-align: center;
+ cursor: pointer;
+ transition: all 0.2s cubic-bezier(0.445, 0.05, 0.55, 0.95);
+}
+.CurtainTool #curtainIcons > div:hover {
+ color: var(--color-mmgis);
+ background: var(--color-m1);
+}
+
+.CurtainTool #curtainLeft {
+ width: 390px;
+ height: 100%;
+ display: flex;
+ flex-flow: column;
+}
+
+.CurtainTool #curtainLeft #curtainTitle {
+ height: 34px;
+ line-height: 34px;
+ font-size: 16px;
+ padding-left: 6px;
+ font-family: 'lato-light';
+ text-transform: uppercase;
+ color: var(--color-l);
+}
+
+.CurtainTool #curtainUrl {
+ padding: 7px 7px 0px 7px;
+ font-size: 14px;
+ color: white;
+ text-align: center;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.CurtainTool #curtainStats {
+ display: flex;
+ justify-content: center;
+ font-size: 13px;
+ padding: 7px;
+ color: white;
+ border-bottom: 1px solid var(--color-i);
+}
+.CurtainTool #curtainStats > div {
+ padding: 0px 7px;
+ height: 29px;
+}
+.CurtainTool #curtainStats > div:not(:last-child) {
+ border-right: 1px solid var(--color-i);
+}
+.CurtainTool #curtainStats > div > div:first-child {
+ color: #b3b3b3;
+ font-size: 12px;
+}
+.CurtainTool #curtainStats > div > div {
+ text-align: center;
+}
+
+.CurtainTool #curtainBottom {
+ padding: 7px 14px 0px 14px;
+}
+.CurtainTool #curtainMode {
+ display: flex;
+ justify-content: space-between;
+ font-size: 14px;
+ line-height: 21px;
+ margin: 0px 7px 7px 7px;
+}
+
+.CurtainTool #curtain3dExag,
+.CurtainTool #curtain3dOffset {
+ display: flex;
+ justify-content: space-between;
+ font-size: 14px;
+ line-height: 21px;
+ margin: 0px 5px 7px 7px;
+}
+.CurtainTool .curtainSliderLabel {
+ padding: 0px 3px;
+ color: #b3b3b3;
+ font-size: 12px;
+ line-height: 21px;
+}
+.CurtainTool #curtainMode > select {
+ width: 130px;
+}
+.CurtainTool #curtain3dExag .slider2,
+.CurtainTool #curtain3dOffset .slider2 {
+ width: 130px;
+ margin-top: 3px;
+ margin-bottom: 1px;
+}
+
+.CurtainTool #curtainMiddle {
+ flex: 1;
+ height: 100%;
+ background: var(--color-n);
+ position: relative;
+}
+.CurtainTool #curtainViewer {
+ width: 100%;
+ height: 100%;
+ cursor: crosshair;
+}
+
+.CurtainTool #curtainMessage {
+ width: 100%;
+ height: 100%;
+ position: absolute;
+ top: 0px;
+ opacity: 1;
+ pointer-events: all;
+ background: var(--color-n);
+ transition: opacity 1s cubic-bezier(0.785, 0.135, 0.15, 0.86);
+}
+.CurtainTool #curtainMessage.curtainMessageShown {
+}
+.CurtainTool #curtainMessage.curtainMessageHidden {
+ opacity: 0;
+ pointer-events: none;
+}
+.CurtainTool #curtainMessage > div {
+ position: absolute;
+ left: 50%;
+ top: 50%;
+ transform: translateX(-50%) translateY(-50%);
+}
+.CurtainTool #curtainTooltip {
+ position: absolute;
+ right: 0;
+ line-height: 26px;
+ padding: 0px 4px 0px 6px;
+ background: var(--color-k);
+ pointer-events: none;
+ transition: all 0.2s cubic-bezier(0.39, 0.575, 0.565, 1);
+}
+
+.CurtainTool #curtainToolBar {
+ width: 30px;
+ height: 100%;
+ display: flex;
+ flex-flow: column;
+}
+.CurtainTool #curtainToolBar > div {
+ width: 30px;
+ height: 30px;
+ line-height: 30px;
+ text-align: center;
+ cursor: pointer;
+ transition: all 0.2s cubic-bezier(0.445, 0.05, 0.55, 0.95);
+}
+.CurtainTool #curtainToolBar > div:hover {
+ color: var(--color-mmgis);
+ background: var(--color-m1);
+}
+
+/*dropdown*/
+.CurtainTool .dropdown {
+ background-color: var(--color-m1);
+ color: #e1e1e1;
+ border: none;
+ margin-top: 1px;
+ width: 100%;
+ height: 20px;
+ font-size: 14px;
+ padding-left: 2px;
+}
+.CurtainTool .dropdown::after {
+ color: #e1e1e1;
+}
+
+.CurtainTool .upper-canvas {
+ cursor: crosshair !important;
+}
+
+.CurtainTool #curtainLoading {
+ width: 100%;
+ height: 100%;
+ position: absolute;
+ top: 0px;
+ opacity: 1;
+ pointer-events: all;
+ background: var(--color-n);
+ transition: opacity 1s cubic-bezier(0.785, 0.135, 0.15, 0.86);
+}
+.CurtainTool #curtainLoading.curtainLoadingShown {
+}
+.CurtainTool #curtainLoading.curtainLoadingHidden {
+ opacity: 0;
+ pointer-events: none;
+}
+.CurtainTool #curtainLoading > div {
+ position: absolute;
+ left: 50%;
+ top: 50%;
+ transform: translateX(-50%) translateY(-50%);
+}
diff --git a/src/essence/Tools/Curtain/CurtainTool.js b/src/essence/Tools/Curtain/CurtainTool.js
new file mode 100644
index 00000000..489a48d1
--- /dev/null
+++ b/src/essence/Tools/Curtain/CurtainTool.js
@@ -0,0 +1,852 @@
+import $ from 'jquery'
+import * as d3 from 'd3'
+import F_ from '../../Basics/Formulae_/Formulae_'
+import L_ from '../../Basics/Layers_/Layers_'
+import TC_ from '../../Basics/ToolController_/ToolController_'
+import Viewer_ from '../../Basics/Viewer_/Viewer_'
+import Map_ from '../../Basics/Map_/Map_'
+import Globe_ from '../../Basics/Globe_/Globe_'
+import CursorInfo from '../../Ancillary/CursorInfo'
+import calls from '../../../pre/calls'
+
+import ReactDOM from 'react-dom'
+import React, { useState, useEffect, useRef } from 'react'
+
+import './CurtainTool.css'
+
+// Expose setStates externally
+const state = {
+ setActiveFeature: function () {},
+ setActiveImages: function () {},
+ setActiveImageId: function () {},
+ setMouseCoords: function () {},
+ setMouseCoordsVis: function () {},
+ setKeepOnCheckbox: function () {},
+ setActiveImageLoading: function () {},
+}
+const Curtain = () => {
+ const [activeFeature, setActiveFeature] = useState([])
+ const [activeImages, setActiveImages] = useState([])
+ const [activeImageLoading, setActiveImageLoading] = useState(false)
+ const [activeImageId, setActiveImageId] = useState(null)
+ const [mouseCoords, setMouseCoords] = useState({})
+ const [mouseCoordsVis, setMouseCoordsVis] = useState(false)
+ const [verticalExag, setVerticalExag] = useState(1)
+ const [verticalOffset, setVerticalOffset] = useState(100)
+ const [keepOnCheckbox, setKeepOnCheckbox] = useState(false)
+
+ useEffect(() => {
+ // code to run on component mount
+ state.setActiveFeature = setActiveFeature
+ state.setActiveImages = setActiveImages
+ state.setActiveImageId = setActiveImageId
+ state.setMouseCoords = setMouseCoords
+ state.setMouseCoordsVis = setMouseCoordsVis
+ state.setKeepOnCheckbox = setKeepOnCheckbox
+ state.setActiveImageLoading = setActiveImageLoading
+ }, [])
+
+ const activeImage = activeImages[activeImageId]
+
+ return (
+
+
+
+
Curtain
+
+
{
+ CurtainTool.reset()
+ }}
+ >
+
+
+
+
+ {
+ CurtainTool.toggleKeepImageOn(
+ !keepOnCheckbox
+ )
+ }}
+ id='checkbox_curtainKeepOn'
+ />
+
+
+
+
+
+ {activeImage && (
+ <>
+
+ {F_.fileNameFromPath(activeImage.url)}
+
+
+
+
+
Sol
+
{activeFeature?.properties?.sol}
+
+
+
RMC
+
{`${activeFeature?.properties?.fromRMC} → ${activeFeature?.properties?.toRMC}`}
+
+
+
Length
+
{`${activeImage.length}m`}
+
+
+
Depth
+
{`${activeImage.depth}m`}
+
+
+
Top Elevation
+
{`${activeImage.topElev}m`}
+
+
+
+
+
Mode
+
+
+
+
+ 3D Vertical Exaggeration
+
+
+
+ ({verticalExag}x)
+
+
{
+ CurtainTool.set3DVerticalOptions(
+ e.target.value,
+ verticalOffset
+ )
+
+ setVerticalExag(e.target.value)
+ }}
+ />
+
+
+
+
+ 3D Vertical Offset
+
+
+
+ ({verticalOffset}%)
+
+
{
+ CurtainTool.set3DVerticalOptions(
+ verticalExag,
+ e.target.value
+ )
+ setVerticalOffset(e.target.value)
+ }}
+ />
+
+
+
+ >
+ )}
+
+
+
{}}>
+
+ {mouseCoords.text || ''}
+
+
+
Select a valid line segment on the map to begin.
+
+
+
+
+
+ )
+}
+
+let CurtainTool = {
+ height: 196,
+ width: 'full',
+ vars: {},
+ data: [],
+ osd: null,
+ activeImage: null,
+ lastImageId: 0,
+ currentMapLayer: null,
+ focus: {
+ canvas: null,
+ scale: 2000,
+ topCircle: null,
+ cursorCircle: null,
+ mapLayer: null,
+ },
+ options: {
+ verticalExag: 1,
+ verticalOffset: 100,
+ },
+ drawn3DCurtainIds: [],
+ keptOnImages: {},
+ init: function () {},
+ make: function () {
+ //Get tool variables
+ this.vars = L_.getToolVars('curtain')
+
+ ReactDOM.render(, document.getElementById('tools'))
+
+ this.osd = OpenSeadragon({
+ id: 'curtainViewer',
+ //prefixUrl: 'scripts/external/OpenSeadragon/images/',
+ defaultZoomLevel: 0.95,
+ //showNavigationControl: false,
+ showFullPageControl: false,
+ zoomInButton: 'curtainZoomIn',
+ zoomOutButton: 'curtainZoomOut',
+ homeButton: 'curtainReset',
+ showNavigator: false,
+ constrainDuringPan: true,
+ visibilityRatio: 1,
+ animationTime: 0.5,
+ minZoomLevel: 0.5,
+ maxZoomLevel: 12,
+ ajaxWithCredentials: true,
+ //zoomPerClick: 1, //disables click to zoom for tools...
+ imageSmoothingEnabled: false,
+ })
+ },
+ destroy: function () {
+ ReactDOM.unmountComponentAtNode(document.getElementById('tools'))
+ CurtainTool.currentMapLayer = null
+ L_.setActiveFeature()
+ },
+ reset() {
+ // Reset viewer
+ this.changeImage()
+
+ state.setActiveImages([])
+ state.setActiveImageId(null)
+
+ // Clear all Globe curtains
+ CurtainTool.drawn3DCurtainIds.forEach((id) => {
+ if (Globe_.litho.hasLayer(id)) Globe_.litho.removeLayer(id)
+ })
+ },
+ getCurrentImageDimensions: function () {
+ return (
+ CurtainTool.osd?.world?._items?.[0]?.source?.dimensions || {
+ x: 0,
+ y: 0,
+ }
+ )
+ },
+ changeImage: function (images, imageId, layerChanged) {
+ // Clear existing
+ const numImgs = CurtainTool.osd.world._items.length
+ for (let i = 0; i < numImgs; i++) {
+ const oldImg = CurtainTool.osd.world.getItemAt(i)
+ if (oldImg) {
+ CurtainTool.osd.world.removeItem(oldImg)
+ }
+ }
+
+ // Change to no image
+ if (images == null) {
+ CurtainTool.removeKeptOffImages()
+ CurtainTool.removeLastSameLayerImage()
+ return
+ }
+
+ const img = images[imageId]
+ CurtainTool.activeImage = img
+ CurtainTool.activeImage._id = `CurtainTool_${CurtainTool.currentMapLayer?._leaflet_id}.${imageId}`
+ const isKeptOn = CurtainTool.isKeptOn()
+ CurtainTool.keptOnImages[CurtainTool.activeImage._id] = isKeptOn
+ CurtainTool.lastImageId = imageId
+ state.setActiveImages(images)
+ state.setActiveImageId(CurtainTool.lastImageId)
+
+ // Remove previous Globe curtain if any
+ CurtainTool.removeKeptOffImages()
+ CurtainTool.removeLastSameLayerImage()
+
+ // Update keep on checkbox
+ if (layerChanged) {
+ state.setKeepOnCheckbox(isKeptOn)
+ }
+
+ // Add new
+ if (img && img.url) {
+ let url = img.url
+ if (!F_.isUrlAbsolute(url)) url = L_.missionPath + url
+
+ state.setActiveImageLoading(true)
+
+ this.osd.addSimpleImage({
+ url: url,
+ success: function () {
+ CurtainTool.attachFocus()
+ state.setActiveImageLoading(false)
+ },
+ error: function () {
+ CurtainTool.detachFocus(true)
+ state.setActiveImageLoading(false)
+ },
+ })
+
+ // Add to globe
+ CurtainTool.addCurtainToGlobe(img, url, CurtainTool.currentMapLayer)
+ }
+
+ CurtainTool.attachFocus()
+ },
+ notify: function (type, payload) {
+ switch (type) {
+ case 'setActiveFeature':
+ this.onActiveFeatureChange(payload)
+ break
+ default:
+ break
+ }
+ },
+ onActiveFeatureChange(payload) {
+ if (payload == null) {
+ this.changeImage()
+ return
+ }
+ // Look for radargram images
+ CurtainTool.radargrams = []
+ const oldFeatureId = CurtainTool.currentMapLayer?._leaflet_id
+ CurtainTool.currentMapLayer = null
+ const images = payload.feature?.properties?.images
+ if (images && images.length > 0) {
+ images.forEach((img) => {
+ if (img.type && img.type.toLowerCase() === 'radargram') {
+ CurtainTool.radargrams.push(img)
+ }
+ })
+ }
+
+ let imageId = CurtainTool.lastImageId
+ if (imageId > CurtainTool.radargrams.length) {
+ imageId = 0
+ }
+
+ if (CurtainTool.radargrams.length === 0) this.changeImage()
+ else {
+ CurtainTool.currentMapLayer = payload.layer
+ state.setActiveFeature(CurtainTool.currentMapLayer.feature)
+ const layerChanged =
+ oldFeatureId !== CurtainTool.currentMapLayer?._leaflet_id
+ this.changeImage(CurtainTool.radargrams, imageId, layerChanged)
+ }
+ },
+ toggleKeepImageOn: function (on) {
+ const id = CurtainTool.activeImage?._id
+ if (id == null) return
+
+ const partialId = id.split('.')[0]
+
+ // Always Disable Siblings
+ for (let imgId in CurtainTool.keptOnImages) {
+ const imgPartialId = imgId.split('.')[0]
+ if (imgPartialId === partialId)
+ if (CurtainTool.keptOnImages[imgId] === true) {
+ // false means we'll remove it later
+ CurtainTool.keptOnImages[imgId] = false
+ }
+ }
+
+ CurtainTool.keptOnImages[id] = on
+
+ state.setKeepOnCheckbox(on)
+ },
+ isKeptOn: function () {
+ const id = CurtainTool.activeImage?._id
+ if (id == null) return false
+
+ const partialId = id.split('.')[0]
+
+ for (let imgId in CurtainTool.keptOnImages) {
+ const imgPartialId = imgId.split('.')[0]
+ if (
+ partialId === imgPartialId &&
+ CurtainTool.keptOnImages[imgId] === true
+ )
+ return true
+ }
+
+ return false
+ },
+ removeLastSameLayerImage: function () {
+ const currentId = CurtainTool.activeImage?._id
+ if (currentId == null) return
+
+ const partialId = currentId.split('.')[0]
+
+ for (let imgId in CurtainTool.keptOnImages) {
+ const imgPartialId = imgId.split('.')[0]
+ if (partialId === imgPartialId && currentId !== imgId) {
+ Globe_.litho.removeLayer(imgId)
+ CurtainTool.drawn3DCurtainIds =
+ CurtainTool.drawn3DCurtainIds.filter((e) => e !== imgId)
+ if (CurtainTool.keptOnImages[imgId] != null) {
+ delete CurtainTool.keptOnImages[imgId]
+ }
+ }
+ }
+ },
+ removeKeptOffImages: function () {
+ const currentId = CurtainTool.activeImage?._id
+ if (currentId == null) return
+
+ const partialId = currentId.split('.')[0]
+
+ for (let imgId in CurtainTool.keptOnImages) {
+ const imgPartialId = imgId.split('.')[0]
+ if (
+ partialId !== imgPartialId &&
+ CurtainTool.keptOnImages[imgId] === false
+ ) {
+ Globe_.litho.removeLayer(imgId)
+ CurtainTool.drawn3DCurtainIds =
+ CurtainTool.drawn3DCurtainIds.filter((e) => e !== imgId)
+ delete CurtainTool.keptOnImages[imgId]
+ }
+ }
+ },
+ attachFocus: function () {
+ CurtainTool.detachFocus(true)
+ if (CurtainTool.activeImage == null) return
+ const overlay = CurtainTool.osd.fabricjsOverlay({
+ scale: CurtainTool.focus.scale,
+ })
+ CurtainTool.focus.canvas = overlay.fabricCanvas()
+ CurtainTool.focus.canvas.selection = false
+ CurtainTool.focus.canvas.hoverCursor = 'default'
+
+ $('#curtainViewer').off('mouseout')
+ $('#curtainViewer').on('mouseout', function () {
+ // Clear focus
+ CurtainTool.detachFocus(true)
+ CurtainTool.enableMove = false
+ })
+ $('#curtainViewer').off('mouseenter')
+ $('#curtainViewer').on('mouseenter', function () {
+ CurtainTool.enableMove = true
+ })
+
+ CurtainTool.focus.canvas.on('mouse:move', CurtainTool.mouseMove)
+ },
+ detachFocus: function (clearCoordsToo) {
+ if (CurtainTool.focus.topCircle)
+ CurtainTool.focus.canvas.remove(CurtainTool.focus.topCircle)
+ if (CurtainTool.focus.cursorCircle)
+ CurtainTool.focus.canvas.remove(CurtainTool.focus.cursorCircle)
+ Map_.rmNotNull(CurtainTool.focus.mapLayer)
+
+ if (clearCoordsToo)
+ // Clear coords
+ state.setMouseCoordsVis(false)
+
+ CurtainTool.detachFocusFromGlobe()
+ },
+ pxToLngLat: function (xy) {
+ //Built initially only for MultiLineString
+ if (CurtainTool.currentMapLayer) {
+ //Get feature coordinates
+ let g = CurtainTool.currentMapLayer.feature.geometry.coordinates[0]
+ if (
+ CurtainTool.currentMapLayer.feature.geometry.type ==
+ 'LineString'
+ )
+ g = CurtainTool.currentMapLayer.feature.geometry.coordinates
+ //Get length data
+ const lengthArray = [0]
+ let totalLength = 0
+ let i0 = 1
+ let i1 = 0
+ if (true) {
+ i0 = 0
+ i1 = 1
+ }
+ for (let i = 1; i < g.length; i++) {
+ let l = F_.lngLatDistBetween(
+ g[i - 1][i0],
+ g[i - 1][i1],
+ g[i][i0],
+ g[i][i1]
+ )
+ totalLength += l
+ lengthArray.push(totalLength)
+ }
+
+ //Find xy's place
+ const place = (xy[0] / CurtainTool.focus.scale) * totalLength
+ for (let i = 1; i < lengthArray.length; i++) {
+ if (place <= lengthArray[i]) {
+ const p =
+ (place - lengthArray[i - 1]) /
+ (lengthArray[i] - lengthArray[i - 1])
+ return F_.interpolatePointsPerun(
+ { x: g[i - 1][0], y: g[i - 1][1], z: g[i - 1][2] },
+ { x: g[i][0], y: g[i][1], z: g[i][2] },
+ p
+ )
+ }
+ }
+ }
+ },
+ mouseMove: function (o) {
+ if (!CurtainTool.enableMove) return
+ CurtainTool.detachFocus()
+
+ const currentZoom = CurtainTool.osd.viewport.getZoom()
+ let circleRadius = 8
+ let divider = currentZoom || 1
+
+ circleRadius /= divider
+
+ let hasCursorCircle = false
+ const dim = CurtainTool.getCurrentImageDimensions()
+ const maxY = CurtainTool.focus.scale * (dim.y / dim.x)
+
+ let pointer
+ // if it's an openseadragon event
+ if (o.e) {
+ pointer = CurtainTool.focus.canvas.getPointer(o.e)
+ pointer.x = Math.max(0, pointer.x)
+ pointer.x = Math.min(CurtainTool.focus.scale, pointer.x)
+ pointer.y = Math.max(0, pointer.y)
+ pointer.y = Math.min(maxY, pointer.y)
+ } else if (o.uv) {
+ pointer = {
+ x: o.uv.x * CurtainTool.focus.scale,
+ y: (1 - o.uv.y) * maxY,
+ }
+ // Because this is a cursor link from 3D, we'll show a separate cursor
+ hasCursorCircle = true
+ }
+
+ CurtainTool.focus.topCircle = new fabric.Circle({
+ left: pointer.x,
+ top: 0,
+ radius: circleRadius,
+ strokeWidth: 3 / divider,
+ fill: 'yellow',
+ opacity: 1,
+ stroke: 'black',
+ evented: false,
+ originX: 'center',
+ originY: 'center',
+ })
+ CurtainTool.focus.topCircle.lockMovementX = true
+ CurtainTool.focus.topCircle.lockMovementY = true
+ CurtainTool.focus.canvas.add(CurtainTool.focus.topCircle)
+
+ if (hasCursorCircle) {
+ CurtainTool.focus.cursorCircle = new fabric.Circle({
+ left: pointer.x,
+ top: pointer.y,
+ radius: circleRadius,
+ strokeWidth: 2 / divider,
+ fill: 'transparent',
+ stroke: 'black',
+ evented: false,
+ originX: 'center',
+ originY: 'center',
+ })
+ CurtainTool.focus.cursorCircle.lockMovementX = true
+ CurtainTool.focus.cursorCircle.lockMovementY = true
+ CurtainTool.focus.canvas.add(CurtainTool.focus.cursorCircle)
+ }
+
+ // Update coords
+ const distance = (
+ (pointer.x / CurtainTool.focus.scale) *
+ CurtainTool.activeImage.length
+ ).toFixed(2)
+ const depth = (
+ (pointer.y / maxY) *
+ CurtainTool.activeImage.depth
+ ).toFixed(2)
+ state.setMouseCoords({
+ alignment:
+ pointer.x / CurtainTool.focus.scale > 0.8 &&
+ pointer.y / maxY > 0.5
+ ? 'top'
+ : 'bottom',
+ text: `Distance: ${distance}m, Depth: ${depth}m, Elevation: ${(
+ CurtainTool.activeImage.topElev - depth
+ ).toFixed(2)}m`,
+ })
+ state.setMouseCoordsVis(true)
+
+ // Update markers on map and globe
+ const ll = CurtainTool.pxToLngLat([pointer.x, pointer.y])
+
+ if (ll) {
+ //Map
+ const llM = [ll.y, ll.x]
+ Map_.rmNotNull(CurtainTool.focus.mapLayer)
+ CurtainTool.focus.mapLayer = new L.circleMarker(llM, {
+ fillColor: 'yellow',
+ fillOpacity: 1,
+ color: 'black',
+ weight: 2,
+ })
+ .setRadius(6)
+ .addTo(Map_.map)
+
+ // If mousing over in openseadragon
+ if (o.e)
+ CurtainTool.attachFocusToGlobe(
+ ll.y,
+ ll.x,
+ CurtainTool.activeImage.topElev,
+ depth
+ )
+ }
+ },
+ set3DVerticalOptions: function (verticalExag, verticalOffset) {
+ CurtainTool.options.verticalExag = verticalExag
+ CurtainTool.options.verticalOffset = verticalOffset
+ CurtainTool.drawn3DCurtainIds.forEach((id) => {
+ Globe_.litho.setLayerSpecificOptions(id, {
+ verticalExaggeration: CurtainTool.options.verticalExag,
+ verticalOffset:
+ (CurtainTool.options.verticalOffset / 100) *
+ CurtainTool.activeImage.depth *
+ (CurtainTool.options.verticalExag || 1),
+ })
+ })
+ },
+ addCurtainToGlobe: function (img, url, layer) {
+ // First, format the feature so that topElev is consistent
+ // Skip if bad geom
+ if (
+ layer?.feature?.geometry?.type !== 'LineString' ||
+ layer?.feature?.geometry?.coordinates == null
+ )
+ return
+ const coordinates = []
+ layer.feature.geometry.coordinates.forEach((coord) => {
+ const c = coord
+ c[2] = img.topElev || c[2] || 0
+ coordinates.push(c)
+ })
+
+ if (Globe_.litho.hasLayer(CurtainTool.activeImage._id)) return
+ CurtainTool.drawn3DCurtainIds.push(CurtainTool.activeImage._id)
+
+ Globe_.litho.addLayer(
+ 'curtain',
+ {
+ name: CurtainTool.activeImage._id,
+ on: true,
+ opacity: 1,
+ withCredentials: CurtainTool.vars.withCredentials,
+ imagePath: url,
+ // depth of image in meters
+ depth: img.depth,
+ // length of image in meters
+ length: img.length,
+ // GeoJSON feature geometry that corresponds to the top of the curtain/image
+ lineGeometry: {
+ type: 'LineString',
+ coordinates: coordinates,
+ },
+ options: {
+ verticalExaggeration: CurtainTool.options.verticalExag,
+ verticalOffset:
+ (CurtainTool.options.verticalOffset / 100) *
+ CurtainTool.activeImage.depth *
+ (CurtainTool.options.verticalExag || 1),
+ },
+ onMouseMove: function (
+ e,
+ layer,
+ mesh,
+ intersection,
+ intersectedLngLat,
+ intersectionXYZ
+ ) {
+ if (layer.name === CurtainTool.activeImage._id) {
+ CurtainTool.enableMove = true
+ CurtainTool.mouseMove({
+ uv: intersection.uv,
+ })
+ } else {
+ CurtainTool.detachFocus()
+ }
+ },
+ },
+ () => {}
+ )
+ },
+ attachFocusToGlobe: function (lat, lng, elev, depth) {
+ let z =
+ elev +
+ (CurtainTool.options.verticalOffset / 100) *
+ CurtainTool.activeImage.depth *
+ (CurtainTool.options.verticalExag || 1)
+ z -= depth * CurtainTool.options.verticalExag
+
+ CurtainTool.detachFocusFromGlobe()
+ Globe_.litho.addLayer(
+ 'vector',
+ {
+ name: '_curtainPointTop',
+ id: '_curtainPointTop',
+ on: true,
+ opacity: 1,
+ order: 2,
+ minZoom: 0,
+ maxZoom: 30,
+ style: {
+ default: {
+ fillColor: 'yellow',
+ color: '#000000',
+ weight: 2,
+ radius: 8,
+ elevOffset: 0,
+ },
+ },
+ geojson: {
+ type: 'FeatureCollection',
+ features: [
+ {
+ type: 'Feature',
+ geometry: {
+ type: 'Point',
+ coordinates: [[lng, lat, elev]],
+ },
+ },
+ ],
+ },
+ },
+ 1
+ )
+ Globe_.litho.addLayer(
+ 'vector',
+ {
+ name: '_curtainPointCursor',
+ id: '_curtainPointCursor',
+ on: true,
+ opacity: 1,
+ order: 2,
+ minZoom: 0,
+ maxZoom: 30,
+ style: {
+ default: {
+ fillColor: 'rgba(0,0,0,0.3)',
+ color: '#000000',
+ weight: 2,
+ radius: 8,
+ elevOffset: 0,
+ },
+ },
+ geojson: {
+ type: 'FeatureCollection',
+ features: [
+ {
+ type: 'Feature',
+ geometry: {
+ type: 'Point',
+ coordinates: [[lng, lat, z]],
+ },
+ },
+ ],
+ },
+ },
+ 1
+ )
+ },
+ detachFocusFromGlobe: function () {
+ Globe_.litho.removeLayer('_curtainPointTop')
+
+ Globe_.litho.removeLayer('_curtainPointCursor')
+ },
+}
+
+CurtainTool.init()
+
+export default CurtainTool
diff --git a/src/essence/Tools/Curtain/config.json b/src/essence/Tools/Curtain/config.json
new file mode 100644
index 00000000..79b79f5e
--- /dev/null
+++ b/src/essence/Tools/Curtain/config.json
@@ -0,0 +1,31 @@
+{
+ "defaultIcon": "waves",
+ "description": "Curtain views of Ground Penetrating Radar data.",
+ "descriptionFull": {
+ "title": "Vertical imagery aligned under terrain for visualizing data from ground penetrating radar.",
+ "example": {
+ "withCredentials": false
+ }
+ },
+ "hasVars": true,
+ "name": "Curtain",
+ "paths": {
+ "CurtainTool": "essence/Tools/Curtain/CurtainTool"
+ },
+ "config": {
+ "rows": [
+ {
+ "components": [
+ {
+ "field": "variables.withCredentials",
+ "name": "With Credentials",
+ "description": "",
+ "type": "checkbox",
+ "width": 3,
+ "defaultChecked": false
+ }
+ ]
+ }
+ ]
+ }
+}