pull/1755/head
BasilBP 2025-09-29 16:09:50 +05:30
rodzic 00606435b4
commit a8a2b5a4a0
3 zmienionych plików z 707 dodań i 583 usunięć

Plik binarny nie jest wyświetlany.

Po

Szerokość:  |  Wysokość:  |  Rozmiar: 1.5 KiB

Wyświetl plik

@ -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;
}

Wyświetl plik

@ -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='&copy; <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='&copy; <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;