dengdeyan commited on
Commit
d747847
·
1 Parent(s): 863ee08

add polyline

Browse files
client/package-lock.json CHANGED
@@ -12,6 +12,9 @@
12
  "@aws-sdk/credential-provider-cognito-identity": "^3.509.0",
13
  "@aws-sdk/s3-request-presigner": "^3.509.0",
14
  "@aws-sdk/util-endpoints": "^3.509.0",
 
 
 
15
  "@emotion/react": "^11.14.0",
16
  "@emotion/styled": "^11.14.1",
17
  "@mui/icons-material": "^7.3.4",
@@ -19,7 +22,9 @@
19
  "cesium": "^1.134.1",
20
  "react": "^19.1.1",
21
  "react-dom": "^19.1.1",
22
- "resium": "^1.19.0-beta.1"
 
 
23
  },
24
  "devDependencies": {
25
  "@eslint/js": "^9.36.0",
@@ -1437,6 +1442,59 @@
1437
  "node": ">=20.19.0"
1438
  }
1439
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1440
  "node_modules/@emotion/babel-plugin": {
1441
  "version": "11.13.5",
1442
  "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz",
@@ -2636,6 +2694,15 @@
2636
  "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==",
2637
  "license": "BSD-3-Clause"
2638
  },
 
 
 
 
 
 
 
 
 
2639
  "node_modules/@rolldown/pluginutils": {
2640
  "version": "1.0.0-rc.3",
2641
  "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz",
@@ -4286,6 +4353,15 @@
4286
  "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
4287
  "license": "MIT"
4288
  },
 
 
 
 
 
 
 
 
 
4289
  "node_modules/debug": {
4290
  "version": "4.4.3",
4291
  "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -5189,6 +5265,11 @@
5189
  "dev": true,
5190
  "license": "ISC"
5191
  },
 
 
 
 
 
5192
  "node_modules/js-tokens": {
5193
  "version": "4.0.0",
5194
  "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -5282,6 +5363,21 @@
5282
  "graceful-fs": "^4.1.6"
5283
  }
5284
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5285
  "node_modules/kdbush": {
5286
  "version": "4.0.2",
5287
  "resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.0.2.tgz",
@@ -5938,6 +6034,38 @@
5938
  "node": ">=0.10.0"
5939
  }
5940
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5941
  "node_modules/react-transition-group": {
5942
  "version": "4.4.5",
5943
  "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
@@ -6181,6 +6309,17 @@
6181
  "node": ">=8"
6182
  }
6183
  },
 
 
 
 
 
 
 
 
 
 
 
6184
  "node_modules/slash": {
6185
  "version": "3.0.0",
6186
  "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz",
 
12
  "@aws-sdk/credential-provider-cognito-identity": "^3.509.0",
13
  "@aws-sdk/s3-request-presigner": "^3.509.0",
14
  "@aws-sdk/util-endpoints": "^3.509.0",
15
+ "@dnd-kit/core": "^6.1.0",
16
+ "@dnd-kit/sortable": "^8.0.0",
17
+ "@dnd-kit/utilities": "^3.2.2",
18
  "@emotion/react": "^11.14.0",
19
  "@emotion/styled": "^11.14.1",
20
  "@mui/icons-material": "^7.3.4",
 
22
  "cesium": "^1.134.1",
23
  "react": "^19.1.1",
24
  "react-dom": "^19.1.1",
25
+ "react-router-dom": "^6.25.1",
26
+ "resium": "^1.19.0-beta.1",
27
+ "shp-write": "^0.3.2"
28
  },
29
  "devDependencies": {
30
  "@eslint/js": "^9.36.0",
 
1442
  "node": ">=20.19.0"
1443
  }
1444
  },
1445
+ "node_modules/@dnd-kit/accessibility": {
1446
+ "version": "3.1.1",
1447
+ "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
1448
+ "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
1449
+ "license": "MIT",
1450
+ "dependencies": {
1451
+ "tslib": "^2.0.0"
1452
+ },
1453
+ "peerDependencies": {
1454
+ "react": ">=16.8.0"
1455
+ }
1456
+ },
1457
+ "node_modules/@dnd-kit/core": {
1458
+ "version": "6.3.1",
1459
+ "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
1460
+ "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
1461
+ "license": "MIT",
1462
+ "dependencies": {
1463
+ "@dnd-kit/accessibility": "^3.1.1",
1464
+ "@dnd-kit/utilities": "^3.2.2",
1465
+ "tslib": "^2.0.0"
1466
+ },
1467
+ "peerDependencies": {
1468
+ "react": ">=16.8.0",
1469
+ "react-dom": ">=16.8.0"
1470
+ }
1471
+ },
1472
+ "node_modules/@dnd-kit/sortable": {
1473
+ "version": "8.0.0",
1474
+ "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-8.0.0.tgz",
1475
+ "integrity": "sha512-U3jk5ebVXe1Lr7c2wU7SBZjcWdQP+j7peHJfCspnA81enlu88Mgd7CC8Q+pub9ubP7eKVETzJW+IBAhsqbSu/g==",
1476
+ "license": "MIT",
1477
+ "dependencies": {
1478
+ "@dnd-kit/utilities": "^3.2.2",
1479
+ "tslib": "^2.0.0"
1480
+ },
1481
+ "peerDependencies": {
1482
+ "@dnd-kit/core": "^6.1.0",
1483
+ "react": ">=16.8.0"
1484
+ }
1485
+ },
1486
+ "node_modules/@dnd-kit/utilities": {
1487
+ "version": "3.2.2",
1488
+ "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
1489
+ "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
1490
+ "license": "MIT",
1491
+ "dependencies": {
1492
+ "tslib": "^2.0.0"
1493
+ },
1494
+ "peerDependencies": {
1495
+ "react": ">=16.8.0"
1496
+ }
1497
+ },
1498
  "node_modules/@emotion/babel-plugin": {
1499
  "version": "11.13.5",
1500
  "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz",
 
2694
  "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==",
2695
  "license": "BSD-3-Clause"
2696
  },
2697
+ "node_modules/@remix-run/router": {
2698
+ "version": "1.23.2",
2699
+ "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz",
2700
+ "integrity": "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==",
2701
+ "license": "MIT",
2702
+ "engines": {
2703
+ "node": ">=14.0.0"
2704
+ }
2705
+ },
2706
  "node_modules/@rolldown/pluginutils": {
2707
  "version": "1.0.0-rc.3",
2708
  "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz",
 
4353
  "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
4354
  "license": "MIT"
4355
  },
4356
+ "node_modules/dbf": {
4357
+ "version": "0.1.4",
4358
+ "resolved": "https://registry.npmjs.org/dbf/-/dbf-0.1.4.tgz",
4359
+ "integrity": "sha512-7tQ8w5NB74PL1f0Z/NQ6Y+URjBFhtEsFxzEQSzot2+VpLwWfrNnxFVhzWm6dJyEtFq0WkYWcGEMDf39fy8JFaw==",
4360
+ "license": "BSD-2-Clause",
4361
+ "dependencies": {
4362
+ "jdataview": "~2.5.0"
4363
+ }
4364
+ },
4365
  "node_modules/debug": {
4366
  "version": "4.4.3",
4367
  "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
 
5265
  "dev": true,
5266
  "license": "ISC"
5267
  },
5268
+ "node_modules/jdataview": {
5269
+ "version": "2.5.0",
5270
+ "resolved": "https://registry.npmjs.org/jdataview/-/jdataview-2.5.0.tgz",
5271
+ "integrity": "sha512-ZJop3D5nyDcWPBPv4NPnhCvx3HgQNsCXMfw8gpNKY16BobgxmVF+kJ08aHuqk6bJQVeL2mkf6nDCcZPMompalw=="
5272
+ },
5273
  "node_modules/js-tokens": {
5274
  "version": "4.0.0",
5275
  "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
 
5363
  "graceful-fs": "^4.1.6"
5364
  }
5365
  },
