Repository: chihacknight/il-solar-map
Pull request #82: Tooltip redux
Author: login: derekeder
Created: 2025-05-01T22:04:19Z | Merged: 2025-05-07T18:40:24Z

## Overview

Updates the map interface to support click interactions and now saves state in the URL for easier sharing. The popups allow text to be easier to read and copy/paste over the hover, that updates whenever the mouse moves.

- clicking a feature shows a popup tooltip
- hovering over a feature updates a simple info box in the lower left corner
- clicking a feature saves the ID to the URL. loading from that URL clicks the feature on load

Related to #78. This likely eliminates/reduces the need for creating detail pages.

Examples:

* https://deploy-preview-82--il-solar-map.netlify.app/#/?id=17045070200
* https://deploy-preview-82--il-solar-map.netlify.app/#/?category=utility_kw&status=planned&id=17019010800
* https://deploy-preview-82--il-solar-map.netlify.app/#/?category=total_kw&status=energized&geography=places&id=53886039

### Demo

![Screenshot 2025-05-01 at 4 59 27 PM](https://github.com/user-attachments/assets/7b789537-50d3-4016-a25c-1a9421010c9d)

## Testing Instructions

* toggle hover and click on the map and confirm features work as expected
* reload the page and confirm the selected feature is still shown
* save a few URLs and confirm it loads the selected feature and layer



Diff:
diff --git a/css/custom.css b/css/custom.css
index aeb9663..e1b3770 100644
--- a/css/custom.css
+++ b/css/custom.css
@@ -34,6 +34,23 @@ body, p {
   width: 10px;
 }
 
+.maplibregl-popup-content {
+  width: 400px !important;
+}
+
+.info {
+    padding: 6px 8px;
+    margin: 5px;
+    font: 14px/16px Arial, Helvetica, sans-serif;
+    background: white;
+    box-shadow: 0 0 15px rgba(0,0,0,0.2);
+    border-radius: 5px;
+}
+.info h4 {
+    margin: 0 0 5px;
+    color: #777;
+}
+
 .custom-nav a {
   padding-right: 20px;
 }
diff --git a/js/map.js b/js/map.js
index 49541e8..e458e7d 100644
--- a/js/map.js
+++ b/js/map.js
@@ -48,29 +48,61 @@ const friendly_category_names = {
 const geoButtonsSelector = '#geography-select button'
 const catButtonsSelector = '#category-select button'
 const statButtonsSelector = '#status-select button'
+const hoverMsgDefault = 'Hover over a feature to see details'
 
-function getTooltip(props){
+class InfoControl {
+  constructor() {
+    this.container = document.createElement('div')
+    this.container.className = 'info'
+    this.container.innerHTML = hoverMsgDefault
+  }
+
+  onAdd(map) {
+    this.map = map
+    return this.container
+  }
+
+  onRemove() {
+    this.container.parentNode.removeChild(this.container)
+    this.map = undefined
+  }
+
+  updateInfo(content) {
+    this.container.style.display = content ? 'block' : 'none'
+    this.container.innerHTML = content
+  }
+}
+
+function getTooltipHeader(props){
   let header = ''
   if (props.census_tract !== undefined) {
-    header = `<strong>Tract ${props.census_tract}</strong><br />`
+    header = `Tract ${props.census_tract}`
   } else if (props.place !== undefined) {
-    header = `<strong>${props.place}</strong><br />`
+    header = `${props.place}`
   } else if (props.county_name !== undefined) {
-    header = `<strong>${props.county_name} County</strong><br />`
+    header = `${props.county_name} County`
   } else if (props.house_district !== undefined) {
-    header = `
-    <strong>IL House District ${props.house_district}</strong><br />
-      <strong>Rep. ${props.legislator} (${props.party})</strong><br />
-      `
+    header = `IL House District ${props.house_district}
+      <small><br />Rep. ${props.legislator} (${props.party})</small>`
   } else if (props.senate_district !== undefined) {
-    header = `
-      <strong>IL Senate District ${props.senate_district}</strong><br />
-      <strong>Sen. ${props.legislator} (${props.party})</strong><br />
-      `
+    header = `IL Senate District ${props.senate_district}
+      <small><br />Sen. ${props.legislator} (${props.party})</small>`
   }
+  return header
+}
 
+function getHoverTooltip(props){
   return `
-    ${header}
+    <strong>${getTooltipHeader(props)}</strong><br />
+    Energized: ${props.total_kw.toLocaleString()} kW<br />
+    Planned: ${props.planned_total_kw.toLocaleString()} kW
+    `
+}
+
+function getTooltip(props){
+
+  return `
+    <h4>${getTooltipHeader(props)}</h4>
     <table class='table table-sm map-tooltip'>
       <thead>
         <tr>
@@ -142,6 +174,14 @@ function updateLegend(layerSource, category, status){
   $('#solar-legend').html(legendText)
 }
 
+function resetClickedState(){
+  // reset the previous clicked feature
+  map.setFeatureState(
+    { source: selectedGeography, id: selectedId },
+    { clicked: false }
+  )
+}
+
 function getFillColor(layerSource, category, status){
   let var_prefix = ""
   let colors = energized_colors
@@ -158,6 +198,45 @@ function getFillColor(layerSource, category, status){
   return fillColor
 }
 
+function featureClicked(feature, lngLat=null){
+
+  map.setFeatureState(
+    { source: selectedGeography, id: feature.id },
+    { clicked: true }
+  )
+  
+  // collapse all coordinates to calculate bounds
+  if (feature.geometry.type === 'Polygon' || feature.geometry.type === 'MultiPolygon') {
+    const coordinates = feature.geometry.coordinates
+
+    // Flatten all coordinates from all rings (and all polygons if MultiPolygon)
+    let allCoords = []
+    if (feature.geometry.type === 'Polygon') {
+      allCoords = coordinates.flat()  // One polygon with rings
+    } else {
+      allCoords = coordinates.flat(2) // MultiPolygon
+    }
+
+    const bounds = allCoords.reduce((b, coord) => b.extend(coord), new maplibregl.LngLatBounds(allCoords[0], allCoords[0]))
+
+    if (lngLat == null) {
+      lngLat = bounds.getCenter()
+    }
+
+    // Fit to bounds
+    map.fitBounds(bounds, {
+      padding: 300,
+      duration: 500,
+      easing: t => t
+    })
+  }
+  // Display info popup
+  popup = new maplibregl.Popup()
+    .setLngLat(lngLat)
+    .setHTML(getTooltip(feature.properties))
+    .addTo(map)
+}
+
 async function loadSourceFromGzip(url, map, layerSource) {
   const response = await fetch(url)
   const arrayBuffer = await response.arrayBuffer()
@@ -188,38 +267,60 @@ function addLayer(map, layerSource, visible = 'none'){
       },
     'paint': {
       'fill-color': getFillColor(layerSource, 'total_kw', "energized"),
-      'fill-opacity': 0.5,
-      'fill-outline-color': [
+      'fill-opacity': [
         'case',
         ['boolean', ['feature-state', 'hover'], false],
-        '#000000',
-        '#CCCCCC'
+        0.7,
+        0.5
       ]
     }
   })
 
-  const popup = new maplibregl.Popup({
-    closeButton: false,
-    closeOnClick: false
+  // polygon line styles
+  map.addLayer({
+    'id': `${layerSource}-outline`,
+    'type': 'line',
+    'source': layerSource, // reference the data source
+    'layout': {
+      // Make the layer not visible by default.
+      'visibility': visible
+    },
+    'paint': {
+      'line-color': [
+        'case',
+        ['boolean', ['feature-state', 'clicked'], false],
+        '#000000',
+        '#CCCCCC'
+      ],
+      'line-width': [
+        'case',
+        ['boolean', ['feature-state', 'clicked'], false],
+        2,
+        0.5
+      ],
+      'line-opacity': 1,
+    }
   })
   
   map.on('mousemove', `${layerSource}-fills`, (e) => {
     // populate tooltip
     map.getCanvas().style.cursor = 'pointer'
-    const coordinates = e.lngLat
-    popup.setLngLat(coordinates).setHTML(getTooltip(e.features[0].properties)).addTo(map)
+    const feature = e.features[0]
 
-    // highlight tract
-    if (e.features.length > 0) {
+    if (feature) {
+      infoControl.updateInfo(getHoverTooltip(feature.properties))
+      
       if (hoveredPolygonId !== null) {
         map.setFeatureState(
           { source: layerSource, id: hoveredPolygonId },
           { hover: false }
         )
+      } else {
+        infoControl.updateInfo(hoverMsgDefault)
       }
 
       // geojson data must have a unique id property (outside of properties)
-      hoveredPolygonId = e.features[0].id
+      hoveredPolygonId = feature.id
       map.setFeatureState(
         { source: layerSource, id: hoveredPolygonId },
         { hover: true }
@@ -229,14 +330,39 @@ function addLayer(map, layerSource, visible = 'none'){
   
   map.on('mouseleave', `${layerSource}-fills`, () => {
     map.getCanvas().style.cursor = ''
-    popup.remove()
+    map.setFeatureState(
+      { source: layerSource, id: hoveredPolygonId },
+      { hover: false }
+    )
+    infoControl.updateInfo(hoverMsgDefault)
+  })
+
+  map.on('click', `${layerSource}-fills`, (e) => {
+    const feat = e.features[0]
+    resetClickedState()
+    selectedId = feat.id
+    $.address.parameter('id', selectedId)
+    featureClicked(feat, e.lngLat)
   })
 }
 
-function showLayer(selectedGeography, selectedCategory, selectedStatus) {
+function showLayer(selectedGeography, selectedCategory, selectedStatus) {  
   map.setLayoutProperty(selectedGeography + '-fills', 'visibility', 'visible')
+  map.setLayoutProperty(selectedGeography + '-outline', 'visibility', 'visible')
   map.setPaintProperty(selectedGeography + '-fills', 'fill-color', getFillColor(selectedGeography, selectedCategory, selectedStatus))
   updateLegend(selectedGeography, selectedCategory, selectedStatus)
+
+  // remove previous popup
+  if (popup) {
+    popup.remove()
+    $.address.parameter('id', null)
+  }
+}
+
+function hideLayers(layerIds) {
+  layerIds.forEach(layerId => {
+    map.setLayoutProperty(layerId, 'visibility', 'none')
+  })
 }
 
 function toggleActive (selectorForSiblings, activeButton){
@@ -253,9 +379,10 @@ function loadParam(param_name, param_default){
     param = load_val
   }
   
-  // sets the active and aria to the parameter
-  toggleActive('#' + param_name + '-select button',$(':button[value=' + param + ']')[0])
-
+  if (param_default) {
+    // sets the active and aria to the parameter
+    toggleActive('#' + param_name + '-select button',$(':button[value=' + param + ']')[0])
+  }
   return param
 }
 
@@ -273,11 +400,17 @@ const map = new maplibregl.Map({
     zoom: 6, // starting zoom
 })
 
+const infoControl = new InfoControl()
+map.addControl(infoControl, 'bottom-left')
+
 let hoveredPolygonId = null
 
 let selectedGeography = loadParam('geography','tracts')
 let selectedCategory = loadParam('category','total_kw')
 let selectedStatus = loadParam('status','energized')
+let selectedId = loadParam('id', 0)
+let selectedFeatureLoaded = false
+let popup = null
 
 map.on('load', () => {
   // load our 5 main data sources
@@ -289,12 +422,10 @@ map.on('load', () => {
 
   $('#geography-select button').click(function(e){
     // reset layers
-    map.setLayoutProperty('tracts-fills', 'visibility', 'none')
-    map.setLayoutProperty('places-fills', 'visibility', 'none')
-    map.setLayoutProperty('counties-fills', 'visibility', 'none')
-    map.setLayoutProperty('il-senate-fills', 'visibility', 'none')
-    map.setLayoutProperty('il-house-fills', 'visibility', 'none')
+    hideLayers(['tracts-fills','places-fills','counties-fills','il-senate-fills','il-house-fills'])
+    hideLayers(['tracts-outline','places-outline','counties-outline','il-senate-outline','il-house-outline'])
     
+    resetClickedState()
     selectedGeography = this.value
     $.address.parameter('geography', selectedGeography)
     
@@ -322,4 +453,25 @@ map.on('load', () => {
 
     showLayer(selectedGeography, selectedCategory, selectedStatus)
   })
+
+  // if an ID is in the URL, load it. 
+  // waiting for sourcedata event to trigger before we can access features
+  map.on('sourcedata', function (e) {
+    if (
+      !selectedFeatureLoaded &&
+      selectedId &&
+      e.sourceId === selectedGeography &&
+      e.isSourceLoaded
+    ) {
+      selectedId = Number(selectedId)
+      const features = map.querySourceFeatures(selectedGeography)
+      const feat = features.find(f => f.id === selectedId)
+
+      featureClicked(feat)
+
+      // ensure this is only done once. sourcedata events are fired often
+      selectedFeatureLoaded = true
+    }
+  })
+
 })
\ No newline at end of file


Reviews:
user: login: vkoves
body: Have a few minor code comments, but the outline seems a little finicky.

1. It doesn't show on _just clicking_ (you have to reload)

![image](https://github.com/user-attachments/assets/fc11a323-94a3-4962-a0d6-6c1d3f44cdcc)

2. It goes away when you hover over something else, even though the tooltips stays.

I'd consider making a distinct active and hover state to make this a little cleaner - this could be adding a background effect to the active district as well, making the outline persist and thicker, etc.

But also it's your baby, so feel free to ignore all my thoughts!
state: CHANGES_REQUESTED
author_association: COLLABORATOR
submitted_at: 2025-05-06T02:06:05Z
user: login: derekeder
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-06T20:49:10Z
user: login: derekeder
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-06T20:56:46Z
user: login: vkoves
body: This looks great now, awesome stuff!

One side thing I wanted to mention is that it'd be nice to have a back button that snaps you back to the state level view - at least for me it's a bit annoying to zoom all the way back out after clicking into a small tract, but 🤷🏻‍♂️ 
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-05-07T18:36:46Z

Review comments:
diff_hunk: @@ -229,14 +310,30 @@ function addLayer(map, layerSource, visible = 'none'){
   
   map.on('mouseleave', `${layerSource}-fills`, () => {
     map.getCanvas().style.cursor = ''
-    popup.remove()
+    map.setFeatureState(
+      { source: layerSource, id: hoveredPolygonId },
+      { hover: false }
+    )
+    infoControl.updateInfo('Hover over a feature to see details')
path: js/map.js
user: login: vkoves
body: This is copy pasted in three places, so it might be worth turning into a constant. You could even have a `Messages` constant to make it more generic!
created_at: 2025-05-06T01:54:22Z
author_association: COLLABORATOR
diff_hunk: @@ -294,6 +398,12 @@ map.on('load', () => {
     map.setLayoutProperty('counties-fills', 'visibility', 'none')
     map.setLayoutProperty('il-senate-fills', 'visibility', 'none')
     map.setLayoutProperty('il-house-fills', 'visibility', 'none')
+
+    map.setLayoutProperty('tracts-outline', 'visibility', 'none')
path: js/map.js
user: login: vkoves
body: This plus the above could be a cleaner for loop, or even a helper function. E.g.

```ts
[ 'tracts-outline', 'places-outline', ...].forEach(layerId => {
  map.setLayoutProperty(layerId, 'visibility', 'none);
});

// Or
function hideLayers(layerIds: Array<string>) {
  layerIds.forEach(layerId => {
  map.setLayoutProperty(layerId, 'visibility', 'none);
});
}

// Exceution
const layersToHide = ['tracts-outline', ... ];

hideLayers(layersToHide);
```

That way you can really easily add or edit more stuff to hide later
created_at: 2025-05-06T01:57:51Z
author_association: COLLABORATOR
diff_hunk: @@ -158,6 +189,38 @@ function getFillColor(layerSource, category, status){
   return fillColor
 }
 
+function featureClicked(feature, lngLat=null){
+  if (feature.geometry.type === 'Polygon' || feature.geometry.type === 'MultiPolygon') {
path: js/map.js
user: login: vkoves
body: What is all of this code doing? (193 to 208). It might be worth adding a doc block here
created_at: 2025-05-06T01:59:56Z
author_association: COLLABORATOR
diff_hunk: @@ -34,6 +34,24 @@ body, p {
   width: 10px;
 }
 
+.maplibregl-popup-content {
+  width: 400px !important;
+}
+
+.info {
+    padding: 6px 8px;
+    margin: 5px;
+    font: 14px/16px Arial, Helvetica, sans-serif;
+    background: white;
+    /*background: rgba(255,255,255,0.8);*/
path: css/custom.css
user: login: vkoves
body: This should be deleted
created_at: 2025-05-06T02:01:13Z
author_association: COLLABORATOR
diff_hunk: @@ -322,4 +432,29 @@ map.on('load', () => {
 
     showLayer(selectedGeography, selectedCategory, selectedStatus)
   })
+
+  // if an ID is in the URL, load it
+  map.on('sourcedata', function (e) {
path: js/map.js
user: login: vkoves
body: Might be worth noting what this `e` event type is, for future explorers
created_at: 2025-05-06T02:02:03Z
author_association: COLLABORATOR
diff_hunk: @@ -49,28 +49,59 @@ const geoButtonsSelector = '#geography-select button'
 const catButtonsSelector = '#category-select button'
 const statButtonsSelector = '#status-select button'
 
-function getTooltip(props){
+class InfoControl {
+  constructor() {
+    this.container = document.createElement('div')
+    this.container.className = 'info'
+    this.container.innerHTML = 'Hover over a feature to see details'
+  }
+
+  onAdd(map) {
+    this.map = map
+    return this.container
+  }
+
+  onRemove() {
+    this.container.parentNode.removeChild(this.container)
+    this.map = undefined
+  }
+
+  updateInfo(content) {
path: js/map.js
user: login: vkoves
body: I wish this was Typescript, but since it's not, typed doc blocks might be helpful (e.g. `/** @param content {string} The CSS display style to use */`)
created_at: 2025-05-06T02:05:37Z
author_association: COLLABORATOR
diff_hunk: @@ -229,14 +310,30 @@ function addLayer(map, layerSource, visible = 'none'){
   
   map.on('mouseleave', `${layerSource}-fills`, () => {
     map.getCanvas().style.cursor = ''
-    popup.remove()
+    map.setFeatureState(
+      { source: layerSource, id: hoveredPolygonId },
+      { hover: false }
+    )
+    infoControl.updateInfo('Hover over a feature to see details')
path: js/map.js
user: login: derekeder
body: good catch!
created_at: 2025-05-06T20:49:10Z
author_association: MEMBER
diff_hunk: @@ -294,6 +398,12 @@ map.on('load', () => {
     map.setLayoutProperty('counties-fills', 'visibility', 'none')
     map.setLayoutProperty('il-senate-fills', 'visibility', 'none')
     map.setLayoutProperty('il-house-fills', 'visibility', 'none')
+
+    map.setLayoutProperty('tracts-outline', 'visibility', 'none')
path: js/map.js
user: login: derekeder
body: yup we can tighten this up
created_at: 2025-05-06T20:56:46Z
author_association: MEMBER

PR comments:
user: login: netlify[bot]
created_at: 2025-05-01T22:04:36Z
body: ### <span aria-hidden="true">✅</span> Deploy Preview for *il-solar-map* ready!


|  Name | Link |
|:-:|------------------------|
|<span aria-hidden="true">🔨</span> Latest commit | e704f0d8cf69835aa7ea486c7e4b562282e3724c |
|<span aria-hidden="true">🔍</span> Latest deploy log | https://app.netlify.com/sites/il-solar-map/deploys/681a82e06e6a3600089aeee2 |
|<span aria-hidden="true">😎</span> Deploy Preview | [https://deploy-preview-82--il-solar-map.netlify.app](https://deploy-preview-82--il-solar-map.netlify.app) |
|<span aria-hidden="true">📱</span> Preview on mobile | <details><summary> Toggle QR Code... </summary><br /><br />![QR Code](https://app.netlify.com/qr-code/eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1cmwiOiJodHRwczovL2RlcGxveS1wcmV2aWV3LTgyLS1pbC1zb2xhci1tYXAubmV0bGlmeS5hcHAifQ.F4qfqSIwix3OCCylyhLyCMCfoD1-qcN0R-0PXeet068)<br /><br />_Use your smartphone camera to open QR code link._</details> |
---
<!-- [il-solar-map Preview](https://deploy-preview-82--il-solar-map.netlify.app) -->
_To edit notification comments on pull requests, go to your [Netlify site configuration](https://app.netlify.com/sites/il-solar-map/configuration/notifications#deploy-webhooks)._
author_association: NONE
user: login: derekeder
created_at: 2025-05-04T22:19:22Z
body: update: now showing the hover outlines more clearly. this required adding outline-specific layers for each geography and styling accordingly:
![Screenshot 2025-05-04 at 5 18 05 PM](https://github.com/user-attachments/assets/79fca301-1d8b-4b54-9094-cbb62b2b4f66)


author_association: MEMBER
user: login: derekeder
created_at: 2025-05-06T21:51:35Z
body: @vkoves thx! I addressed the code comments and updated the hover/clicked logic and styles to be independent.

On hover, the opacity is slightly increased. On click, the black outline is shown. Here's a demo:

https://github.com/user-attachments/assets/931196a6-1954-44c7-bad8-53dfb01b2950


author_association: MEMBER
user: login: derekeder
created_at: 2025-05-07T18:40:12Z
body: @vkoves thx! i had a similar thought. I'll open an issue and add it in another PR
author_association: MEMBER