Skip to content

Commit

Permalink
Migrate ZoneList component to React (electricitymaps#2145)
Browse files Browse the repository at this point in the history
* 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
fbarl authored and con-cat committed May 18, 2021
1 parent 4a61388 commit 7db97ac
Show file tree
Hide file tree
Showing 5 changed files with 182 additions and 269 deletions.
3 changes: 3 additions & 0 deletions web/.eslintrc
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,10 @@
"dot-notation": "warn",
"implicit-arrow-linebreak": "off",
"import/prefer-default-export": "off",
"jsx-a11y/anchor-is-valid": "warn",
"jsx-a11y/alt-text": "warn",
"jsx-a11y/click-events-have-key-events": "warn",
"jsx-a11y/no-static-element-interactions": "warn",
"max-len": "warn",
"no-await-in-loop": "warn",
"no-continue": "warn",
Expand Down
365 changes: 150 additions & 215 deletions web/src/components/zonelist.js
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);

0 comments on commit 7db97ac

Please sign in to comment.