Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@
| 🗓 Timeline | Chronological photo browser |
| 🖼 Lightbox | Full-size viewer with navigation and camera info |
| 📝 Notes | Add notes to any pin |
| 🛰 Map styles | Light, Bright, Dark, Terrain, 3D Terrain, Satellite, Globe |
| 🛰 Map styles | Light, Bright, Dark, Terrain, 3D Terrain, Satellite, 3D Satellite, Globe |
| 🔄 Clustering | Pins cluster by zoom, expand on click |
| 💾 Auto-save | Background backup to disk via `serve.py` |
| 📦 Export / Import | Full dataset as compressed `.json.gz` |
Expand Down
9 changes: 9 additions & 0 deletions css/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,15 @@ input,textarea,select{font-family:var(--font)}
.dest-item-name{font-weight:500;color:var(--text)}
.dest-item-detail{font-size:.66rem;color:var(--muted);margin-top:1px}
#dest-loading{padding:10px 12px;font-size:.73rem;color:var(--muted);display:none}
#search-cat-wrap{position:relative}
#search-cat-btn{background:var(--surface2);border:1px solid var(--border);color:var(--muted);font-size:.73rem;font-weight:500;padding:5px 10px;border-radius:20px;cursor:pointer;white-space:nowrap;transition:all .15s;line-height:1}
#search-cat-btn:hover{background:var(--surface3);color:var(--text)}
#search-cat-btn.active{border-color:var(--accent);color:var(--accent)}
#search-cat-menu{position:absolute;top:calc(100% + 6px);right:0;background:var(--surface);border:1px solid var(--border);border-radius:10px;box-shadow:var(--shadow);display:none;z-index:100;min-width:130px;overflow:hidden}
#search-cat-menu.open{display:block}
.search-cat-item{padding:8px 14px;font-size:.73rem;cursor:pointer;transition:background .12s;color:var(--text)}
.search-cat-item:hover{background:var(--surface2)}
.search-cat-item.active{color:var(--accent);font-weight:500}

