From f14c5fdaf96110ee80de89bad926e8b4369b7ae0 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Fri, 2 Jan 2026 18:32:02 +0100 Subject: [PATCH] feat: Add custom heatmap visualization for photo locations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../task-3 - Immich-Heatmap-Integration.md | 50 ++ ...mich-Power-Tools-for-v2.4-compatibility.md | 39 ++ docker-compose.yml | 82 ++- heatmap-app/Dockerfile | 10 + heatmap-app/index.html | 517 ++++++++++++++++++ heatmap-app/server.js | 106 ++++ 6 files changed, 784 insertions(+), 20 deletions(-) create mode 100644 backlog/tasks/task-3 - Immich-Heatmap-Integration.md create mode 100644 backlog/tasks/task-4 - Update-Immich-Power-Tools-for-v2.4-compatibility.md create mode 100644 heatmap-app/Dockerfile create mode 100644 heatmap-app/index.html create mode 100644 heatmap-app/server.js diff --git a/backlog/tasks/task-3 - Immich-Heatmap-Integration.md b/backlog/tasks/task-3 - Immich-Heatmap-Integration.md new file mode 100644 index 0000000..6b5cd90 --- /dev/null +++ b/backlog/tasks/task-3 - Immich-Heatmap-Integration.md @@ -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 + + +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 + + +## Acceptance Criteria + +- [ ] #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 + diff --git a/backlog/tasks/task-4 - Update-Immich-Power-Tools-for-v2.4-compatibility.md b/backlog/tasks/task-4 - Update-Immich-Power-Tools-for-v2.4-compatibility.md new file mode 100644 index 0000000..c40ce37 --- /dev/null +++ b/backlog/tasks/task-4 - Update-Immich-Power-Tools-for-v2.4-compatibility.md @@ -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 + + +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 + + +## Acceptance Criteria + +- [ ] #1 Power Tools releases v2.4 compatible version +- [ ] #2 Update container to new version +- [ ] #3 Geo-heatmap feature works without errors + diff --git a/docker-compose.yml b/docker-compose.yml index 87d6344..052a8b0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/heatmap-app/Dockerfile b/heatmap-app/Dockerfile new file mode 100644 index 0000000..dd06eec --- /dev/null +++ b/heatmap-app/Dockerfile @@ -0,0 +1,10 @@ +FROM node:20-alpine + +WORKDIR /app + +COPY server.js . +COPY index.html . + +EXPOSE 3000 + +CMD ["node", "server.js"] diff --git a/heatmap-app/index.html b/heatmap-app/index.html new file mode 100644 index 0000000..352f8e9 --- /dev/null +++ b/heatmap-app/index.html @@ -0,0 +1,517 @@ + + + + + + Immich Photo Heatmap + + + + +
+
+
+
Loading photo locations...
+
+ + +
+
+
+ +
+ + + + + + + + diff --git a/heatmap-app/server.js b/heatmap-app/server.js new file mode 100644 index 0000000..a355b63 --- /dev/null +++ b/heatmap-app/server.js @@ -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}`); +});