immich-docker/heatmap-app/index.html

519 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="icon" type="image/png" href="/favicon.png">
<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()">&times;</button>
</div>
<div id="gallery"></div>
</div>
</div>
<div id="lightbox">
<button id="lightbox-close" onclick="closeLightbox()">&times;</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: '&copy; 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>