feat: Add custom heatmap visualization for photo locations
- Add heatmap-app with Node.js server and Leaflet.js frontend - Implement API proxy to handle CORS for internal Immich server - Add pagination support for fetching all geotagged photos (84k+) - Support click-to-view photos in selected area with gallery sidebar - Integrate with docker-compose and Traefik for heatmap.jeffemmett.com - Add backlog tasks for heatmap work and Power Tools v2.4 tracking 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
189fde225d
commit
f14c5fdaf9
|
|
@ -0,0 +1,50 @@
|
|||
---
|
||||
id: task-3
|
||||
title: Immich Heatmap Integration
|
||||
status: Done
|
||||
assignee: []
|
||||
created_date: '2026-01-02 13:53'
|
||||
labels:
|
||||
- immich
|
||||
- heatmap
|
||||
- infrastructure
|
||||
dependencies: []
|
||||
priority: medium
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Custom heatmap solution built for Immich photo library visualization. Allows clicking on map areas to view photos from that location in a gallery sidebar.
|
||||
|
||||
## What was done:
|
||||
- Updated Immich to v2.4.1
|
||||
- Enabled map/reverse geocoding in admin settings
|
||||
- Built custom heatmap app (Node.js + Leaflet.js)
|
||||
- Deployed at https://heatmap.jeffemmett.com
|
||||
- Proxies API requests to Immich internal server to avoid CORS issues
|
||||
|
||||
## Power Tools Status:
|
||||
⚠️ Immich Power Tools geo-heatmap feature is currently INCOMPATIBLE with Immich v2.4.x due to database schema changes (assetId column renamed). Awaiting Power Tools update for v2.4 compatibility.
|
||||
|
||||
## Custom Heatmap Features:
|
||||
- Color gradient heatmap (blue→cyan→lime→yellow→red)
|
||||
- Click anywhere to see photos from that area
|
||||
- Gallery sidebar with thumbnails
|
||||
- Lightbox view with prev/next navigation
|
||||
- "Open in Immich" link for full photo view
|
||||
- 84,162 photos with GPS data available
|
||||
|
||||
## Files:
|
||||
- /opt/immich/heatmap-app/server.js - Node.js proxy server
|
||||
- /opt/immich/heatmap-app/index.html - Frontend app
|
||||
- /opt/immich/docker-compose.yml - Added heatmap service
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [ ] #1 Heatmap displays photo locations
|
||||
- [ ] #2 Click on area shows photos in sidebar
|
||||
- [ ] #3 Photos can be viewed in lightbox
|
||||
- [ ] #4 Open in Immich link works
|
||||
<!-- AC:END -->
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
---
|
||||
id: task-4
|
||||
title: Update Immich Power Tools for v2.4 compatibility
|
||||
status: To Do
|
||||
assignee: []
|
||||
created_date: '2026-01-02 13:54'
|
||||
labels:
|
||||
- immich
|
||||
- power-tools
|
||||
- dependency
|
||||
- blocked
|
||||
dependencies: []
|
||||
priority: low
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Immich Power Tools geo-heatmap feature is currently broken with Immich v2.4.x due to database schema changes.
|
||||
|
||||
## Issue:
|
||||
- Power Tools queries fail with: `column "assetId" does not exist`
|
||||
- Database schema changed in Immich v2.4 (column renamed to different case or structure)
|
||||
- Power Tools latest version (v0.19.0 from Oct 2025) predates Immich v2.4.1 (Dec 2025)
|
||||
|
||||
## Action Required:
|
||||
- Monitor https://github.com/varun-raj/immich-power-tools/releases for v2.4 compatible release
|
||||
- Once available, update Power Tools container and test geo-heatmap feature
|
||||
|
||||
## Workaround:
|
||||
Custom heatmap solution deployed at https://heatmap.jeffemmett.com as alternative
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Acceptance Criteria
|
||||
<!-- AC:BEGIN -->
|
||||
- [ ] #1 Power Tools releases v2.4 compatible version
|
||||
- [ ] #2 Update container to new version
|
||||
- [ ] #3 Geo-heatmap feature works without errors
|
||||
<!-- AC:END -->
|
||||
|
|
@ -1,42 +1,36 @@
|
|||
#
|
||||
# WARNING: To install Immich, follow our guide: https://docs.immich.app/install/docker-compose
|
||||
#
|
||||
# Make sure to use the docker-compose.yml of the current release:
|
||||
#
|
||||
# https://github.com/immich-app/immich/releases/latest/download/docker-compose.yml
|
||||
#
|
||||
# The compose file on main may not be compatible with the latest release.
|
||||
|
||||
services:
|
||||
immich-server:
|
||||
container_name: immich_server
|
||||
image: ghcr.io/immich-app/immich-server:${IMMICH_VERSION:-release}
|
||||
# extends:
|
||||
# file: hwaccel.transcoding.yml
|
||||
# service: cpu # set to one of [nvenc, quicksync, rkmpp, vaapi, vaapi-wsl] for accelerated transcoding
|
||||
volumes:
|
||||
# Do not edit the next line. If you want to change the media storage location on your system, edit the value of UPLOAD_LOCATION in the .env file
|
||||
- ${UPLOAD_LOCATION}:/data
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
env_file:
|
||||
- .env
|
||||
ports:
|
||||
- '2283:2283'
|
||||
- "2283:2283"
|
||||
depends_on:
|
||||
- redis
|
||||
- database
|
||||
restart: always
|
||||
healthcheck:
|
||||
disable: false
|
||||
networks:
|
||||
- default
|
||||
- traefik-public
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.immich.rule=Host(`photos.jeffemmett.com`)"
|
||||
- "traefik.http.routers.immich.entrypoints=web"
|
||||
- "traefik.http.services.immich.loadbalancer.server.port=2283"
|
||||
- "traefik.docker.network=traefik-public"
|
||||
|
||||
immich-machine-learning:
|
||||
container_name: immich_machine_learning
|
||||
# For hardware acceleration, add one of -[armnn, cuda, rocm, openvino, rknn] to the image tag.
|
||||
# Example tag: ${IMMICH_VERSION:-release}-cuda
|
||||
image: ghcr.io/immich-app/immich-machine-learning:${IMMICH_VERSION:-release}
|
||||
# extends: # uncomment this section for hardware acceleration - see https://docs.immich.app/features/ml-hardware-acceleration
|
||||
# file: hwaccel.ml.yml
|
||||
# service: cpu # set to one of [armnn, cuda, rocm, openvino, openvino-wsl, rknn] for accelerated inference - use the `-wsl` version for WSL2 where applicable
|
||||
volumes:
|
||||
- model-cache:/cache
|
||||
env_file:
|
||||
|
|
@ -59,14 +53,62 @@ services:
|
|||
POSTGRES_PASSWORD: ${DB_PASSWORD}
|
||||
POSTGRES_USER: ${DB_USERNAME}
|
||||
POSTGRES_DB: ${DB_DATABASE_NAME}
|
||||
POSTGRES_INITDB_ARGS: '--data-checksums'
|
||||
# Uncomment the DB_STORAGE_TYPE: 'HDD' var if your database isn't stored on SSDs
|
||||
# DB_STORAGE_TYPE: 'HDD'
|
||||
POSTGRES_INITDB_ARGS: "--data-checksums"
|
||||
volumes:
|
||||
# Do not edit the next line. If you want to change the database storage location on your system, edit the value of DB_DATA_LOCATION in the .env file
|
||||
- ${DB_DATA_LOCATION}:/var/lib/postgresql/data
|
||||
shm_size: 128mb
|
||||
restart: always
|
||||
|
||||
power-tools:
|
||||
container_name: immich_power_tools
|
||||
image: ghcr.io/varun-raj/immich-power-tools:latest
|
||||
ports:
|
||||
- "8001:3000"
|
||||
environment:
|
||||
- IMMICH_URL=http://immich-server:2283
|
||||
- IMMICH_API_KEY=${IMMICH_API_KEY}
|
||||
- DB_HOST=database
|
||||
- DB_PORT=5432
|
||||
- DB_USERNAME=${DB_USERNAME}
|
||||
- DB_PASSWORD=${DB_PASSWORD}
|
||||
- DB_DATABASE_NAME=${DB_DATABASE_NAME}
|
||||
- HOSTNAME=0.0.0.0
|
||||
depends_on:
|
||||
- immich-server
|
||||
- database
|
||||
restart: always
|
||||
networks:
|
||||
- default
|
||||
- traefik-public
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.immich-tools.rule=Host(`photos-tools.jeffemmett.com`)"
|
||||
- "traefik.http.routers.immich-tools.entrypoints=web"
|
||||
- "traefik.http.services.immich-tools.loadbalancer.server.port=3000"
|
||||
- "traefik.docker.network=traefik-public"
|
||||
|
||||
heatmap:
|
||||
container_name: immich_heatmap
|
||||
build: ./heatmap-app
|
||||
environment:
|
||||
- IMMICH_URL=http://immich-server:2283
|
||||
- IMMICH_PUBLIC_URL=https://photos.jeffemmett.com
|
||||
depends_on:
|
||||
- immich-server
|
||||
restart: always
|
||||
networks:
|
||||
- default
|
||||
- traefik-public
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.immich-heatmap.rule=Host(`heatmap.jeffemmett.com`)"
|
||||
- "traefik.http.routers.immich-heatmap.entrypoints=web"
|
||||
- "traefik.http.services.immich-heatmap.loadbalancer.server.port=3000"
|
||||
- "traefik.docker.network=traefik-public"
|
||||
|
||||
volumes:
|
||||
model-cache:
|
||||
|
||||
networks:
|
||||
traefik-public:
|
||||
external: true
|
||||
|
|
|
|||
|
|
@ -0,0 +1,10 @@
|
|||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY server.js .
|
||||
COPY index.html .
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["node", "server.js"]
|
||||
|
|
@ -0,0 +1,517 @@
|
|||
<!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>
|
||||
|
|
@ -0,0 +1,106 @@
|
|||
const http = require('http');
|
||||
const https = require('https');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const PORT = process.env.PORT || 3000;
|
||||
const IMMICH_INTERNAL_URL = process.env.IMMICH_URL || 'http://immich-server:2283';
|
||||
const IMMICH_PUBLIC_URL = process.env.IMMICH_PUBLIC_URL || 'https://photos.jeffemmett.com';
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
// CORS headers
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, x-api-key');
|
||||
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.writeHead(204);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
// Config endpoint
|
||||
if (req.url === '/api/config') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
immichUrl: '' // Empty - we'll proxy through this server
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
// Proxy Immich API requests
|
||||
if (req.url.startsWith('/api/')) {
|
||||
const apiKey = req.headers['x-api-key'];
|
||||
const targetUrl = IMMICH_INTERNAL_URL + req.url;
|
||||
|
||||
let body = '';
|
||||
req.on('data', chunk => { body += chunk; });
|
||||
req.on('end', () => {
|
||||
const urlParts = new URL(targetUrl);
|
||||
const options = {
|
||||
hostname: urlParts.hostname,
|
||||
port: urlParts.port || 80,
|
||||
path: urlParts.pathname + urlParts.search,
|
||||
method: req.method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-api-key': apiKey
|
||||
}
|
||||
};
|
||||
|
||||
const proxyReq = http.request(options, (proxyRes) => {
|
||||
res.writeHead(proxyRes.statusCode, proxyRes.headers);
|
||||
proxyRes.pipe(res);
|
||||
});
|
||||
|
||||
proxyReq.on('error', (e) => {
|
||||
console.error('Proxy error:', e.message);
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: e.message }));
|
||||
});
|
||||
|
||||
if (body) {
|
||||
proxyReq.write(body);
|
||||
}
|
||||
proxyReq.end();
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Serve static files
|
||||
let filePath = req.url === '/' ? '/index.html' : req.url.split('?')[0];
|
||||
filePath = path.join(__dirname, filePath);
|
||||
|
||||
const extname = path.extname(filePath);
|
||||
const contentTypes = {
|
||||
'.html': 'text/html',
|
||||
'.js': 'application/javascript',
|
||||
'.css': 'text/css',
|
||||
'.json': 'application/json',
|
||||
'.png': 'image/png',
|
||||
'.ico': 'image/x-icon'
|
||||
};
|
||||
|
||||
const contentType = contentTypes[extname] || 'application/octet-stream';
|
||||
|
||||
fs.readFile(filePath, (err, content) => {
|
||||
if (err) {
|
||||
if (err.code === 'ENOENT') {
|
||||
res.writeHead(404);
|
||||
res.end('Not found');
|
||||
} else {
|
||||
res.writeHead(500);
|
||||
res.end('Server error');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(200, { 'Content-Type': contentType });
|
||||
res.end(content);
|
||||
});
|
||||
});
|
||||
|
||||
server.listen(PORT, '0.0.0.0', () => {
|
||||
console.log(`Immich Heatmap server running on port ${PORT}`);
|
||||
console.log(`Proxying to: ${IMMICH_INTERNAL_URL}`);
|
||||
});
|
||||
Loading…
Reference in New Issue