5366
+ "node_modules/jszip": {
5367
+ "version": "2.5.0",
5368
+ "resolved": "https://registry.npmjs.org/jszip/-/jszip-2.5.0.tgz",
5369
+ "integrity": "sha512-IRoyf8JSYY3nx+uyh5xPc0qdy8pUDTp2UkHOWYNF/IO/3D8nx7899UlSAjD8rf8wUgOmm0lACWx/GbW3EaxIXQ==",
5370
+ "license": "MIT or GPLv3",
5371
+ "dependencies": {
5372
+ "pako": "~0.2.5"
5373
+ }
5374
+ },
5375
+ "node_modules/jszip/node_modules/pako": {
5376
+ "version": "0.2.9",
5377
+ "resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz",
5378
+ "integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==",
5379
+ "license": "MIT"
5380
+ },
5381
  "node_modules/kdbush": {
5382
  "version": "4.0.2",
5383
  "resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.0.2.tgz",
 
6034
  "node": ">=0.10.0"
6035
  }
6036
  },
6037
+ "node_modules/react-router": {
6038
+ "version": "6.30.3",
6039
+ "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz",
6040
+ "integrity": "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==",
6041
+ "license": "MIT",
6042
+ "dependencies": {
6043
+ "@remix-run/router": "1.23.2"
6044
+ },
6045
+ "engines": {
6046
+ "node": ">=14.0.0"
6047
+ },
6048
+ "peerDependencies": {
6049
+ "react": ">=16.8"
6050
+ }
6051
+ },
6052
+ "node_modules/react-router-dom": {
6053
+ "version": "6.30.3",
6054
+ "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.3.tgz",
6055
+ "integrity": "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==",
6056
+ "license": "MIT",
6057
+ "dependencies": {
6058
+ "@remix-run/router": "1.23.2",
6059
+ "react-router": "6.30.3"
6060
+ },
6061
+ "engines": {
6062
+ "node": ">=14.0.0"
6063
+ },
6064
+ "peerDependencies": {
6065
+ "react": ">=16.8",
6066
+ "react-dom": ">=16.8"
6067
+ }
6068
+ },
6069
  "node_modules/react-transition-group": {
6070
  "version": "4.4.5",
6071
  "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
 
6309
  "node": ">=8"
6310
  }
6311
  },
6312
+ "node_modules/shp-write": {
6313
+ "version": "0.3.2",
6314
+ "resolved": "https://registry.npmjs.org/shp-write/-/shp-write-0.3.2.tgz",
6315
+ "integrity": "sha512-RNmfm+qzIwgwGMiV21lCxfEAtgP/owAd+sHLr6Qu+aDR1bbrCZ42H89nA9FQWUqfL+WHJy3n8+cTZxJrL/ZKWA==",
6316
+ "deprecated": "This package has moved under the @mapbox organization, and can be found here: https://www.npmjs.com/package/@mapbox/shp-write",
6317
+ "license": "BSD-2-Clause",
6318
+ "dependencies": {
6319
+ "dbf": "0.1.4",
6320
+ "jszip": "2.5.0"
6321
+ }
6322
+ },
6323
  "node_modules/slash": {
6324
  "version": "3.0.0",
6325
  "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz",
client/package.json CHANGED
@@ -22,7 +22,10 @@
22
  "react": "^19.1.1",
23
  "react-dom": "^19.1.1",
24
  "resium": "^1.19.0-beta.1",
25
- "react-router-dom": "^6.25.1"
 
 
 
26
  },
27
  "devDependencies": {
28
  "@eslint/js": "^9.36.0",
 
22
  "react": "^19.1.1",
23
  "react-dom": "^19.1.1",
24
  "resium": "^1.19.0-beta.1",
25
+ "react-router-dom": "^6.25.1",
26
+ "@dnd-kit/core": "^6.1.0",
27
+ "@dnd-kit/sortable": "^8.0.0",
28
+ "@dnd-kit/utilities": "^3.2.2"
29
  },
30
  "devDependencies": {
31
  "@eslint/js": "^9.36.0",
client/src/components/CesiumViewer.jsx CHANGED
@@ -6,9 +6,8 @@ import React, {
6
  useImperativeHandle,
7
  useMemo,
8
  } from "react";
9
- import { Viewer } from "resium";
10
  import * as Cesium from "cesium";
11
- import { Entity } from "resium";
12
  import { Cartesian3, Color, Matrix3, Quaternion, PolygonHierarchy } from "cesium";
13
 
14
  // Cesium Ion token is safe to embed in client code (not a secret)
@@ -205,27 +204,41 @@ const CesiumViewer = forwardRef(
205
  polygons,
206
  onPointSelectForDrawing,
207
  selectedGeometry,
 
208
  }, ref) => {
209
  const viewerRef = useRef(null);
210
  const currentModeRef = useRef("default"); // keep internal mode
211
  const [measurements, setMeasurements] = useState([]);
 
212
  const tilesetRef = useRef(null);
213
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
214
  const addMeausure = (cameraPosition, cartesian) => {
215
- setMeasurements((prev) => {
216
- const id = prev.length + 1; // compute id based on latest state
217
- const newMeasurement = {
218
- id,
219
- camera: {
220
- x: cameraPosition.x,
221
- y: cameraPosition.y,
222
- z: cameraPosition.z,
223
- },
224
- point: { x: cartesian.x, y: cartesian.y, z: cartesian.z },
225
- };
226
- console.log("Adding measure:", newMeasurement);
227
- return [...prev, newMeasurement];
228
- });
229
  };
230
  // Expose a function to parent
231
  useImperativeHandle(ref, () => ({
@@ -267,6 +280,7 @@ const CesiumViewer = forwardRef(
267
  }
268
  currentModeRef.current = "default";
269
  setMeasurements([]);
 
270
  },
271
  }));
272
 
@@ -459,24 +473,32 @@ const CesiumViewer = forwardRef(
459
  // Set up mouse event handler
460
  handler = new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas);
461
 
 
 
462
  handler.setInputAction((click) => {
463
- const currentMode = currentModeRef.current;
464
-
465
- if (currentMode === 'polyline' || currentMode === 'polygon') {
466
- // Pick position on the 3D model or globe
467
- const position = viewer.scene.pickPosition(click.position);
468
- if (Cesium.defined(position)) {
469
- const carto = Cesium.Cartographic.fromCartesian(position);
470
- const newPoint = {
471
- x: position.x,
472
- y: position.y,
473
- z: position.z,
474
- lon: Cesium.Math.toDegrees(carto.longitude),
475
- lat: Cesium.Math.toDegrees(carto.latitude),
476
- alt: carto.height,
477
- };
478
- onPointSelectForDrawing(newPoint);
 
 
 
 
479
  }
 
 
480
  return;
481
  }
482
 
@@ -519,14 +541,14 @@ const CesiumViewer = forwardRef(
519
  tilesetRef.current = null;
520
  }
521
  };
522
- }, [tilesetUrl, offsetHeight, onPointSelectForDrawing]);
523
 
