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"/> <LineSymbolizer stroke="#888" stroke-width="3.0"/>
</Rule> </Rule>
</Style> </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"> <Style name="landuse">
<Rule> <Rule>
<Filter>[current_landuse_order] = "Agriculture And Fisheries"</Filter> <Filter>[current_landuse_order] = "Agriculture And Fisheries"</Filter>

View File

@ -23,8 +23,8 @@ export interface CategoryMapDefinition {
export const defaultMapCategory = Category.Age; export const defaultMapCategory = Category.Age;
export const categoryMapsConfig: {[key in Category]: CategoryMapDefinition} = { export const categoryMapsConfig: {[key in Category]: CategoryMapDefinition[]} = {
[Category.Age]: { [Category.Age]: [{
mapStyle: 'date_year', mapStyle: 'date_year',
legend: { legend: {
title: 'Age', title: 'Age',
@ -46,8 +46,8 @@ export const categoryMapsConfig: {[key in Category]: CategoryMapDefinition} = {
{ color: '#d0c291', text: '<1700' }, { color: '#d0c291', text: '<1700' },
] ]
}, },
}, }],
[Category.Size]: { [Category.Size]: [{
mapStyle: 'size_height', mapStyle: 'size_height',
legend: { legend: {
title: 'Height to apex', title: 'Height to apex',
@ -62,15 +62,15 @@ export const categoryMapsConfig: {[key in Category]: CategoryMapDefinition} = {
{ color: '#980043', text: '≥152'} { color: '#980043', text: '≥152'}
] ]
}, },
}, }],
[Category.Team]: { [Category.Team]: [{
mapStyle: undefined, mapStyle: undefined,
legend: { legend: {
title: 'Team', title: 'Team',
elements: [] elements: []
}, },
}, }],
[Category.Construction]: { [Category.Construction]: [{
mapStyle: 'construction_core_material', mapStyle: 'construction_core_material',
legend: { legend: {
title: 'Construction', title: 'Construction',
@ -85,8 +85,8 @@ export const categoryMapsConfig: {[key in Category]: CategoryMapDefinition} = {
{ color: "#c48a85", text: "Other Man-Made Material" } { color: "#c48a85", text: "Other Man-Made Material" }
] ]
}, },
}, }],
[Category.Location]: { [Category.Location]: [{
mapStyle: 'location', mapStyle: 'location',
legend: { legend: {
title: 'Location', title: 'Location',
@ -99,8 +99,9 @@ export const categoryMapsConfig: {[key in Category]: CategoryMapDefinition} = {
{ color: '#bae4bc', text: '<20%' } { color: '#bae4bc', text: '<20%' }
] ]
}, },
}, }],
[Category.Community]: { [Category.Community]: [
{
mapStyle: 'likes', mapStyle: 'likes',
legend: { legend: {
title: 'Like Me', title: 'Like Me',
@ -115,7 +116,35 @@ export const categoryMapsConfig: {[key in Category]: CategoryMapDefinition} = {
] ]
} }
}, },
[Category.Planning]: { {
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]: [{
mapStyle: 'planning_combined', mapStyle: 'planning_combined',
legend: { legend: {
title: 'Statutory protections', title: 'Statutory protections',
@ -128,8 +157,8 @@ export const categoryMapsConfig: {[key in Category]: CategoryMapDefinition} = {
{ color: '#858ed4', text: 'Locally listed'}, { color: '#858ed4', text: 'Locally listed'},
] ]
}, },
}, }],
[Category.Sustainability]: { [Category.Sustainability]: [{
mapStyle: 'sust_dec', mapStyle: 'sust_dec',
legend: { legend: {
title: 'Sustainability', title: 'Sustainability',
@ -144,8 +173,8 @@ export const categoryMapsConfig: {[key in Category]: CategoryMapDefinition} = {
{ color: "#e31d23", text: 'G' }, { color: "#e31d23", text: 'G' },
] ]
}, },
}, }],
[Category.Type]: { [Category.Type]: [{
mapStyle: 'building_attachment_form', mapStyle: 'building_attachment_form',
legend: { legend: {
title: 'Type', title: 'Type',
@ -156,8 +185,8 @@ export const categoryMapsConfig: {[key in Category]: CategoryMapDefinition} = {
{ color: "#226291", text: "Mid-Terrace" } { color: "#226291", text: "Mid-Terrace" }
] ]
}, },
}, }],
[Category.LandUse]: { [Category.LandUse]: [{
mapStyle: 'landuse', mapStyle: 'landuse',
legend: { legend: {
title: 'Land Use', title: 'Land Use',
@ -177,15 +206,15 @@ export const categoryMapsConfig: {[key in Category]: CategoryMapDefinition} = {
{ color: '#ffffff', text: 'Vacant & Derelict' } { color: '#ffffff', text: 'Vacant & Derelict' }
] ]
}, },
}, }],
[Category.Streetscape]: { [Category.Streetscape]: [{
mapStyle: undefined, mapStyle: undefined,
legend: { legend: {
title: 'Streetscape', title: 'Streetscape',
elements: [] elements: []
}, },
}, }],
[Category.Dynamics]: { [Category.Dynamics]: [{
mapStyle: 'dynamics_demolished_count', mapStyle: 'dynamics_demolished_count',
legend: { legend: {
title: 'Dynamics', 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' | 'construction_core_material' |
'location' | 'location' |
'likes' | 'likes' |
'community_local_significance_total' |
'community_in_public_ownership' |
'planning_combined' | 'planning_combined' |
'sust_dec' | 'sust_dec' |
'building_attachment_form' | 'building_attachment_form' |

View File

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

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 .h4,
.map-legend p, .map-legend p,
.data-legend { .data-legend {

View File

@ -1,72 +1,76 @@
import React from 'react'; import React, { FC, useCallback, useEffect, useState } from 'react';
import './legend.css'; import './legend.css';
import { DownIcon, UpIcon } from '../components/icons'; import { DownIcon, UpIcon } from '../components/icons';
import { Logo } from '../components/logo'; 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 { interface LegendProps {
legendConfig: LegendConfig; mapColourScaleDefinitions: CategoryMapDefinition[];
mapColourScale: BuildingMapTileset;
onMapColourScale: (x: BuildingMapTileset) => void;
} }
interface LegendState { export const Legend : FC<LegendProps> = ({
collapseList: boolean; mapColourScaleDefinitions,
} mapColourScale,
onMapColourScale
}) => {
const [collapseList, setCollapseList] = useState(false);
class Legend extends React.Component<LegendProps, LegendState> { const handleToggle = useCallback(() => {
constructor(props) { setCollapseList(!collapseList);
super(props); }, [collapseList]);
this.state = {collapseList: false};
this.handleClick = this.handleClick.bind(this);
this.onResize= this.onResize.bind(this);
}
const onResize = useCallback(({target}) => {
setCollapseList((target.outerHeight < 670 || target.outerWidth < 768))
}, []);
handleClick() { useEffect(() => {
this.setState(state => ({ window.addEventListener('resize', onResize);
collapseList: !state.collapseList
}));
}
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 // if we're in the browser, pass in as though from event to initialise
this.onResize({target: window}); onResize({target: window});
}
} }
return () => {
componentWillUnmount() { window.removeEventListener('resize', onResize);
window.removeEventListener('resize', this.onResize);
} }
}, [onResize]);
const legendConfig = mapColourScaleDefinitions.find(def => def.mapStyle === mapColourScale)?.legend;
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 { const {
title = undefined, title = undefined,
elements = [], elements = [],
description = undefined, description = undefined,
disclaimer = undefined disclaimer = undefined
} = this.props.legendConfig ?? {}; } = legendConfig ?? {};
return ( return (
<div className="map-legend"> <div className="map-legend">
<Logo variant="default" /> <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> title && <h4 className="h4">{title}</h4>
} }
{ {
elements.length > 0 && elements.length > 0 &&
<button className="expander-button btn btn-outline-secondary btn-sm" type="button" onClick={this.handleClick} > <button className="expander-button btn btn-outline-secondary btn-sm" type="button" onClick={handleToggle} >
{ {
this.state.collapseList ? collapseList ?
<UpIcon /> : <UpIcon /> :
<DownIcon /> <DownIcon />
} }
@ -78,7 +82,7 @@ class Legend extends React.Component<LegendProps, LegendState> {
{ {
elements.length === 0 ? elements.length === 0 ?
<p className="data-intro">Coming soon</p> : <p className="data-intro">Coming soon</p> :
<ul className={this.state.collapseList ? 'collapse data-legend' : 'data-legend'} > <ul className={collapseList ? 'collapse data-legend' : 'data-legend'} >
{ {
disclaimer && <p className='legend-disclaimer'>{disclaimer}</p> disclaimer && <p className='legend-disclaimer'>{disclaimer}</p>
} }
@ -108,9 +112,4 @@ class Legend extends React.Component<LegendProps, LegendState> {
} }
</div> </div>
); );
} }
}
export default Legend;

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

View File

@ -84,6 +84,27 @@ const LAYER_QUERIES = {
buildings buildings
WHERE WHERE
likes_total > 0`, 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: ` planning_combined: `
SELECT SELECT
geometry_id, geometry_id,