feat(places): photo map view (MapLibre + PMTiles, Approach A)

Adds a Places tab to the Photos section: a self-hosted MapLibre GL map of
the user's geotagged photos.

- places.js: lazy-loads vendored MapLibre + pmtiles.js on first open; draws
  the server-aggregated clusters (GET /api/photos/geo) as HTML thumbnail
  markers (no glyphs/sprites, no client-side clustering); pan/zoom refetches
  for the new viewport; click a cluster to zoom in, or a single photo to
  open it in the lightbox.
- Optional vector basemap read directly from /basemaps/basemap.pmtiles over
  HTTP Range (label-light Protomaps style, light/dark aware); falls back to
  a themed background when no basemap is present. ODbL attribution shown.
- "Moments | Places" sub-nav wired into the Photos section.
- enable_places now defaults on, since the route + UI are ready.

No new backend serving code: tower-http ServeDir already serves static/
with Range, so the .pmtiles basemap is just a static file.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JW6ghFMDtnRYuYNzZhb47M
This commit is contained in:
Claude
2026-06-19 11:17:56 +00:00
parent bb3d73926a
commit 513b6224b4
6 changed files with 479 additions and 3 deletions
+90
View File
@@ -0,0 +1,90 @@
/* Photos sub-navigation (Moments | Places) */
.photos-subnav {
display: flex;
gap: var(--space-1);
padding: var(--space-2) var(--space-2) 0;
}
.photos-subnav.hidden {
display: none;
}
.photos-subnav-tab {
background: none;
border: none;
padding: var(--space-2) var(--space-3);
font-size: var(--text-base);
font-weight: var(--weight-medium);
color: var(--color-text-faint);
cursor: pointer;
border-radius: var(--radius-md);
border-bottom: 2px solid transparent;
}
.photos-subnav-tab:hover {
color: var(--color-text);
}
.photos-subnav-tab.active {
color: var(--color-accent);
border-bottom-color: var(--color-accent);
}
/* Map view */
.places-container {
display: none;
}
.places-container.active {
display: flex;
flex-direction: column;
height: calc(100vh - 150px);
min-height: 360px;
padding: var(--space-2);
}
.places-map {
flex: 1 1 auto;
width: 100%;
border-radius: var(--radius-2xl);
overflow: hidden;
}
.places-loading,
.places-error {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
color: var(--color-text-faint);
}
.places-loading i {
animation: spin 1s linear infinite;
}
/* Cluster markers — a circular photo thumbnail with a count badge */
.places-cluster {
background-size: cover;
background-position: center;
background-color: var(--color-bg-muted);
border-radius: 50%;
border: 2px solid var(--color-bg-surface);
box-shadow: 0 2px 8px var(--color-shadow-sm);
cursor: pointer;
display: flex;
align-items: flex-end;
justify-content: center;
}
.places-cluster-count {
background: var(--color-accent);
color: var(--color-danger-text);
font-size: var(--text-2xs);
font-weight: var(--weight-bold);
line-height: 1;
padding: var(--space-0-5) var(--space-1-5);
border-radius: var(--radius-full);
transform: translateY(35%);
}
+2
View File
@@ -36,6 +36,7 @@
<link rel="stylesheet" href="/css/views/trash.css">
<link rel="stylesheet" href="/css/views/photos.css">
<link rel="stylesheet" href="/css/views/photosLightbox.css">
<link rel="stylesheet" href="/css/views/places.css">
<link rel="stylesheet" href="/css/views/music.css">
<!-- Scripts (defer: download in parallel, execute in order, after HTML parsed) -->
@@ -57,6 +58,7 @@
<script defer type="module" src="/js/features/library/favorites.js"></script>
<script defer type="module" src="/js/features/library/recent.js"></script>
<script defer type="module" src="/js/features/library/photos.js"></script>
<script defer type="module" src="/js/features/library/places.js"></script>
<script defer type="module" src="/js/features/library/music.js"></script>
<script defer type="module" src="/js/features/sharing/fileSharing.js"></script>
<script defer type="module" src="/js/model/recentModel.js"></script>
+4 -1
View File
@@ -10,6 +10,7 @@ import { batchToolbar } from '../features/files/batchToolbar.js';
import { favorites } from '../features/library/favorites.js';
import { musicView } from '../features/library/music.js';
import { photosView } from '../features/library/photos.js';
import { placesView } from '../features/library/places.js';
import { grants } from '../model/grants.js';
import { favoritesView } from '../views/favorites/favoritesView.js';
import { mySharesView } from '../views/myShares/mySharesView.js';
@@ -225,9 +226,10 @@ function setCurrentSection(section) {
// Reset owner column — sections that need it re-enable it explicitly below.
ui.setOwnerColumnVisible(false);
// Hide photosView when switching to any other section
// Hide photosView (+ the Places sub-view) when switching to any other section
if (section !== 'photos' && photosView) {
photosView.hide();
placesView.unmountTabs();
}
// Hide musicView when switching to any other section
@@ -451,6 +453,7 @@ function switchToPhotosSection() {
if (photosView) {
photosView.show();
}
placesView.mountTabs();
if (batchToolbar) batchToolbar.clear();
}
+377
View File
@@ -0,0 +1,377 @@
/**
* OxiCloud - Places (photo map)
*
* Renders the user's geotagged photos on a self-hosted MapLibre GL map.
* Photos are clustered *server-side* (GET /api/photos/geo, grid aggregation),
* so we draw one lightweight HTML marker per cluster — no glyph/sprite assets
* and no client-side clustering needed. The vector basemap is optional: if a
* `static/basemaps/basemap.pmtiles` is present it is read directly by the
* browser over HTTP Range (pmtiles.js); otherwise the map falls back to a
* plain themed background and still shows the photo clusters.
*
* MapLibre + pmtiles.js are heavy, so they are vendored and lazy-loaded only
* when the Places tab is first opened.
*/
import { getCsrfHeaders } from '../../core/csrf.js';
import { i18n } from '../../core/i18n.js';
import { photosView } from './photos.js';
import { photosLightbox } from './photosLightbox.js';
/** @import {FileItem} from '../../core/types.js' */
/** @typedef {{lng: number, lat: number, count: number, sample_file_id: string}} GeoClusterItem */
const BASEMAP_URL = '/basemaps/basemap.pmtiles';
export const placesView = {
/** @type {HTMLElement|null} */
_container: null,
/** @type {HTMLElement|null} */
_subnav: null,
/** @type {any} MapLibre Map instance */
_map: null,
/** @type {any[]} current cluster markers */
_markers: [],
/** @type {{maplibregl: any, pmtiles: any}|null} */
_libs: null,
/** @type {number} debounce timer for moveend refresh */
_moveTimer: 0,
/** @type {'moments'|'places'} */
_activeTab: 'moments',
/** @type {boolean|null} cached basemap availability */
_hasBasemap: null,
/** Auth headers (HttpOnly cookies + CSRF) */
_headers() {
return getCsrfHeaders();
},
// ── Sub-navigation (Moments | Places) ───────────────────────────
// Lives at the top of `.content-area`; mounted while the Photos section
// is active and torn down (hidden) when the user leaves it.
/** Create/show the Moments|Places tab bar and the map container. */
mountTabs() {
const contentArea = document.querySelector('.content-area');
if (!contentArea) return;
if (!this._subnav) {
const bar = document.createElement('div');
bar.className = 'photos-subnav';
bar.innerHTML =
`<button class="photos-subnav-tab active" type="button" data-ptab="moments">${this._esc(i18n.t('photos.tab_moments'))}</button>` +
`<button class="photos-subnav-tab" type="button" data-ptab="places">${this._esc(i18n.t('photos.tab_places'))}</button>`;
bar.addEventListener('click', (e) => {
const btn = /** @type {HTMLElement} */ (e.target).closest('[data-ptab]');
if (btn) this._switchTab(/** @type {'moments'|'places'} */ (btn.getAttribute('data-ptab')));
});
contentArea.insertBefore(bar, contentArea.firstChild);
this._subnav = bar;
}
this._subnav.classList.remove('hidden');
if (!this._container) {
const el = document.createElement('div');
el.id = 'places-container';
el.className = 'places-container';
contentArea.appendChild(el);
this._container = el;
}
// Always (re)enter the Photos section on the Moments tab.
this._activeTab = 'moments';
this._setActiveTab('moments');
this.hide();
},
/** Hide the tab bar and the map (called when leaving the Photos section). */
unmountTabs() {
this._subnav?.classList.add('hidden');
this.hide();
},
/** Hide the map container (without destroying the map). */
hide() {
this._container?.classList.remove('active');
},
/**
* @param {'moments'|'places'} tab
*/
_switchTab(tab) {
if (tab === this._activeTab) return;
this._activeTab = tab;
this._setActiveTab(tab);
if (tab === 'places') {
photosView.hide();
this._showMap();
} else {
this.hide();
photosView.show();
}
},
/** @param {string} tab */
_setActiveTab(tab) {
this._subnav?.querySelectorAll('[data-ptab]').forEach((b) => {
b.classList.toggle('active', b.getAttribute('data-ptab') === tab);
});
},
// ── Map ─────────────────────────────────────────────────────────
/** Reveal the map container and (lazily) build the map. */
async _showMap() {
if (!this._container) return;
this._container.classList.add('active');
if (this._map) {
this._map.resize();
this._refreshClusters(false);
return;
}
this._container.innerHTML = '<div class="places-map" id="places-map"></div>' + '<div class="places-loading"><i class="fas fa-spinner"></i></div>';
try {
const libs = await this._loadLibs();
await this._initMap(libs);
} catch (err) {
console.error('Places map failed to load:', err);
if (this._container) {
this._container.innerHTML = `<div class="places-error">${this._esc(i18n.t('photos.map_error'))}</div>`;
}
}
},
/** Inject a vendored script once, resolving when it has loaded.
* @param {string} src
* @returns {Promise<void>}
*/
_loadScript(src) {
return new Promise((resolve, reject) => {
if (document.querySelector(`script[data-vendor="${src}"]`)) {
resolve();
return;
}
const s = document.createElement('script');
s.src = src;
s.async = true;
s.dataset.vendor = src;
s.addEventListener('load', () => resolve());
s.addEventListener('error', () => reject(new Error(`Failed to load ${src}`)));
document.head.appendChild(s);
});
},
/** Lazy-load MapLibre GL + pmtiles.js (+ MapLibre CSS) and read their globals. */
async _loadLibs() {
if (this._libs) return this._libs;
if (!document.querySelector('link[data-vendor="maplibre-css"]')) {
const l = document.createElement('link');
l.rel = 'stylesheet';
l.href = '/js/vendors/maplibre-gl.css';
l.dataset.vendor = 'maplibre-css';
document.head.appendChild(l);
}
await this._loadScript('/js/vendors/maplibre-gl.js');
await this._loadScript('/js/vendors/pmtiles.js');
const w = /** @type {any} */ (window);
this._libs = { maplibregl: w.maplibregl, pmtiles: w.pmtiles };
return this._libs;
},
/** Whether a basemap .pmtiles is available (cached after first probe). */
async _checkBasemap() {
if (this._hasBasemap !== null) return this._hasBasemap;
try {
const res = await fetch(BASEMAP_URL, { headers: { Range: 'bytes=0-0' } });
this._hasBasemap = res.ok; // 200/206 = present, 404 = absent
} catch {
this._hasBasemap = false;
}
return this._hasBasemap;
},
/**
* @param {{maplibregl: any, pmtiles: any}} libs
*/
async _initMap({ maplibregl, pmtiles }) {
const hasBasemap = await this._checkBasemap();
if (hasBasemap) {
try {
const protocol = new pmtiles.Protocol();
maplibregl.addProtocol('pmtiles', protocol.tile);
} catch (e) {
console.error('pmtiles protocol registration failed:', e);
}
}
this._map = new maplibregl.Map({
container: 'places-map',
style: hasBasemap ? this._basemapStyle() : this._blankStyle(),
center: [0, 25],
zoom: 1.3,
attributionControl: false
});
this._map.addControl(new maplibregl.NavigationControl({ showCompass: false }), 'top-right');
if (hasBasemap) {
this._map.addControl(
new maplibregl.AttributionControl({
customAttribution: 'Protomaps © <a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">OpenStreetMap</a>'
})
);
}
this._map.on('load', () => {
this._removeLoading();
this._refreshClusters(true);
});
this._map.on('moveend', () => {
clearTimeout(this._moveTimer);
this._moveTimer = window.setTimeout(() => this._refreshClusters(false), 250);
});
},
_removeLoading() {
this._container?.querySelector('.places-loading')?.remove();
},
/** Fetch clusters for the current viewport and render them.
* @param {boolean} fit Fit the map to the returned clusters (first load).
*/
async _refreshClusters(fit) {
if (!this._map) return;
const b = this._map.getBounds();
const bbox = `${b.getWest()},${b.getSouth()},${b.getEast()},${b.getNorth()}`;
const zoom = Math.round(this._map.getZoom());
try {
const res = await fetch(`/api/photos/geo?bbox=${bbox}&zoom=${zoom}`, {
credentials: 'include',
headers: this._headers()
});
if (!res.ok) return;
/** @type {GeoClusterItem[]} */
const clusters = await res.json();
this._renderMarkers(clusters);
if (fit && clusters.length) this._fitTo(clusters);
} catch (err) {
console.error('Places geo fetch failed:', err);
}
},
/** @param {GeoClusterItem[]} clusters */
_renderMarkers(clusters) {
for (const m of this._markers) m.remove();
this._markers = [];
if (!this._libs) return;
const { maplibregl } = this._libs;
for (const c of clusters) {
const size = Math.round(Math.min(64, 30 + Math.log2(c.count + 1) * 6));
const el = document.createElement('div');
el.className = 'places-cluster';
el.style.width = `${size}px`;
el.style.height = `${size}px`;
el.style.backgroundImage = `url(/api/files/${c.sample_file_id}/thumbnail/icon)`;
if (c.count > 1) {
el.innerHTML = `<span class="places-cluster-count">${c.count}</span>`;
}
el.addEventListener('click', () => this._onClusterClick(c));
const marker = new maplibregl.Marker({ element: el }).setLngLat([c.lng, c.lat]).addTo(this._map);
this._markers.push(marker);
}
},
/** @param {GeoClusterItem} c */
_onClusterClick(c) {
const zoom = this._map.getZoom();
if (c.count === 1 || zoom >= 16) {
// Drill down to the representative photo. We only know its id, so
// build a minimal item and let the lightbox load the rest.
const item = /** @type {FileItem} */ (
/** @type {any} */ ({
id: c.sample_file_id,
name: '',
mime_type: 'image/jpeg',
created_at: 0,
sort_date: 0,
size_formatted: ''
})
);
photosLightbox.open([item], 0);
} else {
this._map.easeTo({ center: [c.lng, c.lat], zoom: Math.min(zoom + 2.5, 17) });
}
},
/** @param {GeoClusterItem[]} clusters */
_fitTo(clusters) {
if (!this._libs) return;
const { maplibregl } = this._libs;
const bounds = new maplibregl.LngLatBounds();
for (const c of clusters) bounds.extend([c.lng, c.lat]);
if (!bounds.isEmpty()) {
this._map.fitBounds(bounds, { padding: 64, maxZoom: 14, duration: 0 });
}
},
/** @returns {boolean} */
_isDark() {
return document.documentElement.getAttribute('data-color-scheme') === 'dark';
},
/** Minimal MapLibre style: themed background only (no basemap). */
_blankStyle() {
return {
version: 8,
sources: {},
layers: [
{
id: 'bg',
type: 'background',
paint: { 'background-color': this._isDark() ? '#0f172a' : '#e8eef3' }
}
]
};
},
/** Label-light Protomaps vector style (no glyphs/sprites required). */
_basemapStyle() {
const dark = this._isDark();
const c = dark
? { earth: '#1b2433', land: '#222d3d', water: '#0d1b2a', roads: '#3a4860', buildings: '#2a3547', boundary: '#475569' }
: { earth: '#f3efe9', land: '#e9e4da', water: '#a8c8e8', roads: '#ffffff', buildings: '#e0dccf', boundary: '#c9c2b6' };
return {
version: 8,
sources: {
protomaps: {
type: 'vector',
url: `pmtiles://${BASEMAP_URL}`,
attribution: 'Protomaps © OpenStreetMap'
}
},
layers: [
{ id: 'bg', type: 'background', paint: { 'background-color': c.earth } },
{ id: 'earth', type: 'fill', source: 'protomaps', 'source-layer': 'earth', paint: { 'fill-color': c.earth } },
{ id: 'landuse', type: 'fill', source: 'protomaps', 'source-layer': 'landuse', paint: { 'fill-color': c.land, 'fill-opacity': 0.6 } },
{ id: 'water', type: 'fill', source: 'protomaps', 'source-layer': 'water', paint: { 'fill-color': c.water } },
{ id: 'roads', type: 'line', source: 'protomaps', 'source-layer': 'roads', minzoom: 7, paint: { 'line-color': c.roads, 'line-width': 0.8 } },
{ id: 'buildings', type: 'fill', source: 'protomaps', 'source-layer': 'buildings', minzoom: 13, paint: { 'fill-color': c.buildings } },
{
id: 'boundaries',
type: 'line',
source: 'protomaps',
'source-layer': 'boundaries',
paint: { 'line-color': c.boundary, 'line-width': 0.6, 'line-dasharray': [2, 2] }
}
]
};
},
/** @param {any} s */
_esc(s) {
const d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
};
+5 -1
View File
@@ -73,7 +73,11 @@
"delete_selected_confirm": "Move the selected items to Trash?",
"delete_one_confirm": "Move \"{{name}}\" to Trash?",
"layout_square": "Grid",
"layout_justified": "Justified"
"layout_justified": "Justified",
"tab_moments": "Moments",
"tab_places": "Places",
"map_loading": "Loading map…",
"map_error": "Could not load the map"
},
"music": {
"create_playlist": "Create Playlist",