Spaces:
Running
Running
| import React, { useEffect, useState } from "react"; | |
| import { | |
| Card, CardContent, Typography, Button, List, ListItemButton, | |
| ListItemText, Divider, Stack, FormControl, InputLabel, Select, | |
| MenuItem, Menu, TextField, Dialog, DialogTitle, DialogContent, ToggleButton, | |
| ToggleButtonGroup, | |
| DialogActions, Tabs, Tab, Box | |
| } from "@mui/material"; | |
| import DownloadIcon from "@mui/icons-material/Download"; | |
| import DeleteIcon from "@mui/icons-material/Delete"; | |
| import EditIcon from "@mui/icons-material/Edit"; | |
| import AddIcon from "@mui/icons-material/Add"; | |
| import { getDefaultBucket } from "../utils/awsConfig"; | |
| const DEFAULT_CUSTOM_MODEL = { | |
| bucket: getDefaultBucket(), | |
| prefix: "data/3dtiles", | |
| tileset: "custom/tileset.json", | |
| offsetHeight: 0, | |
| }; | |
| const DEFAULT_URL_MODEL = { | |
| tilesetUrl: "", | |
| offsetHeight: 0, | |
| }; | |
| function TabPanel(props) { | |
| const { children, value, index, ...other } = props; | |
| return ( | |
| <div | |
| role="tabpanel" | |
| hidden={value !== index} | |
| id={`simple-tabpanel-${index}`} | |
| aria-labelledby={`simple-tab-${index}`} | |
| {...other} | |
| > | |
| {value === index && <Box sx={{ pt: 2 }}>{children}</Box>} | |
| </div> | |
| ); | |
| } | |
| function a11yProps(index) { | |
| return { | |
| id: `simple-tab-${index}`, | |
| "aria-controls": `simple-tabpanel-${index}`, | |
| }; | |
| } | |
| function ExportMenu({ onExport, disabled }) { | |
| const [anchorEl, setAnchorEl] = useState(null); | |
| const open = Boolean(anchorEl); | |
| const handleClick = (event) => { | |
| setAnchorEl(event.currentTarget); | |
| }; | |
| const handleClose = () => { | |
| setAnchorEl(null); | |
| }; | |
| const handleSelect = (format) => { | |
| onExport(format); | |
| handleClose(); | |
| }; | |
| return ( | |
| <> | |
| <Button | |
| variant="outlined" | |
| startIcon={<DownloadIcon />} | |
| onClick={handleClick} | |
| disabled={disabled} | |
| fullWidth | |
| > | |
| Export | |
| </Button> | |
| <Menu anchorEl={anchorEl} open={open} onClose={handleClose}> | |
| <MenuItem onClick={() => handleSelect('geojson')}>As GeoJSON (.json)</MenuItem> | |
| <MenuItem onClick={() => handleSelect('csv')}>As CSV (.csv)</MenuItem> | |
| <MenuItem onClick={() => handleSelect('kml')}>As KML (.kml)</MenuItem> | |
| </Menu> | |
| </> | |
| ); | |
| } | |
| export default function MeasurePanel({ | |
| points, | |
| onSelectPoint, | |
| onDeletePoint, | |
| onToggleMeasure, | |
| measuring, | |
| offsetHeight, | |
| models, | |
| selectedModelId, | |
| customModel, | |
| urlModel, | |
| onModelChange, | |
| onSaveCustomModel, | |
| onSaveUrlModel, | |
| onOffsetHeightChange, | |
| onEditModel, | |
| pointAppearance, | |
| onPointAppearanceChange, | |
| // New geometry props | |
| polylines, | |
| polygons, | |
| drawingState, | |
| selectedGeometry, | |
| onStartDrawing, | |
| onFinishDrawing, | |
| onCancelDrawing, | |
| onSelectGeometry, | |
| onDeleteGeometry, | |
| onExportGeometries, | |
| }) { | |
| const [selectedIndex, setSelectedIndex] = useState(null); | |
| const [customDialogOpen, setCustomDialogOpen] = useState(false); | |
| const [draftCustomModel, setDraftCustomModel] = useState( | |
| customModel || DEFAULT_CUSTOM_MODEL | |
| ); | |
| const [urlDialogOpen, setUrlDialogOpen] = useState(false); | |
| const [draftUrlModel, setDraftUrlModel] = useState( | |
| urlModel || DEFAULT_URL_MODEL | |
| ); | |
| const [tabIndex, setTabIndex] = useState(0); | |
| const handleTabChange = (event, newValue) => { | |
| setTabIndex(newValue); | |
| }; | |
| useEffect(() => { | |
| if (!customDialogOpen) { | |
| setDraftCustomModel(customModel || DEFAULT_CUSTOM_MODEL); | |
| } | |
| }, [customDialogOpen, customModel]); | |
| useEffect(() => { | |
| if (!urlDialogOpen) { | |
| setDraftUrlModel(urlModel || DEFAULT_URL_MODEL); | |
| } | |
| }, [urlDialogOpen, urlModel]); | |
| const handleSelect = (index) => { | |
| // Allow deselecting | |
| const nextIndex = selectedIndex === index ? null : index; | |
| setSelectedIndex(nextIndex); | |
| if (onSelectPoint) onSelectPoint(points[index]); | |
| }; | |
| const openCustomDialog = () => { | |
| setDraftCustomModel(customModel || DEFAULT_CUSTOM_MODEL); | |
| setCustomDialogOpen(true); | |
| }; | |
| const openUrlDialog = () => { | |
| setDraftUrlModel(urlModel || DEFAULT_URL_MODEL); | |
| setUrlDialogOpen(true); | |
| }; | |
| const handleModelChange = (event) => { | |
| const nextModelId = event.target.value; | |
| if (onModelChange) onModelChange(nextModelId); | |
| if (nextModelId === "custom") { | |
| openCustomDialog(); | |
| } | |
| if (nextModelId === "url") { | |
| openUrlDialog(); | |
| } | |
| }; | |
| const handleCustomFieldChange = (field) => (event) => { | |
| setDraftCustomModel((prev) => ({ | |
| ...prev, | |
| [field]: event.target.value, | |
| })); | |
| }; | |
| const handleCustomSave = () => { | |
| const nextOffset = Number(draftCustomModel.offsetHeight); | |
| const payload = { | |
| ...draftCustomModel, | |
| offsetHeight: Number.isNaN(nextOffset) ? 0 : nextOffset, | |
| }; | |
| if (onSaveCustomModel) onSaveCustomModel(payload); | |
| setCustomDialogOpen(false); | |
| }; | |
| const handleUrlFieldChange = (field) => (event) => { | |
| setDraftUrlModel((prev) => ({ | |
| ...prev, | |
| [field]: event.target.value, | |
| })); | |
| }; | |
| const handleUrlSave = () => { | |
| const nextOffset = Number(draftUrlModel.offsetHeight); | |
| const payload = { | |
| ...draftUrlModel, | |
| offsetHeight: Number.isNaN(nextOffset) ? 0 : nextOffset, | |
| }; | |
| if (onSaveUrlModel) onSaveUrlModel(payload); | |
| setUrlDialogOpen(false); | |
| }; | |
| const handleCustomClose = () => { | |
| setCustomDialogOpen(false); | |
| }; | |
| const handleUrlClose = () => { | |
| setUrlDialogOpen(false); | |
| }; | |
| return ( | |
| <Card | |
| sx={{ | |
| width: 400, | |
| position: "absolute", | |
| top: 20, | |
| left: 20, | |
| bgcolor: "background.paper", | |
| boxShadow: 5, | |
| borderRadius: 3, | |
| }} | |
| > | |
| <CardContent> | |
| <Typography variant="h6" gutterBottom> | |
| 3D Measurement | |
| </Typography> | |
| <Stack spacing={2} mt={2}> | |
| <Stack direction="row" spacing={1}> | |
| <FormControl size="small" fullWidth sx={{ flex: 1 }}> | |
| <InputLabel id="model-select-label">Model</InputLabel> | |
| <Select | |
| labelId="model-select-label" | |
| value={selectedModelId} | |
| label="Model" | |
| onChange={handleModelChange} | |
| > | |
| {models.map((model) => ( | |
| <MenuItem key={model.id} value={model.id}> | |
| {model.name} | |
| </MenuItem> | |
| ))} | |
| </Select> | |
| </FormControl> | |
| {(selectedModelId === "custom" || selectedModelId === "url" || selectedModelId === "upload") && ( | |
| <Button | |
| variant="outlined" | |
| startIcon={<EditIcon />} | |
| onClick={() => { | |
| if (selectedModelId === "custom") openCustomDialog(); | |
| if (selectedModelId === "url") openUrlDialog(); | |
| if (selectedModelId === "upload" && onEditModel) onEditModel(); | |
| }} | |
| > | |
| {selectedModelId === "upload" ? "Upload" : "Edit"} | |
| </Button> | |
| )} | |
| </Stack> | |
| <TextField | |
| size="small" | |
| label="Offset Height" | |
| type="number" | |
| value={offsetHeight} | |
| onChange={(event) => | |
| onOffsetHeightChange && onOffsetHeightChange(event.target.value) | |
| } | |
| inputProps={{ step: 1 }} | |
| /> | |
| </Stack> | |
| <Box sx={{ borderBottom: 1, borderColor: "divider", mt: 2 }}> | |
| <Tabs | |
| value={tabIndex} | |
| onChange={handleTabChange} | |
| aria-label="points and buildings tabs" | |
| > | |
| <Tab label={`Points (${points.length})`} {...a11yProps(0)} sx={{ textTransform: 'none' }} /> | |
| <Tab label={`Polylines (${polylines.length})`} {...a11yProps(1)} sx={{ textTransform: 'none' }} /> | |
| <Tab label={`Polygons (${polygons.length})`} {...a11yProps(2)} sx={{ textTransform: 'none' }} /> | |
| </Tabs> | |
| </Box> | |
| <TabPanel value={tabIndex} index={0}> | |
| <List dense sx={{ maxHeight: 200, overflowY: "auto" }}> | |
| {points.length === 0 && ( | |
| <Typography variant="body2" color="text.secondary" sx={{ px: 1 }}> | |
| No points measured yet. | |
| </Typography> | |
| )} | |
| {points.map((p, index) => ( | |
| <React.Fragment key={p.id}> | |
| <ListItemButton | |
| selected={selectedIndex === index} | |
| onClick={() => handleSelect(index)} | |
| sx={{ | |
| borderRadius: 2, | |
| "&.Mui-selected": { | |
| bgcolor: "primary.light", | |
| color: "white", | |
| "&:hover": { bgcolor: "primary.main" }, | |
| }, | |
| }} | |
| > | |
| <ListItemText | |
| primary={`Point ${p.id}`} | |
| secondary={`(${p.lat.toFixed(8)}, ${p.lon.toFixed(8)}, ${p.alt.toFixed(4)}) | Iz = ${p.accuracy.toFixed(3)} m`} | |
| /> | |
| </ListItemButton> | |
| {index < points.length - 1 && <Divider component="li" />} | |
| </React.Fragment> | |
| ))} | |
| </List> | |
| <Stack spacing={1} sx={{ mt: 1 }}> | |
| <ToggleButtonGroup | |
| value={pointAppearance} | |
| exclusive | |
| fullWidth | |
| size="small" | |
| onChange={(event, newAppearance) => { | |
| // Prevent unselecting all buttons | |
| if (newAppearance !== null) { | |
| onPointAppearanceChange(newAppearance); | |
| } | |
| }} | |
| aria-label="point appearance" | |
| > | |
| <ToggleButton value="ellipsoid" sx={{ flex: 1 }}>Error Ellipsoid</ToggleButton> | |
| <ToggleButton value="point" sx={{ flex: 1 }}>Simple Point</ToggleButton> | |
| </ToggleButtonGroup> | |
| <Button | |
| variant="contained" | |
| color={measuring ? "error" : "primary"} | |
| onClick={onToggleMeasure} | |
| fullWidth | |
| > | |
| {measuring ? "End Measure" : "Start Measure"} | |
| </Button> | |
| <Stack direction="row" spacing={1}> | |
| <ExportMenu onExport={(format) => onExportGeometries('point', format)} disabled={points.length === 0} /> | |
| <Button | |
| variant="outlined" | |
| color="error" | |
| startIcon={<DeleteIcon />} | |
| onClick={onDeletePoint} | |
| disabled={selectedIndex === null} | |
| fullWidth | |
| > | |
| Delete | |
| </Button> | |
| </Stack> | |
| </Stack> | |
| </TabPanel> | |
| <TabPanel value={tabIndex} index={1}> | |
| <List dense sx={{ maxHeight: 150, overflowY: "auto" }}> | |
| {polylines.length === 0 && ( | |
| <Typography variant="body2" color="text.secondary" sx={{ px: 1 }}> | |
| No polylines created yet. | |
| </Typography> | |
| )} | |
| {polylines.map((line) => ( | |
| <ListItemButton | |
| key={line.id} | |
| selected={selectedGeometry?.type === 'polyline' && selectedGeometry?.id === line.id} | |
| onClick={() => onSelectGeometry('polyline', line.id)} | |
| sx={{ | |
| borderRadius: 2, | |
| "&.Mui-selected": { | |
| bgcolor: "primary.light", | |
| color: "white", | |
| "&:hover": { bgcolor: "primary.dark" }, | |
| }, | |
| }} | |
| > | |
| <ListItemText primary={line.id} secondary={`${line.points.length} points`} /> | |
| </ListItemButton> | |
| ))} | |
| </List> | |
| <Stack spacing={1} sx={{ mt: 2 }}> | |
| <Button | |
| variant="contained" | |
| startIcon={<AddIcon />} | |
| onClick={() => onStartDrawing('polyline')} | |
| disabled={drawingState.mode !== 'none'} | |
| > | |
| Create Polyline | |
| </Button> | |
| <Stack direction="row" spacing={1}> | |
| <ExportMenu onExport={(format) => onExportGeometries('polyline', format)} disabled={polylines.length === 0} /> | |
| <Button | |
| variant="outlined" | |
| color="error" | |
| startIcon={<DeleteIcon />} | |
| onClick={onDeleteGeometry} | |
| disabled={selectedGeometry?.type !== 'polyline'} | |
| fullWidth | |
| > | |
| Delete | |
| </Button> | |
| </Stack> | |
| </Stack> | |
| </TabPanel> | |
| <TabPanel value={tabIndex} index={2}> | |
| <List dense sx={{ maxHeight: 150, overflowY: "auto" }}> | |
| {polygons.length === 0 && ( | |
| <Typography variant="body2" color="text.secondary" sx={{ px: 1 }}> | |
| No polygons created yet. | |
| </Typography> | |
| )} | |
| {polygons.map((poly) => ( | |
| <ListItemButton | |
| key={poly.id} | |
| selected={selectedGeometry?.type === 'polygon' && selectedGeometry?.id === poly.id} | |
| onClick={() => onSelectGeometry('polygon', poly.id)} | |
| sx={{ | |
| borderRadius: 2, | |
| "&.Mui-selected": { | |
| bgcolor: "primary.light", | |
| color: "white", | |
| "&:hover": { bgcolor: "primary.dark" }, | |
| }, | |
| }} | |
| > | |
| <ListItemText primary={poly.id} secondary={`${poly.points.length} points`} /> | |
| </ListItemButton> | |
| ))} | |
| </List> | |
| <Stack spacing={1} sx={{ mt: 2 }}> | |
| <Button | |
| variant="contained" | |
| startIcon={<AddIcon />} | |
| onClick={() => onStartDrawing('polygon')} | |
| disabled={drawingState.mode !== 'none'} | |
| > | |
| Create Polygon | |
| </Button> | |
| <Stack direction="row" spacing={1}> | |
| <ExportMenu onExport={(format) => onExportGeometries('polygon', format)} disabled={polygons.length === 0} /> | |
| <Button | |
| variant="outlined" | |
| color="error" | |
| startIcon={<DeleteIcon />} | |
| onClick={onDeleteGeometry} | |
| disabled={selectedGeometry?.type !== 'polygon'} | |
| fullWidth | |
| > | |
| Delete | |
| </Button> | |
| </Stack> | |
| </Stack> | |
| </TabPanel> | |
| </CardContent> | |
| <Dialog open={customDialogOpen} onClose={handleCustomClose} maxWidth="sm" fullWidth> | |
| <DialogTitle>Custom S3 Model</DialogTitle> | |
| <DialogContent> | |
| <Stack spacing={2} mt={1}> | |
| <TextField | |
| size="small" | |
| label="Bucket" | |
| value={draftCustomModel.bucket} | |
| onChange={handleCustomFieldChange("bucket")} | |
| fullWidth | |
| /> | |
| <TextField | |
| size="small" | |
| label="Prefix" | |
| value={draftCustomModel.prefix} | |
| onChange={handleCustomFieldChange("prefix")} | |
| fullWidth | |
| /> | |
| <TextField | |
| size="small" | |
| label="Tileset" | |
| value={draftCustomModel.tileset} | |
| onChange={handleCustomFieldChange("tileset")} | |
| fullWidth | |
| /> | |
| <TextField | |
| size="small" | |
| label="Offset Height" | |
| type="number" | |
| value={draftCustomModel.offsetHeight} | |
| onChange={handleCustomFieldChange("offsetHeight")} | |
| inputProps={{ step: 1 }} | |
| fullWidth | |
| /> | |
| </Stack> | |
| </DialogContent> | |
| <DialogActions> | |
| <Button onClick={handleCustomClose}>Cancel</Button> | |
| <Button variant="contained" onClick={handleCustomSave}> | |
| Save | |
| </Button> | |
| </DialogActions> | |
| </Dialog> | |
| <Dialog open={urlDialogOpen} onClose={handleUrlClose} maxWidth="sm" fullWidth> | |
| <DialogTitle>URL Model</DialogTitle> | |
| <DialogContent> | |
| <Stack spacing={2} mt={1}> | |
| <TextField | |
| size="small" | |
| label="Tileset URL" | |
| placeholder="http://localhost:8000/tileset.json" | |
| value={draftUrlModel.tilesetUrl} | |
| onChange={handleUrlFieldChange("tilesetUrl")} | |
| fullWidth | |
| /> | |
| <TextField | |
| size="small" | |
| label="Offset Height" | |
| type="number" | |
| value={draftUrlModel.offsetHeight} | |
| onChange={handleUrlFieldChange("offsetHeight")} | |
| inputProps={{ step: 1 }} | |
| fullWidth | |
| /> | |
| </Stack> | |
| </DialogContent> | |
| <DialogActions> | |
| <Button onClick={handleUrlClose}>Cancel</Button> | |
| <Button variant="contained" onClick={handleUrlSave}> | |
| Save | |
| </Button> | |
| </DialogActions> | |
| </Dialog> | |
| </Card> | |
| ); | |
| } | |