From 6fd0ed8711c722858052080f060cffce043243a6 Mon Sep 17 00:00:00 2001 From: Eli Ribble Date: Wed, 14 Jan 2026 18:21:56 +0000 Subject: [PATCH] Move to using web components for custom components They're modular, which is really nice. --- htmlpage/static.go | 20 ++ htmlpage/static/js/address-display.js | 127 ++++++++++ htmlpage/static/js/address-suggestion.js | 226 ++++++++++++++++++ htmlpage/static/js/geocode.js | 2 - public-report/page.go | 2 +- .../component/location-geocode-header.html | 206 ---------------- .../template/component/location-geocode.html | 52 ---- public-report/template/pool.html | 76 ++++-- sync/template/district.html | 42 +++- 9 files changed, 473 insertions(+), 280 deletions(-) create mode 100644 htmlpage/static.go create mode 100644 htmlpage/static/js/address-display.js create mode 100644 htmlpage/static/js/address-suggestion.js delete mode 100644 public-report/template/component/location-geocode-header.html delete mode 100644 public-report/template/component/location-geocode.html diff --git a/htmlpage/static.go b/htmlpage/static.go new file mode 100644 index 00000000..0a544c00 --- /dev/null +++ b/htmlpage/static.go @@ -0,0 +1,20 @@ +package htmlpage + +import ( + "embed" + "net/http" + + "github.com/go-chi/chi/v5" +) + +//go:embed static/* +var EmbeddedStaticFS embed.FS + +var localFS http.Dir + +func AddStaticRoute(r chi.Router, path string) { + if localFS == "" { + localFS = http.Dir("./htmlpage/static") + } + FileServer(r, "/static", localFS, EmbeddedStaticFS, "static") +} diff --git a/htmlpage/static/js/address-display.js b/htmlpage/static/js/address-display.js new file mode 100644 index 00000000..051e147b --- /dev/null +++ b/htmlpage/static/js/address-display.js @@ -0,0 +1,127 @@ +class AddressDisplay extends HTMLElement { + constructor() { + super(); + + // Create a shadow DOM + this.attachShadow({mode: "open" }); + + // Initial render + this.render(); + + // Element references + this._locationDisplay = this.shadowRoot.querySelector(".location-display"); + this._streetAddress = this.shadowRoot.querySelector(".street-address"); + this._postCode = this.shadowRoot.querySelector(".post-code"); + this._district = this.shadowRoot.querySelector(".district"); + this._region = this.shadowRoot.querySelector(".region"); + this._country = this.shadowRoot.querySelector(".country"); + } + + // Initial render of component + render() { + this.shadowRoot.innerHTML = ` + + +
+
Location Details
+
+
+
Street Address
+
-
+
+ +
+
Post Code
+
-
+
+ +
+
District/Place
+
-
+
+ +
+
Region/State
+
-
+
+ +
+
Country
+
-
+
+
+
+ `; + } + + // Public methods + show(location) { + console.log("Showing location", location); + // Extract context data from properties + const props = location.properties; + const context = props.context || {}; + + // Populate structured fields + // Street Address - combine address, street, housenumber if available + let addressStr = ''; + if (context.address) addressStr += context.address.address_number; + if (context.street) { + if (addressStr) addressStr += ' '; + addressStr += context.street.name; + } + if (addressStr === '') { + addressStr = props.name || props.full_address || '-'; + } + this._streetAddress.textContent = addressStr; + + // Post Code + this._postCode.textContent = context.postcode.name || '-'; + + // District (could be district, locality, or place) + this._district.textContent = context.district.name || context.place.name || context.locality.name || '-'; + + // Region (state, province, etc.) + this._region.textContent = context.region.name || '-'; + + // Country + this._country.textContent = context.country.name || '-'; + } +} + +customElements.define('address-display', AddressDisplay); diff --git a/htmlpage/static/js/address-suggestion.js b/htmlpage/static/js/address-suggestion.js new file mode 100644 index 00000000..8b63b3df --- /dev/null +++ b/htmlpage/static/js/address-suggestion.js @@ -0,0 +1,226 @@ +class AddressInput extends HTMLElement { + constructor() { + super(); + + // Create a shadow DOM + this.attachShadow({mode: "open" }); + + // Initial render + this.render(); + + // Element references + this._input = this.shadowRoot.querySelector('input'); + this._suggestions = this.shadowRoot.querySelector('.suggestions-container'); + + // Bind methods + this._handleInput = this._handleInput.bind(this); + + // Debounce timer + this._debounceTimer = null; + + // The suggestion data + this._suggestionData = null; + } + + // Lifecycle: when element is added to the DOM + connectedCallback() { + this._input.addEventListener("input", this._handleInput); + } + + // Lifecycle: when element is removed from the DOM + disconnectedCallback() { + this._input.removeEventListener('input', this._handleInput); + } + + // Lifecycle: watch these attributes for changes + static get observedAttributes() { + return ['placeholder', 'api-key']; + } + + // Lifecycle: respond to attribute changes + attributeChangedCallback(name, oldValue, newValue) { + if (name === 'placeholder' && this._input) { + this._input.placeholder = newValue; + } + + if (name === 'api-key') { + this._apiKey = newValue; + } + } + + // Properties API + get value() { + return this._input ? this._input.value : ''; + } + + set value(val) { + if (this._input) { + this._input.value = val; + } + } + + // Private methods + _handleInput(event) { + const searchText = event.target.value.trim(); + + // Clear previous timer + clearTimeout(this._debounceTimer); + + // Clear suggestions if input is less than 3 characters + if (searchText.length < 3) { + this._suggestions.innerHTML = ''; + return; + } + + // Debounce API calls (wait 300ms after typing stops) + this._debounceTimer = setTimeout(() => { + this._fetchAddressSuggestions(searchText) + .then(response => { + this._renderSuggestions(response.features); + }); + }, 300); + + } + + async _fetchAddressSuggestions(text) { + try { + const url = `https://api.mapbox.com/search/geocode/v6/forward?q=${encodeURIComponent(text)}&access_token=${this._apiKey}`; + + const response = await fetch(url); + const data = await response.json(); + return data; + } catch (error) { + console.error('Error fetching geocoding suggestions:', error); + } + } + + _renderSuggestions(suggestions) { + console.log("Rendering suggestions", suggestions); + this._suggestions.innerHTML = suggestions.map((item, index) => { + if (item.properties.place_formatted != "") { + return ` +
+
${item.properties.name || item.properties.full_address}
+
${item.properties.place_formatted}
+
` + } else { + return ` +
+
${item.properties.name || item.properties.full_address}
+
${item.properties.place_formatted}
+
` + } + }).join(''); + + // Add click listeners to suggestions + this.shadowRoot.querySelectorAll('.suggestion-item').forEach(el => { + el.addEventListener('click', e => { + const index = parseInt(el.dataset.index); + const suggestion = suggestions[index]; + this.value = suggestion.properties.full_address; + this._suggestions.innerHTML = ''; + + // Dispatch custom event + this.dispatchEvent(new CustomEvent('address-selected', { + bubbles: true, + composed: true, // Allows event to cross shadow DOM boundary + detail: { + location: suggestion + } + })); + }); + }); + } + + // Initial render of component + render() { + const placeholder = this.getAttribute('placeholder') || 'Enter address'; + + this.shadowRoot.innerHTML = ` + + + + +
+ `; + } + + // Public methods + clear() { + if (this._input) { + this._input.value = ''; + this._suggestions.innerHTML = ''; + } + } +} + +customElements.define('address-input', AddressInput); + +function setLocationInputs(suggestion) { + let address = document.getElementById('address'); + let country = document.getElementById('address-country'); + let latitude = document.getElementById('latitude'); + let longitude = document.getElementById('longitude'); + let latlngAccuracyType = document.getElementById('latlng-accuracy-type'); + let postcode = document.getElementById('address-postcode'); + let place = document.getElementById('address-place'); + let region = document.getElementById('address-region'); + let street = document.getElementById('address-street'); + + // Extract context data from properties + const props = suggestion.properties; + const context = props.context || {}; + + // Populate structured fields + address.value = props.full_address; + country.value = context.country.name; + latitude.value = props.coordinates.latitude; + longitude.value = props.coordinates.longitude; + latlngAccuracyType.value = props.coordinates.accuracy; + postcode.value = context.postcode.name; + place.value = context.place.name; + region.value = context.region.name; + street.value = context.country.name; +} diff --git a/htmlpage/static/js/geocode.js b/htmlpage/static/js/geocode.js index 763854d5..fcfa1f72 100644 --- a/htmlpage/static/js/geocode.js +++ b/htmlpage/static/js/geocode.js @@ -8,6 +8,4 @@ async function geocodeReverse(MAPBOX_ACCESS_TOKEN, lngLat) { return; } const match = data.features[0]; - displaySelectedLocation(match); - setLocationInputs(match); } diff --git a/public-report/page.go b/public-report/page.go index 84c18982..4a4c5e56 100644 --- a/public-report/page.go +++ b/public-report/page.go @@ -13,7 +13,7 @@ var embeddedFiles embed.FS //go:embed static/* var EmbeddedStaticFS embed.FS -var components = [...]string{"footer", "location-geocode", "location-geocode-header", "photo-upload", "photo-upload-header"} +var components = [...]string{"footer", "photo-upload", "photo-upload-header"} func buildTemplate(files ...string) *htmlpage.BuiltTemplate { subdir := "public-report" diff --git a/public-report/template/component/location-geocode-header.html b/public-report/template/component/location-geocode-header.html deleted file mode 100644 index a392eba8..00000000 --- a/public-report/template/component/location-geocode-header.html +++ /dev/null @@ -1,206 +0,0 @@ -{{define "location-geocode-header"}} - - -{{end}} diff --git a/public-report/template/component/location-geocode.html b/public-report/template/component/location-geocode.html deleted file mode 100644 index 3198e9f9..00000000 --- a/public-report/template/component/location-geocode.html +++ /dev/null @@ -1,52 +0,0 @@ -{{define "location-geocode"}} - - - - - - - - - - -
-
- - -
-
-
- -
-
Location Details
-
-
-
Street Address
-
-
-
- -
-
Post Code
-
-
-
- -
-
District/Place
-
-
-
- -
-
Region/State
-
-
-
- -
-
Country
-
-
-
-
-
-
-
-{{end}} diff --git a/public-report/template/pool.html b/public-report/template/pool.html index 487de068..70390fdf 100644 --- a/public-report/template/pool.html +++ b/public-report/template/pool.html @@ -2,9 +2,10 @@ {{define "title"}}Green Pool{{end}} {{define "extraheader"}} -{{template "location-geocode-header" .}} + + @@ -163,6 +164,31 @@ function onMapMarkerDragEnd(marker) { geocodeReverse(MAPBOX_ACCESS_TOKEN, lngLat); } +function setLocationInputs(location) { + let country = document.getElementById('address-country'); + let latitude = document.getElementById('latitude'); + let longitude = document.getElementById('longitude'); + let latlngAccuracyType = document.getElementById('latlng-accuracy-type'); + let postcode = document.getElementById('address-postcode'); + let place = document.getElementById('address-place'); + let region = document.getElementById('address-region'); + let street = document.getElementById('address-street'); + + // Extract context data from properties + const props = location.properties; + const context = props.context || {}; + + // Populate structured fields + country.value = context.country.name; + latitude.value = props.coordinates.latitude; + longitude.value = props.coordinates.longitude; + latlngAccuracyType.value = props.coordinates.accuracy; + postcode.value = context.postcode.name; + place.value = context.place.name; + region.value = context.region.name; + street.value = context.country.name; +} + document.addEventListener('DOMContentLoaded', function() { // Elements const photoInput = document.getElementById('photos'); @@ -224,9 +250,21 @@ document.addEventListener('DOMContentLoaded', function() { onMapMarkerDragEnd(e.detail.marker); }); - const suggestionsContainer = document.getElementById('suggestions'); - suggestionsContainer.addEventListener("locationselected", (e) => { - mapSetMarker(e.detail.coordinates); + const addressDisplay = document.querySelector("address-display"); + const addressInput = document.querySelector("address-input"); + const mapComponent = document.querySelector("map-component"); + addressInput.addEventListener("address-selected", (event) => { + const l = event.detail.location; + console.log("Address selected", l); + // Center map on selected address + mapSetMarker(l.geometry.coordinates); + mapJumpTo({ + center: l.geometry.coordinates, + zoom: 14, + }); + + addressDisplay.show(l); + setLocationInputs(l); }); }); function displaySelectedCoordinates(lngLat) { @@ -284,18 +322,26 @@ function displaySelectedCoordinates(lngLat) {

Please provide the location of the potential mosquito breeding source. We may be able to extract this information from your photos if they contain location data.

-
- {{template "location-geocode"}} + + + + + + + + + + +
+
+ + +
-
-
-
Longitude
-
-
-
-
-
Latitude
-
-
-
+
+
diff --git a/sync/template/district.html b/sync/template/district.html index dbfed900..a75253cd 100644 --- a/sync/template/district.html +++ b/sync/template/district.html @@ -4,6 +4,8 @@ {{define "extraheader"}} + + @@ -11,7 +13,7 @@ const MAPBOX_ACCESS_TOKEN = '{{.MapboxToken}}'; function setDistrictColors(map) { const features = map.querySourceFeatures('tegola-mosquito', {sourceLayer: 'district'}); - console.log("features", features); + //console.log("features", features); const regionIds = [...new Set(features.map(f => f.properties.regionid))]; map.setPaintProperty('districts', 'fill-opacity', 0.4); if (regionIds.length > 0) { @@ -26,11 +28,11 @@ function setDistrictColors(map) { matchExpression.push(id, colorScale[id]); }); matchExpression.push('#cccccc'); // Default color - console.log("using district coloring", matchExpression); + //console.log("using district coloring", matchExpression); map.setPaintProperty('districts', 'fill-color', matchExpression); } else { map.setPaintProperty('districts', 'fill-color', 'rgb(250, 100, 100)'); - console.log("using fallback district coloring"); + //console.log("using fallback district coloring"); } } function onLoad() { @@ -88,7 +90,6 @@ function onLoad() { } }); map.on('sourcedata', (e) => { - console.log("source loaded", e); if (e.sourceId == 'tegola-mosquito' && e.isSourceLoaded) { setDistrictColors(map) } @@ -98,6 +99,26 @@ function onLoad() { map.addControl(new mapboxgl.NavigationControl()); console.log("Map init done."); + + /*const address = document.getElementById('address'); + const suggestionsContainer = document.getElementById('suggestions'); + addressSuggestionEnable(address, suggestionsContainer);*/ + const addressDisplay = document.querySelector("address-display"); + const addressInput = document.querySelector("address-input"); + const mapComponent = document.querySelector("map-component"); + addressInput.addEventListener("address-selected", (event) => { + const l = event.detail.location; + // Center map on selected address + + //mapComponent.jumpTo(coordinates); + + // Add marker for selected address + /*mapComponent.addMarker(coordinates, { + title: event.detail.address + });*/ + + addressDisplay.show(l); + }); } document.addEventListener("DOMContentLoaded", onLoad); @@ -176,10 +197,23 @@ body { {{end}} {{define "content"}}
+
+
+ + +
+
+
+
+
+ +
{{end}}