524
  return (
525
  <Viewer ref={viewerRef} full infoBox={false} selectionIndicator={false}>
526
  {points.map((pt) => {
527
  const isSelectedForInspection = selectedPoint === pt.id;
528
- const isSelectedForBuilding = false; // buildingPoints.some(bp => bp.id === pt.id);
529
- const isPreviewed = false; // previewPointId === pt.id;
530
 
531
  let ellipsoidColor = Color.BLUE.withAlpha(0.5);
532
  let pointColor = Color.WHITE;
@@ -538,7 +560,7 @@ const CesiumViewer = forwardRef(
538
  pointColor = Color.CYAN;
539
  pixelSize = 16;
540
  scale = 0.3;
541
- } else if (isSelectedForBuilding) {
542
  ellipsoidColor = Color.ORANGE.withAlpha(0.7);
543
  pointColor = Color.ORANGE;
544
  pixelSize = 12;
@@ -564,7 +586,7 @@ const CesiumViewer = forwardRef(
564
  outlineColor: Color.BLACK,
565
  outlineWidth: 2,
566
  // Disable depth test when highlighted to ensure visibility
567
- disableDepthTestDistance: isSelectedForBuilding || isPreviewed || isSelectedForInspection ? Number.POSITIVE_INFINITY : 0,
568
  }}
569
  />
570
  );
@@ -581,12 +603,12 @@ const CesiumViewer = forwardRef(
581
  const isSelected = selectedGeometry?.type === 'polyline' && selectedGeometry?.id === line.id;
582
  return (
583
  <Entity key={line.id} id={line.id}>
584
- <polyline
585
  positions={line.points.map(p => new Cartesian3(p.x, p.y, p.z))}
586
  width={isSelected ? 5 : 3}
587
  material={isSelected ? Color.CYAN : Color.YELLOW}
588
  clampToGround={false}
589
- zIndex={1}
590
  />
591
  </Entity>
592
  );
@@ -595,14 +617,20 @@ const CesiumViewer = forwardRef(
595
  {/* Render completed polygons */}
596
  {polygons.map(poly => {
597
  const isSelected = selectedGeometry?.type === 'polygon' && selectedGeometry?.id === poly.id;
 
598
  return (
599
  <Entity key={poly.id} id={poly.id}>
600
- <polygon
 
601
  hierarchy={new PolygonHierarchy(poly.points.map(p => new Cartesian3(p.x, p.y, p.z)))}
602
  material={isSelected ? Color.CYAN.withAlpha(0.5) : Color.ORANGE.withAlpha(0.5)}
603
- outline={true}
604
- outlineColor={isSelected ? Color.CYAN : Color.BLACK}
605
- outlineWidth={isSelected ? 3 : 1}
 
 
 
 
606
  />
607
  </Entity>
608
  );
@@ -611,22 +639,26 @@ const CesiumViewer = forwardRef(
611
  {/* Render the geometry being currently drawn */}
612
  {drawingState.mode === 'polyline' && drawingState.points.length > 0 && (
613
  <Entity>
614
- <polyline
615
  positions={drawingState.points.map(p => new Cartesian3(p.x, p.y, p.z))}
616
  width={3}
617
  material={Color.RED.withAlpha(0.7)}
 
618
  />
619
  </Entity>
620
  )}
621
  {drawingState.mode === 'polygon' && drawingState.points.length > 0 && (
622
  <Entity>
623
- <polyline
624
  positions={[...drawingState.points, drawingState.points[0]].map(p => new Cartesian3(p.x, p.y, p.z))}
625
  width={3}
626
  material={Color.RED.withAlpha(0.7)}
 
627
  />
628
  {drawingState.points.length > 2 && (
629
- <polygon hierarchy={new PolygonHierarchy(drawingState.points.map(p => new Cartesian3(p.x, p.y, p.z)))} material={Color.RED.withAlpha(0.3)} />
 
 
630
  )}
631
  </Entity>
632
  )}
 
6
  useImperativeHandle,
7
  useMemo,
8
  } from "react";
9
+ import { Viewer, Entity, PolylineGraphics, PolygonGraphics } from "resium";
10
  import * as Cesium from "cesium";
 
11
  import { Cartesian3, Color, Matrix3, Quaternion, PolygonHierarchy } from "cesium";
12
 
13
  // Cesium Ion token is safe to embed in client code (not a secret)
 
204
  polygons,
205
  onPointSelectForDrawing,
206
  selectedGeometry,
207
+ previewPointId,
208
  }, ref) => {
209
  const viewerRef = useRef(null);
210
  const currentModeRef = useRef("default"); // keep internal mode
211
  const [measurements, setMeasurements] = useState([]);
212
+ const measurementIdCounter = useRef(0);
213
  const tilesetRef = useRef(null);
214
 
215
+ // Refs to hold the latest props that change frequently.
216
+ // This prevents the main `useEffect` from re-running unnecessarily, which would
217
+ // cause the entire 3D scene to reload when points are added or drawing mode changes.
218
+ const pointsRef = useRef(points);
219
+ useEffect(() => { pointsRef.current = points; }, [points]);
220
+
221
+ const drawingStateRef = useRef(drawingState);
222
+ useEffect(() => { drawingStateRef.current = drawingState; }, [drawingState]);
223
+
224
+ // Use a ref to hold the latest onPointSelectForDrawing callback to avoid re-running the main effect.
225
+ const onPointSelectForDrawingRef = useRef(onPointSelectForDrawing);
226
+ useEffect(() => { onPointSelectForDrawingRef.current = onPointSelectForDrawing; }, [onPointSelectForDrawing]);
227
+
228
+
229
  const addMeausure = (cameraPosition, cartesian) => {
230
+ measurementIdCounter.current += 1;
231
+ const newMeasurement = {
232
+ id: measurementIdCounter.current,
233
+ camera: {
234
+ x: cameraPosition.x,
235
+ y: cameraPosition.y,
236
+ z: cameraPosition.z,
237
+ },
238
+ point: { x: cartesian.x, y: cartesian.y, z: cartesian.z },
239
+ };
240
+ console.log("Adding measure:", newMeasurement);
241
+ setMeasurements((prev) => [...prev, newMeasurement]);
 
 
242
  };
243
  // Expose a function to parent
244
  useImperativeHandle(ref, () => ({
 
280
  }
281
  currentModeRef.current = "default";
282
  setMeasurements([]);
283
+ measurementIdCounter.current = 0;
284
  },
285
  }));
286
 
 
473
  // Set up mouse event handler
474
  handler = new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas);
475
 
476
+ // Prevent the default double-click behavior which zooms into the point
477
+ // and can cause a "matrix is not invertible" crash.
478
  handler.setInputAction((click) => {
479
+ // Explicitly set the tracked entity to undefined to fully disable
480
+ // the default camera flight behavior on double-click, which can
481
+ // cause the rendering to crash.
482
+ viewer.trackedEntity = undefined;
483
+ }, Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK);
484
+
485
+ handler.setInputAction((click) => {
486
+ const { mode: drawingMode } = drawingStateRef.current;
487
+
488
+ if (drawingMode === 'polyline' || drawingMode === 'polygon') {
489
+ const viewer = viewerRef.current.cesiumElement;
490
+
491
+ // 1. Try to pick an existing point entity
492
+ const pickedObject = viewer.scene.pick(click.position);
493
+ if (Cesium.defined(pickedObject) && Cesium.defined(pickedObject.id) && typeof pickedObject.id.id === 'string' && pickedObject.id.id.startsWith('point_')) {
494
+ const pointId = parseInt(pickedObject.id.id.split('_')[1], 10);
495
+ const point = pointsRef.current.find(p => p.id === pointId);
496
+ if (point) {
497
+ onPointSelectForDrawingRef.current(point);
498
+ }
499
  }
500
+
501
+ // After attempting to pick a point, stop further click processing for this mode.
502
  return;
503
  }
504
 
 
541
  tilesetRef.current = null;
542
  }
543
  };
544
+ }, [tilesetUrl, offsetHeight]);
545
 
546
  return (
547
  <Viewer ref={viewerRef} full infoBox={false} selectionIndicator={false}>
548
  {points.map((pt) => {
549
  const isSelectedForInspection = selectedPoint === pt.id;
550
+ const isSelectedForGeometry = drawingState.points.some(p => p.id === pt.id);
551
+ const isPreviewed = previewPointId === pt.id;
552
 
553
  let ellipsoidColor = Color.BLUE.withAlpha(0.5);
554
  let pointColor = Color.WHITE;
 
560
  pointColor = Color.CYAN;
561
  pixelSize = 16;
562
  scale = 0.3;
563
+ } else if (isSelectedForGeometry) {
564
  ellipsoidColor = Color.ORANGE.withAlpha(0.7);
565
  pointColor = Color.ORANGE;
566
  pixelSize = 12;
 
586
  outlineColor: Color.BLACK,
587
  outlineWidth: 2,
588
  // Disable depth test when highlighted to ensure visibility
589
+ disableDepthTestDistance: isSelectedForGeometry || isPreviewed || isSelectedForInspection ? Number.POSITIVE_INFINITY : 0,
590
  }}
591
  />
592
  );
 
603
  const isSelected = selectedGeometry?.type === 'polyline' && selectedGeometry?.id === line.id;
604
  return (
605
  <Entity key={line.id} id={line.id}>
606
+ <PolylineGraphics
607
  positions={line.points.map(p => new Cartesian3(p.x, p.y, p.z))}
608
  width={isSelected ? 5 : 3}
609
  material={isSelected ? Color.CYAN : Color.YELLOW}
610
  clampToGround={false}
611
+ disableDepthTestDistance={Number.POSITIVE_INFINITY}
612
  />
613
  </Entity>
614
  );
 
617
  {/* Render completed polygons */}
618
  {polygons.map(poly => {
619
  const isSelected = selectedGeometry?.type === 'polygon' && selectedGeometry?.id === poly.id;
620
+ const polylinePositions = [...poly.points.map(p => new Cartesian3(p.x, p.y, p.z)), new Cartesian3(poly.points[0].x, poly.points[0].y, poly.points[0].z)];
621
  return (
622
  <Entity key={poly.id} id={poly.id}>
623
+ {/* The fill, which can be occluded to prevent "floating" */}
624
+ <PolygonGraphics
625
  hierarchy={new PolygonHierarchy(poly.points.map(p => new Cartesian3(p.x, p.y, p.z)))}
626
  material={isSelected ? Color.CYAN.withAlpha(0.5) : Color.ORANGE.withAlpha(0.5)}
627
+ />
628
+ {/* The outline, which is always on top for visibility */}
629
+ <PolylineGraphics
630
+ positions={polylinePositions}
631
+ width={isSelected ? 3 : 1.5}
632
+ material={isSelected ? Color.CYAN : Color.BLACK}
633
+ disableDepthTestDistance={Number.POSITIVE_INFINITY}
634
  />
635
  </Entity>
636
  );
 
639
  {/* Render the geometry being currently drawn */}
640
  {drawingState.mode === 'polyline' && drawingState.points.length > 0 && (
641
  <Entity>
642
+ <PolylineGraphics
643
  positions={drawingState.points.map(p => new Cartesian3(p.x, p.y, p.z))}
644
  width={3}
645
  material={Color.RED.withAlpha(0.7)}
646
+ disableDepthTestDistance={Number.POSITIVE_INFINITY}
647
  />
648
  </Entity>
649
  )}
650
  {drawingState.mode === 'polygon' && drawingState.points.length > 0 && (
651
  <Entity>
652
+ <PolylineGraphics
653
  positions={[...drawingState.points, drawingState.points[0]].map(p => new Cartesian3(p.x, p.y, p.z))}
654
  width={3}
655
  material={Color.RED.withAlpha(0.7)}
656
+ disableDepthTestDistance={Number.POSITIVE_INFINITY}
657
  />
658
  {drawingState.points.length > 2 && (
659
+ <PolygonGraphics
660
+ hierarchy={new PolygonHierarchy(drawingState.points.map(p => new Cartesian3(p.x, p.y, p.z)))}
661
+ material={Color.RED.withAlpha(0.3)} />
662
  )}
663
  </Entity>
664
  )}
client/src/components/GeometryCreatorPanel.jsx ADDED
@@ -0,0 +1,221 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useMemo } from 'react';
2
+ import {
3
+ Card, CardContent, CardActions, Button, Typography, List, ListItem,
4
+ ListItemText, ListItemSecondaryAction, IconButton, Divider, Box, ListItemButton,
5
+ ListItemIcon
6
+ } from '@mui/material';
7
+ import DeleteIcon from '@mui/icons-material/Delete';
8
+ import DragIndicatorIcon from '@mui/icons-material/DragIndicator';
9
+ import {
10
+ DndContext,
11
+ closestCenter,
12
+ KeyboardSensor,
13
+ PointerSensor,
14
+ useSensor,
15
+ useSensors,
16
+ } from '@dnd-kit/core';
17
+ import {
18
+ SortableContext,
19
+ sortableKeyboardCoordinates,
20
+ verticalListSortingStrategy,
21
+ useSortable,
22
+ } from '@dnd-kit/sortable';
23
+ import { CSS } from '@dnd-kit/utilities';
24
+
25
+ // A new component for the sortable list item, using dnd-kit hooks
26
+ function SortablePointItem({ point, index, onPreviewPoint, onPointSelect }) {
27
+ const {
28
+ attributes,
29
+ listeners,
30
+ setNodeRef,
31
+ transform,
32
+ transition,
33
+ isDragging,
34
+ } = useSortable({ id: String(point.id) });
35
+
36
+ const style = {
37
+ transform: CSS.Transform.toString(transform),
38
+ transition,
39
+ zIndex: isDragging ? 100 : 'auto', // Ensure dragging item is on top
40
+ };
41
+
42
+ return (
43
+ <ListItem
44
+ ref={setNodeRef}
45
+ style={style}
46
+ {...attributes}
47
+ {...listeners}
48
+ onMouseEnter={() => onPreviewPoint(point.id || 0)}
49
+ onMouseLeave={() => onPreviewPoint(0)}
50
+ sx={{
51
+ cursor: 'grab',
52
+ touchAction: 'none',
53
+ bgcolor: isDragging ? 'action.hover' : 'transparent',
54
+ }}
55
+ >
56
+ <ListItemIcon sx={{ minWidth: 'auto', pr: 1 }}>
57
+ <DragIndicatorIcon fontSize="small" />
58
+ </ListItemIcon>
59
+ <ListItemText primary={`Point ${point.id}`} secondary={`(${point.lon.toFixed(4)}, ${point.lat.toFixed(4)}, ${point.alt.toFixed(2)})`} />
60
+ <ListItemSecondaryAction><IconButton edge="end" aria-label="delete" onClick={() => onPointSelect(point)}><DeleteIcon /></IconButton></ListItemSecondaryAction>
61
+ </ListItem>
62
+ );
63
+ }
64
+
65
+ export default function GeometryCreatorPanel({
66
+ isOpen,
67
+ drawingState,
68
+ onCancel,
69
+ onFinish,
70
+ onPointSelect, // To remove a point from the list
71
+ onPreviewPoint,
72
+ // For the available points list
73
+ availablePoints,
74
+ multiSelectPoints,
75
+ onAvailablePointClick,
76
+ onAddMultiSelectedPoints,
77
+ onClearPoints,
78
+ onReorderPoints,
79
+ }) {
80
+ if (!isOpen) {
81
+ return null;
82
+ }
83
+
84
+ const { mode, points: selectedPoints } = drawingState;
85
+ const title = mode === 'polyline' ? 'Create Polyline' : 'Create Polygon';
86
+ const finishText = mode === 'polyline' ? 'Finish Polyline' : 'Finish Polygon';
87
+ const minPoints = mode === 'polyline' ? 2 : 3;
88
+
89
+ const availablePointsToShow = useMemo(() => {
90
+ const selectedIds = new Set(selectedPoints.map(p => p.id));
91
+ return availablePoints.filter(p => !selectedIds.has(p.id));
92
+ }, [availablePoints, selectedPoints]);
93
+
94
+ const sensors = useSensors(
95
+ useSensor(PointerSensor, {
96
+ // Require the mouse to move by a few pixels before activating
97
+ activationConstraint: {
98
+ distance: 5,
99
+ },
100
+ }),
101
+ useSensor(KeyboardSensor, {
102
+ coordinateGetter: sortableKeyboardCoordinates,
103
+ })
104
+ );
105
+
106
+ function handleDragEnd(event) {
107
+ const { active, over } = event;
108
+ if (over && active.id !== over.id) {
109
+ const oldIndex = selectedPoints.findIndex(p => String(p.id) === active.id);
110
+ const newIndex = selectedPoints.findIndex(p => String(p.id) === over.id);
111
+ if (oldIndex !== -1 && newIndex !== -1) {
112
+ onReorderPoints(oldIndex, newIndex);
113
+ }
114
+ }
115
+ }
116
+
117
+ return (
118
+ <Card
119
+ sx={{
120
+ position: 'absolute',
121
+ top: 80,
122
+ right: 20,
123
+ width: 400,
124
+ zIndex: 1000,
125
+ boxShadow: 5,
126
+ borderRadius: 2,
127
+ }}
128
+ >
129
+ <CardContent>
130
+ <Typography variant="h6" gutterBottom>{title}</Typography>
131
+ <Typography variant="body2" color="textSecondary" gutterBottom>
132
+ Select existing measured points from the 3D view or the list below.
133
+ </Typography>
134
+
135
+ <Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mt: 2 }}>
136
+ <Typography variant="h6" gutterBottom component="div" sx={{ mb: 0 }}>
137
+ Selected Points ({selectedPoints.length})
138
+ </Typography>
139
+ {selectedPoints.length > 0 && (
140
+ <Button
141
+ onClick={onClearPoints}
142
+ size="small"
143
+ color="error"
144
+ >
145
+ Clear All
146
+ </Button>
147
+ )}
148
+ </Box>
149
+
150
+ {selectedPoints.length === 0 ? (
151
+ <Typography variant="body2" color="textSecondary" sx={{ mt: 1 }}>No points selected yet.</Typography>
152
+ ) : (
153
+ <DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
154
+ <SortableContext items={selectedPoints.map(p => String(p.id))} strategy={verticalListSortingStrategy}>
155
+ <List dense sx={{ maxHeight: 150, overflow: "auto" }}>
156
+ {selectedPoints.map((point, index) => (
157
+ <SortablePointItem
158
+ key={point.id}
159
+ point={point}
160
+ index={index}
161
+ onPreviewPoint={onPreviewPoint}
162
+ onPointSelect={onPointSelect}
163
+ />
164
+ ))}
165
+ </List>
166
+ </SortableContext>
167
+ </DndContext>
168
+ )}
169
+
170
+ <Divider style={{ margin: '16px 0' }} />
171
+
172
+ <Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
173
+ <Typography variant="h6" gutterBottom component="div">
174
+ Available Measured Points
175
+ </Typography>
176
+ <Button
177
+ onClick={onAddMultiSelectedPoints}
178
+ disabled={multiSelectPoints.length === 0}
179
+ size="small"
180
+ variant="outlined"
181
+ >
182
+ Add Selected ({multiSelectPoints.length})
183
+ </Button>
184
+ </Box>
185
+ <List dense style={{ maxHeight: 200, overflow: 'auto' }}>
186
+ {availablePointsToShow.map((point) => (
187
+ <ListItemButton
188
+ key={point.id}
189
+ onClick={(event) => onAvailablePointClick(event, point.id)}
190
+ selected={multiSelectPoints.includes(point.id)}
191
+ onMouseEnter={() => onPreviewPoint(point.id)}
192
+ onMouseLeave={() => onPreviewPoint(0)}
193
+ sx={{
194
+ borderRadius: 1,
195
+ "&.Mui-selected": {
196
+ bgcolor: "primary.light",
197
+ color: "primary.contrastText",
198
+ "&:hover": { bgcolor: "primary.main" },
199
+ },
200
+ }}
201
+ >
202
+ <ListItemText primary={`Point ${point.id}`} />
203
+ </ListItemButton>
204
+ ))}
205
+ {availablePointsToShow.length === 0 && (
206
+ <ListItem>
207
+ <ListItemText primary="No other measured points available." />
208
+ </ListItem>
209
+ )}
210
+ </List>
211
+
212
+ </CardContent>
213
+ <CardActions sx={{ justifyContent: 'flex-end', p: 2 }}>
214
+ <Button onClick={onCancel}>Cancel</Button>
215
+ <Button variant="contained" onClick={onFinish} disabled={selectedPoints.length < minPoints} >
216
+ {finishText}
217
+ </Button>
218
+ </CardActions>
219
+ </Card>
220
+ );
221
+ }
client/src/components/MeasurePanel.jsx CHANGED
@@ -2,7 +2,8 @@ import React, { useEffect, useState } from "react";
2
  import {
3
  Card, CardContent, Typography, Button, List, ListItemButton,
4
  ListItemText, Divider, Stack, FormControl, InputLabel, Select,
5
- MenuItem, TextField, Dialog, DialogTitle, DialogContent,
 
6
  DialogActions, Tabs, Tab, Box
7
  } from "@mui/material";
8
  import DownloadIcon from "@mui/icons-material/Download";
@@ -46,6 +47,43 @@ function a11yProps(index) {
46
  };
47
  }
48
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
49
  export default function MeasurePanel({
50
  points,
51
  onSelectPoint,
@@ -109,20 +147,6 @@ export default function MeasurePanel({
109
  setSelectedIndex(nextIndex);
110
  if (onSelectPoint) onSelectPoint(points[index]);
111
  };
112
- const handleDeletePoint = () => {
113
- if (onDeletePoint) onDeletePoint();
114
- };
115
- const handleExport = () => {
116
- const dataStr =
117
- "data:text/json;charset=utf-8," +
118
- encodeURIComponent(JSON.stringify(points, null, 2));
119
- const downloadAnchorNode = document.createElement("a");
120
- downloadAnchorNode.setAttribute("href", dataStr);
121
- downloadAnchorNode.setAttribute("download", "measured_points.json");
122
- document.body.appendChild(downloadAnchorNode); // required for firefox
123
- downloadAnchorNode.click();
124
- downloadAnchorNode.remove();
125
- };
126
 
127
  const openCustomDialog = () => {
128
  setDraftCustomModel(customModel || DEFAULT_CUSTOM_MODEL);
@@ -292,18 +316,22 @@ export default function MeasurePanel({
292
  ))}
293
  </List>
294
  <Stack spacing={1} sx={{ mt: 1 }}>
295
- <FormControl size="small" fullWidth>
296
- <InputLabel id="point-appearance-label">Appearance</InputLabel>
297
- <Select
298
- labelId="point-appearance-label"
299
- value={pointAppearance}
300
- label="Appearance"
301
- onChange={(e) => onPointAppearanceChange(e.target.value)}
302
- >
303
- <MenuItem value="ellipsoid">Error Ellipsoid</MenuItem>
304
- <MenuItem value="point">Simple Point</MenuItem>
305
- </Select>
306
- </FormControl>
 
 
 
 
307
  <Button
308
  variant="contained"
309
  color={measuring ? "error" : "primary"}
@@ -313,21 +341,12 @@ export default function MeasurePanel({
313
  {measuring ? "End Measure" : "Start Measure"}
314
  </Button>
315
  <Stack direction="row" spacing={1}>
316
- <Button
317
- variant="outlined"
318
- color="primary"
319
- startIcon={<DownloadIcon />}
320
- onClick={() => handleExport()}
321
- disabled={points.length === 0}
322
- fullWidth
323
- >
324
- Export
325
- </Button>
326
  <Button
327
  variant="outlined"
328
  color="error"
329
  startIcon={<DeleteIcon />}
330
- onClick={() => handleDeletePoint()}
331
  disabled={selectedIndex === null}
332
  fullWidth
333
  >
@@ -362,13 +381,7 @@ export default function MeasurePanel({
362
  </ListItemButton>
363
  ))}
364
  </List>
365
- {drawingState.mode === 'polyline' ? (
366
- <Stack direction="row" spacing={1} sx={{ mt: 2 }}>
367
- <Button variant="contained" onClick={onFinishDrawing} fullWidth>Finish Drawing</Button>
368
- <Button variant="outlined" color="error" onClick={onCancelDrawing} fullWidth>Cancel</Button>
369
- </Stack>
370
- ) : (
371
- <Stack spacing={1} sx={{ mt: 2 }}>
372
  <Button
373
  variant="contained"
374
  startIcon={<AddIcon />}
@@ -378,15 +391,7 @@ export default function MeasurePanel({
378
  Create Polyline
379
  </Button>
380
  <Stack direction="row" spacing={1}>
381
- <Button
382
- variant="outlined"
383
- startIcon={<DownloadIcon />}
384
- onClick={() => onExportGeometries('polyline')}
385
- disabled={polylines.length === 0}
386
- fullWidth
387
- >
388
- Export
389
- </Button>
390
  <Button
391
  variant="outlined"
392
  color="error"
@@ -399,7 +404,6 @@ export default function MeasurePanel({
399
  </Button>
400
  </Stack>
401
  </Stack>
402
- )}
403
  </TabPanel>
404
 
405
  <TabPanel value={tabIndex} index={2}>
@@ -427,13 +431,7 @@ export default function MeasurePanel({
427
  </ListItemButton>
428
  ))}
429
  </List>
430
- {drawingState.mode === 'polygon' ? (
431
- <Stack direction="row" spacing={1} sx={{ mt: 2 }}>
432
- <Button variant="contained" onClick={onFinishDrawing} fullWidth>Finish Drawing</Button>
433
- <Button variant="outlined" color="error" onClick={onCancelDrawing} fullWidth>Cancel</Button>
434
- </Stack>
435
- ) : (
436
- <Stack spacing={1} sx={{ mt: 2 }}>
437
  <Button
438
  variant="contained"
439
  startIcon={<AddIcon />}
@@ -443,7 +441,7 @@ export default function MeasurePanel({
443
  Create Polygon
444
  </Button>
445
  <Stack direction="row" spacing={1}>
446
- <Button variant="outlined" startIcon={<DownloadIcon />} onClick={() => onExportGeometries('polygon')} disabled={polygons.length === 0} fullWidth>Export</Button>
447
  <Button
448
  variant="outlined"
449
  color="error"
@@ -456,7 +454,6 @@ export default function MeasurePanel({
456
  </Button>
457
  </Stack>
458
  </Stack>
459
- )}
460
  </TabPanel>
461
  </CardContent>
462
 
 
2
  import {
3
  Card, CardContent, Typography, Button, List, ListItemButton,
4
  ListItemText, Divider, Stack, FormControl, InputLabel, Select,
5
+ MenuItem, Menu, TextField, Dialog, DialogTitle, DialogContent, ToggleButton,
6
+ ToggleButtonGroup,
7
  DialogActions, Tabs, Tab, Box
8
  } from "@mui/material";
9
  import DownloadIcon from "@mui/icons-material/Download";
 
47
  };
48
  }
49
 
50
+ function ExportMenu({ onExport, disabled }) {
51
+ const [anchorEl, setAnchorEl] = useState(null);
52
+ const open = Boolean(anchorEl);
53
+
54
+ const handleClick = (event) => {
55
+ setAnchorEl(event.currentTarget);
56
+ };
57
+
58
+ const handleClose = () => {
59
+ setAnchorEl(null);
60
+ };
61
+
62
+ const handleSelect = (format) => {
63
+ onExport(format);
64
+ handleClose();
65
+ };
66
+
67
+ return (
68
+ <>
69
+ <Button
70
+ variant="outlined"
71
+ startIcon={<DownloadIcon />}
72
+ onClick={handleClick}
73
+ disabled={disabled}
74
+ fullWidth
75
+ >
76
+ Export
77
+ </Button>
78
+ <Menu anchorEl={anchorEl} open={open} onClose={handleClose}>
79
+ <MenuItem onClick={() => handleSelect('geojson')}>As GeoJSON (.json)</MenuItem>
80
+ <MenuItem onClick={() => handleSelect('csv')}>As CSV (.csv)</MenuItem>
81
+ <MenuItem onClick={() => handleSelect('kml')}>As KML (.kml)</MenuItem>
82
+ </Menu>
83
+ </>
84
+ );
85
+ }
86
+
87
  export default function MeasurePanel({
88
  points,
89
  onSelectPoint,
 
147
  setSelectedIndex(nextIndex);
148
  if (onSelectPoint) onSelectPoint(points[index]);
149
  };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
150
 
151
  const openCustomDialog = () => {
152
  setDraftCustomModel(customModel || DEFAULT_CUSTOM_MODEL);
 
316
  ))}
317
  </List>
318
  <Stack spacing={1} sx={{ mt: 1 }}>
319
+ <ToggleButtonGroup
320
+ value={pointAppearance}
321
+ exclusive
322
+ fullWidth
323
+ size="small"
324
+ onChange={(event, newAppearance) => {
325
+ // Prevent unselecting all buttons
326
+ if (newAppearance !== null) {
327
+ onPointAppearanceChange(newAppearance);
328
+ }
329
+ }}
330
+ aria-label="point appearance"
331
+ >
332
+ <ToggleButton value="ellipsoid" sx={{ flex: 1 }}>Error Ellipsoid</ToggleButton>
333
+ <ToggleButton value="point" sx={{ flex: 1 }}>Simple Point</ToggleButton>
334
+ </ToggleButtonGroup>
335
  <Button
336
  variant="contained"
337
  color={measuring ? "error" : "primary"}
 
341
  {measuring ? "End Measure" : "Start Measure"}
342
  </Button>
343
  <Stack direction="row" spacing={1}>
344
+ <ExportMenu onExport={(format) => onExportGeometries('point', format)} disabled={points.length === 0} />
 
 
 
 
 
 
 
 
 
345
  <Button
346
  variant="outlined"
347
  color="error"
348
  startIcon={<DeleteIcon />}
349
+ onClick={onDeletePoint}
350
  disabled={selectedIndex === null}
351
  fullWidth
352
  >
 
381
  </ListItemButton>
382
  ))}
383
  </List>
384
+ <Stack spacing={1} sx={{ mt: 2 }}>
 
 
 
 
 
 
385
  <Button
386
  variant="contained"
387
  startIcon={<AddIcon />}
 
391
  Create Polyline
392
  </Button>
393
  <Stack direction="row" spacing={1}>
394
+ <ExportMenu onExport={(format) => onExportGeometries('polyline', format)} disabled={polylines.length === 0} />
 
 
 
 
 
 
 
 
395
  <Button
396
  variant="outlined"
397
  color="error"
 
404
  </Button>
405
  </Stack>
406
  </Stack>
 
407
  </TabPanel>
408
 
409
  <TabPanel value={tabIndex} index={2}>
 
431
  </ListItemButton>
432
  ))}
433
  </List>
434
+ <Stack spacing={1} sx={{ mt: 2 }}>
 
 
 
 
 
 
435
  <Button
436
  variant="contained"
437
  startIcon={<AddIcon />}
 
441
  Create Polygon
442
  </Button>
443
  <Stack direction="row" spacing={1}>
444
+ <ExportMenu onExport={(format) => onExportGeometries('polygon', format)} disabled={polygons.length === 0} />
445
  <Button
446
  variant="outlined"
447
  color="error"
 
454
  </Button>
455
  </Stack>
456
  </Stack>
 
457
  </TabPanel>
458
  </CardContent>
459
 
client/src/pages/Home.jsx CHANGED
@@ -1,6 +1,7 @@
1
- import React, { useEffect, useMemo, useRef, useState } from "react";
2
  import CesiumViewer from "../components/CesiumViewer";
3
  import MeasurePanel from "../components/MeasurePanel";
 
4
  import { getDefaultBucket, validateAwsConfig, AWS_REGION } from "../utils/awsConfig";
5
  import { getApiUrl, fetchWithAuth } from "../utils/apiConfig";
6
  import { recordVisit } from "../utils/visitorTracking";
@@ -8,7 +9,6 @@ import {
8
  Dialog, DialogTitle, DialogContent, DialogActions, Button,
9
  TextField, Stack
10
  } from "@mui/material";
11
- import * as shp from 'shp-write';
12
 
13
 
14
  // Models configured to use AWS Cognito authentication
@@ -67,6 +67,19 @@ function buildTilesetUrl(model) {
67
  return s3Key;
68
  }
69
 
 
 
 
 
 
 
 
 
 
 
 
 
 
70
  export default function Home() {
71
  // AWS configuration validation
72
  useEffect(() => {
@@ -106,6 +119,10 @@ export default function Home() {
106
  const [polygons, setPolygons] = useState([]);
107
  const [selectedGeometry, setSelectedGeometry] = useState(null); // { type: 'polyline' | 'polygon', id: string | number }
108
  const geometryIdCounter = useRef(0);
 
 
 
 
109
  const models = useMemo(
110
  () => [
111
  ...BASE_MODELS,
@@ -174,11 +191,11 @@ export default function Home() {
174
  const [selectedPoint, setSelectedPoint] = useState(0);
175
  const [pointAppearance, setPointAppearance] = useState("ellipsoid"); // 'ellipsoid' or 'point'
176
 
177
- const handleAddPoint = (point) => {
178
  maxPointId.current += 1;
179
  console.log("New 3D point added:", point);
180
  setPoints((prev) => [...prev, { ...point, id: maxPointId.current }]);
181
- };
182
 
183
  const handleMeasureClick = () => {
184
  if (!cesiumRef.current) return;
@@ -216,16 +233,17 @@ export default function Home() {
216
  setDrawingState({ mode, points: [] });
217
  setSelectedPoint(0); // Deselect any points
218
  setSelectedGeometry(null); // Deselect any geometry
219
- cesiumRef.current?.setMode(mode); // Tell viewer to expect drawing clicks
220
  };
221
 
222
  const handleCancelDrawing = () => {
223
  setDrawingState({ mode: 'none', points: [] });
224
- cesiumRef.current?.setMode('default');
 
225
  };
226
 
227
  const handleFinishDrawing = () => {
228
- const { mode, points } = drawingState;
229
  if (mode === 'none') {
230
  handleCancelDrawing();
231
  return;
@@ -235,26 +253,78 @@ export default function Home() {
235
  const newId = `${mode}-${geometryIdCounter.current}`;
236
 
237
  if (mode === 'polyline') {
238
- if (points.length < 2) {
239
  alert("A polyline requires at least 2 points.");
240
  return;
241
  }
242
- const newPolyline = { id: newId, points };
243
  setPolylines(prev => [...prev, newPolyline]);
244
  } else if (mode === 'polygon') {
245
- if (points.length < 3) {
246
  alert("A polygon requires at least 3 points.");
247
  return;
248
  }
249
- const newPolygon = { id: newId, points };
250
  setPolygons(prev => [...prev, newPolygon]);
251
  }
252
 
253
  handleCancelDrawing();
254
  };
255
 
256
- const handlePointSelectForDrawing = (point) => {
257
- setDrawingState(prev => ({ ...prev, points: [...prev.points, point] }));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
258
  };
259
 
260
  const handleSelectGeometry = (type, id) => {
@@ -275,33 +345,79 @@ export default function Home() {
275
  setSelectedGeometry(null);
276
  };
277
 
278
- const handleExportGeometries = (type) => {
279
- const geometries = type === 'polyline' ? polylines : polygons;
 
 
 
 
 
 
 
 
280
  if (geometries.length === 0) {
281
  alert(`No ${type}s to export.`);
282
  return;
283
  }
284
-
285
- const geojson = {
286
- type: 'FeatureCollection',
287
- features: geometries.map(geom => ({
288
- type: 'Feature',
289
- geometry: {
290
- type: type === 'polyline' ? 'LineString' : 'Polygon',
291
- // For polygons, GeoJSON requires the first and last point to be the same.
292
- coordinates: type === 'polygon'
293
- ? [[...geom.points.map(p => [p.lon, p.lat, p.alt]), [geom.points[0].lon, geom.points[0].lat, geom.points[0].alt]]]
294
- : geom.points.map(p => [p.lon, p.lat, p.alt]),
295
- },
296
- properties: { id: geom.id }
297
- }))
298
- };
299
 
300
- try {
301
- shp.download(geojson, { folder: `measurements_${type}`, types: { line: type, polygon: type } });
302
- } catch (e) {
303
- console.error("Error exporting to shapefile:", e);
304
- alert("Failed to export shapefile. See console for details.");
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
305
  }
306
  };
307
 
@@ -437,8 +553,9 @@ export default function Home() {
437
  drawingState={drawingState}
438
  polylines={polylines}
439
  polygons={polygons}
440
- onPointSelectForDrawing={handlePointSelectForDrawing}
441
  selectedGeometry={selectedGeometry}
 
442
  />
443
 
444
  {/* MUI Overlay */}
@@ -517,6 +634,22 @@ export default function Home() {
517
  </DialogActions>
518
  </Dialog>
519
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
520
  </div>
521
  );
522
  }
 
1
+ import React, { useEffect, useMemo, useRef, useState, useCallback } from "react";
2
  import CesiumViewer from "../components/CesiumViewer";
3
  import MeasurePanel from "../components/MeasurePanel";
4
+ import GeometryCreatorPanel from "../components/GeometryCreatorPanel";
5
  import { getDefaultBucket, validateAwsConfig, AWS_REGION } from "../utils/awsConfig";
6
  import { getApiUrl, fetchWithAuth } from "../utils/apiConfig";
7
  import { recordVisit } from "../utils/visitorTracking";
 
9
  Dialog, DialogTitle, DialogContent, DialogActions, Button,
10
  TextField, Stack
11
  } from "@mui/material";
 
12
 
13
 
14
  // Models configured to use AWS Cognito authentication
 
67
  return s3Key;
68
  }
69
 
70
+ function downloadFile(content, fileName, contentType) {
71
+ const blob = new Blob([content], { type: contentType });
72
+ const url = window.URL.createObjectURL(blob);
73
+ const a = document.createElement('a');
74
+ a.style.display = 'none';
75
+ a.href = url;
76
+ a.download = fileName;
77
+ document.body.appendChild(a);
78
+ a.click();
79
+ window.URL.revokeObjectURL(url);
80
+ a.remove();
81
+ }
82
+
83
  export default function Home() {
84
  // AWS configuration validation
85
  useEffect(() => {
 
119
  const [polygons, setPolygons] = useState([]);
120
  const [selectedGeometry, setSelectedGeometry] = useState(null); // { type: 'polyline' | 'polygon', id: string | number }
121
  const geometryIdCounter = useRef(0);
122
+
123
+ // --- State for the Geometry Creator Panel ---
124
+ const [previewPointId, setPreviewPointId] = useState(0);
125
+ const [multiSelectPoints, setMultiSelectPoints] = useState([]);
126
  const models = useMemo(
127
  () => [
128
  ...BASE_MODELS,
 
191
  const [selectedPoint, setSelectedPoint] = useState(0);
192
  const [pointAppearance, setPointAppearance] = useState("ellipsoid"); // 'ellipsoid' or 'point'
193
 
194
+ const handleAddPoint = useCallback((point) => {
195
  maxPointId.current += 1;
196
  console.log("New 3D point added:", point);
197
  setPoints((prev) => [...prev, { ...point, id: maxPointId.current }]);
198
+ }, []);
199
 
200
  const handleMeasureClick = () => {
201
  if (!cesiumRef.current) return;
 
233
  setDrawingState({ mode, points: [] });
234
  setSelectedPoint(0); // Deselect any points
235
  setSelectedGeometry(null); // Deselect any geometry
236
+ setMultiSelectPoints([]); // Reset multi-select
237
  };
238
 
239
  const handleCancelDrawing = () => {
240
  setDrawingState({ mode: 'none', points: [] });
241
+ setMultiSelectPoints([]);
242
+ setPreviewPointId(0);
243
  };
244
 
245
  const handleFinishDrawing = () => {
246
+ const { mode, points: geometryPoints } = drawingState;
247
  if (mode === 'none') {
248
  handleCancelDrawing();
249
  return;
 
253
  const newId = `${mode}-${geometryIdCounter.current}`;
254
 
255
  if (mode === 'polyline') {
256
+ if (geometryPoints.length < 2) {
257
  alert("A polyline requires at least 2 points.");
258
  return;
259
  }
260
+ const newPolyline = { id: newId, points: geometryPoints };
261
  setPolylines(prev => [...prev, newPolyline]);
262
  } else if (mode === 'polygon') {
263
+ if (geometryPoints.length < 3) {
264
  alert("A polygon requires at least 3 points.");
265
  return;
266
  }
267
+ const newPolygon = { id: newId, points: geometryPoints };
268
  setPolygons(prev => [...prev, newPolygon]);
269
  }
270
 
271
  handleCancelDrawing();
272
  };
273
 
274
+ const handlePointSelectForGeometry = useCallback((point) => {
275
+ // Since we only select existing points, they will always have an ID.
276
+ setDrawingState(prev => {
277
+ const isAlreadyAdded = prev.points.some(p => p.id === point.id);
278
+ if (isAlreadyAdded) {
279
+ // Remove the point if it's already in the list
280
+ return { ...prev, points: prev.points.filter(p => p.id !== point.id) };
281
+ } else {
282
+ // Add the point
283
+ return { ...prev, points: [...prev.points, point] };
284
+ }
285
+ });
286
+ }, []);
287
+
288
+ const handleAvailablePointClick = (event, pointId) => {
289
+ const isCtrlOrMeta = event.ctrlKey || event.metaKey;
290
+
291
+ setMultiSelectPoints(prev => {
292
+ if (isCtrlOrMeta) {
293
+ if (prev.includes(pointId)) {
294
+ return prev.filter(id => id !== pointId);
295
+ } else {
296
+ return [...prev, pointId];
297
+ }
298
+ } else {
299
+ if (prev.length === 1 && prev[0] === pointId) {
300
+ return [];
301
+ } else {
302
+ return [pointId];
303
+ }
304
+ }
305
+ });
306
+ };
307
+
308
+ const handleAddMultiSelectedPoints = () => {
309
+ const pointsToAdd = points.filter(p => multiSelectPoints.includes(p.id));
310
+ setDrawingState(prev => {
311
+ const newPoints = pointsToAdd.filter(p => !prev.points.some(dp => dp.id === p.id));
312
+ return { ...prev, points: [...prev.points, ...newPoints] };
313
+ });
314
+ setMultiSelectPoints([]); // Clear selection after adding
315
+ };
316
+
317
+ const handleClearGeometryPoints = () => {
318
+ setDrawingState(prev => ({ ...prev, points: [] }));
319
+ };
320
+
321
+ const handleReorderGeometryPoints = (oldIndex, newIndex) => {
322
+ setDrawingState(prev => {
323
+ const items = Array.from(prev.points);
324
+ const [reorderedItem] = items.splice(oldIndex, 1);
325
+ items.splice(newIndex, 0, reorderedItem);
326
+ return { ...prev, points: items };
327
+ });
328
  };
329
 
330
  const handleSelectGeometry = (type, id) => {
 
345
  setSelectedGeometry(null);
346
  };
347
 
348
+ const handleExportGeometries = (type, format) => {
349
+ let geometries;
350
+ const fileName = `measurements_${type}`;
351
+
352
+ if (type === 'point') {
353
+ geometries = points;
354
+ } else {
355
+ geometries = type === 'polyline' ? polylines : polygons;
356
+ }
357
+
358
  if (geometries.length === 0) {
359
  alert(`No ${type}s to export.`);
360
  return;
361
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
362
 
363
+ // --- GeoJSON is the common base for some formats ---
364
+ const geojson = type === 'point'
365
+ ? {
366
+ type: 'FeatureCollection',
367
+ features: geometries.map(p => ({
368
+ type: 'Feature',
369
+ geometry: { type: 'Point', coordinates: [p.lon, p.lat, p.alt] },
370
+ properties: { id: p.id, accuracy: p.accuracy }
371
+ }))
372
+ }
373
+ : { // polyline or polygon
374
+ type: 'FeatureCollection',
375
+ features: geometries.map(geom => ({
376
+ type: 'Feature',
377
+ geometry: {
378
+ type: type === 'polyline' ? 'LineString' : 'Polygon',
379
+ coordinates: type === 'polygon'
380
+ ? [[...geom.points.map(p => [p.lon, p.lat, p.alt]), [geom.points[0].lon, geom.points[0].lat, geom.points[0].alt]]]
381
+ : geom.points.map(p => [p.lon, p.lat, p.alt]),
382
+ },
383
+ properties: { id: geom.id }
384
+ }))
385
+ };
386
+
387
+ // --- Format-specific logic ---
388
+ if (format === 'geojson') {
389
+ const dataStr = JSON.stringify(geojson, null, 2);
390
+ downloadFile(dataStr, `${fileName}.geojson`, 'application/json');
391
+ } else if (format === 'csv') {
392
+ let csvContent = '';
393
+ if (type === 'point') {
394
+ csvContent = 'id,lon,lat,alt,accuracy\n';
395
+ geometries.forEach(p => {
396
+ csvContent += `${p.id},${p.lon},${p.lat},${p.alt},${p.accuracy}\n`;
397
+ });
398
+ } else { // polyline or polygon
399
+ csvContent = 'geometry_id,point_order,lon,lat,alt\n';
400
+ geometries.forEach(geom => {
401
+ geom.points.forEach((p, index) => {
402
+ csvContent += `${geom.id},${index + 1},${p.lon},${p.lat},${p.alt}\n`;
403
+ });
404
+ });
405
+ }
406
+ downloadFile(csvContent, `${fileName}.csv`, 'text/csv;charset=utf-8;');
407
+ } else if (format === 'kml') {
408
+ const placemarks = geometries.map(geom => {
409
+ const name = type === 'point' ? `Point ${geom.id}` : geom.id;
410
+ const points = type === 'point' ? [geom] : geom.points;
411
+ const coords = points.map(p => `${p.lon},${p.lat},${p.alt}`).join(' ');
412
+ let geometryString;
413
+ if (type === 'point') geometryString = `<Point><coordinates>${coords}</coordinates></Point>`;
414
+ else if (type === 'polyline') geometryString = `<LineString><coordinates>${coords}</coordinates></LineString>`;
415
+ else if (type === 'polygon') geometryString = `<Polygon><outerBoundaryIs><LinearRing><coordinates>${coords} ${points[0].lon},${points[0].lat},${points[0].alt}</coordinates></LinearRing></outerBoundaryIs></Polygon>`;
416
+ return `<Placemark><name>${name}</name>${geometryString || ''}</Placemark>`;
417
+ }).join('\n ');
418
+
419
+ const kmlContent = `<?xml version="1.0" encoding="UTF-8"?>\n<kml xmlns="http://www.opengis.net/kml/2.2">\n <Document>\n <name>${fileName}</name>\n ${placemarks}\n </Document>\n</kml>`;
420
+ downloadFile(kmlContent, `${fileName}.kml`, 'application/vnd.google-earth.kml+xml');
421
  }
422
  };
423
 
 
553
  drawingState={drawingState}
554
  polylines={polylines}
555
  polygons={polygons}
556
+ onPointSelectForDrawing={handlePointSelectForGeometry}
557
  selectedGeometry={selectedGeometry}
558
+ previewPointId={previewPointId}
559
  />
560
 
561
  {/* MUI Overlay */}
 
634
  </DialogActions>
635
  </Dialog>
636
 
637
+ <GeometryCreatorPanel
638
+ isOpen={drawingState.mode !== 'none'}
639
+ drawingState={drawingState}
640
+ onCancel={handleCancelDrawing}
641
+ onFinish={handleFinishDrawing}
642
+ onPointSelect={handlePointSelectForGeometry}
643
+ onPreviewPoint={setPreviewPointId}
644
+ // Props for available points list
645
+ availablePoints={points}
646
+ multiSelectPoints={multiSelectPoints}
647
+ onAvailablePointClick={handleAvailablePointClick}
648
+ onAddMultiSelectedPoints={handleAddMultiSelectedPoints}
649
+ onClearPoints={handleClearGeometryPoints}
650
+ onReorderPoints={handleReorderGeometryPoints}
651
+ />
652
+
653
  </div>
654
  );
655
  }