Rewrite map with hooks, add map colour toggle

Map and Legend components rewritten using hooks.
Also, each category can now have multiple available colour scales.
These can be switched using a select dropdown in the legend.
This commit is contained in:
Maciej Ziarkowski 2021-10-01 13:30:03 +01:00
parent d3a17f2e5f
commit a4d1afab81
8 changed files with 365 additions and 242 deletions

View File

@ -446,6 +446,68 @@
<LineSymbolizer stroke="#888" stroke-width="3.0"/>
</Rule>
</Style>
<Style name="community_local_significance_total">
<Rule>
<Filter>[community_local_significance_total] &gt;= 100</Filter>
<PolygonSymbolizer fill="#bd0026" />
</Rule>
<Rule>
<Filter>[community_local_significance_total] &gt;= 50 and [community_local_significance_total] &lt; 100</Filter>
<PolygonSymbolizer fill="#e31a1c" />
</Rule>
<Rule>
<Filter>[community_local_significance_total] &gt;= 20 and [community_local_significance_total] &lt; 50</Filter>
<PolygonSymbolizer fill="#fc4e2a" />
</Rule>
<Rule>
<Filter>[community_local_significance_total] &gt;= 10 and [community_local_significance_total] &lt; 20</Filter>
<PolygonSymbolizer fill="#fd8d3c" />
</Rule>
<Rule>
<Filter>[community_local_significance_total] &gt;= 3 and [community_local_significance_total] &lt; 10</Filter>
<PolygonSymbolizer fill="#feb24c" />
</Rule>
<Rule>
<Filter>[community_local_significance_total] = 2</Filter>
<PolygonSymbolizer fill="#fed976" />
</Rule>
<Rule>
<Filter>[community_local_significance_total] = 1</Filter>
<PolygonSymbolizer fill="#ffe8a9" />
</Rule>
<Rule>
<MaxScaleDenominator>17061</MaxScaleDenominator>
<MinScaleDenominator>4264</MinScaleDenominator>
<LineSymbolizer stroke="#888" stroke-width="1.0"/>
</Rule>
<Rule>
<MaxScaleDenominator>4264</MaxScaleDenominator>
<MinScaleDenominator>0</MinScaleDenominator>
<LineSymbolizer stroke="#888" stroke-width="3.0"/>
</Rule>
</Style>
<Style name="community_in_public_ownership">
<Rule>
<Filter>[in_public_ownership] = true</Filter>
<PolygonSymbolizer fill="#1166ff" />
</Rule>
<Rule>
<Filter>[in_public_ownership] = false</Filter>
<PolygonSymbolizer fill="#ffaaa0" />
</Rule>
<Rule>
<MaxScaleDenominator>17061</MaxScaleDenominator>
<MinScaleDenominator>4264</MinScaleDenominator>
<LineSymbolizer stroke="#888" stroke-width="1.0"/>
</Rule>
<Rule>
<MaxScaleDenominator>4264</MaxScaleDenominator>
<MinScaleDenominator>0</MinScaleDenominator>
<LineSymbolizer stroke="#888" stroke-width="3.0"/>
</Rule>
</Style>
<Style name="landuse">
<Rule>
<Filter>[current_landuse_order] = "Agriculture And Fisheries"</Filter>

View File

