forked from electricitymaps/electricitymaps-contrib
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Migrate ZoneList component to React (electricitymaps#2145)
* Started writing ZoneList React component; DOM rendering works * Keyboard navigation through the zones list * Mouse click action for the zones * Make zones search bar work * Remove old ZoneList component * Rename zonelistreact -> zonelist * Extract common getCo2Scale helper function * Fix linting errors
- Loading branch information
Showing
5 changed files
with
182 additions
and
269 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,228 +1,163 @@ | ||
import React, { useState, useEffect } from 'react'; | ||
import { connect } from 'react-redux'; | ||
|
||
import { dispatchApplication } from '../store'; | ||
import { themes } from '../helpers/themes'; | ||
import { getCo2Scale } from '../helpers/scales'; | ||
import { __, getFullZoneName } from '../helpers/translation'; | ||
import { flagUri } from '../helpers/flags'; | ||
|
||
const d3 = Object.assign( | ||
{}, | ||
require('d3-array'), | ||
require('d3-collection'), | ||
require('d3-scale'), | ||
require('d3-selection'), | ||
); | ||
|
||
const translation = require('../helpers/translation'); | ||
const flags = require('../helpers/flags'); | ||
|
||
export default class ZoneList { | ||
constructor(selectorId, argConfig) { | ||
this.selectorId = selectorId; | ||
|
||
const config = argConfig || {}; | ||
this.co2ColorScale = config.co2ColorScale; | ||
this.clickHandler = config.clickHandler; | ||
this.selectedItemIndex = null; | ||
this.visibleListItems = []; | ||
if (config.zones) { | ||
this.setZones(config.zones); | ||
} | ||
} | ||
|
||
setZones(zonesData) { | ||
const zones = d3.values(zonesData); | ||
const validatedAndSortedZones = this._sortAndValidateZones(zones); | ||
this.zones = this._saveZoneRankings(validatedAndSortedZones); | ||
} | ||
|
||
setCo2ColorScale(colorScale) { | ||
this.co2ColorScale = colorScale; | ||
} | ||
|
||
setClickHandler(clickHandler) { | ||
this.clickHandler = clickHandler; | ||
} | ||
|
||
setElectricityMixMode(arg) { | ||
this.electricityMixMode = arg; | ||
} | ||
|
||
filterZonesByQuery(query) { | ||
this._deHighlightSelectedItem(); | ||
d3.select(this.selectorId).selectAll('a').each((zone, i, nodes) => { | ||
const listItem = d3.select(nodes[i]); | ||
if (this._zoneMatchesQuery(zone, query)) { | ||
listItem.style('display', 'flex'); | ||
} else { | ||
listItem.style('display', 'none'); | ||
} | ||
}); | ||
this._resetToDefaultSelectedItem(); | ||
} | ||
|
||
clickSelectedItem() { | ||
// Nothing to do if no item is selected | ||
if (!this.visibleListItems[this.selectedItemIndex]) { | ||
return; | ||
} | ||
this.visibleListItems[this.selectedItemIndex].click(); | ||
} | ||
|
||
selectNextItem() { | ||
if (this.selectedItemIndex === null) { | ||
this.selectedItemIndex = 0; | ||
this._highlightSelectedItem(); | ||
} else if (this.selectedItemIndex < this.visibleListItems.length - 1) { | ||
this._deHighlightSelectedItem(); | ||
this.selectedItemIndex += 1; | ||
this._highlightSelectedItem(); | ||
} | ||
} | ||
|
||
selectPreviousItem() { | ||
if (this.selectedItemIndex === null) { | ||
this.selectedItemIndex = 0; | ||
this._highlightSelectedItem(); | ||
} else if (this.selectedItemIndex >= 1 | ||
&& this.selectedItemIndex < this.visibleListItems.length) { | ||
this._deHighlightSelectedItem(); | ||
this.selectedItemIndex -= 1; | ||
this._highlightSelectedItem(); | ||
} | ||
} | ||
|
||
render() { | ||
this._createListItems(); | ||
this._setItemAttributes(); | ||
this._setItemClickHandlers(); | ||
} | ||
|
||
_resetToDefaultSelectedItem() { | ||
if (this.selectedItemIndex !== null) { | ||
this.selectedItemIndex = 0; | ||
} | ||
|
||
this.visibleListItems = d3.select(this.selectorId).selectAll('a').nodes() | ||
.filter(node => node.style.display !== 'none'); | ||
if (this.visibleListItems.length) { | ||
this._highlightSelectedItem(); | ||
} | ||
} | ||
|
||
_highlightSelectedItem() { | ||
const item = this.visibleListItems[this.selectedItemIndex]; | ||
if (item) { | ||
item.setAttribute('class', 'selected'); | ||
this._scrollToItemIfNeeded(item); | ||
} | ||
} | ||
|
||
_scrollToItemIfNeeded(item) { | ||
const parent = item.parentNode; | ||
const parentComputedStyle = window.getComputedStyle(parent, null); | ||
const parentBorderTopWidth = parseInt(parentComputedStyle.getPropertyValue('border-top-width'), 10); | ||
const overTop = item.offsetTop - parent.offsetTop < parent.scrollTop; | ||
const overBottom = (item.offsetTop - parent.offsetTop + item.clientHeight - parentBorderTopWidth) > (parent.scrollTop + parent.clientHeight); | ||
const alignWithTop = overTop && !overBottom; | ||
|
||
if (overTop || overBottom) { | ||
item.scrollIntoView(alignWithTop); | ||
} | ||
} | ||
|
||
_deHighlightSelectedItem() { | ||
const item = this.visibleListItems[this.selectedItemIndex]; | ||
if (item) { | ||
item.setAttribute('class', ''); | ||
} | ||
} | ||
|
||
_zoneMatchesQuery(zone, queryString) { | ||
const queries = queryString.split(' '); | ||
return queries.every(query => | ||
translation.getFullZoneName(zone.countryCode) | ||
.toLowerCase() | ||
.indexOf(query.toLowerCase()) !== -1); | ||
} | ||
|
||
_getCo2IntensityAccessor() { | ||
return d => (this.electricityMixMode === 'consumption' | ||
? d.co2intensity | ||
: d.co2intensityProduction); | ||
} | ||
|
||
_sortAndValidateZones(zones) { | ||
const accessor = this._getCo2IntensityAccessor(); | ||
return zones | ||
.filter(accessor) | ||
.sort((x, y) => { | ||
if (!x.co2intensity && !x.countryCode) { | ||
return d3.ascending( | ||
x.shortname || x.countryCode, | ||
y.shortname || y.countryCode, | ||
); | ||
} | ||
function withZoneRankings(zones) { | ||
return zones.map((zone) => { | ||
const ret = Object.assign({}, zone); | ||
ret.ranking = zones.indexOf(zone) + 1; | ||
return ret; | ||
}); | ||
} | ||
|
||
function getCo2IntensityAccessor(electricityMixMode) { | ||
return d => (electricityMixMode === 'consumption' | ||
? d.co2intensity | ||
: d.co2intensityProduction); | ||
} | ||
|
||
function sortAndValidateZones(zones, accessor) { | ||
return zones | ||
.filter(accessor) | ||
.sort((x, y) => { | ||
if (!x.co2intensity && !x.countryCode) { | ||
return d3.ascending( | ||
accessor(x) || Infinity, | ||
accessor(y) || Infinity, | ||
x.shortname || x.countryCode, | ||
y.shortname || y.countryCode, | ||
); | ||
}); | ||
} | ||
|
||
_saveZoneRankings(zones) { | ||
return zones.map((zone) => { | ||
const ret = Object.assign({}, zone); | ||
ret.ranking = zones.indexOf(zone) + 1; | ||
return ret; | ||
} | ||
return d3.ascending( | ||
accessor(x) || Infinity, | ||
accessor(y) || Infinity, | ||
); | ||
}); | ||
} | ||
|
||
_createListItems() { | ||
this.selector = d3.select(this.selectorId) | ||
.selectAll('a') | ||
.data(this.zones); | ||
|
||
const itemLinks = this.selector.enter().append('a'); | ||
|
||
itemLinks.append('div').attr('class', 'ranking'); | ||
itemLinks.append('img').attr('class', 'flag'); | ||
|
||
|
||
const nameDiv = itemLinks.append('div').attr('class', 'name'); | ||
nameDiv.append('div').attr('class', 'zone-name'); | ||
nameDiv.append('div').attr('class', 'country-name'); | ||
|
||
itemLinks.append('div').attr('class', 'co2-intensity-tag'); | ||
|
||
this.visibleListItems = itemLinks.nodes(); | ||
this.selector = itemLinks.merge(this.selector); | ||
} | ||
|
||
_setItemAttributes() { | ||
this._setItemRanks(); | ||
this._setItemFlags(); | ||
this._setItemNames(); | ||
this._setItemCO2IntensityTag(); | ||
} | ||
|
||
_setItemNames() { | ||
this.selector.select('.zone-name') | ||
.text(zone => translation.translate(`zoneShortName.${zone.countryCode}.zoneName`)); | ||
|
||
this.selector.select('.country-name') | ||
.text(zone => translation.translate(`zoneShortName.${zone.countryCode}.countryName`)); | ||
} | ||
|
||
_setItemRanks() { | ||
this.selector.select('div.ranking') | ||
.text(zone => zone.ranking); | ||
} | ||
|
||
_setItemFlags() { | ||
this.selector.select('.flag') | ||
.attr('src', zone => flags.flagUri(zone.countryCode, 32)); | ||
} | ||
} | ||
|
||
_setItemCO2IntensityTag() { | ||
const accessor = this._getCo2IntensityAccessor(); | ||
this.selector.select('.co2-intensity-tag') | ||
.style('background-color', zone => (accessor(zone) && this.co2ColorScale ? this.co2ColorScale(accessor(zone)) : 'gray')); | ||
} | ||
function processZones(zonesData, accessor) { | ||
const zones = d3.values(zonesData); | ||
const validatedAndSortedZones = sortAndValidateZones(zones, accessor); | ||
return withZoneRankings(validatedAndSortedZones); | ||
} | ||
|
||
_setItemClickHandlers() { | ||
this.selector.on('click', this.clickHandler); | ||
} | ||
function zoneMatchesQuery(zone, queryString) { | ||
if (!queryString) return true; | ||
const queries = queryString.split(' '); | ||
return queries.every(query => | ||
getFullZoneName(zone.countryCode) | ||
.toLowerCase() | ||
.indexOf(query.toLowerCase()) !== -1); | ||
} | ||
|
||
const mapStateToProps = state => ({ | ||
colorBlindModeEnabled: state.application.colorBlindModeEnabled, | ||
currentPage: state.application.showPageState, | ||
electricityMixMode: state.application.electricityMixMode, | ||
gridZones: state.data.grid.zones, | ||
searchQuery: state.application.searchQuery, | ||
}); | ||
|
||
const ZoneList = ({ | ||
colorBlindModeEnabled, | ||
currentPage, | ||
electricityMixMode, | ||
gridZones, | ||
searchQuery, | ||
}) => { | ||
const co2ColorScale = getCo2Scale(colorBlindModeEnabled); | ||
const co2IntensityAccessor = getCo2IntensityAccessor(electricityMixMode); | ||
const zones = processZones(gridZones, co2IntensityAccessor) | ||
.filter(z => zoneMatchesQuery(z, searchQuery)); | ||
|
||
const ref = React.createRef(); | ||
const [selectedItemIndex, setSelectedItemIndex] = useState(null); | ||
|
||
// Click action | ||
const handleClick = (countryCode) => { | ||
dispatchApplication('showPageState', 'country'); | ||
dispatchApplication('selectedZoneName', countryCode); | ||
}; | ||
|
||
// Keyboard navigation | ||
useEffect(() => { | ||
const scrollToItemIfNeeded = (index) => { | ||
const item = ref.current.children[index]; | ||
if (!item) return; | ||
|
||
const parent = item.parentNode; | ||
const parentComputedStyle = window.getComputedStyle(parent, null); | ||
const parentBorderTopWidth = parseInt(parentComputedStyle.getPropertyValue('border-top-width'), 10); | ||
const overTop = item.offsetTop - parent.offsetTop < parent.scrollTop; | ||
const overBottom = (item.offsetTop - parent.offsetTop + item.clientHeight - parentBorderTopWidth) > (parent.scrollTop + parent.clientHeight); | ||
const alignWithTop = overTop && !overBottom; | ||
|
||
if (overTop || overBottom) { | ||
item.scrollIntoView(alignWithTop); | ||
} | ||
}; | ||
const keyHandler = (e) => { | ||
if (e.key && currentPage === 'map') { | ||
if (e.key === 'Enter' && zones[selectedItemIndex]) { | ||
handleClick(zones[selectedItemIndex].countryCode); | ||
} else if (e.key === 'ArrowUp') { | ||
const prevItemIndex = selectedItemIndex === null ? 0 : Math.max(0, selectedItemIndex - 1); | ||
scrollToItemIfNeeded(prevItemIndex); | ||
setSelectedItemIndex(prevItemIndex); | ||
} else if (e.key === 'ArrowDown') { | ||
const nextItemIndex = selectedItemIndex === null ? 0 : Math.min(zones.length - 1, selectedItemIndex + 1); | ||
scrollToItemIfNeeded(nextItemIndex); | ||
setSelectedItemIndex(nextItemIndex); | ||
} else if (e.key.match(/^[A-z]$/)) { | ||
// Focus on the first item if modified the search query | ||
scrollToItemIfNeeded(0); | ||
setSelectedItemIndex(0); | ||
} | ||
} | ||
}; | ||
document.addEventListener('keyup', keyHandler); | ||
return () => { | ||
document.removeEventListener('keyup', keyHandler); | ||
}; | ||
}); | ||
|
||
return ( | ||
<div className="zone-list" ref={ref}> | ||
{zones.map((zone, ind) => ( | ||
<a | ||
key={zone.shortname} | ||
className={selectedItemIndex === ind ? 'selected' : ''} | ||
onClick={() => handleClick(zone.countryCode)} | ||
> | ||
<div className="ranking">{zone.ranking}</div> | ||
<img className="flag" src={flagUri(zone.countryCode, 32)} /> | ||
<div className="name"> | ||
<div className="zone-name">{__(`zoneShortName.${zone.countryCode}.zoneName`)}</div> | ||
<div className="country-name">{__(`zoneShortName.${zone.countryCode}.countryName`)}</div> | ||
</div> | ||
<div | ||
className="co2-intensity-tag" | ||
style={{ | ||
backgroundColor: co2IntensityAccessor(zone) && co2ColorScale | ||
? co2ColorScale(co2IntensityAccessor(zone)) | ||
: 'gray', | ||
}} | ||
/> | ||
</a> | ||
))} | ||
</div> | ||
); | ||
}; | ||
|
||
export default connect(mapStateToProps)(ZoneList); |
Oops, something went wrong.