Files
Oxicloud/static/js/features/library/places.js
T
Claude 513b6224b4 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
2026-06-19 11:17:56 +00:00

378 lines
14 KiB
JavaScript

/**
* 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;
}
};