518 lines
17 KiB
HTML
518 lines
17 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Immich Photo Heatmap</title>
|
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
|
<style>
|
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; }
|
|
|
|
#app { display: flex; height: 100vh; }
|
|
|
|
#map-container { flex: 1; position: relative; }
|
|
#map { height: 100%; width: 100%; }
|
|
|
|
#sidebar {
|
|
width: 0;
|
|
background: #1a1a2e;
|
|
color: white;
|
|
overflow-y: auto;
|
|
transition: width 0.3s ease;
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
#sidebar.open { width: 400px; }
|
|
|
|
#sidebar-header {
|
|
padding: 16px;
|
|
background: #16213e;
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
position: sticky;
|
|
top: 0;
|
|
z-index: 10;
|
|
}
|
|
|
|
#sidebar-header h2 { font-size: 16px; font-weight: 500; }
|
|
|
|
#close-sidebar {
|
|
background: none;
|
|
border: none;
|
|
color: white;
|
|
font-size: 24px;
|
|
cursor: pointer;
|
|
padding: 4px 8px;
|
|
}
|
|
|
|
#photo-count { font-size: 12px; color: #888; margin-top: 4px; }
|
|
|
|
#gallery {
|
|
display: grid;
|
|
grid-template-columns: repeat(3, 1fr);
|
|
gap: 4px;
|
|
padding: 8px;
|
|
flex: 1;
|
|
}
|
|
|
|
.photo-thumb {
|
|
aspect-ratio: 1;
|
|
background-size: cover;
|
|
background-position: center;
|
|
border-radius: 4px;
|
|
cursor: pointer;
|
|
transition: transform 0.2s;
|
|
}
|
|
|
|
.photo-thumb:hover { transform: scale(1.05); }
|
|
|
|
#loading {
|
|
position: absolute;
|
|
top: 50%;
|
|
left: 50%;
|
|
transform: translate(-50%, -50%);
|
|
background: rgba(0,0,0,0.8);
|
|
color: white;
|
|
padding: 20px 40px;
|
|
border-radius: 8px;
|
|
z-index: 1000;
|
|
}
|
|
|
|
#controls {
|
|
position: absolute;
|
|
top: 10px;
|
|
right: 10px;
|
|
z-index: 1000;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 8px;
|
|
}
|
|
|
|
.control-btn {
|
|
background: white;
|
|
border: 2px solid rgba(0,0,0,0.2);
|
|
border-radius: 4px;
|
|
padding: 8px 12px;
|
|
cursor: pointer;
|
|
font-size: 14px;
|
|
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
|
|
}
|
|
|
|
.control-btn:hover { background: #f0f0f0; }
|
|
|
|
#stats {
|
|
position: absolute;
|
|
bottom: 20px;
|
|
left: 20px;
|
|
background: rgba(0,0,0,0.8);
|
|
color: white;
|
|
padding: 12px 16px;
|
|
border-radius: 8px;
|
|
z-index: 1000;
|
|
font-size: 13px;
|
|
}
|
|
|
|
#lightbox {
|
|
display: none;
|
|
position: fixed;
|
|
top: 0;
|
|
left: 0;
|
|
width: 100%;
|
|
height: 100%;
|
|
background: rgba(0,0,0,0.95);
|
|
z-index: 2000;
|
|
justify-content: center;
|
|
align-items: center;
|
|
}
|
|
|
|
#lightbox.open { display: flex; }
|
|
|
|
#lightbox img {
|
|
max-width: 90%;
|
|
max-height: 90%;
|
|
object-fit: contain;
|
|
}
|
|
|
|
#lightbox-close {
|
|
position: absolute;
|
|
top: 20px;
|
|
right: 20px;
|
|
background: none;
|
|
border: none;
|
|
color: white;
|
|
font-size: 36px;
|
|
cursor: pointer;
|
|
}
|
|
|
|
#lightbox-nav {
|
|
position: absolute;
|
|
bottom: 20px;
|
|
display: flex;
|
|
gap: 20px;
|
|
}
|
|
|
|
#lightbox-nav button {
|
|
background: rgba(255,255,255,0.2);
|
|
border: none;
|
|
color: white;
|
|
padding: 10px 20px;
|
|
border-radius: 4px;
|
|
cursor: pointer;
|
|
font-size: 14px;
|
|
}
|
|
|
|
#lightbox-nav button:hover { background: rgba(255,255,255,0.3); }
|
|
|
|
#open-immich {
|
|
position: absolute;
|
|
bottom: 20px;
|
|
right: 20px;
|
|
background: #4263eb;
|
|
border: none;
|
|
color: white;
|
|
padding: 10px 16px;
|
|
border-radius: 4px;
|
|
cursor: pointer;
|
|
text-decoration: none;
|
|
font-size: 14px;
|
|
}
|
|
|
|
@media (max-width: 768px) {
|
|
#sidebar.open { width: 100%; position: absolute; right: 0; z-index: 1001; height: 60%; bottom: 0; }
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div id="app">
|
|
<div id="map-container">
|
|
<div id="map"></div>
|
|
<div id="loading">Loading photo locations...</div>
|
|
<div id="controls">
|
|
<button class="control-btn" onclick="resetView()">Reset View</button>
|
|
<button class="control-btn" onclick="toggleHeatmap()">Toggle Heatmap</button>
|
|
</div>
|
|
<div id="stats"></div>
|
|
</div>
|
|
<div id="sidebar">
|
|
<div id="sidebar-header">
|
|
<div>
|
|
<h2>Photos in this area</h2>
|
|
<div id="photo-count">0 photos</div>
|
|
</div>
|
|
<button id="close-sidebar" onclick="closeSidebar()">×</button>
|
|
</div>
|
|
<div id="gallery"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="lightbox">
|
|
<button id="lightbox-close" onclick="closeLightbox()">×</button>
|
|
<img id="lightbox-img" src="" alt="">
|
|
<a id="open-immich" href="#" target="_blank">Open in Immich</a>
|
|
<div id="lightbox-nav">
|
|
<button onclick="prevPhoto()">← Previous</button>
|
|
<button onclick="nextPhoto()">Next →</button>
|
|
</div>
|
|
</div>
|
|
|
|
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
|
<script src="https://unpkg.com/leaflet.heat@0.2.0/dist/leaflet-heat.js"></script>
|
|
<script>
|
|
// Configuration - will be injected by server or use defaults
|
|
const CONFIG = {
|
|
immichUrl: '', // Empty - all API calls proxied through this server
|
|
immichPublicUrl: 'https://photos.jeffemmett.com', // For "Open in Immich" links
|
|
apiKey: '' // User will enter this
|
|
};
|
|
|
|
let map, heatLayer, markerLayer;
|
|
let allPhotos = [];
|
|
let currentAreaPhotos = [];
|
|
let currentPhotoIndex = 0;
|
|
let heatmapVisible = true;
|
|
|
|
// Initialize map
|
|
function initMap() {
|
|
map = L.map('map').setView([0, 0], 2);
|
|
|
|
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
|
attribution: '© OpenStreetMap contributors'
|
|
}).addTo(map);
|
|
|
|
markerLayer = L.layerGroup().addTo(map);
|
|
|
|
// Click handler for map
|
|
map.on('click', onMapClick);
|
|
}
|
|
|
|
// Fetch photo locations from Immich
|
|
async function fetchPhotos() {
|
|
const loading = document.getElementById('loading');
|
|
|
|
// Check for API key in URL or localStorage
|
|
const urlParams = new URLSearchParams(window.location.search);
|
|
const apiKey = urlParams.get('apiKey') || localStorage.getItem('immichApiKey');
|
|
|
|
if (!apiKey) {
|
|
loading.innerHTML = `
|
|
<div style="text-align: center;">
|
|
<h3>Enter your Immich API Key</h3>
|
|
<p style="margin: 10px 0; font-size: 12px; color: #888;">
|
|
Get it from Immich → Account Settings → API Keys
|
|
</p>
|
|
<input type="text" id="api-key-input" placeholder="Your API key"
|
|
style="width: 100%; padding: 10px; margin: 10px 0; border: 1px solid #444; border-radius: 4px; background: #333; color: white;">
|
|
<button onclick="saveApiKey()"
|
|
style="padding: 10px 20px; background: #4263eb; color: white; border: none; border-radius: 4px; cursor: pointer;">
|
|
Load Photos
|
|
</button>
|
|
</div>
|
|
`;
|
|
return;
|
|
}
|
|
|
|
try {
|
|
CONFIG.apiKey = apiKey;
|
|
loading.textContent = 'Fetching photo locations...';
|
|
|
|
// Fetch all assets with location data via pagination (API max is 1000 per page)
|
|
let page = 1;
|
|
let hasMore = true;
|
|
const pageSize = 1000;
|
|
|
|
while (hasMore) {
|
|
loading.textContent = `Fetching photos... (page ${page}, ${allPhotos.length} with GPS so far)`;
|
|
|
|
const response = await fetch(`${CONFIG.immichUrl}/api/search/metadata`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'x-api-key': CONFIG.apiKey,
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify({
|
|
withExif: true,
|
|
size: pageSize,
|
|
page: page
|
|
})
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`API error: ${response.status}`);
|
|
}
|
|
|
|
const data = await response.json();
|
|
const items = data.assets?.items || [];
|
|
|
|
// Filter for photos with GPS and add to collection
|
|
const photosWithGps = items.filter(a => a.exifInfo?.latitude && a.exifInfo?.longitude);
|
|
allPhotos = allPhotos.concat(photosWithGps);
|
|
|
|
// Check if there are more pages
|
|
hasMore = data.assets?.nextPage != null && items.length === pageSize;
|
|
page++;
|
|
|
|
// Safety limit to prevent infinite loops
|
|
if (page > 200) {
|
|
console.warn('Reached page limit, stopping pagination');
|
|
break;
|
|
}
|
|
}
|
|
|
|
loading.style.display = 'none';
|
|
renderHeatmap();
|
|
updateStats();
|
|
|
|
} catch (error) {
|
|
console.error('Error:', error);
|
|
loading.innerHTML = `
|
|
<div style="color: #ff6b6b;">
|
|
Error loading photos: ${error.message}<br>
|
|
<button onclick="localStorage.removeItem('immichApiKey'); location.reload()"
|
|
style="margin-top: 10px; padding: 8px 16px; background: #333; color: white; border: none; border-radius: 4px; cursor: pointer;">
|
|
Try Different API Key
|
|
</button>
|
|
</div>
|
|
`;
|
|
}
|
|
}
|
|
|
|
function saveApiKey() {
|
|
const key = document.getElementById('api-key-input').value.trim();
|
|
if (key) {
|
|
localStorage.setItem('immichApiKey', key);
|
|
location.reload();
|
|
}
|
|
}
|
|
|
|
function renderHeatmap() {
|
|
// Prepare heatmap data: [lat, lng, intensity]
|
|
const heatData = allPhotos.map(photo => [
|
|
photo.exifInfo.latitude,
|
|
photo.exifInfo.longitude,
|
|
0.5 // Base intensity
|
|
]);
|
|
|
|
if (heatLayer) {
|
|
map.removeLayer(heatLayer);
|
|
}
|
|
|
|
heatLayer = L.heatLayer(heatData, {
|
|
radius: 25,
|
|
blur: 15,
|
|
maxZoom: 17,
|
|
gradient: {
|
|
0.0: 'blue',
|
|
0.25: 'cyan',
|
|
0.5: 'lime',
|
|
0.75: 'yellow',
|
|
1.0: 'red'
|
|
}
|
|
}).addTo(map);
|
|
|
|
// Fit bounds to show all photos
|
|
if (heatData.length > 0) {
|
|
const bounds = L.latLngBounds(heatData.map(d => [d[0], d[1]]));
|
|
map.fitBounds(bounds, { padding: [50, 50] });
|
|
}
|
|
}
|
|
|
|
function updateStats() {
|
|
document.getElementById('stats').innerHTML = `
|
|
<strong>${allPhotos.length.toLocaleString()}</strong> photos with location data
|
|
`;
|
|
}
|
|
|
|
function onMapClick(e) {
|
|
const clickRadius = getClickRadius();
|
|
const clickLat = e.latlng.lat;
|
|
const clickLng = e.latlng.lng;
|
|
|
|
// Find photos within click radius
|
|
currentAreaPhotos = allPhotos.filter(photo => {
|
|
const distance = getDistance(
|
|
clickLat, clickLng,
|
|
photo.exifInfo.latitude, photo.exifInfo.longitude
|
|
);
|
|
return distance <= clickRadius;
|
|
});
|
|
|
|
if (currentAreaPhotos.length > 0) {
|
|
openSidebar();
|
|
renderGallery();
|
|
|
|
// Show selection circle
|
|
markerLayer.clearLayers();
|
|
L.circle([clickLat, clickLng], {
|
|
radius: clickRadius * 1000, // Convert km to m
|
|
color: '#4263eb',
|
|
fillColor: '#4263eb',
|
|
fillOpacity: 0.1
|
|
}).addTo(markerLayer);
|
|
}
|
|
}
|
|
|
|
function getClickRadius() {
|
|
// Radius in km based on zoom level
|
|
const zoom = map.getZoom();
|
|
if (zoom >= 15) return 0.5;
|
|
if (zoom >= 12) return 2;
|
|
if (zoom >= 9) return 10;
|
|
if (zoom >= 6) return 50;
|
|
return 200;
|
|
}
|
|
|
|
function getDistance(lat1, lon1, lat2, lon2) {
|
|
// Haversine formula - returns distance in km
|
|
const R = 6371;
|
|
const dLat = (lat2 - lat1) * Math.PI / 180;
|
|
const dLon = (lon2 - lon1) * Math.PI / 180;
|
|
const a = Math.sin(dLat/2) * Math.sin(dLat/2) +
|
|
Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) *
|
|
Math.sin(dLon/2) * Math.sin(dLon/2);
|
|
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
|
|
return R * c;
|
|
}
|
|
|
|
function openSidebar() {
|
|
document.getElementById('sidebar').classList.add('open');
|
|
}
|
|
|
|
function closeSidebar() {
|
|
document.getElementById('sidebar').classList.remove('open');
|
|
markerLayer.clearLayers();
|
|
}
|
|
|
|
function renderGallery() {
|
|
const gallery = document.getElementById('gallery');
|
|
document.getElementById('photo-count').textContent = `${currentAreaPhotos.length} photos`;
|
|
|
|
gallery.innerHTML = currentAreaPhotos.map((photo, idx) => `
|
|
<div class="photo-thumb"
|
|
style="background-image: url('${CONFIG.immichUrl}/api/assets/${photo.id}/thumbnail?size=thumbnail&key=${CONFIG.apiKey}')"
|
|
onclick="openLightbox(${idx})">
|
|
</div>
|
|
`).join('');
|
|
}
|
|
|
|
function openLightbox(index) {
|
|
currentPhotoIndex = index;
|
|
const photo = currentAreaPhotos[index];
|
|
document.getElementById('lightbox-img').src =
|
|
`${CONFIG.immichUrl}/api/assets/${photo.id}/thumbnail?size=preview&key=${CONFIG.apiKey}`;
|
|
document.getElementById('open-immich').href =
|
|
`${CONFIG.immichPublicUrl}/photos/${photo.id}`;
|
|
document.getElementById('lightbox').classList.add('open');
|
|
}
|
|
|
|
function closeLightbox() {
|
|
document.getElementById('lightbox').classList.remove('open');
|
|
}
|
|
|
|
function prevPhoto() {
|
|
currentPhotoIndex = (currentPhotoIndex - 1 + currentAreaPhotos.length) % currentAreaPhotos.length;
|
|
openLightbox(currentPhotoIndex);
|
|
}
|
|
|
|
function nextPhoto() {
|
|
currentPhotoIndex = (currentPhotoIndex + 1) % currentAreaPhotos.length;
|
|
openLightbox(currentPhotoIndex);
|
|
}
|
|
|
|
function resetView() {
|
|
if (allPhotos.length > 0) {
|
|
const bounds = L.latLngBounds(allPhotos.map(p => [p.exifInfo.latitude, p.exifInfo.longitude]));
|
|
map.fitBounds(bounds, { padding: [50, 50] });
|
|
}
|
|
closeSidebar();
|
|
}
|
|
|
|
function toggleHeatmap() {
|
|
heatmapVisible = !heatmapVisible;
|
|
if (heatmapVisible) {
|
|
heatLayer.addTo(map);
|
|
} else {
|
|
map.removeLayer(heatLayer);
|
|
}
|
|
}
|
|
|
|
// Keyboard navigation
|
|
document.addEventListener('keydown', (e) => {
|
|
if (document.getElementById('lightbox').classList.contains('open')) {
|
|
if (e.key === 'Escape') closeLightbox();
|
|
if (e.key === 'ArrowLeft') prevPhoto();
|
|
if (e.key === 'ArrowRight') nextPhoto();
|
|
} else if (e.key === 'Escape') {
|
|
closeSidebar();
|
|
}
|
|
});
|
|
|
|
// Initialize
|
|
initMap();
|
|
fetchPhotos();
|
|
</script>
|
|
</body>
|
|
</html>
|