kopia lustrzana https://github.com/OpenDroneMap/WebODM
step1
rodzic
00606435b4
commit
a8a2b5a4a0
Plik binarny nie jest wyświetlany.
Po Szerokość: | Wysokość: | Rozmiar: 1.5 KiB |
|
@ -1,263 +1,359 @@
|
|||
:root {
|
||||
--panel-color: #ffffff;
|
||||
--header-color: #6c757d;
|
||||
--text-color: #333;
|
||||
--border-color: #dee2e6;
|
||||
--button-bg: #e9ecef;
|
||||
--button-hover-bg: #d4dae0;
|
||||
}
|
||||
:root {
|
||||
--panel-color: #f0f0f0; /* Light grey panel background from your last input */
|
||||
--header-color: #6c757d;
|
||||
--text-color: #333;
|
||||
--border-color: #dee2e6;
|
||||
--button-bg: #e9ecef;
|
||||
--button-hover-bg: #d4dae0;
|
||||
}
|
||||
|
||||
.gcp-window {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
width: 100%;
|
||||
}
|
||||
/* --- GcpInterface.css Layout Styles --- */
|
||||
|
||||
.gcp-window {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Hide old unused elements */
|
||||
.top-bar { display: none; }
|
||||
.gcp-list-section .no-points { display: none; }
|
||||
.gcp-list-section ul { padding: 0; margin: 0; }
|
||||
.gcp-list-section ul { list-style-type: none; }
|
||||
|
||||
|
||||
.top-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 10px 20px;
|
||||
color: var(--text-color);
|
||||
font-weight: bold;
|
||||
}
|
||||
.main-content {
|
||||
display: flex;
|
||||
flex-grow: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.top-bar .title {
|
||||
font-size: 1.2em;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.main-content.ui-match-content {
|
||||
flex-grow: 1;
|
||||
overflow: hidden;
|
||||
background-color: var(--panel-color);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.top-bar {
|
||||
font-size: 1.5em;
|
||||
}
|
||||
.left-panel.ui-match-left-panel {
|
||||
flex: 0 0 350px;
|
||||
max-width: 350px;
|
||||
padding: 20px;
|
||||
border-right: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.top-bar-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.right-panel.ui-match-right-panel {
|
||||
flex: 1;
|
||||
max-width: none;
|
||||
padding: 10px;
|
||||
background: #e0f0ff;
|
||||
}
|
||||
|
||||
.top-bar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
/* --- Panel Headers (Linked Points & How to use) --- */
|
||||
.left-panel h4 {
|
||||
/* Resetting the default h4 styles for a cleaner look */
|
||||
color: var(--text-color);
|
||||
font-size: 1.1em;
|
||||
font-weight: 500;
|
||||
margin-top: 0;
|
||||
margin-bottom: 5px;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.export-button {
|
||||
background-color: var(--panel-color);
|
||||
color: var(--text-color);
|
||||
border: 1px solid var(--border-color);
|
||||
padding: 8px 16px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-weight: bold;
|
||||
}
|
||||
.gcp-list-section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.export-button:hover {
|
||||
background-color: var(--button-bg);
|
||||
}
|
||||
.directions-section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.ui-match-header {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid var(--border-color); /* Grey line below the header */
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
display: flex;
|
||||
flex-grow: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
.ui-match-header span {
|
||||
font-size: 0.8em;
|
||||
margin-left: 5px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.directions-section ol {
|
||||
font-size: 0.9em;
|
||||
color: #555;
|
||||
padding-left: 20px;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.left-panel {
|
||||
flex: 1 1 40%;
|
||||
max-width: 40%;
|
||||
padding: 15px;
|
||||
border-right: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
}
|
||||
/* --- Linked Points Mock UI (Blue Tiles - New Design) --- */
|
||||
|
||||
.right-panel {
|
||||
flex: 1 1 60%;
|
||||
max-width: 60%;
|
||||
}
|
||||
.linked-points-mock-ui {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.left-panel h4 {
|
||||
color: #888;
|
||||
font-size: 0.9em;
|
||||
margin-top: 0;
|
||||
.mock-link-row-ui {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.mock-link-block {
|
||||
height: 20px;
|
||||
width: 90px;
|
||||
background-color: #4a90e2; /* Blue color for the blocks */
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.mock-link-line {
|
||||
flex-grow: 1;
|
||||
height: 2px;
|
||||
background-color: #ccc; /* Grey line connecting the blocks */
|
||||
margin: 0 8px;
|
||||
}
|
||||
|
||||
/* GcpInterface.css */
|
||||
/* GcpInterface.css */
|
||||
|
||||
/* --- Linked Points List (Actual Data) --- */
|
||||
|
||||
.linked-points-list {
|
||||
list-style-type: none;
|
||||
padding: 0;
|
||||
margin: 10px 0;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
border-radius: 4px;
|
||||
background-color: #f0f0f0;
|
||||
}
|
||||
|
||||
.linked-points-list li {
|
||||
/* Set up for the two containers and the delete button */
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 6px 12px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.gcp-list-section .no-points {
|
||||
color: #999;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.gcp-list-section ul {
|
||||
list-style-type: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.directions-section .collapsible {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.directions-section ol {
|
||||
font-size: 0.9em;
|
||||
color: #555;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.file-controls {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.file-controls button {
|
||||
padding: 10px;
|
||||
border: 1px dashed var(--border-color);
|
||||
background-color: var(--button-bg);
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.file-controls button:hover {
|
||||
background-color: var(--button-hover-bg);
|
||||
border-color: #aaa;
|
||||
}
|
||||
|
||||
|
||||
.image-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(100px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.thumbnail {
|
||||
|
||||
/* NEW: Use gap for spacing and make it a relative container for the line */
|
||||
gap: 20px;
|
||||
position: relative;
|
||||
border: 1px solid var(--border-color);
|
||||
padding: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.thumbnail img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
.linked-points-list li:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.thumbnail .image-name {
|
||||
display: none;
|
||||
position: absolute;
|
||||
bottom: 4px;
|
||||
left: 4px;
|
||||
background: rgba(0,0,0,0.7);
|
||||
color: #fff;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-size: 0.80em;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.thumbnail:hover .image-name {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.thumbnail .delete-btn {
|
||||
position: absolute;
|
||||
top: -5px;
|
||||
right: -5px;
|
||||
background-color: red;
|
||||
/* Styles for the blue containers (blocks) */
|
||||
.linked-points-list li > .link-image-name,
|
||||
.linked-points-list li > .link-gcp-id {
|
||||
background-color: #4a90e2; /* Matching the blue from the mockup */
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
cursor: pointer;
|
||||
font-weight: bold;
|
||||
line-height: 20px;
|
||||
text-align: center;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.thumbnail .delete-btn:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.link-count-badge {
|
||||
position: absolute;
|
||||
top: -5px;
|
||||
left: -5px;
|
||||
background-color: #007bff;
|
||||
color: white;
|
||||
border-radius: 50%;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
border: 1px solid white;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
|
||||
.right-panel {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.map-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.export-button:disabled {
|
||||
background-color: #e9ecef;
|
||||
color: #adb5bd;
|
||||
cursor: not-allowed;
|
||||
border-color: #dee2e6;
|
||||
}
|
||||
|
||||
.linked-points-list {
|
||||
list-style-type: none;
|
||||
padding: 0;
|
||||
margin: 10px 0;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
padding: 6px 10px;
|
||||
text-align: center;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
|
||||
/* FIX: Enforce equal width */
|
||||
flex: 1 1 0; /* flex-grow: 1, flex-shrink: 1, flex-basis: 0 ensures equal width */
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.linked-points-list li {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
font-size: 0.85em;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
/* Remove margin and pseudo-element from .link-image-name to enable equal width */
|
||||
.linked-points-list li > .link-image-name {
|
||||
/* margin-right: 20px; - REMOVED */
|
||||
/* position: relative; - REMOVED */
|
||||
}
|
||||
|
||||
.linked-points-list li:last-child {
|
||||
border-bottom: none;
|
||||
/* Creating the connecting line using a pseudo-element on the LI itself */
|
||||
.linked-points-list li::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
|
||||
/* FIX: Position the line in the center of the 20px gap:
|
||||
Start position is half the LI width (50%), minus half the gap (10px),
|
||||
and minus half the line width (5px). */
|
||||
left: calc(50% - 15px);
|
||||
|
||||
width: 10px;
|
||||
height: 2px;
|
||||
background-color: #6c757d; /* Grey line color */
|
||||
transform: translateY(-50%);
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.delete-link-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #dc3545;
|
||||
cursor: pointer;
|
||||
font-size: 1.2em;
|
||||
font-weight: bold;
|
||||
padding: 0 5px;
|
||||
/* Push delete button to the right */
|
||||
.delete-link-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #dc3545;
|
||||
cursor: pointer;
|
||||
font-size: 1.2em;
|
||||
font-weight: bold;
|
||||
padding: 0 5px;
|
||||
margin-left: 10px;
|
||||
flex-shrink: 0; /* Prevents button from shrinking */
|
||||
}
|
||||
.delete-link-btn:hover {
|
||||
color: #a71d2a;
|
||||
}
|
||||
|
||||
.delete-link-btn:hover {
|
||||
color: #a71d2a;
|
||||
/* --- File Controls (Choose/Load Buttons) --- */
|
||||
.file-controls.ui-match-controls {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 15px;
|
||||
margin: 10px 0 20px 0;
|
||||
}
|
||||
|
||||
.file-controls .ui-match-button {
|
||||
flex: 1;
|
||||
padding: 12px 10px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-weight: bold;
|
||||
color: white;
|
||||
background-color: #4a90e2; /* Blue color */
|
||||
transition: background-color 0.2s;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.file-controls .ui-match-button:hover {
|
||||
background-color: #357bd1;
|
||||
}
|
||||
|
||||
/* --- Image Grid (Real Images) --- */
|
||||
.image-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(100px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.thumbnail {
|
||||
position: relative;
|
||||
border: 1px solid var(--border-color);
|
||||
padding: 4px;
|
||||
cursor: pointer;
|
||||
background-color: #fff; /* White background for image tile border */
|
||||
}
|
||||
|
||||
.thumbnail img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Removed styles for .thumbnail.mock-thumbnail (grey tiles) */
|
||||
|
||||
.thumbnail .image-name {
|
||||
display: none;
|
||||
position: absolute;
|
||||
bottom: 4px;
|
||||
left: 4px;
|
||||
background: rgba(0,0,0,0.7);
|
||||
color: #fff;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-size: 0.80em;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.thumbnail:hover .image-name {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.thumbnail .delete-btn {
|
||||
position: absolute;
|
||||
top: -5px;
|
||||
right: -5px;
|
||||
background-color: red;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
cursor: pointer;
|
||||
font-weight: bold;
|
||||
line-height: 20px;
|
||||
text-align: center;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.thumbnail .delete-btn:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.link-count-badge {
|
||||
position: absolute;
|
||||
top: -5px;
|
||||
left: -5px;
|
||||
background-color: #007bff;
|
||||
color: white;
|
||||
border-radius: 50%;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
border: 1px solid white;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
/* --- Map Panel and Export Button --- */
|
||||
|
||||
.map-container.ui-match-map-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.export-button-overlay {
|
||||
position: absolute;
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.export-button.ui-match-export-button {
|
||||
padding: 10px 20px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-weight: bold;
|
||||
color: white;
|
||||
background-color: #4a90e2;
|
||||
transition: background-color 0.2s;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.export-button.ui-match-export-button:hover:not(:disabled) {
|
||||
background-color: #357bd1;
|
||||
}
|
||||
|
||||
.export-button.ui-match-export-button:disabled {
|
||||
background-color: #a0c4ec;
|
||||
color: #fff;
|
||||
cursor: not-allowed;
|
||||
border-color: #dee2e6;
|
||||
}
|
|
@ -1,373 +1,401 @@
|
|||
import React, { useState, useRef, useEffect } from "react";
|
||||
import { MapContainer, TileLayer, Marker, Tooltip, useMap, useMapEvents } from "react-leaflet";
|
||||
import '../leafletConfig.js'; // Configure Leaflet icons
|
||||
import exifr from 'exifr';
|
||||
import proj4 from 'proj4';
|
||||
import './GcpInterface.css';
|
||||
import ImageViewer from "./ImageViewer";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import Login from './Login';
|
||||
import React, { useState, useRef, useEffect } from "react";
|
||||
import { MapContainer, TileLayer, Marker, Tooltip, useMap, useMapEvents } from "react-leaflet";
|
||||
import '../leafletConfig.js'; // Configure Leaflet icons
|
||||
import exifr from 'exifr';
|
||||
import proj4 from 'proj4';
|
||||
import './GcpInterface.css';
|
||||
import ImageViewer from "./ImageViewer";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import Login from './Login'; // Assuming this is needed for the original logic
|
||||
|
||||
const MapBoundsUpdater = ({ bounds }) => {
|
||||
const map = useMap();
|
||||
useEffect(() => {
|
||||
if (bounds && bounds.length > 0) {
|
||||
map.fitBounds(bounds);
|
||||
}
|
||||
}, [bounds, map]);
|
||||
return null;
|
||||
};
|
||||
const MapBoundsUpdater = ({ bounds }) => {
|
||||
const map = useMap();
|
||||
useEffect(() => {
|
||||
if (bounds && bounds.length > 0) {
|
||||
map.fitBounds(bounds);
|
||||
}
|
||||
}, [bounds, map]);
|
||||
return null;
|
||||
};
|
||||
|
||||
const MapClickHandler = ({ onMapClick }) => {
|
||||
useMapEvents({
|
||||
click: () => {
|
||||
onMapClick();
|
||||
},
|
||||
});
|
||||
return null;
|
||||
};
|
||||
const MapClickHandler = ({ onMapClick }) => {
|
||||
useMapEvents({
|
||||
click: () => {
|
||||
onMapClick();
|
||||
},
|
||||
});
|
||||
return null;
|
||||
};
|
||||
|
||||
function distance2D(lat1, lon1, lat2, lon2) {
|
||||
const dLat = (lat1 || 0) - (lat2 || 0);
|
||||
const dLon = (lon1 || 0) - (lon2 || 0);
|
||||
return Math.sqrt(dLat * dLat + dLon * dLon);
|
||||
}
|
||||
function distance2D(lat1, lon1, lat2, lon2) {
|
||||
const dLat = (lat1 || 0) - (lat2 || 0);
|
||||
const dLon = (lon1 || 0) - (lon2 || 0);
|
||||
return Math.sqrt(dLat * dLat + dLon * dLon);
|
||||
}
|
||||
|
||||
function GcpInterface() {
|
||||
const [gcpPoints, setGcpPoints] = useState([]);
|
||||
const [images, setImages] = useState([]);
|
||||
const [showDirections, setShowDirections] = useState(false);
|
||||
const [selectedGcpPoint, setSelectedGcpPoint] = useState(null);
|
||||
const [selectedImage, setSelectedImage] = useState(null);
|
||||
const [selectedIndex, setSelectedIndex] = useState(null);
|
||||
const [gcpLinks, setGcpLinks] = useState([]);
|
||||
const [pendingPoint, setPendingPoint] = useState(null);
|
||||
const [loggedOut, setLoggedOut] = useState(false);
|
||||
const gcpInputRef = useRef(null);
|
||||
const imageInputRef = useRef(null);
|
||||
const navigate = useNavigate();
|
||||
function GcpInterface() {
|
||||
const [gcpPoints, setGcpPoints] = useState([]);
|
||||
const [images, setImages] = useState([]);
|
||||
// State is renamed to match the UI title
|
||||
const [showHowToUse, setShowHowToUse] = useState(false);
|
||||
const [selectedGcpPoint, setSelectedGcpPoint] = useState(null);
|
||||
const [selectedImage, setSelectedImage] = useState(null);
|
||||
const [selectedIndex, setSelectedIndex] = useState(null);
|
||||
const [gcpLinks, setGcpLinks] = useState([]);
|
||||
const [pendingPoint, setPendingPoint] = useState(null);
|
||||
const [loggedOut, setLoggedOut] = useState(false);
|
||||
const gcpInputRef = useRef(null);
|
||||
const imageInputRef = useRef(null);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleRemoveImage = (e, imageUrlToRemove) => {
|
||||
e.stopPropagation();
|
||||
setImages(currentImages => currentImages.filter(img => img.url !== imageUrlToRemove));
|
||||
setGcpLinks(currentLinks => currentLinks.filter(link => link.image.url !== imageUrlToRemove));
|
||||
};
|
||||
// --- Handlers (unchanged logic) ---
|
||||
|
||||
const handleGcpFileChange = (event) => {
|
||||
const file = event.target.files[0];
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const text = e.target.result;
|
||||
const lines = text.split('\n').filter(line => line.trim() !== '');
|
||||
const points = lines.slice(1).map((line, index) => {
|
||||
const parts = line.split(/\s+/);
|
||||
if (parts.length >= 3) {
|
||||
return {
|
||||
id: parts[0] || `Point ${index + 1}`,
|
||||
lat: parseFloat(parts[2]),
|
||||
lon: parseFloat(parts[1]),
|
||||
alt: parts[3] ? parseFloat(parts[3]) : null,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}).filter(p => p && !isNaN(p.lat) && !isNaN(p.lon));
|
||||
setGcpPoints(points);
|
||||
};
|
||||
reader.readAsText(file);
|
||||
}
|
||||
};
|
||||
const handleRemoveImage = (e, imageUrlToRemove) => {
|
||||
e.stopPropagation();
|
||||
setImages(currentImages => currentImages.filter(img => img.url !== imageUrlToRemove));
|
||||
setGcpLinks(currentLinks => currentLinks.filter(link => link.image.url !== imageUrlToRemove));
|
||||
};
|
||||
|
||||
const handleImageChange = async (event) => {
|
||||
const files = Array.from(event.target.files);
|
||||
|
||||
const imagePromises = files.map(async (file) => {
|
||||
try {
|
||||
const { latitude, longitude, GPSAltitude } = await exifr.parse(file);
|
||||
return {
|
||||
url: URL.createObjectURL(file),
|
||||
name: file.name,
|
||||
lat: latitude,
|
||||
lon: longitude,
|
||||
alt: GPSAltitude,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Could not read EXIF data for file:", file.name);
|
||||
return {
|
||||
url: URL.createObjectURL(file),
|
||||
name: file.name,
|
||||
lat: null,
|
||||
lon: null,
|
||||
alt: null,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
const newImages = await Promise.all(imagePromises);
|
||||
setImages(currentImages => [...currentImages, ...newImages]);
|
||||
};
|
||||
const handleGcpFileChange = (event) => {
|
||||
const file = event.target.files[0];
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const text = e.target.result;
|
||||
const lines = text.split('\n').filter(line => line.trim() !== '');
|
||||
const points = lines.slice(1).map((line, index) => {
|
||||
const parts = line.split(/\s+/);
|
||||
if (parts.length >= 3) {
|
||||
return {
|
||||
id: parts[0] || `Point ${index + 1}`,
|
||||
lat: parseFloat(parts[2]),
|
||||
lon: parseFloat(parts[1]),
|
||||
alt: parts[3] ? parseFloat(parts[3]) : null,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}).filter(p => p && !isNaN(p.lat) && !isNaN(p.lon));
|
||||
setGcpPoints(points);
|
||||
};
|
||||
reader.readAsText(file);
|
||||
}
|
||||
};
|
||||
|
||||
const handleImagePointSelect = (coords) => {
|
||||
setPendingPoint({
|
||||
image: selectedImage,
|
||||
imageCoords: coords
|
||||
});
|
||||
};
|
||||
const handleImageChange = async (event) => {
|
||||
const files = Array.from(event.target.files);
|
||||
|
||||
const imagePromises = files.map(async (file) => {
|
||||
try {
|
||||
const { latitude, longitude, GPSAltitude } = await exifr.parse(file);
|
||||
return {
|
||||
url: URL.createObjectURL(file),
|
||||
name: file.name,
|
||||
lat: latitude,
|
||||
lon: longitude,
|
||||
alt: GPSAltitude,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Could not read EXIF data for file:", file.name);
|
||||
return {
|
||||
url: URL.createObjectURL(file),
|
||||
name: file.name,
|
||||
lat: null,
|
||||
lon: null,
|
||||
alt: null,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
const newImages = await Promise.all(imagePromises);
|
||||
setImages(currentImages => [...currentImages, ...newImages]);
|
||||
};
|
||||
|
||||
const handleImagePointDelete = () => {
|
||||
setPendingPoint(null);
|
||||
};
|
||||
|
||||
const handleGcpMarkerClick = (gcpPoint) => {
|
||||
if (pendingPoint && pendingPoint.imageCoords) {
|
||||
const newLink = {
|
||||
id: `${pendingPoint.image.name}-${gcpPoint.id}-${Date.now()}`,
|
||||
gcp: gcpPoint,
|
||||
image: pendingPoint.image,
|
||||
imageCoords: pendingPoint.imageCoords,
|
||||
};
|
||||
setGcpLinks(prevLinks => [...prevLinks, newLink]);
|
||||
setPendingPoint(null);
|
||||
setSelectedImage(null);
|
||||
setSelectedIndex(null);
|
||||
} else {
|
||||
setSelectedGcpPoint(gcpPoint);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExport = () => {
|
||||
if (gcpLinks.length === 0) return;
|
||||
const handleImagePointSelect = (coords) => {
|
||||
setPendingPoint({
|
||||
image: selectedImage,
|
||||
imageCoords: coords
|
||||
});
|
||||
};
|
||||
|
||||
const sampleLon = gcpLinks[0].gcp.lon;
|
||||
const utmZone = Math.floor((sampleLon + 180) / 6) + 1;
|
||||
const handleImagePointDelete = () => {
|
||||
setPendingPoint(null);
|
||||
};
|
||||
|
||||
const handleGcpMarkerClick = (gcpPoint) => {
|
||||
if (pendingPoint && pendingPoint.imageCoords) {
|
||||
const newLink = {
|
||||
id: `${pendingPoint.image.name}-${gcpPoint.id}-${Date.now()}`,
|
||||
gcp: gcpPoint,
|
||||
image: pendingPoint.image,
|
||||
imageCoords: pendingPoint.imageCoords,
|
||||
};
|
||||
setGcpLinks(prevLinks => [...prevLinks, newLink]);
|
||||
setPendingPoint(null);
|
||||
setSelectedImage(null);
|
||||
setSelectedIndex(null);
|
||||
} else {
|
||||
setSelectedGcpPoint(gcpPoint);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExport = () => {
|
||||
if (gcpLinks.length === 0) return;
|
||||
|
||||
const sourceCRS = 'EPSG:4326';
|
||||
const destCRS = `+proj=utm +zone=${utmZone} +ellps=WGS84 +datum=WGS84 +units=m +no_defs`;
|
||||
const header = destCRS;
|
||||
const dataLines = gcpLinks.map(link => {
|
||||
const lon = link.gcp.lon;
|
||||
const lat = link.gcp.lat;
|
||||
const [easting, northing] = proj4(sourceCRS, destCRS, [lon, lat]);
|
||||
const alt = link.gcp.alt ?? 0;
|
||||
const u = link.imageCoords.x.toFixed(2);
|
||||
const v = link.imageCoords.y.toFixed(2);
|
||||
const imgName = link.image.name;
|
||||
const gcpId = link.gcp.id;
|
||||
const sampleLon = gcpLinks[0].gcp.lon;
|
||||
const utmZone = Math.floor((sampleLon + 180) / 6) + 1;
|
||||
|
||||
return `${easting.toFixed(2)}\t${northing.toFixed(2)}\t${alt}\t${u}\t${v}\t${imgName}\t${gcpId}`;
|
||||
}).join('\n');
|
||||
const fileContent = `${header}\n${dataLines}`;
|
||||
const sourceCRS = 'EPSG:4326';
|
||||
const destCRS = `+proj=utm +zone=${utmZone} +ellps=WGS84 +datum=WGS84 +units=m +no_defs`;
|
||||
const header = destCRS;
|
||||
const dataLines = gcpLinks.map(link => {
|
||||
const lon = link.gcp.lon;
|
||||
const lat = link.gcp.lat;
|
||||
const [easting, northing] = proj4(sourceCRS, destCRS, [lon, lat]);
|
||||
const alt = link.gcp.alt ?? 0;
|
||||
const u = link.imageCoords.x.toFixed(2);
|
||||
const v = link.imageCoords.y.toFixed(2);
|
||||
const imgName = link.image.name;
|
||||
const gcpId = link.gcp.id;
|
||||
|
||||
const blob = new Blob([fileContent], { type: 'text/plain;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = 'gcp_export.txt';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
return `${easting.toFixed(2)}\t${northing.toFixed(2)}\t${alt}\t${u}\t${v}\t${imgName}\t${gcpId}`;
|
||||
}).join('\n');
|
||||
const fileContent = `${header}\n${dataLines}`;
|
||||
|
||||
const closeImageViewer = () => {
|
||||
setSelectedImage(null);
|
||||
setSelectedIndex(null);
|
||||
setPendingPoint(null);
|
||||
};
|
||||
const blob = new Blob([fileContent], { type: 'text/plain;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = 'gcp_export.txt';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
|
||||
const mapBounds = gcpPoints.length > 0 ? gcpPoints.map(p => [p.lat, p.lon]) : [];
|
||||
const closeImageViewer = () => {
|
||||
setSelectedImage(null);
|
||||
setSelectedIndex(null);
|
||||
setPendingPoint(null);
|
||||
};
|
||||
|
||||
const imageLinkCounts = gcpLinks.reduce((acc, link) => {
|
||||
const imageUrl = link.image.url;
|
||||
acc[imageUrl] = (acc[imageUrl] || 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
const mapBounds = gcpPoints.length > 0 ? gcpPoints.map(p => [p.lat, p.lon]) : [];
|
||||
|
||||
const latestLinkTimestamps = gcpLinks.reduce((acc, link) => {
|
||||
const imageUrl = link.image.url;
|
||||
const timestamp = parseInt(link.id.split('-').pop(), 10);
|
||||
if (!acc[imageUrl] || timestamp > acc[imageUrl]) {
|
||||
acc[imageUrl] = timestamp;
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
const imageLinkCounts = gcpLinks.reduce((acc, link) => {
|
||||
const imageUrl = link.image.url;
|
||||
acc[imageUrl] = (acc[imageUrl] || 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
let displayedImages = images;
|
||||
if (selectedGcpPoint) {
|
||||
displayedImages = [...images]
|
||||
.filter(img => img.lat !== null && img.lon !== null)
|
||||
.map(img => ({
|
||||
...img,
|
||||
dist: distance2D(selectedGcpPoint.lat, selectedGcpPoint.lon, img.lat, img.lon)
|
||||
}))
|
||||
.sort((a, b) => a.dist - b.dist)
|
||||
.slice(0, 12);
|
||||
} else {
|
||||
displayedImages = [...images].sort((a, b) => {
|
||||
const timeA = latestLinkTimestamps[a.url] || 0;
|
||||
const timeB = latestLinkTimestamps[b.url] || 0;
|
||||
if (timeA !== timeB) {
|
||||
return timeB - timeA;
|
||||
}
|
||||
return images.indexOf(b) - images.indexOf(a);
|
||||
});
|
||||
}
|
||||
const latestLinkTimestamps = gcpLinks.reduce((acc, link) => {
|
||||
const imageUrl = link.image.url;
|
||||
const timestamp = parseInt(link.id.split('-').pop(), 10);
|
||||
if (!acc[imageUrl] || timestamp > acc[imageUrl]) {
|
||||
acc[imageUrl] = timestamp;
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const handleMapClick = () => {
|
||||
setSelectedGcpPoint(null);
|
||||
};
|
||||
let displayedImages = images;
|
||||
if (selectedGcpPoint) {
|
||||
displayedImages = [...images]
|
||||
.filter(img => img.lat !== null && img.lon !== null)
|
||||
.map(img => ({
|
||||
...img,
|
||||
dist: distance2D(selectedGcpPoint.lat, selectedGcpPoint.lon, img.lat, img.lon)
|
||||
}))
|
||||
.sort((a, b) => a.dist - b.dist)
|
||||
.slice(0, 12);
|
||||
} else {
|
||||
displayedImages = [...images].sort((a, b) => {
|
||||
const timeA = latestLinkTimestamps[a.url] || 0;
|
||||
const timeB = latestLinkTimestamps[b.url] || 0;
|
||||
if (timeA !== timeB) {
|
||||
return timeB - timeA;
|
||||
}
|
||||
return images.indexOf(b) - images.indexOf(a);
|
||||
});
|
||||
}
|
||||
|
||||
if (loggedOut) {
|
||||
return <Login />;
|
||||
}
|
||||
const handleMapClick = () => {
|
||||
setSelectedGcpPoint(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="gcp-window">
|
||||
<div className="top-bar">
|
||||
<div className="top-bar-left">
|
||||
<div className="title">
|
||||
Ground Control Point Interface
|
||||
</div>
|
||||
</div>
|
||||
<div className="top-bar-right">
|
||||
<button
|
||||
className="export-button"
|
||||
onClick={handleExport}
|
||||
disabled={gcpLinks.length === 0}
|
||||
>
|
||||
EXPORT FILE
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="main-content">
|
||||
<div className="left-panel">
|
||||
{selectedImage ? (
|
||||
<ImageViewer
|
||||
image={selectedImage}
|
||||
index={selectedIndex}
|
||||
onClose={closeImageViewer}
|
||||
onPointSelect={handleImagePointSelect}
|
||||
onPointDelete={handleImagePointDelete}
|
||||
hasPendingPoint={!!pendingPoint}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className="gcp-list-section">
|
||||
<h4>LINKED POINTS ({gcpLinks.length})</h4>
|
||||
{gcpLinks.length === 0 ? (
|
||||
<p className="no-points">No links created yet...</p>
|
||||
) : (
|
||||
<ul className="linked-points-list">
|
||||
{gcpLinks.map((link) => (
|
||||
<li key={link.id}>
|
||||
<span> {link.image.name} ↔️ {link.gcp.id}</span>
|
||||
<button
|
||||
className="delete-link-btn"
|
||||
onClick={() => setGcpLinks(links => links.filter(l => l.id !== link.id))}
|
||||
>
|
||||
✖
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
<div className="gcp-list-section">
|
||||
<h4>GROUND CONTROL POINTS</h4>
|
||||
{gcpPoints.length === 0 ? (
|
||||
<p className="no-points">No points...</p>
|
||||
) : (
|
||||
<ul>
|
||||
{gcpPoints.map((p, i) => <li key={i}>{p.id}</li>)}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
<div className="directions-section">
|
||||
<h4 onClick={() => setShowDirections(!showDirections)} className="collapsible">
|
||||
{showDirections ? '▼' : '►'} DIRECTIONS
|
||||
</h4>
|
||||
{showDirections && (
|
||||
<>
|
||||
<div style={{ marginBottom: "8px" }}>
|
||||
Connect at least 5 high-contrast objects in 3 or more photos to their corresponding locations on the map.
|
||||
</div>
|
||||
<ol>
|
||||
<li>Upload images (jpeg or png).</li>
|
||||
<li>Set a point in an image.</li>
|
||||
<li>Set a corresponding point on the map.</li>
|
||||
<li>Repeat as desired (at least until the goal is achieved).</li>
|
||||
<li>Generate the ground control point file.</li>
|
||||
</ol>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="file-controls">
|
||||
<input
|
||||
type="file"
|
||||
accept=".txt"
|
||||
ref={gcpInputRef}
|
||||
onChange={handleGcpFileChange}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
<button onClick={() => gcpInputRef.current.click()}>
|
||||
Load existing Control Point File
|
||||
</button>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/jpeg, image/png"
|
||||
multiple
|
||||
ref={imageInputRef}
|
||||
onChange={handleImageChange}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
<button onClick={() => imageInputRef.current.click()}>
|
||||
Choose images
|
||||
</button>
|
||||
</div>
|
||||
<div className="image-grid">
|
||||
{displayedImages.map((image, i) => {
|
||||
const linkCount = imageLinkCounts[image.url] || 0;
|
||||
return (
|
||||
<div key={image.url} className="thumbnail" onClick={() => {
|
||||
setSelectedImage(image);
|
||||
setSelectedIndex(i);
|
||||
}}>
|
||||
<img src={image.url} alt={image.name} />
|
||||
<span className="image-name">{image.name}</span>
|
||||
<span className="link-count-badge">{linkCount}</span>
|
||||
<button className="delete-btn" onClick={(e) => handleRemoveImage(e, image.url)}>
|
||||
X</button>
|
||||
</div>
|
||||
)})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="right-panel">
|
||||
<MapContainer center={[20, 0]} zoom={2} className="map-container" style={{cursor: pendingPoint ? 'crosshair' : 'auto'}}>
|
||||
<MapClickHandler onMapClick={handleMapClick} />
|
||||
<TileLayer
|
||||
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
/>
|
||||
{gcpPoints.map((point) => (
|
||||
<Marker
|
||||
key={point.id}
|
||||
position={[point.lat, point.lon]}
|
||||
eventHandlers={{ click: () => handleGcpMarkerClick(point) }}
|
||||
>
|
||||
<Tooltip direction="top" offset={[0, -10]} opacity={1} permanent={false}>
|
||||
{point.id}
|
||||
</Tooltip>
|
||||
</Marker>
|
||||
))}
|
||||
<MapBoundsUpdater bounds={mapBounds} />
|
||||
</MapContainer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (loggedOut) {
|
||||
return <Login />;
|
||||
}
|
||||
|
||||
// --- Component JSX (updated structure and content) ---
|
||||
|
||||
return (
|
||||
<div className="gcp-window ui-match-window">
|
||||
{/* The Top Bar is removed to match the UI which assumes a parent sidebar. */}
|
||||
|
||||
<div className="main-content ui-match-content">
|
||||
{/* Left Panel: Controls and Image Thumbnails */}
|
||||
<div className="left-panel ui-match-left-panel">
|
||||
{selectedImage ? (
|
||||
<ImageViewer
|
||||
image={selectedImage}
|
||||
index={selectedIndex}
|
||||
onClose={closeImageViewer}
|
||||
onPointSelect={handleImagePointSelect}
|
||||
onPointDelete={handleImagePointDelete}
|
||||
hasPendingPoint={!!pendingPoint}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className="gcp-list-section">
|
||||
{/* New Collapsible Header for Linked Points */}
|
||||
<h4 onClick={() => {}} className="collapsible ui-match-header linked-points-header">
|
||||
Linked Points <span>▼</span>
|
||||
</h4>
|
||||
|
||||
{/* Mock UI/Empty state for Linked Points - Matches the blue tile design */}
|
||||
{gcpLinks.length === 0 ? (
|
||||
<div className="linked-points-mock-ui">
|
||||
<div className="mock-link-row-ui">
|
||||
<div className="mock-link-block left"></div>
|
||||
<div className="mock-link-line"></div>
|
||||
<div className="mock-link-block right"></div>
|
||||
</div>
|
||||
<div className="mock-link-row-ui">
|
||||
<div className="mock-link-block left"></div>
|
||||
<div className="mock-link-line"></div>
|
||||
<div className="mock-link-block right"></div>
|
||||
</div>
|
||||
<div className="mock-link-row-ui">
|
||||
<div className="mock-link-block left"></div>
|
||||
<div className="mock-link-line"></div>
|
||||
<div className="mock-link-block right"></div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
// Actual linked points list
|
||||
<ul className="linked-points-list">
|
||||
{gcpLinks.map((link) => (
|
||||
<li key={link.id}>
|
||||
<span className="link-image-name">{link.image.name}</span>
|
||||
{/* Separator span REMOVED. The line will be created in the gap below. */}
|
||||
<span className="link-gcp-id">{link.gcp.id}</span>
|
||||
<button
|
||||
className="delete-link-btn"
|
||||
onClick={() => setGcpLinks(links => links.filter(l => l.id !== link.id))}
|
||||
>
|
||||
✖
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* The GCP Points section is removed as requested by the user. */}
|
||||
|
||||
<div className="directions-section">
|
||||
{/* Renamed to match the UI image */}
|
||||
<h4 onClick={() => setShowHowToUse(!showHowToUse)} className="collapsible ui-match-header">
|
||||
How to use <span>{showHowToUse ? '▲' : '▼'}</span>
|
||||
</h4>
|
||||
{showHowToUse && (
|
||||
<>
|
||||
<div style={{ marginBottom: "8px" }}>
|
||||
Connect at least 5 high-contrast objects in 3 or more photos to their corresponding locations on the map.
|
||||
</div>
|
||||
<ol>
|
||||
<li>Upload images (jpeg or png).</li>
|
||||
<li>Set a point in an image.</li>
|
||||
<li>Set a corresponding point on the map.</li>
|
||||
<li>Repeat as desired (at least until the goal is achieved).</li>
|
||||
<li>Generate the ground control point file.</li>
|
||||
</ol>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="file-controls ui-match-controls">
|
||||
<input
|
||||
type="file"
|
||||
accept="image/jpeg, image/png"
|
||||
multiple
|
||||
ref={imageInputRef}
|
||||
onChange={handleImageChange}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
<button className="ui-match-button" onClick={() => imageInputRef.current.click()}>
|
||||
Choose Images
|
||||
</button>
|
||||
<input
|
||||
type="file"
|
||||
accept=".txt"
|
||||
ref={gcpInputRef}
|
||||
onChange={handleGcpFileChange}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
<button className="ui-match-button" onClick={() => gcpInputRef.current.click()}>
|
||||
Load GCP
|
||||
</button>
|
||||
</div>
|
||||
<div className="image-grid">
|
||||
{/* Only map real images. Mock tiles are removed as requested. */}
|
||||
{displayedImages.map((image, i) => {
|
||||
const linkCount = imageLinkCounts[image.url] || 0;
|
||||
return (
|
||||
<div key={image.url} className="thumbnail" onClick={() => {
|
||||
setSelectedImage(image);
|
||||
setSelectedIndex(i);
|
||||
}}>
|
||||
<img src={image.url} alt={image.name} />
|
||||
<span className="image-name">{image.name}</span>
|
||||
<span className="link-count-badge">{linkCount}</span>
|
||||
<button className="delete-btn" onClick={(e) => handleRemoveImage(e, image.url)}>
|
||||
X</button>
|
||||
</div>
|
||||
)})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{/* Right Panel: Map */}
|
||||
<div className="right-panel ui-match-right-panel">
|
||||
<MapContainer
|
||||
center={[20, 0]}
|
||||
zoom={2}
|
||||
className="map-container ui-match-map-container"
|
||||
style={{cursor: pendingPoint ? 'crosshair' : 'auto'}}
|
||||
>
|
||||
<MapClickHandler onMapClick={handleMapClick} />
|
||||
<TileLayer
|
||||
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
/>
|
||||
{gcpPoints.map((point) => (
|
||||
<Marker
|
||||
key={point.id}
|
||||
position={[point.lat, point.lon]}
|
||||
eventHandlers={{ click: () => handleGcpMarkerClick(point) }}
|
||||
>
|
||||
<Tooltip direction="top" offset={[0, -10]} opacity={1} permanent={false}>
|
||||
{point.id}
|
||||
</Tooltip>
|
||||
</Marker>
|
||||
))}
|
||||
<MapBoundsUpdater bounds={mapBounds} />
|
||||
|
||||
{/* Export Button overlay, matching UI image placement */}
|
||||
<div className="export-button-overlay">
|
||||
<button
|
||||
className="export-button ui-match-export-button"
|
||||
onClick={handleExport}
|
||||
disabled={gcpLinks.length === 0}
|
||||
>
|
||||
Export File
|
||||
</button>
|
||||
</div>
|
||||
</MapContainer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default GcpInterface;
|
Ładowanie…
Reference in New Issue