@ -23,8 +23,8 @@ export interface CategoryMapDefinition {
export const defaultMapCategory = Category.Age;
export const categoryMapsConfig: {[key in Category]: CategoryMapDefinition} = {
[Category.Age]: {
export const categoryMapsConfig: {[key in Category]: CategoryMapDefinition[]} = {
[Category.Age]: [{
mapStyle: 'date_year',
legend: {
title: 'Age',
@ -46,8 +46,8 @@ export const categoryMapsConfig: {[key in Category]: CategoryMapDefinition} = {
{ color: '#d0c291', text: '<1700' },
]
},
},
[Category.Size]: {
}],
[Category.Size]: [{
mapStyle: 'size_height',
legend: {
title: 'Height to apex',
@ -62,15 +62,15 @@ export const categoryMapsConfig: {[key in Category]: CategoryMapDefinition} = {
{ color: '#980043', text: '≥152'}
]
},
},
[Category.Team]: {
}],
[Category.Team]: [{
mapStyle: undefined,
legend: {
title: 'Team',
elements: []
},
},
[Category.Construction]: {
}],
[Category.Construction]: [{
mapStyle: 'construction_core_material',
legend: {
title: 'Construction',
@ -85,8 +85,8 @@ export const categoryMapsConfig: {[key in Category]: CategoryMapDefinition} = {
{ color: "#c48a85", text: "Other Man-Made Material" }
]
},
},
[Category.Location]: {
}],
[Category.Location]: [{
mapStyle: 'location',
legend: {
title: 'Location',
@ -99,23 +99,52 @@ export const categoryMapsConfig: {[key in Category]: CategoryMapDefinition} = {
{ color: '#bae4bc', text: '<20%' }
]
},
},
[Category.Community]: {
mapStyle: 'likes',
legend: {
title: 'Like Me',
elements: [
{ color: '#bd0026', text: '👍👍👍👍 100+' },
{ color: '#e31a1c', text: '👍👍👍 5099' },
{ color: '#fc4e2a', text: '👍👍 2049' },
{ color: '#fd8d3c', text: '👍👍 1019' },
{ color: '#feb24c', text: '👍 39' },
{ color: '#fed976', text: '👍 2' },
{ color: '#ffe8a9', text: '👍 1'}
]
}],
[Category.Community]: [
{
mapStyle: 'likes',
legend: {
title: 'Like Me',
elements: [
{ color: '#bd0026', text: '👍👍👍👍 100+' },
{ color: '#e31a1c', text: '👍👍👍 5099' },
{ color: '#fc4e2a', text: '👍👍 2049' },
{ color: '#fd8d3c', text: '👍👍 1019' },
{ color: '#feb24c', text: '👍 39' },
{ color: '#fed976', text: '👍 2' },
{ color: '#ffe8a9', text: '👍 1'}
]
}
},
{
mapStyle: 'community_local_significance_total',
legend: {
title: 'Local Significance',
description: 'People who think the building should be locally listed',
elements: [
{ color: '#bd0026', text: '100+' },
{ color: '#e31a1c', text: '5099' },
{ color: '#fc4e2a', text: '2049' },
{ color: '#fd8d3c', text: '1019' },
{ color: '#feb24c', text: '39' },
{ color: '#fed976', text: '2' },
{ color: '#ffe8a9', text: '1'}
]
}
},
{
mapStyle: 'community_in_public_ownership',
legend: {
title: 'Public Ownership',
description: 'Is the building in some form of public/community ownership',
elements: [
{color: '#1166ff', text: 'Yes'},
{color: '#ffaaa0', text: 'No'}
]
}
}
},
[Category.Planning]: {
],
[Category.Planning]: [{
mapStyle: 'planning_combined',
legend: {
title: 'Statutory protections',
@ -128,8 +157,8 @@ export const categoryMapsConfig: {[key in Category]: CategoryMapDefinition} = {
{ color: '#858ed4', text: 'Locally listed'},
]
},
},
[Category.Sustainability]: {
}],
[Category.Sustainability]: [{
mapStyle: 'sust_dec',
legend: {
title: 'Sustainability',
@ -144,8 +173,8 @@ export const categoryMapsConfig: {[key in Category]: CategoryMapDefinition} = {
{ color: "#e31d23", text: 'G' },
]
},
},
[Category.Type]: {
}],
[Category.Type]: [{
mapStyle: 'building_attachment_form',
legend: {
title: 'Type',
@ -156,8 +185,8 @@ export const categoryMapsConfig: {[key in Category]: CategoryMapDefinition} = {
{ color: "#226291", text: "Mid-Terrace" }
]
},
},
[Category.LandUse]: {
}],
[Category.LandUse]: [{
mapStyle: 'landuse',
legend: {
title: 'Land Use',
@ -177,15 +206,15 @@ export const categoryMapsConfig: {[key in Category]: CategoryMapDefinition} = {
{ color: '#ffffff', text: 'Vacant & Derelict' }
]
},
},
[Category.Streetscape]: {
}],
[Category.Streetscape]: [{
mapStyle: undefined,
legend: {
title: 'Streetscape',
elements: []
},
},
[Category.Dynamics]: {
}],
[Category.Dynamics]: [{
mapStyle: 'dynamics_demolished_count',
legend: {
title: 'Dynamics',
@ -218,6 +247,6 @@ export const categoryMapsConfig: {[key in Category]: CategoryMapDefinition} = {
}
],
},
}
}]
};

View File

@ -8,6 +8,8 @@ export type BuildingMapTileset = 'date_year' |
'construction_core_material' |
'location' |
'likes' |
'community_local_significance_total' |
'community_in_public_ownership' |
'planning_combined' |
'sust_dec' |
'building_attachment_form' |

View File

@ -35,8 +35,8 @@ import { sendBuildingUpdate } from './api-data/building-update';
* to all modules that import leaflet or react-leaflet.
*/
const ColouringMap = loadable(
() => import('./map/map'),
{ ssr: false }
async () => (await import('./map/map')).ColouringMap,
{ ssr: false }
);
interface MapAppProps {

View File

@ -56,6 +56,16 @@
}
}
.map-legend .style-select {
background-color: inherit;
padding: 0.5rem 0.25rem;
margin: 0.25rem 0.5rem;
width: auto;
font-size: 18px;
border: 1px solid;
border-radius: 4px;
}
.map-legend .h4,
.map-legend p,
.data-legend {

View File

@ -1,116 +1,115 @@
import React from 'react';
import React, { FC, useCallback, useEffect, useState } from 'react';
import './legend.css';
import { DownIcon, UpIcon } from '../components/icons';
import { Logo } from '../components/logo';
import { LegendConfig } from '../config/category-maps-config';
import { CategoryMapDefinition, LegendConfig } from '../config/category-maps-config';
import { BuildingMapTileset } from '../config/tileserver-config';
interface LegendProps {
legendConfig: LegendConfig;
mapColourScaleDefinitions: CategoryMapDefinition[];
mapColourScale: BuildingMapTileset;
onMapColourScale: (x: BuildingMapTileset) => void;
}
interface LegendState {
collapseList: boolean;
}
export const Legend : FC<LegendProps> = ({
mapColourScaleDefinitions,
mapColourScale,
onMapColourScale
}) => {
const [collapseList, setCollapseList] = useState(false);
class Legend extends React.Component<LegendProps, LegendState> {
constructor(props) {
super(props);
this.state = {collapseList: false};
this.handleClick = this.handleClick.bind(this);
this.onResize= this.onResize.bind(this);
}
const handleToggle = useCallback(() => {
setCollapseList(!collapseList);
}, [collapseList]);
const onResize = useCallback(({target}) => {
setCollapseList((target.outerHeight < 670 || target.outerWidth < 768))
}, []);
handleClick() {
this.setState(state => ({
collapseList: !state.collapseList
}));
}
useEffect(() => {
window.addEventListener('resize', onResize);
if(window?.outerHeight) {
componentDidMount() {
window.addEventListener('resize', this.onResize);
if (window && window.outerHeight) {
// if we're in the browser, pass in as though from event to initialise
this.onResize({target: window});
onResize({target: window});
}
}
return () => {
window.removeEventListener('resize', onResize);
}
}, [onResize]);
const legendConfig = mapColourScaleDefinitions.find(def => def.mapStyle === mapColourScale)?.legend;
componentWillUnmount() {
window.removeEventListener('resize', this.onResize);
}
const {
title = undefined,
elements = [],
description = undefined,
disclaimer = undefined
} = legendConfig ?? {};
onResize(e) {
this.setState({collapseList: (e.target.outerHeight < 670 || e.target.outerWidth < 768)}); // magic number needs to be consistent with CSS expander-button media query
}
render() {
const {
title = undefined,
elements = [],
description = undefined,
disclaimer = undefined
} = this.props.legendConfig ?? {};
return (
<div className="map-legend">
<Logo variant="default" />
{
return (
<div className="map-legend">
<Logo variant="default" />
{
mapColourScaleDefinitions.length > 1 ?
<select className='style-select' onChange={e => onMapColourScale(e.target.value as BuildingMapTileset)}>
{
mapColourScaleDefinitions.map(def =>
<option key={def.mapStyle} value={def.mapStyle}>{def.legend.title}</option>
)
}
</select> :
title && <h4 className="h4">{title}</h4>
}
{
elements.length > 0 &&
<button className="expander-button btn btn-outline-secondary btn-sm" type="button" onClick={this.handleClick} >
{
this.state.collapseList ?
<UpIcon /> :
<DownIcon />
}
</button>
}
{
description && <p>{description}</p>
}
{
elements.length === 0 ?
<p className="data-intro">Coming soon</p> :
<ul className={this.state.collapseList ? 'collapse data-legend' : 'data-legend'} >
{
disclaimer && <p className='legend-disclaimer'>{disclaimer}</p>
}
{
elements.map((item) => {
let key: string,
content: React.ReactElement;
if('subtitle' in item) {
key = item.subtitle;
content = <h6>{item.subtitle}</h6>;
} else {
key = `${item.text}-${item.color}`;
content = <>
<div className="key" style={ { background: item.color, border: item.border } } />
{ item.text }
</>;
}
return (
<li key={key}>
{content}
</li>
);
})
}
</ul>
}
</div>
);
}
}
export default Legend;
}
{
elements.length > 0 &&
<button className="expander-button btn btn-outline-secondary btn-sm" type="button" onClick={handleToggle} >
{
collapseList ?
<UpIcon /> :
<DownIcon />
}
</button>
}
{
description && <p>{description}</p>
}
{
elements.length === 0 ?
<p className="data-intro">Coming soon</p> :
<ul className={collapseList ? 'collapse data-legend' : 'data-legend'} >
{
disclaimer && <p className='legend-disclaimer'>{disclaimer}</p>
}
{
elements.map((item) => {
let key: string,
content: React.ReactElement;
if('subtitle' in item) {
key = item.subtitle;
content = <h6>{item.subtitle}</h6>;
} else {
key = `${item.text}-${item.color}`;
content = <>
<div className="key" style={ { background: item.color, border: item.border } } />
{ item.text }
</>;
}
return (
<li key={key}>
{content}
</li>
);
})
}
</ul>
}
</div>
);
}

View File

@ -1,4 +1,4 @@
import React, { Component, Fragment, useEffect } from 'react';
import React, { FC, useCallback, useEffect, useMemo, useState } from 'react';
import { AttributionControl, MapContainer, ZoomControl, useMapEvent, Pane, useMap } from 'react-leaflet';
import 'leaflet/dist/leaflet.css';
@ -18,9 +18,10 @@ import { BuildingDataLayer } from './layers/building-data-layer';
import { BuildingNumbersLayer } from './layers/building-numbers-layer';
import { BuildingHighlightLayer } from './layers/building-highlight-layer';
import Legend from './legend';
import { Legend } from './legend';
import SearchBox from './search-box';
import ThemeSwitcher from './theme-switcher';
import { BuildingMapTileset } from '../config/tileserver-config';
interface ColouringMapProps {
selectedBuildingId: number;
@ -30,124 +31,125 @@ interface ColouringMapProps {
onBuildingAction: (building: Building) => void;
}
interface ColouringMapState {
theme: MapTheme;
position: [number, number];
zoom: number;
}
export const ColouringMap : FC<ColouringMapProps> = ({
category,
mode,
revisionId,
onBuildingAction,
selectedBuildingId,
children
}) => {
/**
* Map area
*/
class ColouringMap extends Component<ColouringMapProps, ColouringMapState> {
constructor(props) {
super(props);
this.state = {
theme: 'light',
...initialMapViewport
};
this.handleClick = this.handleClick.bind(this);
this.handleLocate = this.handleLocate.bind(this);
this.themeSwitch = this.themeSwitch.bind(this);
}
const [theme, setTheme] = useState<MapTheme>('light');
const [position, setPosition] = useState(initialMapViewport.position);
const [zoom, setZoom] = useState(initialMapViewport.zoom);
handleLocate(lat: number, lng: number, zoom: number){
this.setState({
position: [lat, lng],
zoom: zoom
});
}
const [mapColourScale, setMapColourScale] = useState<BuildingMapTileset>();
handleClick(e) {
const { lat, lng } = e.latlng;
apiGet(`/api/buildings/locate?lat=${lat}&lng=${lng}`)
.then(data => {
const building = data?.[0];
this.props.onBuildingAction(building);
}).catch(err => console.error(err));
}
const handleLocate = useCallback(
(lat: number, lng: number, zoom: number) => {
setPosition([lat, lng]);
setZoom(zoom);
},
[]
);
themeSwitch(e) {
e.preventDefault();
const newTheme = (this.state.theme === 'light')? 'night' : 'light';
this.setState({theme: newTheme});
}
const handleClick = useCallback(
async (e) => {
const {lat, lng} = e.latlng;
const data = await apiGet(`/api/buildings/locate?lat=${lat}&lng=${lng}`);
const building = data?.[0];
onBuildingAction(building);
},
[onBuildingAction],
)
render() {
const categoryMapDefinition = categoryMapsConfig[this.props.category];
const themeSwitch = useCallback(
(e) => {
e.preventDefault();
const newTheme = (theme === 'light')? 'night' : 'light';
setTheme(newTheme);
},
[theme],
)
const tileset = categoryMapDefinition.mapStyle;
const categoryMapDefinitions = useMemo(() => categoryMapsConfig[category], [category]);
const hasSelection = this.props.selectedBuildingId != undefined;
const isEdit = ['edit', 'multi-edit'].includes(this.props.mode);
useEffect(() => {
if(!categoryMapDefinitions.some(def => def.mapStyle === mapColourScale)) {
setMapColourScale(categoryMapDefinitions[0].mapStyle);
}
}, [categoryMapDefinitions, mapColourScale]);
return (
<div className="map-container">
<MapContainer
center={initialMapViewport.position}
zoom={initialMapViewport.zoom}
minZoom={9}
maxZoom={18}
doubleClickZoom={false}
zoomControl={false}
attributionControl={false}
const hasSelection = selectedBuildingId != undefined;
const isEdit = ['edit', 'multi-edit'].includes(mode);
return (
<div className="map-container">
<MapContainer
center={initialMapViewport.position}
zoom={initialMapViewport.zoom}
minZoom={9}
maxZoom={18}
doubleClickZoom={false}
zoomControl={false}
attributionControl={false}
>
<ClickHandler onClick={handleClick} />
<MapBackgroundColor theme={theme} />
<MapViewport position={position} zoom={zoom} />
<Pane
key={theme}
name={'cc-base-pane'}
style={{zIndex: 50}}
>
<ClickHandler onClick={this.handleClick} />
<MapBackgroundColor theme={this.state.theme} />
<MapViewport position={this.state.position} zoom={this.state.zoom} />
<CityBaseMapLayer theme={theme} />
<BuildingBaseLayer theme={theme} />
</Pane>
<Pane
key={this.state.theme}
name={'cc-base-pane'}
style={{zIndex: 50}}
>
<CityBaseMapLayer theme={this.state.theme} />
<BuildingBaseLayer theme={this.state.theme} />
</Pane>
{
mapColourScale &&
<BuildingDataLayer
tileset={mapColourScale}
revisionId={revisionId}
/>
}
<Pane
name='cc-overlay-pane'
style={{zIndex: 300}}
>
<CityBoundaryLayer />
<BuildingNumbersLayer revisionId={revisionId} />
{
tileset &&
<BuildingDataLayer
tileset={tileset}
revisionId={this.props.revisionId}
selectedBuildingId &&
<BuildingHighlightLayer
selectedBuildingId={selectedBuildingId}
baseTileset={mapColourScale}
/>
}
</Pane>
<Pane
name='cc-overlay-pane'
style={{zIndex: 300}}
>
<CityBoundaryLayer />
<BuildingNumbersLayer revisionId={this.props.revisionId} />
{
this.props.selectedBuildingId &&
<BuildingHighlightLayer
selectedBuildingId={this.props.selectedBuildingId}
baseTileset={tileset}
/>
}
</Pane>
<ZoomControl position="topright" />
<AttributionControl prefix=""/>
</MapContainer>
{
this.props.mode !== 'basic' &&
<Fragment>
{
!hasSelection &&
<div className="map-notice">
<HelpIcon /> {isEdit ? 'Click a building to edit' : 'Click a building for details'}
</div>
}
<Legend legendConfig={categoryMapDefinition?.legend} />
{/* <ThemeSwitcher onSubmit={this.themeSwitch} currentTheme={this.state.theme} /> */}
<SearchBox onLocate={this.handleLocate} />
</Fragment>
}
</div>
);
}
<ZoomControl position="topright" />
<AttributionControl prefix=""/>
</MapContainer>
{
mode !== 'basic' &&
<>
{
!hasSelection &&
<div className="map-notice">
<HelpIcon /> {isEdit ? 'Click a building to edit' : 'Click a building for details'}
</div>
}
<Legend mapColourScaleDefinitions={categoryMapDefinitions} mapColourScale={mapColourScale} onMapColourScale={setMapColourScale}/>
{/* <ThemeSwitcher onSubmit={this.themeSwitch} currentTheme={theme} /> */}
<SearchBox onLocate={handleLocate} />
</>
}
</div>
);
}
function ClickHandler({ onClick }: {onClick: (e) => void}) {
@ -180,5 +182,3 @@ function MapViewport({
return null;
}
export default ColouringMap;

View File

@ -84,6 +84,27 @@ const LAYER_QUERIES = {
buildings
WHERE
likes_total > 0`,
community_local_significance_total: `
SELECT
geometry_id,
community_local_significance_total
FROM
buildings
WHERE
community_local_significance_total > 0
`,
community_in_public_ownership: `
SELECT
geometry_id,
CASE
WHEN community_public_ownership = 'Not in public/community ownership' THEN false
ELSE true
END AS in_public_ownership
FROM
buildings
WHERE
community_public_ownership IS NOT NULL
`,
planning_combined: `
SELECT
geometry_id,