/* PIN MARKERS (canvas-rendered via symbol layer — no DOM positioning) */
/* Cluster markers still use DOM for click expand */
Expand Down
10 changes: 10 additions & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,15 @@
<button id="dest-clear" onclick="clearDestSearch()">✕</button>
<div id="dest-results"><div id="dest-loading">Searching…</div></div>
</div>
<div id="search-cat-wrap">
<button id="search-cat-btn" onclick="toggleSearchCatMenu()"><span id="search-cat-label">All</span> ▾</button>
<div id="search-cat-menu">
<div class="search-cat-item active" data-cat="all" onclick="setSearchCat('all')">All</div>
<div class="search-cat-item" data-cat="hotel" onclick="setSearchCat('hotel')">🏨 Hotel</div>
<div class="search-cat-item" data-cat="restaurant" onclick="setSearchCat('restaurant')">🍽 Restaurant</div>
<div class="search-cat-item" data-cat="attraction" onclick="setSearchCat('attraction')">🏛 Attraction</div>
</div>
</div>
<div id="tile-spinner" class="tile-spinner"></div>
<div class="tb-sep"></div>
<div id="map-style-wrap" style="position:relative">
Expand All @@ -131,6 +140,7 @@
<div class="style-menu-item" data-style="enriched" onclick="setMapStyle('enriched')">Terrain</div>
<div class="style-menu-item" data-style="terrain3d" onclick="setMapStyle('terrain3d')">3D Terrain</div>
<div class="style-menu-item" data-style="satellite" onclick="setMapStyle('satellite')">Satellite</div>
<div class="style-menu-item" data-style="satellite3d" onclick="setMapStyle('satellite3d')">3D Satellite</div>
<div class="style-menu-item" data-style="globe" onclick="setMapStyle('globe')">Globe</div>
</div>
</div>
Expand Down
5 changes: 1 addition & 4 deletions js/data.js
Original file line number Diff line number Diff line change
Expand Up @@ -405,7 +405,7 @@ async function autoSave() {
if (!_autoSaveAvailable) return;
try {
// Upload new photo files that haven't been saved yet
const uploads = photos.filter(p => !_savedPhotoDisk.has(p.id) && p.dataUrl && p.dataUrl.startsWith('data:'));
const uploads = photos.filter(p => !_savedPhotoDisk.has(p.id) && !p.isEmptyPin);
await Promise.all(uploads.map(p => uploadPhotoFile(p)));

// Build metadata-only payload with file paths instead of base64
Expand Down Expand Up @@ -704,9 +704,6 @@ window.addEventListener('offline', () => updateOfflineState(true));
const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
if ('serviceWorker' in navigator && !isSafari) {
navigator.serviceWorker.register('/sw.js', { updateViaCache: 'none' }).catch(err => console.warn('SW registration failed:', err));
navigator.serviceWorker.ready.then(reg => {
if (reg.active) reg.active.postMessage({ type: 'set-port', port: location.port || '8765' });
});
} else if (isSafari && navigator.serviceWorker) {
// Unregister any existing SW in Safari to clean up stale state
navigator.serviceWorker.getRegistrations().then(regs => regs.forEach(r => r.unregister()));
Expand Down
38 changes: 19 additions & 19 deletions js/map.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
// MAP
// ═══════════════════════════════════════
function _styleUrl() {
if (_mapStyle === 'satellite') return STYLE_SAT;
if (_mapStyle === 'satellite' || _mapStyle === 'satellite3d') return STYLE_SAT;
if (_mapStyle === 'dark') return STYLE_DARK;
if (_mapStyle === 'bright') return STYLE_BRIGHT;
return STYLE_STREET; // light, enriched, terrain3d, globe all use Liberty as base
Expand Down Expand Up @@ -236,7 +236,7 @@ function _scaledTextSize(orig) {
}

function applyLabelScale() {
if (_mapStyle === 'satellite') return;
if (_mapStyle === 'satellite' || _mapStyle === 'satellite3d') return;
const style = map.getStyle();
if (!style || !style.layers) return;
for (const layer of style.layers) {
Expand All @@ -251,7 +251,7 @@ function applyLabelScale() {
}

function applyLabelVisibility() {
if (_mapStyle === 'satellite') return;
if (_mapStyle === 'satellite' || _mapStyle === 'satellite3d') return;
const vis = labelsVisible ? 'visible' : 'none';
const style = map.getStyle();
if (!style || !style.layers) return;
Expand All @@ -276,7 +276,7 @@ function toggleLabels() {
// Apply vivid parks, airport runways, mountain peaks, and optional 3D buildings
function applyExtraLayers() {
const hasOmt = map.getSource && map.getSource('openmaptiles');
if (!hasOmt || ['satellite', 'globe'].includes(_mapStyle)) return;
if (!hasOmt || ['satellite', 'satellite3d', 'globe'].includes(_mapStyle)) return;

// Vivid park fill — more saturated green over the base park layer
if (!map.getLayer('matrix-park-vivid')) {
Expand Down Expand Up @@ -357,7 +357,7 @@ function applyExtraLayers() {

function apply3DBuildings() {
if (!map.getSource || !map.getSource('openmaptiles')) return;
const shouldShow = buildings3DVisible && !['satellite', 'terrain3d', 'globe'].includes(_mapStyle);
const shouldShow = buildings3DVisible && !['satellite', 'satellite3d', 'terrain3d', 'globe'].includes(_mapStyle);
if (shouldShow && !map.getLayer('matrix-buildings-3d')) {
// Insert below the first symbol layer so road/POI labels render on top of buildings
const firstSymbol = map.getStyle()?.layers?.find(l => l.type === 'symbol');
Expand Down Expand Up @@ -418,7 +418,7 @@ function addPinLayers() {
if (!alreadyThere) {
// In 3D Terrain (pitched view), offset the focal point downward in screen space
// so the pin appears in the visible ground area rather than drifting off-screen
const pitchOffset = _mapStyle === 'terrain3d' ? [0, Math.round(map.transform?.height * 0.15 || 100)] : [0, 0];
const pitchOffset = (_mapStyle === 'terrain3d' || _mapStyle === 'satellite3d') ? [0, Math.round(map.transform?.height * 0.15 || 100)] : [0, 0];
map.flyTo({ center: [lng, lat], zoom: targetZoom, speed: 0.8, curve: 1.0, essential: true,
offset: pitchOffset,
easing: t => t < 0.5 ? 4*t*t*t : 1 - Math.pow(-2*t+2, 3)/2 });
Expand Down Expand Up @@ -446,7 +446,7 @@ function applyTheme() {
_tileTemplatesCache = null;
// Set initial button label
const btn = document.getElementById('tb-style-btn');
const labels = { light: 'Light Map', bright: 'Bright Map', enriched: 'Terrain', dark: 'Dark Map', satellite: 'Satellite', terrain3d: '3D Terrain', globe: 'Globe' };
const labels = { light: 'Light Map', bright: 'Bright Map', enriched: 'Terrain', dark: 'Dark Map', satellite: 'Satellite', satellite3d: '3D Satellite', terrain3d: '3D Terrain', globe: 'Globe' };
if (btn) btn.textContent = (labels[_mapStyle] || _mapStyle) + ' ▾';

// Set initial active state in menu
Expand Down Expand Up @@ -624,7 +624,7 @@ async function initMap() {
map.getCanvas().addEventListener('mouseout', () => { coordsEl.style.display = 'none'; });
const updateZoom = () => {
zoomEl.textContent = 'z' + map.getZoom().toFixed(2);
const is3D = _mapStyle === 'terrain3d';
const is3D = _mapStyle === 'terrain3d' || _mapStyle === 'satellite3d';
if (is3D) {
pitchWrap.style.display = 'flex';
pitchEl.textContent = `p${map.getPitch().toFixed(0)}° b${map.getBearing().toFixed(0)}°`;
Expand Down Expand Up @@ -676,7 +676,7 @@ async function initMap() {
bldgBtn.innerHTML = '<span style="font-size:11px;font-weight:700;line-height:29px;display:block;color:var(--text);opacity:.7;font-family:var(--font)">3D</span>';
bldgBtn.addEventListener('click', toggle3DBuildings);
bldgWrap.appendChild(bldgBtn);
if (['satellite', 'terrain3d', 'globe'].includes(_mapStyle)) bldgWrap.style.display = 'none';
if (['satellite', 'satellite3d', 'terrain3d', 'globe'].includes(_mapStyle)) bldgWrap.style.display = 'none';
navGroup.before(bldgWrap);
}
applyLabelScale();
Expand All @@ -701,7 +701,7 @@ async function initMap() {
// Water clicks allowed at any zoom; land clicks require zoom >= 5.
// Satellite/terrain3d/globe have no vector layers for water detection.
const allHits = map.queryRenderedFeatures(e.point);
const noVectorLayers = ['satellite', 'terrain3d', 'globe'].includes(_mapStyle);
const noVectorLayers = ['satellite', 'satellite3d', 'terrain3d', 'globe'].includes(_mapStyle);
const isWater = !noVectorLayers && allHits.some(f => f.layer.type === 'fill' && /^(water|ocean)/.test(f.layer.id));
// If style is mid-transition (queryRenderedFeatures returns nothing), treat as land
if (!isWater && map.getZoom() < 5) return;
Expand Down Expand Up @@ -765,7 +765,7 @@ async function initMap() {

// Elevation — only in 3D Terrain mode
let elevationStr = '';
if (_mapStyle === 'terrain3d') {
if (_mapStyle === 'terrain3d' || _mapStyle === 'satellite3d') {
try {
const elev = map.queryTerrainElevation([lng, lat]);
if (elev !== null && elev !== undefined) {
Expand Down Expand Up @@ -871,21 +871,21 @@ function toggleStyleMenu(e) {
function setMapStyle(mode) {
const wasDark = _mapStyle === 'dark';
_mapStyle = mode;
// Persist style preference (satellite resets to previous on reload)
if (mode !== 'satellite') localStorage.setItem('matrix-theme', mode);
// Persist style preference (satellite/satellite3d reset to previous on reload)
if (mode !== 'satellite' && mode !== 'satellite3d') localStorage.setItem('matrix-theme', mode);

// Defer dark-map CSS class removal until pin icons are re-added with correct
// compensation (otherwise pre-darkened images render without the CSS filter)
const mapEl = document.getElementById('map');
if (_mapStyle === 'dark') mapEl.classList.add('dark-map');
mapEl.classList.toggle('sat-mode', ['satellite', 'terrain3d', 'globe'].includes(_mapStyle));
mapEl.classList.toggle('sat-mode', ['satellite', 'satellite3d', 'terrain3d', 'globe'].includes(_mapStyle));

// Labels toggle visibility
const labelsWrap = document.getElementById('labels-toggle-wrap');
if (labelsWrap) labelsWrap.style.visibility = _mapStyle === 'satellite' ? 'hidden' : 'visible';
if (labelsWrap) labelsWrap.style.visibility = (_mapStyle === 'satellite' || _mapStyle === 'satellite3d') ? 'hidden' : 'visible';
// Hide 3D buildings toggle in modes where buildings don't make sense
const bldgWrap = document.getElementById('buildings-toggle-wrap');
if (bldgWrap) bldgWrap.style.display = ['satellite', 'terrain3d', 'globe'].includes(_mapStyle) ? 'none' : '';
if (bldgWrap) bldgWrap.style.display = ['satellite', 'satellite3d', 'terrain3d', 'globe'].includes(_mapStyle) ? 'none' : '';

// Disable Export Video in Globe mode (flyTo animation doesn't translate to globe projection)
const exportBtn = document.getElementById('tb-export-video');
Expand All @@ -895,7 +895,7 @@ function setMapStyle(mode) {
}

// Update button label
const labels = { light: 'Light Map', bright: 'Bright Map', enriched: 'Terrain', dark: 'Dark Map', satellite: 'Satellite', terrain3d: '3D Terrain', globe: 'Globe' };
const labels = { light: 'Light Map', bright: 'Bright Map', enriched: 'Terrain', dark: 'Dark Map', satellite: 'Satellite', satellite3d: '3D Satellite', terrain3d: '3D Terrain', globe: 'Globe' };
const btn = document.getElementById('tb-style-btn');
if (btn) btn.textContent = (labels[mode] || mode) + ' ▾';

Expand All @@ -914,7 +914,7 @@ function setMapStyle(mode) {
}

function _applyTerrainAndProjection() {
const is3D = _mapStyle === 'terrain3d';
const is3D = _mapStyle === 'terrain3d' || _mapStyle === 'satellite3d';
const isGlobe = _mapStyle === 'globe';

// Set projection FIRST — before any camera moves — so easeTo never runs under the wrong projection
Expand Down Expand Up @@ -956,7 +956,7 @@ function _applyTerrainAndProjection() {
if (map.getLayer('sky') === undefined) {
map.addLayer({ id: 'sky', type: 'sky', paint: { 'sky-type': 'gradient', 'sky-gradient': ['interpolate', ['linear'], ['sky-radial-progress'], 0, 'rgba(255,255,255,0)', 0.5, '#87cefa', 1, '#4682b4'] } });
}
if (!map.getLayer('terrain-hillshade')) {
if (_mapStyle !== 'satellite3d' && !map.getLayer('terrain-hillshade')) {
// Insert below the first road/label layer so hillshading shows through
const firstSymbol = map.getStyle().layers.find(l => l.type === 'line' || l.type === 'symbol');
map.addLayer({
Expand Down
104 changes: 101 additions & 3 deletions js/search.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,25 +9,55 @@ const dClear = document.getElementById('dest-clear');

const _searchCache = {};
let _searchMoveTimer = null;
let _searchCategory = 'all';

const POI_CATEGORIES = {
hotel: { label: '🏨 Hotel', tags: [['amenity','hotel'],['tourism','hotel']] },
restaurant: { label: '🍽 Restaurant', tags: [['amenity','restaurant'],['amenity','cafe'],['amenity','bar'],['amenity','pub'],['amenity','fast_food']] },
attraction: { label: '🏛 Attraction', tags: [['tourism','attraction'],['tourism','museum'],['tourism','viewpoint']] },
};

function toggleSearchCatMenu() {
document.getElementById('search-cat-menu').classList.toggle('open');
}

function setSearchCat(cat) {
_searchCategory = cat;
document.getElementById('search-cat-label').textContent = cat === 'all' ? 'All' : POI_CATEGORIES[cat].label;
document.getElementById('search-cat-btn').classList.toggle('active', cat !== 'all');
document.querySelectorAll('.search-cat-item').forEach(el => el.classList.toggle('active', el.dataset.cat === cat));
document.getElementById('search-cat-menu').classList.remove('open');
Object.keys(_searchCache).forEach(k => delete _searchCache[k]);
const q = dInput.value.trim();
if (q.length >= 2) {
dResults.style.display = 'block';
runDestSearch(q);
} else if (cat !== 'all') {
dResults.style.display = 'block';
dLoading.style.display = 'none';
dResults.innerHTML = '<div style="padding:9px 12px;font-size:.73rem;color:var(--muted)">Type to search nearby</div>';
}
}

// Re-run visible search when the map viewport changes (pan/zoom)
function _onMapMoveForSearch() {
clearTimeout(_searchMoveTimer);
const q = dInput.value.trim();
if (q.length < 2 || dResults.style.display === 'none' || destMarkerObj) return;
if ((q.length < 2 && _searchCategory === 'all') || dResults.style.display === 'none' || destMarkerObj) return;
_searchMoveTimer = setTimeout(() => runDestSearch(q), 600);
}

dInput.addEventListener('input', () => {
const q = dInput.value.trim();
dClear.style.display = q ? 'block' : 'none';
clearTimeout(searchTimer);
if (q.length < 2) { dResults.style.display='none'; return; }
if (q.length < 2 && _searchCategory === 'all') { dResults.style.display='none'; return; }
searchTimer = setTimeout(() => runDestSearch(q), 380);
});
dInput.addEventListener('keydown', e => { if(e.key==='Escape'){clearDestSearch();dInput.blur();} });
document.addEventListener('click', e => {
if (!document.getElementById('dest-search-wrap').contains(e.target)) dResults.style.display='none';
if (!document.getElementById('search-cat-wrap').contains(e.target)) document.getElementById('search-cat-menu').classList.remove('open');
});


Expand Down Expand Up @@ -130,6 +160,69 @@ async function runReverseGeoSearch(lat, lng) {
}
}

async function runCategorySearch(q, cat, cacheKey) {
if (!map || q.length < 2) {
dLoading.style.display = 'none';
dResults.innerHTML = '<div style="padding:9px 12px;font-size:.73rem;color:var(--muted)">Type to search nearby</div>';
return;
}
const b = map.getBounds();

// --- Attempt 1: Overpass via local proxy (precise tag-based POI search) ---
const bbox = `(${b.getSouth().toFixed(5)},${b.getWest().toFixed(5)},${b.getNorth().toFixed(5)},${b.getEast().toFixed(5)})`;
const safe = q.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const nameFilter = `["name"~"${safe}",i]`;
const stmts = POI_CATEGORIES[cat].tags.flatMap(([k, v]) => [
`node["${k}"="${v}"]${nameFilter}${bbox};`,
`way["${k}"="${v}"]${nameFilter}${bbox};`,
`relation["${k}"="${v}"]${nameFilter}${bbox};`,
]).join('\n ');
const query = `[out:json][timeout:10];\n(\n ${stmts}\n);\nout center 20;`;
try {
const r = await fetch('/api/overpass', { method: 'POST', body: new URLSearchParams({ data: query }) });
if (r.ok) {
const data = await r.json();
const results = (data.elements || [])
.filter(el => el.tags?.name)
.map(el => {
const t = el.tags, lat = el.lat ?? el.center?.lat, lon = el.lon ?? el.center?.lon;
if (lat == null || lon == null) return null;
const city = t['addr:city'] || t['addr:town'] || t['addr:village'] || '';
const country = t['addr:country'] || '';
return {
display_name: [t.name, city, country].filter(Boolean).join(', '),
lat: String(lat), lon: String(lon),
address: { city, country }
};
})
.filter(Boolean);
_searchCache[cacheKey] = results;
renderSearchResults(results);
return;
}
} catch(_) { /* proxy unreachable — fall through to Nominatim */ }

// --- Fallback: Nominatim bounded=1 + layer=poi (works everywhere, less precise) ---
const viewbox = `&viewbox=${b.getWest()},${b.getNorth()},${b.getEast()},${b.getSouth()}&bounded=1&layer=poi`;
const searchWait = Math.max(0, 1100 - (Date.now() - _lastNominatimCall));
if (searchWait > 0) await new Promise(r => setTimeout(r, searchWait));
_lastNominatimCall = Date.now();
try {
const r = await fetch(
`https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(q)}&format=json&limit=20&addressdetails=1${viewbox}`,
{ headers: { 'Accept-Language': 'en' } }
);
if (!r.ok) throw new Error(`HTTP ${r.status}`);
const data = await r.json();
_searchCache[cacheKey] = data;
renderSearchResults(data);
} catch(err) {
console.warn('Category search failed:', err);
dLoading.style.display = 'none';
dResults.innerHTML = `<div style="padding:9px 12px;font-size:.73rem;color:var(--accent2)">Search failed — try again</div>`;
}
}

async function runDestSearch(q) {
if (_isOffline) {
dResults.style.display='block'; dLoading.style.display='none';
Expand All @@ -153,8 +246,13 @@ async function runDestSearch(q) {
}

const zoom = map ? map.getZoom() : 0;
const cacheKey = q + (zoom >= 3 ? `@${map.getCenter().lng.toFixed(1)},${map.getCenter().lat.toFixed(1)}` : '');
const cacheKey = q + (zoom >= 3 ? `@${map.getCenter().lng.toFixed(1)},${map.getCenter().lat.toFixed(1)}` : '') + (_searchCategory !== 'all' ? `#${_searchCategory}` : '');
if (_searchCache[cacheKey]) { renderSearchResults(_searchCache[cacheKey]); return; }

if (_searchCategory !== 'all') {
await runCategorySearch(q, _searchCategory, cacheKey);
return;
}
try {
const searchWait = Math.max(0, 1100 - (Date.now() - _lastNominatimCall));
if (searchWait > 0) await new Promise(r => setTimeout(r, searchWait));
Expand Down
Loading
Loading