feat(post): add "More location details" panel — search, map, sync
Closed-by-default disclosure below City/Country holding a city/country geocode lookup (Open-Meteo, city-only query with client-side country ranking), a single-marker draggable MapLibre preview (lazy-loaded via location-map.js), and the relocated lat/lng fields. GPS button, search pick, pin drag, and typed values all stay in sync via syncPinFromFields(), with a visual mismatch flag for unparseable typed input. Built entirely in JS (mirrors the existing 'More options' disclosure pattern) so post-form.html.twig needs no template change.
This commit is contained in:
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,61 @@
|
||||
/*
|
||||
* Single-marker, draggable preview map for the post form's "More location
|
||||
* details" panel. A dedicated sibling to maplibre-utils.js's initEntryMap
|
||||
* rather than an extension of it — that engine is built for multi-marker,
|
||||
* GPX-drawing, popup-bearing read-only maps, none of which this preview
|
||||
* needs (KTD1).
|
||||
*
|
||||
* maplibre-gl itself is lazy-imported on first call (KTD3) so an ordinary
|
||||
* GPS-only submit, where the panel is never opened, never fetches it. The
|
||||
* created map/marker are cached in module scope and reused on every
|
||||
* subsequent call — see the panel's toggle-open handler in post-form.js,
|
||||
* which calls this on every open and then always calls the returned
|
||||
* handle's resize() (the container sits under display:none while the panel
|
||||
* is closed, so the first paint would otherwise get a zero-size canvas).
|
||||
*/
|
||||
import { MAP_STYLE } from './map-style.js';
|
||||
|
||||
var cached = null; // { container, handle }
|
||||
|
||||
function buildPinElement() {
|
||||
var el = document.createElement('div');
|
||||
el.className = 'location-pin'; // visual styling lives in style.css
|
||||
return el;
|
||||
}
|
||||
|
||||
export function getOrCreateLocationMap(container, onDragEnd) {
|
||||
if (cached && cached.container === container) {
|
||||
return Promise.resolve(cached.handle);
|
||||
}
|
||||
|
||||
return import('maplibre-gl').then(function (mod) {
|
||||
var maplibregl = mod.default || mod;
|
||||
var map = new maplibregl.Map({
|
||||
container: container,
|
||||
style: MAP_STYLE,
|
||||
center: [0, 20],
|
||||
zoom: 2,
|
||||
attributionControl: false
|
||||
});
|
||||
map.addControl(new maplibregl.AttributionControl({ compact: true }), 'bottom-left');
|
||||
|
||||
var marker = new maplibregl.Marker({ draggable: true, element: buildPinElement() });
|
||||
var pinSet = false; // R12: no pin shown until a coordinate is first set
|
||||
marker.on('dragend', function () {
|
||||
if (onDragEnd) onDragEnd(marker.getLngLat());
|
||||
});
|
||||
|
||||
var handle = {
|
||||
setPin: function (lat, lng) {
|
||||
marker.setLngLat([lng, lat]);
|
||||
if (!pinSet) { marker.addTo(map); pinSet = true; }
|
||||
map.panTo([lng, lat]);
|
||||
},
|
||||
hasPin: function () { return pinSet; },
|
||||
resize: function () { map.resize(); }
|
||||
};
|
||||
|
||||
cached = { container: container, handle: handle };
|
||||
return handle;
|
||||
});
|
||||
}
|
||||
@@ -10,8 +10,12 @@
|
||||
import EasyMDE from 'easymde';
|
||||
import Sortable from 'sortablejs';
|
||||
import 'easymde/dist/easymde.min.css';
|
||||
// maplibre-gl's CSS is a static import — a dynamically-imported chunk's CSS is
|
||||
// never auto-linked (R10). The JS itself stays a lazy import (see location-map.js).
|
||||
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||
import './post-form.css';
|
||||
import { apiSend, apiErrorMsg } from './api-utils.js';
|
||||
import { getOrCreateLocationMap } from './location-map.js';
|
||||
|
||||
/* ── Markdown editor (EasyMDE) ───────────────────────────── */
|
||||
function initEditor() {
|
||||
@@ -441,6 +445,10 @@ function initGeo() {
|
||||
setStatus(locStatus, '✓ Location captured · ' + lat + ', ' + lng, 'ok');
|
||||
syncWeatherEnabled();
|
||||
reverseGeocode(lat, lng); // fill City/Country in the background
|
||||
// R11/KTD4: only push a live pin update if the panel is already
|
||||
// open — otherwise the toggle-open handler syncs it on next open.
|
||||
var openLocationDetails = document.querySelector('.location-details');
|
||||
if (openLocationDetails && openLocationDetails.open) syncPinFromFields();
|
||||
}, function (err) {
|
||||
locBtn.classList.remove('is-loading');
|
||||
locBtn.disabled = false;
|
||||
@@ -480,6 +488,239 @@ function initGeo() {
|
||||
}
|
||||
}
|
||||
|
||||
/* ── "More location details" panel (U2-U5) ─────────────────────
|
||||
* Closed-by-default disclosure holding a city/country search-by-lookup, a
|
||||
* single-marker MapLibre preview (lazy-loaded, see location-map.js — KTD1/
|
||||
* KTD3), and the relocated lat/lng fields. Built entirely in JS (KTD6),
|
||||
* mirroring initDisclosure()'s create-via-JS + relocate-wrapper approach, so
|
||||
* the template needs no structural change.
|
||||
*
|
||||
* Exposed at module scope (reassigned once the panel exists) so initGeo()'s
|
||||
* GPS success handler — defined earlier in this file, but only invoked later
|
||||
* on user click, after boot() has run initLocationDetails() — can push a live
|
||||
* pin update when the panel is already open (KTD4).
|
||||
*/
|
||||
var syncPinFromFields = function () {}; // no-op until the panel initializes
|
||||
|
||||
function debounce(fn, ms) {
|
||||
var t = null;
|
||||
return function () {
|
||||
var args = arguments;
|
||||
clearTimeout(t);
|
||||
t = setTimeout(function () { fn.apply(null, args); }, ms);
|
||||
};
|
||||
}
|
||||
|
||||
function geocodeResultLabel(r) {
|
||||
return [r.name, r.admin1, r.country].filter(Boolean).join(', ');
|
||||
}
|
||||
|
||||
function initLocationDetails() {
|
||||
var latEl = field('lat');
|
||||
var lngEl = field('lng');
|
||||
if (!latEl || !lngEl) return; // no-op guard: fields absent from the DOM
|
||||
var latWrap = latEl.closest('.form-field');
|
||||
var lngWrap = lngEl.closest('.form-field');
|
||||
if (!latWrap || !lngWrap) return;
|
||||
|
||||
var cityEl = field('location_city');
|
||||
var countryEl = field('location_country');
|
||||
var countryWrap = countryEl ? countryEl.closest('.form-field') : null;
|
||||
var anchor = countryWrap || latWrap;
|
||||
|
||||
// ── Shell (U2, R1-R3, KTD6) ──
|
||||
var details = document.createElement('details');
|
||||
details.className = 'location-details';
|
||||
var summary = document.createElement('summary');
|
||||
summary.className = 'location-details__summary';
|
||||
summary.textContent = 'More location details';
|
||||
details.appendChild(summary);
|
||||
|
||||
var body = document.createElement('div');
|
||||
body.className = 'location-details__body';
|
||||
|
||||
var searchRow = document.createElement('div');
|
||||
searchRow.className = 'location-search-row';
|
||||
var lookupBtn = document.createElement('button');
|
||||
lookupBtn.type = 'button';
|
||||
lookupBtn.id = 'lookup-coords';
|
||||
lookupBtn.className = 'btn-action';
|
||||
var LOOKUP_LABEL = '🔍 Look up coordinates';
|
||||
lookupBtn.textContent = LOOKUP_LABEL;
|
||||
searchRow.appendChild(lookupBtn);
|
||||
body.appendChild(searchRow);
|
||||
|
||||
var hint = document.createElement('p');
|
||||
hint.id = 'location-search-hint';
|
||||
hint.className = 'location-search-hint';
|
||||
hint.setAttribute('role', 'status');
|
||||
body.appendChild(hint);
|
||||
|
||||
var results = document.createElement('ul');
|
||||
results.id = 'location-search-results';
|
||||
results.className = 'location-search-results';
|
||||
body.appendChild(results);
|
||||
|
||||
var mapContainer = document.createElement('div');
|
||||
mapContainer.id = 'location-map';
|
||||
mapContainer.className = 'location-map';
|
||||
body.appendChild(mapContainer);
|
||||
|
||||
details.appendChild(body);
|
||||
|
||||
anchor.parentNode.insertBefore(details, anchor.nextSibling);
|
||||
details.appendChild(latWrap);
|
||||
details.appendChild(lngWrap);
|
||||
|
||||
// ── Mismatch flag (U5, R11/R13) ──
|
||||
function fieldNoteId(el) { return el === latEl ? 'location-lat-note' : 'location-lng-note'; }
|
||||
|
||||
function setMismatch() {
|
||||
[latEl, lngEl].forEach(function (el) {
|
||||
el.classList.add('location-field--mismatch');
|
||||
el.setAttribute('aria-invalid', 'true');
|
||||
var id = fieldNoteId(el);
|
||||
var note = document.getElementById(id);
|
||||
if (!note) {
|
||||
note = document.createElement('span');
|
||||
note.id = id;
|
||||
note.className = 'location-field-note';
|
||||
note.setAttribute('role', 'status');
|
||||
note.setAttribute('aria-live', 'polite');
|
||||
note.textContent = 'Not reflected on the map yet.';
|
||||
el.parentNode.insertBefore(note, el.nextSibling);
|
||||
}
|
||||
el.setAttribute('aria-describedby', id);
|
||||
});
|
||||
}
|
||||
|
||||
function clearMismatch() {
|
||||
[latEl, lngEl].forEach(function (el) {
|
||||
el.classList.remove('location-field--mismatch');
|
||||
el.removeAttribute('aria-invalid');
|
||||
el.removeAttribute('aria-describedby');
|
||||
var note = document.getElementById(fieldNoteId(el));
|
||||
if (note && note.parentNode) note.parentNode.removeChild(note);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Map + sync (U4, U5, R9-R14, KTD1/KTD3/KTD4) ──
|
||||
var mapHandle = null;
|
||||
|
||||
function syncFields() {
|
||||
var lat = parseFloat(latEl.value);
|
||||
var lng = parseFloat(lngEl.value);
|
||||
var valid = isFinite(lat) && isFinite(lng) &&
|
||||
lat >= -90 && lat <= 90 && lng >= -180 && lng <= 180;
|
||||
if (valid) {
|
||||
clearMismatch();
|
||||
if (mapHandle) mapHandle.setPin(lat, lng);
|
||||
} else if (latEl.value.trim() || lngEl.value.trim()) {
|
||||
// Only flag once the traveller has actually typed something —
|
||||
// fresh blank fields are "no pin yet" (R12), not a mismatch.
|
||||
setMismatch();
|
||||
}
|
||||
}
|
||||
syncPinFromFields = syncFields; // expose for initGeo()'s GPS handler
|
||||
|
||||
function onDragEnd(lngLat) {
|
||||
// Drag writes straight into the fields; it does not call syncFields()
|
||||
// back, avoiding a feedback loop (KTD4).
|
||||
latEl.value = lngLat.lat.toFixed(6);
|
||||
lngEl.value = lngLat.lng.toFixed(6);
|
||||
clearMismatch();
|
||||
}
|
||||
|
||||
details.addEventListener('toggle', function () {
|
||||
if (!details.open) return;
|
||||
getOrCreateLocationMap(mapContainer, onDragEnd).then(function (handle) {
|
||||
mapHandle = handle;
|
||||
handle.resize(); // fixes the zero-size canvas from painting while display:none
|
||||
syncFields();
|
||||
});
|
||||
});
|
||||
|
||||
latEl.addEventListener('blur', syncFields);
|
||||
lngEl.addEventListener('blur', syncFields);
|
||||
var debouncedSync = debounce(syncFields, 400);
|
||||
latEl.addEventListener('input', debouncedSync);
|
||||
lngEl.addEventListener('input', debouncedSync);
|
||||
|
||||
// ── Search (U3, R4-R8, KTD2) ──
|
||||
function setHint(msg) { hint.textContent = msg || ''; }
|
||||
|
||||
function hideResults() { results.innerHTML = ''; }
|
||||
|
||||
function showResults(list) {
|
||||
list.forEach(function (r) {
|
||||
var li = document.createElement('li');
|
||||
var btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.textContent = geocodeResultLabel(r); // createElement + textContent — no innerHTML for API data (R7)
|
||||
btn.addEventListener('click', function () {
|
||||
latEl.value = Number(r.latitude).toFixed(6);
|
||||
lngEl.value = Number(r.longitude).toFixed(6);
|
||||
hideResults();
|
||||
syncFields(); // R7: sets lat/lng + pin only — never writes City/Country
|
||||
});
|
||||
li.appendChild(btn);
|
||||
results.appendChild(li);
|
||||
});
|
||||
}
|
||||
|
||||
lookupBtn.addEventListener('click', function () {
|
||||
hideResults();
|
||||
var city = cityEl ? cityEl.value.trim() : '';
|
||||
var country = countryEl ? countryEl.value.trim() : '';
|
||||
if (!city && !country) {
|
||||
setHint('Enter a city or country first.');
|
||||
return;
|
||||
}
|
||||
// R4: never concatenate Country into the query string — verified live,
|
||||
// it silently breaks disambiguation (e.g. "Paris, Texas"). Query by
|
||||
// City; Country only ranks results client-side, and is the query term
|
||||
// only as a fallback when City itself is blank.
|
||||
var query = city || country;
|
||||
setHint('');
|
||||
lookupBtn.disabled = true;
|
||||
lookupBtn.textContent = 'Searching…';
|
||||
var url = 'https://geocoding-api.open-meteo.com/v1/search?name=' +
|
||||
encodeURIComponent(query) + '&count=10&language=en&format=json';
|
||||
fetch(url).then(function (r) { return r.json(); }).then(function (data) {
|
||||
var list = (data && data.results) || [];
|
||||
if (!list.length) {
|
||||
setHint('No matches — try adding a country, or drag the pin on the map.');
|
||||
return;
|
||||
}
|
||||
if (country) {
|
||||
var needle = country.toLowerCase();
|
||||
// Match against admin1 (state/region — e.g. "Texas") as well as
|
||||
// country ("United States"): Open-Meteo's `country` field alone
|
||||
// never reflects a state/province, so a Country field typed as
|
||||
// "Texas" would never rank the Texas Paris above the other
|
||||
// US-state Paris matches without also checking admin1 (verified
|
||||
// live — see the design doc's Paris/Texas disambiguation case).
|
||||
var matches = function (r) {
|
||||
return (r.admin1 || '').toLowerCase().indexOf(needle) !== -1 ||
|
||||
(r.country || '').toLowerCase().indexOf(needle) !== -1;
|
||||
};
|
||||
list = list.slice().sort(function (a, b) {
|
||||
var am = matches(a);
|
||||
var bm = matches(b);
|
||||
if (am === bm) return 0;
|
||||
return am ? -1 : 1;
|
||||
});
|
||||
}
|
||||
showResults(list);
|
||||
}).catch(function () {
|
||||
// R8: network failure degrades silently — fields untouched.
|
||||
}).then(function () {
|
||||
lookupBtn.disabled = false;
|
||||
lookupBtn.textContent = LOOKUP_LABEL;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/* ── Blocking required-field validation (U5, R19) ─────────────
|
||||
* Replaces the template's inline validator. Reads the EasyMDE-synced content
|
||||
* value (initEditor keeps the textarea current) and shows per-field messages.
|
||||
@@ -1206,6 +1447,7 @@ function boot() {
|
||||
initPhotoConversion();
|
||||
initDisclosure();
|
||||
initGeo();
|
||||
initLocationDetails();
|
||||
initValidation();
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user