Refactor map app with hooks and separated config

This commit is contained in:
Maciej Ziarkowski 2021-02-22 06:59:24 +00:00
parent a881b66d76
commit 305f2f1671
42 changed files with 1093 additions and 912 deletions

View File

@ -9,7 +9,7 @@ describe('<App />', () => {
const div = document.createElement('div'); const div = document.createElement('div');
ReactDOM.render( ReactDOM.render(
<MemoryRouter> <MemoryRouter>
<App revisionId={0} /> <App revisionId="0" />
</MemoryRouter>, </MemoryRouter>,
div div
); );

View File

@ -5,10 +5,10 @@ import 'bootstrap/dist/css/bootstrap.min.css';
import './app.css'; import './app.css';
import { AuthRoute, PrivateRoute } from './route'; import { AuthRoute, PrivateRoute } from './route';
import { AuthContext, AuthProvider } from './auth-context'; import { AuthProvider } from './auth-context';
import { Header } from './header'; import { Header } from './header';
import MapApp from './map-app'; import { MapApp } from './map-app';
import { Building } from './models/building'; import { Building, UserVerified } from './models/building';
import { User } from './models/user'; import { User } from './models/user';
import AboutPage from './pages/about'; import AboutPage from './pages/about';
import ChangesPage from './pages/changes'; import ChangesPage from './pages/changes';
@ -33,8 +33,8 @@ interface AppProps {
user?: User; user?: User;
building?: Building; building?: Building;
building_like?: boolean; building_like?: boolean;
user_verified?: object; user_verified?: UserVerified;
revisionId: number; revisionId: string;
} }
/** /**
@ -80,20 +80,14 @@ export const App: React.FC<AppProps> = props => {
<Route exact path="/code-of-conduct.html" component={CodeOfConductPage} /> <Route exact path="/code-of-conduct.html" component={CodeOfConductPage} />
<Route exact path="/leaderboard.html" component={LeaderboardPage} /> <Route exact path="/leaderboard.html" component={LeaderboardPage} />
<Route exact path="/history.html" component={ChangesPage} /> <Route exact path="/history.html" component={ChangesPage} />
<Route exact path={mapAppPaths} render={(routeProps) => ( <Route exact path={mapAppPaths} >
<AuthContext.Consumer>
{({user}) =>
<MapApp <MapApp
{...routeProps}
building={props.building} building={props.building}
building_like={props.building_like} building_like={props.building_like}
user_verified={props.user_verified} user_verified={props.user_verified}
user={user}
revisionId={props.revisionId} revisionId={props.revisionId}
/> />
} </Route>
</AuthContext.Consumer>
)} />
<Route component={NotFound} /> <Route component={NotFound} />
</Switch> </Switch>
</AuthProvider> </AuthProvider>

View File

@ -1,30 +1,21 @@
import React from 'react'; import React from 'react';
import { useAuth } from '../auth-context';
import { Building } from '../models/building'; import { categoriesConfig, Category } from '../config/categories-config';
import { categoryUiConfig } from '../config/category-ui-config';
import { Building, UserVerified } from '../models/building';
import BuildingNotFound from './building-not-found'; import BuildingNotFound from './building-not-found';
import AgeContainer from './data-containers/age';
import CommunityContainer from './data-containers/community';
import ConstructionContainer from './data-containers/construction';
import DynamicsContainer from './data-containers/dynamics';
import LocationContainer from './data-containers/location';
import PlanningContainer from './data-containers/planning';
import SizeContainer from './data-containers/size';
import StreetscapeContainer from './data-containers/streetscape';
import SustainabilityContainer from './data-containers/sustainability';
import TeamContainer from './data-containers/team';
import TypeContainer from './data-containers/type';
import UseContainer from './data-containers/use';
interface BuildingViewProps { interface BuildingViewProps {
cat: string; cat: Category;
mode: 'view' | 'edit'; mode: 'view' | 'edit';
building?: Building; building?: Building;
building_like?: boolean; building_like?: boolean;
user?: any;
selectBuilding: (building: Building) => void;
user_verified?: any; user_verified?: any;
onBuildingUpdate: (buildingId: number, updatedData: Building) => void;
onBuildingLikeUpdate: (buildingId: number, updatedData: boolean) => void;
onUserVerifiedUpdate: (buildingId: number, updatedData: UserVerified) => void;
} }
/** /**
@ -33,100 +24,30 @@ interface BuildingViewProps {
* @param props * @param props
*/ */
const BuildingView: React.FunctionComponent<BuildingViewProps> = (props) => { const BuildingView: React.FunctionComponent<BuildingViewProps> = (props) => {
switch (props.cat) { const { user } = useAuth();
case 'location': const DataContainer = categoryUiConfig[props.cat];
return <LocationContainer
{...props} const categoryConfig = categoriesConfig[props.cat];
title="Location"
help="https://pages.colouring.london/location" if(categoryConfig == undefined) {
intro="Where are the buildings? Address, location and cross-references."
/>;
case 'use':
return <UseContainer
{...props}
inactive={false}
title="Current Use"
intro="How are buildings used, and how does use change over time? Coming soon…"
help="https://pages.colouring.london/use"
/>;
case 'type':
return <TypeContainer
{...props}
inactive={false}
title="Type"
intro="How were buildings previously used?"
help="https://www.pages.colouring.london/buildingtypology"
/>;
case 'age':
return <AgeContainer
{...props}
title="Age"
help="https://pages.colouring.london/age"
intro="Building age data can support energy analysis and help predict long-term change."
/>;
case 'size':
return <SizeContainer
{...props}
title="Size &amp; Shape"
intro="How big are buildings?"
help="https://pages.colouring.london/shapeandsize"
/>;
case 'construction':
return <ConstructionContainer
{...props}
title="Construction"
intro="How are buildings built?"
help="https://pages.colouring.london/construction"
/>;
case 'team':
return <TeamContainer
{...props}
title="Team"
intro="Who built the buildings? Coming soon…"
help="https://pages.colouring.london/team"
inactive={true}
/>;
case 'sustainability':
return <SustainabilityContainer
{...props}
title="Sustainability"
intro="Are buildings energy efficient?"
help="https://pages.colouring.london/sustainability"
inactive={false}
/>;
case 'streetscape':
return <StreetscapeContainer
{...props}
title="Streetscape"
intro="What's the building's context? Coming soon…"
help="https://pages.colouring.london/streetscape"
inactive={true}
/>;
case 'community':
return <CommunityContainer
{...props}
title="Community"
intro="How does this building work for the local community?"
help="https://pages.colouring.london/community"
/>;
case 'planning':
return <PlanningContainer
{...props}
title="Planning"
intro="Planning controls relating to protection and reuse."
help="https://pages.colouring.london/planning"
/>;
case 'dynamics':
return <DynamicsContainer
{...props}
title="Dynamics"
intro="How has the site of this building changed over time?"
help="https://pages.colouring.london/buildingcategories"
inactive={true}
/>;
default:
return <BuildingNotFound mode="view" />; return <BuildingNotFound mode="view" />;
} }
const {
name,
aboutUrl,
intro,
inactive = false
} = categoryConfig;
return <DataContainer
{...props}
title={name}
help={aboutUrl}
intro={intro}
inactive={inactive}
user={user}
/>;
}; };
export default BuildingView; export default BuildingView;

View File

@ -4,6 +4,7 @@ import { CategoryLink } from './category-link';
import { ListWrapper } from '../components/list-wrapper'; import { ListWrapper } from '../components/list-wrapper';
import './categories.css'; import './categories.css';
import { categoriesOrder, categoriesConfig } from '../config/categories-config';
interface CategoriesProps { interface CategoriesProps {
mode: 'view' | 'edit' | 'multi-edit'; mode: 'view' | 'edit' | 'multi-edit';
@ -12,102 +13,24 @@ interface CategoriesProps {
const Categories: React.FC<CategoriesProps> = (props) => ( const Categories: React.FC<CategoriesProps> = (props) => (
<ListWrapper className='data-category-list'> <ListWrapper className='data-category-list'>
<CategoryLink {categoriesOrder.map(category => {
title="Location" const {
slug="location" name,
help="https://pages.colouring.london/location" slug,
inactive={false} aboutUrl,
mode={props.mode} inactive = false
building_id={props.building_id} } = categoriesConfig[category];
/>
<CategoryLink return <CategoryLink
title="Current Use" key={category}
slug="use" title={name}
help="https://pages.colouring.london/use" slug={slug}
inactive={true} help={aboutUrl}
mode={props.mode} inactive={inactive}
building_id={props.building_id}
/>
<CategoryLink
title="Type"
slug="type"
help="https://pages.colouring.london/buildingtypology"
inactive={false}
mode={props.mode}
building_id={props.building_id}
/>
<CategoryLink
title="Age"
slug="age"
help="https://pages.colouring.london/age"
inactive={false}
mode={props.mode}
building_id={props.building_id}
/>
<CategoryLink
title="Size &amp; Shape"
slug="size"
help="https://pages.colouring.london/shapeandsize"
inactive={false}
mode={props.mode}
building_id={props.building_id}
/>
<CategoryLink
title="Construction"
slug="construction"
help="https://pages.colouring.london/construction"
inactive={false}
mode={props.mode}
building_id={props.building_id}
/>
<CategoryLink
title="Streetscape"
slug="streetscape"
help="https://pages.colouring.london/greenery"
inactive={true}
mode={props.mode}
building_id={props.building_id}
/>
<CategoryLink
title="Team"
slug="team"
help="https://pages.colouring.london/team"
inactive={true}
mode={props.mode}
building_id={props.building_id}
/>
<CategoryLink
title="Planning"
slug="planning"
help="https://pages.colouring.london/planning"
inactive={true}
mode={props.mode}
building_id={props.building_id}
/>
<CategoryLink
title="Sustainability"
slug="sustainability"
help="https://pages.colouring.london/sustainability"
inactive={false}
mode={props.mode}
building_id={props.building_id}
/>
<CategoryLink
title="Dynamics"
slug="dynamics"
help="https://pages.colouring.london/dynamics"
inactive={true}
mode={props.mode}
building_id={props.building_id}
/>
<CategoryLink
title="Community"
slug="community"
help="https://pages.colouring.london/community"
inactive={false}
mode={props.mode} mode={props.mode}
building_id={props.building_id} building_id={props.building_id}
/> />
})}
</ListWrapper> </ListWrapper>
); );

View File

@ -2,6 +2,7 @@ import React, { Fragment } from 'react';
import { NavLink } from 'react-router-dom'; import { NavLink } from 'react-router-dom';
import Tooltip from '../../components/tooltip'; import Tooltip from '../../components/tooltip';
import { Category } from '../../config/categories-config';
interface LikeDataEntryProps { interface LikeDataEntryProps {
@ -19,7 +20,7 @@ const LikeDataEntry: React.FunctionComponent<LikeDataEntryProps> = (props) => {
<Tooltip text="People who like the building and think it contributes to the city." /> <Tooltip text="People who like the building and think it contributes to the city." />
<div className="icon-buttons"> <div className="icon-buttons">
<NavLink <NavLink
to={`/multi-edit/like?data=${data_string}`} to={`/multi-edit/${Category.Community}?data=${data_string}`}
className="icon-button like"> className="icon-button like">
Like more Like more
</NavLink> </NavLink>

View File

@ -1,7 +1,7 @@
import React, { Component, Fragment } from 'react'; import React, { Component, Fragment } from 'react';
import Verification from './verification'; import Verification from './verification';
import { dataFields } from '../../data_fields'; import { dataFields } from '../../config/data-fields-config';
import { CopyProps } from '../data-containers/category-view-props'; import { CopyProps } from '../data-containers/category-view-props';
import NumericDataEntry from './numeric-data-entry'; import NumericDataEntry from './numeric-data-entry';

View File

@ -6,7 +6,7 @@ import { apiPost } from '../apiHelpers';
import ErrorBox from '../components/error-box'; import ErrorBox from '../components/error-box';
import InfoBox from '../components/info-box'; import InfoBox from '../components/info-box';
import { compareObjects } from '../helpers'; import { compareObjects } from '../helpers';
import { Building } from '../models/building'; import { Building, UserVerified } from '../models/building';
import { User } from '../models/user'; import { User } from '../models/user';
import ContainerHeader from './container-header'; import ContainerHeader from './container-header';
@ -26,7 +26,9 @@ interface DataContainerProps {
building?: Building; building?: Building;
building_like?: boolean; building_like?: boolean;
user_verified?: any; user_verified?: any;
selectBuilding: (building: Building) => void; onBuildingUpdate: (buildingId: number, updatedData: Building) => void;
onBuildingLikeUpdate: (buildingId: number, updatedData: boolean) => void;
onUserVerifiedUpdate: (buildingId: number, updatedData: UserVerified) => void;
} }
interface DataContainerState { interface DataContainerState {
@ -38,6 +40,8 @@ interface DataContainerState {
buildingEdits: Partial<Building>; buildingEdits: Partial<Building>;
} }
export type DataContainerType = React.ComponentType<DataContainerProps>;
/** /**
* Shared functionality for view/edit forms * Shared functionality for view/edit forms
* *
@ -46,7 +50,7 @@ interface DataContainerState {
* *
* @param WrappedComponent * @param WrappedComponent
*/ */
const withCopyEdit = (WrappedComponent: React.ComponentType<CategoryViewProps>) => { const withCopyEdit: (wc: React.ComponentType<CategoryViewProps>) => DataContainerType = (WrappedComponent: React.ComponentType<CategoryViewProps>) => {
return class DataContainer extends React.Component<DataContainerProps, DataContainerState> { return class DataContainer extends React.Component<DataContainerProps, DataContainerState> {
constructor(props) { constructor(props) {
super(props); super(props);
@ -174,8 +178,9 @@ const withCopyEdit = (WrappedComponent: React.ComponentType<CategoryViewProps>)
if (data.error) { if (data.error) {
this.setState({error: data.error}); this.setState({error: data.error});
} else { } else {
this.props.selectBuilding(data); // like endpoint returns whole building data so we can update both
this.updateBuildingState('likes_total', data.likes_total); this.props.onBuildingUpdate(this.props.building.building_id, data);
this.props.onBuildingLikeUpdate(this.props.building.building_id, like);
} }
} catch(err) { } catch(err) {
this.setState({error: err}); this.setState({error: err});
@ -195,7 +200,7 @@ const withCopyEdit = (WrappedComponent: React.ComponentType<CategoryViewProps>)
if (data.error) { if (data.error) {
this.setState({error: data.error}); this.setState({error: data.error});
} else { } else {
this.props.selectBuilding(data); this.props.onBuildingUpdate(this.props.building.building_id, data);
} }
} catch(err) { } catch(err) {
this.setState({error: err}); this.setState({error: err});
@ -227,7 +232,7 @@ const withCopyEdit = (WrappedComponent: React.ComponentType<CategoryViewProps>)
zIndex: 2000 zIndex: 2000
}); });
} }
this.props.selectBuilding(this.props.building); this.props.onUserVerifiedUpdate(this.props.building.building_id, data);
} }
} catch(err) { } catch(err) {
this.setState({error: err}); this.setState({error: err});

View File

@ -1,6 +1,6 @@
import React, { Fragment } from 'react'; import React, { Fragment } from 'react';
import { dataFields } from '../../data_fields'; import { dataFields } from '../../config/data-fields-config';
import MultiDataEntry from '../data-components/multi-data-entry/multi-data-entry'; import MultiDataEntry from '../data-components/multi-data-entry/multi-data-entry';
import NumericDataEntry from '../data-components/numeric-data-entry'; import NumericDataEntry from '../data-components/numeric-data-entry';
import SelectDataEntry from '../data-components/select-data-entry'; import SelectDataEntry from '../data-components/select-data-entry';

View File

@ -1,3 +1,5 @@
import { Building } from '../../models/building';
interface CopyProps { interface CopyProps {
copying: boolean; copying: boolean;
toggleCopying: () => void; toggleCopying: () => void;
@ -7,7 +9,7 @@ interface CopyProps {
interface CategoryViewProps { interface CategoryViewProps {
intro: string; intro: string;
building: any; // TODO: add Building type with all fields building: Building;
building_like: boolean; building_like: boolean;
mode: 'view' | 'edit' | 'multi-edit'; mode: 'view' | 'edit' | 'multi-edit';
edited: boolean; edited: boolean;

View File

@ -1,6 +1,6 @@
import React, { Fragment } from 'react'; import React, { Fragment } from 'react';
import { dataFields } from '../../data_fields'; import { dataFields } from '../../config/data-fields-config';
import SelectDataEntry from '../data-components/select-data-entry'; import SelectDataEntry from '../data-components/select-data-entry';
import withCopyEdit from '../data-container'; import withCopyEdit from '../data-container';

View File

@ -1,7 +1,7 @@
import React, { Fragment } from 'react'; import React, { Fragment } from 'react';
import InfoBox from '../../components/info-box'; import InfoBox from '../../components/info-box';
import { dataFields } from '../../data_fields'; import { dataFields } from '../../config/data-fields-config';
import DataEntry from '../data-components/data-entry'; import DataEntry from '../data-components/data-entry';
import NumericDataEntry from '../data-components/numeric-data-entry'; import NumericDataEntry from '../data-components/numeric-data-entry';
import UPRNsDataEntry from '../data-components/uprns-data-entry'; import UPRNsDataEntry from '../data-components/uprns-data-entry';

View File

@ -1,7 +1,7 @@
import React, { Fragment } from 'react'; import React, { Fragment } from 'react';
import InfoBox from '../../components/info-box'; import InfoBox from '../../components/info-box';
import { dataFields } from '../../data_fields'; import { dataFields } from '../../config/data-fields-config';
import CheckboxDataEntry from '../data-components/checkbox-data-entry'; import CheckboxDataEntry from '../data-components/checkbox-data-entry';
import DataEntry from '../data-components/data-entry'; import DataEntry from '../data-components/data-entry';
import { DataEntryGroup } from '../data-components/data-entry-group'; import { DataEntryGroup } from '../data-components/data-entry-group';

View File

@ -1,6 +1,6 @@
import React, { Fragment } from 'react'; import React, { Fragment } from 'react';
import { dataFields } from '../../data_fields'; import { dataFields } from '../../config/data-fields-config';
import { DataEntryGroup } from '../data-components/data-entry-group'; import { DataEntryGroup } from '../data-components/data-entry-group';
import NumericDataEntry from '../data-components/numeric-data-entry'; import NumericDataEntry from '../data-components/numeric-data-entry';
import SelectDataEntry from '../data-components/select-data-entry'; import SelectDataEntry from '../data-components/select-data-entry';

View File

@ -1,6 +1,6 @@
import React, { Fragment } from 'react'; import React, { Fragment } from 'react';
import { dataFields } from '../../data_fields'; import { dataFields } from '../../config/data-fields-config';
import NumericDataEntry from '../data-components/numeric-data-entry'; import NumericDataEntry from '../data-components/numeric-data-entry';
import SelectDataEntry from '../data-components/select-data-entry'; import SelectDataEntry from '../data-components/select-data-entry';
import Verification from '../data-components/verification'; import Verification from '../data-components/verification';

View File

@ -1,6 +1,6 @@
import React, { Fragment } from 'react'; import React, { Fragment } from 'react';
import { dataFields } from '../../data_fields'; import { dataFields } from '../../config/data-fields-config';
import DataEntry from '../data-components/data-entry'; import DataEntry from '../data-components/data-entry';
import NumericDataEntry from '../data-components/numeric-data-entry'; import NumericDataEntry from '../data-components/numeric-data-entry';
import SelectDataEntry from '../data-components/select-data-entry'; import SelectDataEntry from '../data-components/select-data-entry';

View File

@ -1,7 +1,7 @@
import React, { Fragment } from 'react'; import React, { Fragment } from 'react';
import InfoBox from '../../components/info-box'; import InfoBox from '../../components/info-box';
import { dataFields } from '../../data_fields'; import { dataFields } from '../../config/data-fields-config';
import DataEntry from '../data-components/data-entry'; import DataEntry from '../data-components/data-entry';
import MultiDataEntry from '../data-components/multi-data-entry/multi-data-entry'; import MultiDataEntry from '../data-components/multi-data-entry/multi-data-entry';
import withCopyEdit from '../data-container'; import withCopyEdit from '../data-container';

View File

@ -3,7 +3,8 @@ import { Link } from 'react-router-dom';
import './building-edit-summary.css'; import './building-edit-summary.css';
import { Category, DataFieldDefinition, dataFields } from '../../data_fields'; import { Category } from '../../config/categories-config';
import { DataFieldDefinition, dataFields } from '../../config/data-fields-config';
import { arrayToDictionary, parseDate } from '../../helpers'; import { arrayToDictionary, parseDate } from '../../helpers';
import { EditHistoryEntry } from '../../models/edit-history-entry'; import { EditHistoryEntry } from '../../models/edit-history-entry';
@ -30,11 +31,15 @@ function enrichHistoryEntries(forwardPatch: object, reversePatch: object) {
return Object return Object
.entries(forwardPatch) .entries(forwardPatch)
.map(([key, value]) => { .map(([key, value]) => {
const info = dataFields[key] || {} as DataFieldDefinition; const {
title = `Unknown field (${key})`,
category = undefined
} = dataFields[key] as DataFieldDefinition ?? {};
return { return {
title: info.title || `Unknown field (${key})`, title,
category: info.category || Category.Unknown, category,
value: value, value,
oldValue: reversePatch && reversePatch[key] oldValue: reversePatch && reversePatch[key]
}; };
}); });
@ -65,6 +70,7 @@ const BuildingEditSummary: React.FunctionComponent<BuildingEditSummaryProps> = (
{ {
Object.entries(entriesByCategory).map(([category, fields]) => Object.entries(entriesByCategory).map(([category, fields]) =>
<CategoryEditSummary <CategoryEditSummary
key={category}
category={category as keyof typeof Category} // https://github.com/microsoft/TypeScript/issues/14106 category={category as keyof typeof Category} // https://github.com/microsoft/TypeScript/issues/14106
fields={fields} fields={fields}
hyperlinkCategory={hyperlinkCategories} hyperlinkCategory={hyperlinkCategories}

View File

@ -3,7 +3,7 @@ import { Link } from 'react-router-dom';
import './category-edit-summary.css'; import './category-edit-summary.css';
import { categories, Category } from '../../data_fields'; import { categoriesConfig, Category } from '../../config/categories-config';
import { FieldEditSummary } from './field-edit-summary'; import { FieldEditSummary } from './field-edit-summary';
@ -19,10 +19,10 @@ interface CategoryEditSummaryProps {
} }
const CategoryEditSummary: React.FunctionComponent<CategoryEditSummaryProps> = props => { const CategoryEditSummary: React.FunctionComponent<CategoryEditSummaryProps> = props => {
const category = Category[props.category]; const {
const categoryInfo = categories[category] || {name: undefined, slug: undefined}; name: categoryName = 'Unknown category',
const categoryName = categoryInfo.name || 'Unknown category'; slug: categorySlug = 'categories'
const categorySlug = categoryInfo.slug || 'categories'; } = categoriesConfig[props.category] ?? {};
return ( return (
<div className='edit-history-category-summary'> <div className='edit-history-category-summary'>

View File

@ -1,80 +1,54 @@
import React from 'react'; import React from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { parseJsonOrDefault } from '../../helpers'; import { useMultiEditData } from '../hooks/use-multi-edit-data';
import ErrorBox from '../components/error-box'; import ErrorBox from '../components/error-box';
import InfoBox from '../components/info-box'; import InfoBox from '../components/info-box';
import { dataFields } from '../data_fields'; import { dataFields } from '../config/data-fields-config';
import { User } from '../models/user';
import DataEntry from './data-components/data-entry'; import DataEntry from './data-components/data-entry';
import Sidebar from './sidebar'; import { Category } from '../config/categories-config';
import Categories from './categories';
interface MultiEditProps { interface MultiEditProps {
user?: User;
category: string; category: string;
dataString: string;
} }
const MultiEdit: React.FC<MultiEditProps> = (props) => { const MultiEdit: React.FC<MultiEditProps> = (props) => {
if (props.category === 'like') { const [data, error] = useMultiEditData();
// special case for likes
return (
<Sidebar>
<Categories mode={'view'} />
<section className='data-section'>
<header className={`section-header view ${props.category} background-${props.category}`}>
<h2 className="h2">Like me!</h2>
</header>
<form className='buttons-container'>
<InfoBox msg='Click all the buildings that you like and think contribute to the city!' />
<Link to='/view/like' className='btn btn-secondary'>Back to view</Link> const isLike = props.category === Category.Community;
<Link to='/edit/like' className='btn btn-secondary'>Back to edit</Link>
</form>
</section>
</Sidebar>
);
}
let data = parseJsonOrDefault(props.dataString);
let error: string;
if(data == null) {
error = 'Invalid parameters supplied';
data = {};
} else if(Object.values(data).some(x => x == undefined)) {
error = 'Cannot copy empty values';
data = {};
}
return ( return (
<Sidebar>
<Categories mode={'view'} />
<section className='data-section'> <section className='data-section'>
<header className={`section-header view ${props.category} background-${props.category}`}> <header className={`section-header view ${props.category} background-${props.category}`}>
<h2 className="h2">Copy {props.category} data</h2> <h2 className="h2">{
isLike ?
<>Like Me!</> :
<>Copy {props.category} data</>
}</h2>
</header> </header>
<div className="section-body"> <div className="section-body">
<form> <form>
{ {
error ? error ?
<ErrorBox msg={error} /> : <ErrorBox msg={error} /> :
<InfoBox msg='Click buildings one at a time to colour using the data below' /> <InfoBox msg={
isLike ?
'Click all the buildings that you like and think contribute to the city!' :
'Click buildings one at a time to colour using the data below'
} />
} }
{ {
Object.keys(data).map((key => { !isLike && data &&
const info = dataFields[key] || {}; Object.keys(data).map((key => (
return (
<DataEntry <DataEntry
title={info.title || `Unknown field (${key})`} key={key}
title={dataFields[key]?.title ?? `Unknown field (${key})`}
slug={key} slug={key}
disabled={true} disabled={true}
value={data[key]} value={data[key]}
/> />
); )))
}))
} }
</form> </form>
<form className='buttons-container'> <form className='buttons-container'>
@ -83,7 +57,6 @@ const MultiEdit: React.FC<MultiEditProps> = (props) => {
</form> </form>
</div> </div>
</section> </section>
</Sidebar>
); );
}; };

View File

@ -1,4 +1,4 @@
import React, { Fragment } from 'react'; import React from 'react';
interface ErrorBoxProps { interface ErrorBoxProps {
msg: string; msg: string;
@ -8,22 +8,16 @@ const ErrorBox: React.FC<ErrorBoxProps> = (props) => {
if (props.msg) { if (props.msg) {
console.error(props.msg); console.error(props.msg);
} }
return (
<Fragment> return props.msg ?
{
(props.msg)?
(
<div className="alert alert-danger" role="alert"> <div className="alert alert-danger" role="alert">
{ {
typeof props.msg === 'string' ? typeof props.msg === 'string' ?
props.msg props.msg
: 'Unexpected error' : 'Unexpected error'
} }
</div> </div> :
) : null null;
}
</Fragment>
);
}; };
export default ErrorBox; export default ErrorBox;

View File

@ -0,0 +1,124 @@
/**
* An enumeration of all categories in the system.
* The string value is also the category URL slug.
*/
export enum Category {
Location = 'location',
LandUse = 'use',
Type = 'type',
Age = 'age',
SizeShape = 'size',
Construction = 'construction',
Streetscape = 'streetscape',
Team = 'team',
Planning = 'planning',
Sustainability = 'sustainability',
Dynamics = 'dynamics',
Community = 'community',
}
/**
* This is the sole configuration variable that defines the order of the categories
* in the category grid. The order in the enum defition or the other configs does
* not affect the order of the grid.
*/
export const categoriesOrder: Category[] = [
Category.Location,
Category.LandUse,
Category.Type,
Category.Age,
Category.SizeShape,
Category.Construction,
Category.Streetscape,
Category.Team,
Category.Planning,
Category.Sustainability,
Category.Dynamics,
Category.Community,
];
interface CategoryDefinition {
inactive?: boolean;
slug: string;
name: string;
aboutUrl: string;
intro: string;
}
export const categoriesConfig: {[key in Category]: CategoryDefinition} = {
[Category.Age]: {
slug: 'age',
name: 'Age',
aboutUrl: 'https://pages.colouring.london/age',
intro: 'Building age data can support energy analysis and help predict long-term change.',
},
[Category.SizeShape]: {
slug: 'size',
name: 'Size & Shape',
aboutUrl: 'https://pages.colouring.london/shapeandsize',
intro: 'How big are buildings?',
},
[Category.Team]: {
inactive: true,
slug: 'team',
name: 'Team',
aboutUrl: 'https://pages.colouring.london/team',
intro: 'Who built the buildings? Coming soon…',
},
[Category.Construction]: {
slug: 'construction',
name: 'Construction',
aboutUrl: 'https://pages.colouring.london/construction',
intro: 'How are buildings built?',
},
[Category.Location]: {
slug: 'location',
name: 'Location',
aboutUrl: 'https://pages.colouring.london/location',
intro: 'Where are the buildings? Address, location and cross-references.',
},
[Category.Community]: {
slug: 'community',
name: 'Community',
aboutUrl: 'https://pages.colouring.london/community',
intro: 'How does this building work for the local community?',
},
[Category.Planning]: {
slug: 'planning',
name: 'Planning',
aboutUrl: 'https://pages.colouring.london/planning',
intro: 'Planning controls relating to protection and reuse.',
},
[Category.Sustainability]: {
slug: 'sustainability',
name: 'Sustainability',
aboutUrl: 'https://pages.colouring.london/sustainability',
intro: 'Are buildings energy efficient?',
},
[Category.Type]: {
slug: 'type',
name: 'Type',
aboutUrl: 'https://pages.colouring.london/buildingtypology',
intro: 'How were buildings previously used?',
},
[Category.LandUse]: {
slug: 'use',
name: 'Current Use',
aboutUrl: 'https://pages.colouring.london/use',
intro: 'How are buildings used, and how does use change over time?',
},
[Category.Streetscape]: {
inactive: true,
slug: 'streetscape',
name: 'Streetscape',
aboutUrl: 'https://pages.colouring.london/greenery',
intro: "What's the building's context? Coming soon…",
},
[Category.Dynamics]: {
inactive: true,
slug: 'dynamics',
name: 'Dynamics',
aboutUrl: 'https://pages.colouring.london/dynamics',
intro: 'How has the site of this building changed over time?'
},
};

View File

@ -0,0 +1,195 @@
import { Category } from './categories-config';
export type LegendElement = {
color: string;
border?: string;
text: string;
} | {
subtitle: string;
};
export interface LegendConfig {
title: string;
description?: string;
disclaimer?: string;
elements: LegendElement[];
}
export interface CategoryMapDefinition {
mapStyle: string;
legend: LegendConfig;
}
export const defaultMapCategory = Category.Age;
export const categoryMapsConfig: {[key in Category]: CategoryMapDefinition} = {
[Category.Age]: {
mapStyle: 'date_year',
legend: {
title: 'Age',
elements: [
{ color: '#fff9b8', text: '>2020' },
{ color: '#fae269', text: '2000-2019' },
{ color: '#fbaf27', text: '1980-1999' },
{ color: '#e6711d', text: '1960-1979' },
{ color: '#cc1212', text: '1940-1959' },
{ color: '#8f0303', text: '1920-1939' },
{ color: '#8f5385', text: '1900-1919' },
{ color: '#c3e1eb', text: '1880-1899' },
{ color: '#6a9dba', text: '1860-1879' },
{ color: '#3b74a3', text: '1840-1859' },
{ color: '#95ded8', text: '1820-1839' },
{ color: '#68aba5', text: '1800-1819' },
{ color: '#acc98f', text: '1750-1799' },
{ color: '#6d8a51', text: '1700-1749' },
{ color: '#d0c291', text: '<1700' },
]
},
},
[Category.SizeShape]: {
mapStyle: 'size_height',
legend: {
title: 'Height to apex',
elements: [
{ color: '#f7f4f9', text: '0-5.55'},
{ color: '#e7e1ef', text: '5.55-7.73'},
{ color: '#d4b9da', text: '7.73-11.38'},
{ color: '#c994c7', text: '11.38-18.45'},
{ color: '#df65b0', text: '18.45-35.05'},
{ color: '#e7298a', text: '35.05-89.30'},
{ color: '#ce1256', text: '89.30-152'},
{ color: '#980043', text: '≥152'}
]
},
},
[Category.Team]: {
mapStyle: undefined,
legend: {
title: 'Team',
elements: []
},
},
[Category.Construction]: {
mapStyle: 'construction_core_material',
legend: {
title: 'Construction',
elements: [
{ color: "#96613b", text: "Wood" },
{ color: "#ffffe3", text: "Stone" },
{ color: "#f5d96b", text: "Brick" },
{ color: "#beffe8", text: "Steel" },
{ color: "#fca89d", text: "Reinforced Concrete" },
{ color: "#5c8970", text: "Other Metal" },
{ color: "#b5a859", text: "Other Natural Material" },
{ color: "#c48a85", text: "Other Man-Made Material" }
]
},
},
[Category.Location]: {
mapStyle: 'location',
legend: {
title: 'Location',
description: '% data collected',
elements: [
{ color: '#084081', text: '≥80%' },
{ color: '#0868ac', text: '6080%' },
{ color: '#43a2ca', text: '4060%' },
{ color: '#7bccc4', text: '2040%' },
{ 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.Planning]: {
mapStyle: 'planning_combined',
legend: {
title: 'Statutory protections',
disclaimer: 'All data relating to designated buildings should be checked on the National Heritage List for England or local authority websites where used for planning or development purposes',
elements: [
{ color: '#95beba', text: 'In conservation area'},
{ color: '#c72e08', text: 'Grade I listed'},
{ color: '#e75b42', text: 'Grade II* listed'},
{ color: '#ffbea1', text: 'Grade II listed'},
{ color: '#858ed4', text: 'Locally listed'},
]
},
},
[Category.Sustainability]: {
mapStyle: 'sust_Dec',
legend: {
title: 'Sustainability',
description: 'DEC Rating',
elements: [
{ color: "#007f3d", text: 'A' },
{ color: "#2c9f29", text: 'B' },
{ color: "#9dcb3c", text: 'C' },
{ color: "#fff200", text: 'D' },
{ color: "#f7af1d", text: 'E' },
{ color: "#ed6823", text: 'F' },
{ color: "#e31d23", text: 'G' },
]
},
},
[Category.Type]: {
mapStyle: 'building_attachment_form',
legend: {
title: 'Type',
elements: [
{ color: "#f2a2b9", text: "Detached" },
{ color: "#ab8fb0", text: "Semi-Detached" },
{ color: "#3891d1", text: "End-Terrace" },
{ color: "#226291", text: "Mid-Terrace" }
]
},
},
[Category.LandUse]: {
mapStyle: 'landuse',
legend: {
title: 'Land Use',
elements: [
{ color: '#e5050d', text: 'Mixed Use' },
{ subtitle: 'Single use:'},
{ color: '#4a54a6', text: 'Residential' },
{ color: '#ff8c00', text: 'Retail' },
{ color: '#f5f58f', text: 'Industry & Business' },
{ color: '#73ccd1', text: 'Community Services' },
{ color: '#ffbfbf', text: 'Recreation & Leisure' },
{ color: '#b3de69', text: 'Transport' },
{ color: '#cccccc', text: 'Utilities & Infrastructure' },
{ color: '#898944', text: 'Defence' },
{ color: '#fa667d', text: 'Agriculture' },
{ color: '#53f5dd', text: 'Minerals' },
{ color: '#ffffff', text: 'Vacant & Derelict' }
]
},
},
[Category.Streetscape]: {
mapStyle: undefined,
legend: {
title: 'Streetscape',
elements: []
},
},
[Category.Dynamics]: {
mapStyle: undefined,
legend: {
title: 'Dynamics',
elements: []
},
}
};

View File

@ -0,0 +1,32 @@
import { Category } from './categories-config';
import AgeContainer from '../building/data-containers/age';
import CommunityContainer from '../building/data-containers/community';
import ConstructionContainer from '../building/data-containers/construction';
import DynamicsContainer from '../building/data-containers/dynamics';
import LocationContainer from '../building/data-containers/location';
import PlanningContainer from '../building/data-containers/planning';
import SizeContainer from '../building/data-containers/size';
import StreetscapeContainer from '../building/data-containers/streetscape';
import SustainabilityContainer from '../building/data-containers/sustainability';
import TeamContainer from '../building/data-containers/team';
import TypeContainer from '../building/data-containers/type';
import UseContainer from '../building/data-containers/use';
import { DataContainerType } from '../building/data-container';
export const categoryUiConfig: {[key in Category]: DataContainerType} = {
[Category.Location]: LocationContainer,
[Category.LandUse]: UseContainer,
[Category.Type]: TypeContainer,
[Category.Age]: AgeContainer,
[Category.SizeShape]: SizeContainer,
[Category.Construction]: ConstructionContainer,
[Category.Streetscape]: StreetscapeContainer,
[Category.Team]: TeamContainer,
[Category.Planning]: PlanningContainer,
[Category.Sustainability]: SustainabilityContainer,
[Category.Dynamics]: DynamicsContainer,
[Category.Community]: CommunityContainer,
};

View File

@ -1,85 +1,4 @@
export enum Category { import { Category } from './categories-config';
Location = 'Location',
LandUse = 'LandUse',
Type = 'Type',
Age = 'Age',
SizeShape = 'SizeShape',
Construction = 'Construction',
Streetscape = 'Streetscape',
Team = 'Team',
Sustainability = 'Sustainability',
Community = 'Community',
Planning = 'Planning',
Like = 'Like',
Unknown = 'Unknown'
}
export const categories = {
[Category.Location]: {
slug: 'location',
name: 'Location'
},
[Category.LandUse]: {
slug: 'use',
name: 'Land Use'
},
[Category.Type]: {
slug: 'type',
name: 'Type'
},
[Category.Age]: {
slug: 'age',
name: 'Age'
},
[Category.SizeShape]: {
slug: 'size',
name: 'Size & Shape'
},
[Category.Construction]: {
slug: 'construction',
name: 'Construction'
},
[Category.Streetscape]: {
slug: 'streetscape',
name: 'Streetscape'
},
[Category.Team]: {
slug: 'team',
name: 'Team'
},
[Category.Sustainability]: {
slug: 'sustainability',
name: 'Sustainability'
},
[Category.Community]: {
slug: 'community',
name: 'Community'
},
[Category.Planning]: {
slug: 'planning',
name: 'Planning'
},
[Category.Like]: {
slug: 'like',
name: 'Like Me!'
}
};
export const categoriesOrder: Category[] = [
Category.Location,
Category.LandUse,
Category.Type,
Category.Age,
Category.SizeShape,
Category.Construction,
Category.Streetscape,
Category.Team,
Category.Sustainability,
Category.Community,
Category.Planning,
Category.Like,
];
/** /**
* This interface is used only in code which uses dataFields, not in the dataFields definition itself * This interface is used only in code which uses dataFields, not in the dataFields definition itself
@ -91,42 +10,51 @@ export interface DataFieldDefinition {
category: Category; category: Category;
title: string; title: string;
tooltip?: string; tooltip?: string;
properties?: { [key: string]: DataFieldDefinition};
example: any; // the example field is used to automatically determine the type of the properties in the Building interface
} }
export const dataFields = { export const dataFields = { /* eslint-disable @typescript-eslint/camelcase */
location_name: { location_name: {
category: Category.Location, category: Category.Location,
title: "Building Name", title: "Building Name",
tooltip: "May not be needed for many buildings.", tooltip: "May not be needed for many buildings.",
example: "The Cruciform",
}, },
location_number: { location_number: {
category: Category.Location, category: Category.Location,
title: "Building number", title: "Building number",
example: 12,
}, },
location_street: { location_street: {
category: Category.Location, category: Category.Location,
title: "Street", title: "Street",
example: "Gower Street",
//tooltip: , //tooltip: ,
}, },
location_line_two: { location_line_two: {
category: Category.Location, category: Category.Location,
title: "Address line 2", title: "Address line 2",
example: "Flat 21",
//tooltip: , //tooltip: ,
}, },
location_town: { location_town: {
category: Category.Location, category: Category.Location,
title: "Town", title: "Town",
example: "London",
//tooltip: , //tooltip: ,
}, },
location_postcode: { location_postcode: {
category: Category.Location, category: Category.Location,
title: "Postcode", title: "Postcode",
example: "W1W 6TR",
//tooltip: , //tooltip: ,
}, },
ref_toid: { ref_toid: {
category: Category.Location, category: Category.Location,
title: "TOID", title: "TOID",
tooltip: "Ordnance Survey Topography Layer ID (to be filled automatically)", tooltip: "Ordnance Survey Topography Layer ID (to be filled automatically)",
example: "",
}, },
/** /**
@ -136,43 +64,51 @@ export const dataFields = {
uprns: { uprns: {
category: Category.Location, category: Category.Location,
title: "UPRNs", title: "UPRNs",
tooltip: "Unique Property Reference Numbers (to be filled automatically)" tooltip: "Unique Property Reference Numbers (to be filled automatically)",
example: [{uprn: "", parent_uprn: "" }, {uprn: "", parent_uprn: "" }],
}, },
ref_osm_id: { ref_osm_id: {
category: Category.Location, category: Category.Location,
title: "OSM ID", title: "OSM ID",
tooltip: "OpenStreetMap feature ID", tooltip: "OpenStreetMap feature ID",
example: "",
}, },
location_latitude: { location_latitude: {
category: Category.Location, category: Category.Location,
title: "Latitude", title: "Latitude",
example: 12.4564,
}, },
location_longitude: { location_longitude: {
category: Category.Location, category: Category.Location,
title: "Longitude", title: "Longitude",
example: 0.12124,
}, },
current_landuse_group: { current_landuse_group: {
category: Category.LandUse, category: Category.LandUse,
title: "Current Land Use (Group)", title: "Current Land Use (Group)",
tooltip: "Land use Groups as classified by [NLUD](https://www.gov.uk/government/statistics/national-land-use-database-land-use-and-land-cover-classification)" tooltip: "Land use Groups as classified by [NLUD](https://www.gov.uk/government/statistics/national-land-use-database-land-use-and-land-cover-classification)",
example: ["", ""],
}, },
current_landuse_order: { current_landuse_order: {
category: Category.LandUse, category: Category.LandUse,
title: "Current Land Use (Order)", title: "Current Land Use (Order)",
tooltip: "Land use Order as classified by [NLUD](https://www.gov.uk/government/statistics/national-land-use-database-land-use-and-land-cover-classification)" tooltip: "Land use Order as classified by [NLUD](https://www.gov.uk/government/statistics/national-land-use-database-land-use-and-land-cover-classification)",
example: "",
}, },
building_attachment_form: { building_attachment_form: {
category: Category.Type, category: Category.Type,
title: "Building configuration (attachment)?", title: "Building configuration (attachment)?",
tooltip: "We have prepopulated these based on their current attachment. A building can either be detached, semi-detached or part of a terrace (middle or end)", tooltip: "We have prepopulated these based on their current attachment. A building can either be detached, semi-detached or part of a terrace (middle or end)",
example: "",
}, },
date_change_building_use: { date_change_building_use: {
category: Category.Type, category: Category.Type,
title:"When did use change?", title:"When did use change?",
tooltip: "This is the date the building stopped being used for for the function it was built for. I.e. if it was Victorian warehouse which is now an office this would be when it became an office or if it was something before that, maybe a garage then the date that happened", tooltip: "This is the date the building stopped being used for for the function it was built for. I.e. if it was Victorian warehouse which is now an office this would be when it became an office or if it was something before that, maybe a garage then the date that happened",
example: 1920,
}, },
/** /**
* original_building_use does not exist in database yet. * original_building_use does not exist in database yet.
@ -182,101 +118,121 @@ export const dataFields = {
category: Category.Type, category: Category.Type,
title: "Original building use", title: "Original building use",
tooltip: "What was the building originally used for when it was built? I.e. If it was Victorian warehouse which is now an office this would be warehouse", tooltip: "What was the building originally used for when it was built? I.e. If it was Victorian warehouse which is now an office this would be warehouse",
example: "",
}, },
date_year: { date_year: {
category: Category.Age, category: Category.Age,
title: "Year built (best estimate)" title: "Year built (best estimate)",
example: 1924,
}, },
date_lower : { date_lower : {
category: Category.Age, category: Category.Age,
title: "Earliest possible start date", title: "Earliest possible start date",
tooltip: "This should be the earliest year in which building could have started." tooltip: "This should be the earliest year in which building could have started.",
example: 1900,
}, },
date_upper: { date_upper: {
category: Category.Age, category: Category.Age,
title: "Latest possible start year", title: "Latest possible start year",
tooltip: "This should be the latest year in which building could have started." tooltip: "This should be the latest year in which building could have started.",
example: 2000,
}, },
facade_year: { facade_year: {
category: Category.Age, category: Category.Age,
title: "Facade year", title: "Facade year",
tooltip: "Best estimate" tooltip: "Best estimate",
example: 1900,
}, },
date_source: { date_source: {
category: Category.Age, category: Category.Age,
title: "Source of information", title: "Source of information",
tooltip: "Source for the main start date" tooltip: "Source for the main start date",
example: "",
}, },
date_source_detail: { date_source_detail: {
category: Category.Age, category: Category.Age,
title: "Source details", title: "Source details",
tooltip: "References for date source (max 500 characters)" tooltip: "References for date source (max 500 characters)",
example: "",
}, },
date_link: { date_link: {
category: Category.Age, category: Category.Age,
title: "Text and Image Links", title: "Text and Image Links",
tooltip: "URL for age and date reference", tooltip: "URL for age and date reference",
example: ["", "", ""],
}, },
size_storeys_core: { size_storeys_core: {
category: Category.SizeShape, category: Category.SizeShape,
title: "Core storeys", title: "Core storeys",
tooltip: "How many storeys between the pavement and start of roof?", tooltip: "How many storeys between the pavement and start of roof?",
example: 10,
}, },
size_storeys_attic: { size_storeys_attic: {
category: Category.SizeShape, category: Category.SizeShape,
title: "Attic storeys", title: "Attic storeys",
tooltip: "How many storeys above start of roof?", tooltip: "How many storeys above start of roof?",
example: 1,
}, },
size_storeys_basement: { size_storeys_basement: {
category: Category.SizeShape, category: Category.SizeShape,
title: "Basement storeys", title: "Basement storeys",
tooltip: "How many storeys below pavement level?", tooltip: "How many storeys below pavement level?",
example: 1,
}, },
size_height_apex: { size_height_apex: {
category: Category.SizeShape, category: Category.SizeShape,
title: "Height to apex (m)", title: "Height to apex (m)",
example: 100.5,
//tooltip: , //tooltip: ,
}, },
size_height_eaves: { size_height_eaves: {
category: Category.SizeShape, category: Category.SizeShape,
title: "Height to eaves (m)", title: "Height to eaves (m)",
example: 20.33,
//tooltip: , //tooltip: ,
}, },
size_floor_area_ground: { size_floor_area_ground: {
category: Category.SizeShape, category: Category.SizeShape,
title: "Ground floor area (m²)", title: "Ground floor area (m²)",
example: 1245.6,
//tooltip: , //tooltip: ,
}, },
size_floor_area_total: { size_floor_area_total: {
category: Category.SizeShape, category: Category.SizeShape,
title: "Total floor area (m²)", title: "Total floor area (m²)",
example: 2001.7,
//tooltip: , //tooltip: ,
}, },
size_width_frontage: { size_width_frontage: {
category: Category.SizeShape, category: Category.SizeShape,
title: "Frontage Width (m)", title: "Frontage Width (m)",
example: 12.2,
//tooltip: , //tooltip: ,
}, },
size_plot_area_total: { size_plot_area_total: {
category: Category.SizeShape, category: Category.SizeShape,
title: "Total area of plot (m²)", title: "Total area of plot (m²)",
example: 123.02,
//tooltip: , //tooltip: ,
}, },
size_far_ratio: { size_far_ratio: {
category: Category.SizeShape, category: Category.SizeShape,
title: "FAR ratio (percentage of plot covered by building)", title: "FAR ratio (percentage of plot covered by building)",
example: 0.1,
//tooltip: , //tooltip: ,
}, },
size_configuration: { size_configuration: {
category: Category.SizeShape, category: Category.SizeShape,
title: "Configuration (semi/detached, end/terrace)", title: "Configuration (semi/detached, end/terrace)",
example: "",
//tooltip: , //tooltip: ,
}, },
size_roof_shape: { size_roof_shape: {
category: Category.SizeShape, category: Category.SizeShape,
title: "Roof shape", title: "Roof shape",
example: "",
//tooltip: , //tooltip: ,
}, },
@ -284,155 +240,172 @@ export const dataFields = {
category: Category.Construction, category: Category.Construction,
title: "Core Material", title: "Core Material",
tooltip:"The main structural material", tooltip:"The main structural material",
example: "",
}, },
construction_secondary_materials: { construction_secondary_materials: {
category: Category.Construction, category: Category.Construction,
title: "Secondary Construction Material/s", title: "Secondary Construction Material/s",
tooltip:"Other construction materials", tooltip:"Other construction materials",
example: "",
}, },
construction_roof_covering: { construction_roof_covering: {
category: Category.Construction, category: Category.Construction,
title: "Main Roof Covering", title: "Main Roof Covering",
tooltip:'Main roof covering material', tooltip:'Main roof covering material',
example: "",
}, },
sust_breeam_rating: { sust_breeam_rating: {
category: Category.Sustainability, category: Category.Sustainability,
title: "BREEAM Rating", title: "BREEAM Rating",
tooltip: "(Building Research Establishment Environmental Assessment Method) May not be present for many buildings", tooltip: "(Building Research Establishment Environmental Assessment Method) May not be present for many buildings",
example: "",
}, },
sust_dec: { sust_dec: {
category: Category.Sustainability, category: Category.Sustainability,
title: "DEC Rating", title: "DEC Rating",
tooltip: "(Display Energy Certificate) Any public building should have (and display) a DEC. Showing how the energy use for that building compares to other buildings with same use", tooltip: "(Display Energy Certificate) Any public building should have (and display) a DEC. Showing how the energy use for that building compares to other buildings with same use",
example: "G",
}, },
sust_aggregate_estimate_epc: { sust_aggregate_estimate_epc: {
category: Category.Sustainability, category: Category.Sustainability,
title: "EPC Rating", title: "EPC Rating",
tooltip: "(Energy Performance Certifcate) Any premises sold or rented is required to have an EPC to show how energy efficient it is. Only buildings rate grade E or higher maybe rented", tooltip: "(Energy Performance Certifcate) Any premises sold or rented is required to have an EPC to show how energy efficient it is. Only buildings rate grade E or higher maybe rented",
example: "",
}, },
sust_retrofit_date: { sust_retrofit_date: {
category: Category.Sustainability, category: Category.Sustainability,
title: "Last significant retrofit", title: "Last significant retrofit",
tooltip: "Date of last major building refurbishment", tooltip: "Date of last major building refurbishment",
example: 1920,
}, },
sust_life_expectancy: { sust_life_expectancy: {
category: Category.Sustainability, category: Category.Sustainability,
title: "Expected lifespan for typology", title: "Expected lifespan for typology",
example: 123,
//tooltip: , //tooltip: ,
}, },
planning_portal_link: { planning_portal_link: {
category: Category.Planning, category: Category.Planning,
title: "Planning portal link", title: "Planning portal link",
example: "",
//tooltip: , //tooltip: ,
}, },
planning_in_conservation_area: { planning_in_conservation_area: {
category: Category.Planning, category: Category.Planning,
title: "In a conservation area?", title: "In a conservation area?",
example: true,
//tooltip: , //tooltip: ,
}, },
planning_conservation_area_name: { planning_conservation_area_name: {
category: Category.Planning, category: Category.Planning,
title: "Conservation area name", title: "Conservation area name",
example: "",
//tooltip: , //tooltip: ,
}, },
planning_in_list: { planning_in_list: {
category: Category.Planning, category: Category.Planning,
title: "Is listed on the National Heritage List for England?", title: "Is listed on the National Heritage List for England?",
example: true,
//tooltip: , //tooltip: ,
}, },
planning_list_id: { planning_list_id: {
category: Category.Planning, category: Category.Planning,
title: "National Heritage List for England list id", title: "National Heritage List for England list id",
example: "121436",
//tooltip: , //tooltip: ,
}, },
planning_list_cat: { planning_list_cat: {
category: Category.Planning, category: Category.Planning,
title: "National Heritage List for England list type", title: "National Heritage List for England list type",
example: "",
//tooltip: , //tooltip: ,
}, },
planning_list_grade: { planning_list_grade: {
category: Category.Planning, category: Category.Planning,
title: "Listing grade", title: "Listing grade",
example: "II",
//tooltip: , //tooltip: ,
}, },
planning_heritage_at_risk_id: { planning_heritage_at_risk_id: {
category: Category.Planning, category: Category.Planning,
title: "Heritage at risk list id", title: "Heritage at risk list id",
example: "",
//tooltip: , //tooltip: ,
}, },
planning_world_list_id: { planning_world_list_id: {
category: Category.Planning, category: Category.Planning,
title: "World heritage list id", title: "World heritage list id",
example: "",
//tooltip: , //tooltip: ,
}, },
planning_in_glher: { planning_in_glher: {
category: Category.Planning, category: Category.Planning,
title: "In the Greater London Historic Environment Record?", title: "In the Greater London Historic Environment Record?",
example: true,
//tooltip: , //tooltip: ,
}, },
planning_glher_url: { planning_glher_url: {
category: Category.Planning, category: Category.Planning,
title: "Greater London Historic Environment Record link", title: "Greater London Historic Environment Record link",
example: "",
//tooltip: , //tooltip: ,
}, },
planning_in_apa: { planning_in_apa: {
category: Category.Planning, category: Category.Planning,
title: "In an Architectural Priority Area?", title: "In an Architectural Priority Area?",
example: true,
//tooltip: , //tooltip: ,
}, },
planning_apa_name: { planning_apa_name: {
category: Category.Planning, category: Category.Planning,
title: "Architectural Priority Area name", title: "Architectural Priority Area name",
example: "",
//tooltip: , //tooltip: ,
}, },
planning_apa_tier: { planning_apa_tier: {
category: Category.Planning, category: Category.Planning,
title: "Architectural Priority Area tier", title: "Architectural Priority Area tier",
example: "2",
//tooltip: , //tooltip: ,
}, },
planning_in_local_list: { planning_in_local_list: {
category: Category.Planning, category: Category.Planning,
title: "Is locally listed?", title: "Is locally listed?",
example: true,
//tooltip: , //tooltip: ,
}, },
planning_local_list_url: { planning_local_list_url: {
category: Category.Planning, category: Category.Planning,
title: "Local list link", title: "Local list link",
example: "",
//tooltip: , //tooltip: ,
}, },
planning_in_historic_area_assessment: { planning_in_historic_area_assessment: {
category: Category.Planning, category: Category.Planning,
title: "Within a historic area assessment?", title: "Within a historic area assessment?",
example: true,
//tooltip: , //tooltip: ,
}, },
planning_historic_area_assessment_url: { planning_historic_area_assessment_url: {
category: Category.Planning, category: Category.Planning,
title: "Historic area assessment link", title: "Historic area assessment link",
example: "",
//tooltip: , //tooltip: ,
}, },
planning_demolition_proposed: { planning_demolition_proposed: {
category: Category.Planning, category: Category.Planning,
title: "Is the building proposed for demolition?", title: "Is the building proposed for demolition?",
//tooltip: , example: true,
},
planning_demolition_complete: {
category: Category.Planning,
title: "Has the building been demolished?",
//tooltip: ,
},
planning_demolition_history: {
category: Category.Planning,
title: "Dates of construction and demolition of previous buildings on site",
//tooltip: , //tooltip: ,
}, },
likes_total: { likes_total: {
category: Category.Like, category: Category.Community,
title: "Total number of likes" title: "Total number of likes",
} example: 100,
},
}; };

View File

@ -0,0 +1,47 @@
import { useCallback, useEffect, useState } from 'react';
import { Building } from '../models/building';
import { apiGet } from '../apiHelpers';
export function useBuildingData(buildingId: number, preloadedData: Building): [Building, (updatedBuilding: Building) => void, () => void] {
const [buildingData, setBuildingData] = useState<Building>(preloadedData);
const [isOld, setIsOld] = useState<boolean>(preloadedData == undefined);
const fetchData = useCallback(async () => {
if(buildingId == undefined) {
setBuildingData(undefined);
setIsOld(false);
return;
}
try {
const [building, buildingUprns] = await Promise.all([
apiGet(`/api/buildings/${buildingId}.json`),
apiGet(`/api/buildings/${buildingId}/uprns.json`)
]);
building.uprns = buildingUprns.uprns;
setBuildingData(building);
} catch(error) {
console.error(error);
// TODO: add UI for API errors
}
setIsOld(false);
}, [buildingId]);
useEffect(() => {
return () => {
setIsOld(true);
};
}, [buildingId]);
useEffect(() => {
if(isOld) {
fetchData();
}
}, [isOld]);
const reloadData = useCallback(() => setIsOld(true), []);
return [buildingData, setBuildingData, reloadData];
}

View File

@ -0,0 +1,43 @@
import { useCallback, useEffect, useState } from 'react';
import { apiGet } from '../apiHelpers';
export function useBuildingLikeData(buildingId: number, preloadedData: boolean): [boolean, (updatedBuildingLike: boolean) => void, () => void] {
const [buildingLikeData, setBuildingLikeData] = useState<boolean>(preloadedData);
// const [fetchedId, setFetchedId] = useState(preloadedData == undefined ? undefined : buildingId);
const [isOld, setIsOld] = useState<boolean>(preloadedData == undefined);
const fetchData = useCallback(async () => {
if(buildingId == undefined) {
setBuildingLikeData(undefined);
setIsOld(false);
return;
}
try {
const { like } = await apiGet(`/api/buildings/${buildingId}/like.json`);
setBuildingLikeData(like);
} catch(error) {
console.error(error);
// TODO: add UI for API errors
}
setIsOld(false);
}, [buildingId]);
useEffect(() => {
return () => {
setIsOld(true);
setBuildingLikeData(undefined);
};
}, [buildingId]);
useEffect(() => {
if(isOld) {
fetchData();
}
}, [isOld]);
const reloadData = useCallback(() => setIsOld(true), []);
return [buildingLikeData, setBuildingLikeData, reloadData];
}

View File

@ -0,0 +1,7 @@
import { usePrevious } from './use-previous';
export function useLastNotEmpty<T>(value: T): T {
const previousValue = usePrevious(value);
return value ?? previousValue;
}

View File

@ -0,0 +1,27 @@
import { useQuery } from './use-query';
import { parseJsonOrDefault } from '../../helpers';
export function useMultiEditData(): [object, string] {
const query = useQuery();
let data: object, error: string;
const dataString = query.data;
if(dataString == undefined) {
return [undefined, undefined];
}
if(Array.isArray(dataString)) {
return [undefined, 'Invalid parameters supplied'];
}
data = parseJsonOrDefault(dataString);
if(data == undefined) {
error = 'Invalid parameters supplied';
} else if(Object.values(data).some(x => x == undefined)) {
error = 'Cannot copy empty values';
data = undefined;
}
return [data, error];
}

View File

@ -0,0 +1,10 @@
import { useEffect, useRef } from 'react';
export function usePrevious<T>(value: T) {
const ref = useRef<T>();
useEffect(() => {
ref.current = value;
});
return ref.current;
}

View File

@ -0,0 +1,6 @@
import { useLocation } from 'react-router';
import { parse as parseQuery, ParsedQuery } from 'query-string';
export function useQuery(): ParsedQuery {
return parseQuery(useLocation().search);
}

View File

@ -0,0 +1,33 @@
import { useCallback, useEffect, useState } from 'react';
import { apiGet } from '../apiHelpers';
export function useRevisionId(initialRevisionId: string): [string, (newId: string) => void, () => void] {
const [revisionId, setRevisionId] = useState(initialRevisionId ?? '0');
const [isOld, setIsOld] = useState(initialRevisionId == undefined);
const updateRevisionId = useCallback(
(newId: string) => newId != undefined && +newId > +revisionId && setRevisionId(newId),
[revisionId]
);
useEffect(() => {
async function fetchLatestRevision() {
try {
const { revision_id: latestRevisionId } = await apiGet(`/api/buildings/revision`);
updateRevisionId(latestRevisionId);
} catch(err) {
console.error(err);
}
setIsOld(false);
}
if(isOld) {
fetchLatestRevision();
}
}, [isOld]);
const reloadRevisionId = useCallback(() => setIsOld(true), []);
return [revisionId, updateRevisionId, reloadRevisionId];
}

View File

@ -0,0 +1,40 @@
import { useCallback, useEffect, useState } from 'react';
import { UserVerified } from '../models/building';
import { apiGet } from '../apiHelpers';
export function useUserVerifiedData(buildingId: number, preloadedData: UserVerified): [UserVerified, (updatedBuilding: UserVerified) => void, () => void] {
const [userVerifyData, setUserVerifyData] = useState<UserVerified>(preloadedData);
const [isOld, setIsOld] = useState<boolean>(preloadedData == undefined);
const fetchData = useCallback(async () => {
if(buildingId == undefined) {
setUserVerifyData(undefined);
setIsOld(false);
return;
}
try {
const userVerify = await apiGet(`/api/buildings/${buildingId}/verify.json`);
setUserVerifyData(userVerify);
} catch(error) {
console.error(error);
// TODO: add UI for API errors
}
setIsOld(false);
}, [buildingId]);
useEffect(() => {
return () => {
setIsOld(true);
}
}, [buildingId]);
useEffect(() => {
if(isOld) {
fetchData();
}
}, [isOld])
return [userVerifyData, setUserVerifyData, () => setIsOld(true)];
}

View File

@ -1,267 +1,170 @@
import { parse as parseQuery } from 'query-string'; import React, { useCallback, useEffect, useState } from 'react';
import React, { Fragment } from 'react'; import { Redirect, Route, Switch } from 'react-router-dom';
import { Redirect, Route, RouteComponentProps, Switch } from 'react-router-dom';
import { parseJsonOrDefault } from '../helpers'; import { useRevisionId } from './hooks/use-revision';
import { strictParseInt } from '../parse'; import { useBuildingData } from './hooks/use-building-data';
import { useBuildingLikeData } from './hooks/use-building-like-data';
import { apiGet, apiPost } from './apiHelpers'; import { useUserVerifiedData } from './hooks/use-user-verified-data';
import { useUrlBuildingParam } from './nav/use-url-building-param';
import { useUrlCategoryParam } from './nav/use-url-category-param';
import { useUrlModeParam } from './nav/use-url-mode-param';
import { apiPost } from './apiHelpers';
import BuildingView from './building/building-view'; import BuildingView from './building/building-view';
import Categories from './building/categories'; import Categories from './building/categories';
import { EditHistory } from './building/edit-history/edit-history'; import { EditHistory } from './building/edit-history/edit-history';
import MultiEdit from './building/multi-edit'; import MultiEdit from './building/multi-edit';
import Sidebar from './building/sidebar'; import Sidebar from './building/sidebar';
import ColouringMap from './map/map'; import ColouringMap from './map/map';
import { Building } from './models/building'; import { Building, UserVerified } from './models/building';
import Welcome from './pages/welcome'; import Welcome from './pages/welcome';
import { PrivateRoute } from './route'; import { PrivateRoute } from './route';
import { useLastNotEmpty } from './hooks/use-last-not-empty';
import { Category } from './config/categories-config';
import { defaultMapCategory } from './config/category-maps-config';
import { useMultiEditData } from './hooks/use-multi-edit-data';
interface MapAppRouteParams { interface MapAppProps {
mode: 'view' | 'edit' | 'multi-edit';
category: string;
building?: string;
}
interface MapAppProps extends RouteComponentProps<MapAppRouteParams> {
building?: Building; building?: Building;
building_like?: boolean; building_like?: boolean;
user?: any; revisionId?: string;
revisionId?: number;
user_verified?: object; user_verified?: object;
} }
interface MapAppState { /** Returns first argument, unless it's equal to the second argument - then returns undefined */
category: string; function unless<V extends string, U extends V>(value: V, unlessValue: U): Exclude<V, U> {
revision_id: number; return value === unlessValue ? undefined : value as Exclude<V, U>;
building: Building;
building_like: boolean;
user_verified: object;
} }
class MapApp extends React.Component<MapAppProps, MapAppState> { /** Returns the new value, unless it is equal to the current value - then returns undefined */
constructor(props: Readonly<MapAppProps>) { function setOrToggle<T>(currentValue: T, newValue: T): T {
super(props); if(newValue == undefined || newValue === currentValue){
return undefined;
this.state = {
category: this.getCategory(props.match.params.category),
revision_id: props.revisionId || 0,
building: props.building,
building_like: props.building_like,
user_verified: props.user_verified || {}
};
this.selectBuilding = this.selectBuilding.bind(this);
this.colourBuilding = this.colourBuilding.bind(this);
this.increaseRevision = this.increaseRevision.bind(this);
}
componentWillReceiveProps(props: Readonly<MapAppProps>) {
const newCategory = this.getCategory(props.match.params.category);
if (newCategory != undefined) {
this.setState({ category: newCategory });
}
}
componentDidMount() {
this.fetchLatestRevision();
if(this.props.match.params.building != undefined && this.props.building == undefined) {
this.fetchBuildingData(strictParseInt(this.props.match.params.building));
}
}
async fetchLatestRevision() {
try {
const {latestRevisionId} = await apiGet(`/api/buildings/revision`);
this.increaseRevision(latestRevisionId);
} catch(error) {
console.error(error);
}
}
/**
* Fetches building data if a building is selected but no data provided through
* props (from server-side rendering)
*/
async fetchBuildingData(buildingId: number) {
try {
// TODO: simplify API calls, create helpers for fetching data
let [building, building_uprns, building_like, user_verified] = await Promise.all([
apiGet(`/api/buildings/${buildingId}.json`),
apiGet(`/api/buildings/${buildingId}/uprns.json`),
apiGet(`/api/buildings/${buildingId}/like.json`),
apiGet(`/api/buildings/${buildingId}/verify.json`)
]);
building.uprns = building_uprns.uprns;
this.setState({
building: building,
building_like: building_like.like,
user_verified: user_verified
});
this.increaseRevision(building.revision_id);
} catch(error) {
console.error(error);
// TODO: add UI for API errors
}
}
getCategory(category: string) {
if (category === 'categories') return undefined;
return category;
}
getMultiEditDataString(): string {
const q = parseQuery(this.props.location.search);
if(Array.isArray(q.data)) {
throw new Error('Invalid format');
} else return q.data;
}
increaseRevision(revisionId) {
revisionId = +revisionId;
// bump revision id, only ever increasing
if (revisionId > this.state.revision_id) {
this.setState({ revision_id: revisionId });
}
}
selectBuilding(building: Building) {
const mode = this.props.match.params.mode || 'view';
const category = this.props.match.params.category || 'age';
if (building == undefined) {
this.setState({ building: undefined });
this.props.history.push(`/${mode}/${category}`);
return;
}
this.fetchBuildingData(building.building_id);
this.props.history.push(`/${mode}/${category}/${building.building_id}`);
}
/**
* Colour building
*
* Used in multi-edit mode to colour buildings on map click
*
* Pulls data from URL to form update
*
* @param {Building} building
*/
colourBuilding(building: Building) {
const cat = this.props.match.params.category;
if (cat === 'like') {
this.likeBuilding(building.building_id);
} else { } else {
const data = parseJsonOrDefault(this.getMultiEditDataString()); return newValue;
if (data != undefined && !Object.values(data).some(x => x == undefined)) {
this.updateBuilding(building.building_id, data);
}
} }
} }
likeBuilding(buildingId) { export const MapApp: React.FC<MapAppProps> = props => {
apiPost(`/api/buildings/${buildingId}/like.json`, { like: true }) const [categoryUrlParam] = useUrlCategoryParam();
.then(res => { const [selectedBuildingId, setSelectedBuildingId] = useUrlBuildingParam();
const [mode] = useUrlModeParam();
const [currentCategory, setCategory] = useState<Category>();
useEffect(() => setCategory(unless(categoryUrlParam, 'categories')), [categoryUrlParam]);
const displayCategory = useLastNotEmpty(currentCategory) ?? defaultMapCategory;
const [building, updateBuilding, reloadBuilding] = useBuildingData(selectedBuildingId, props.building);
const [buildingLike, updateBuildingLike] = useBuildingLikeData(selectedBuildingId, props.building_like);
const [userVerified, updateUserVerified, reloadUserVerified] = useUserVerifiedData(selectedBuildingId, props.user_verified);
const [revisionId, updateRevisionId] = useRevisionId(props.revisionId);
useEffect(() => {
updateRevisionId(building?.revision_id)
}, [building]);
const viewEditMode = unless(mode, 'multi-edit');
const [multiEditData, multiEditError] = useMultiEditData();
const selectBuilding = useCallback((selectedBuilding: Building) => {
updateBuilding(Object.assign({}, building, selectedBuilding));
setSelectedBuildingId(setOrToggle(selectedBuildingId, selectedBuilding?.building_id));
}, [selectedBuildingId, setSelectedBuildingId, updateBuilding, building]);
const colourBuilding = useCallback(async (building: Building) => {
const buildingId = building?.building_id;
if(buildingId != undefined && multiEditError == undefined) {
const isLike = currentCategory === Category.Community;
const endpoint = isLike ?
`/api/buildings/${buildingId}/like.json`:
`/api/buildings/${buildingId}.json`;
const payload = isLike ? {like: true} : multiEditData;
try {
const res = await apiPost(endpoint, payload);
if(res.error) { if(res.error) {
console.error({ error: res.error }); console.error({ error: res.error });
} else { } else {
this.increaseRevision(res.revision_id); updateRevisionId(res.revision_id);
} }
}).catch( } catch(error) {
(err) => console.error({ error: err }) console.error({ error });
);
} }
}
}, [multiEditError, multiEditData, currentCategory]);
updateBuilding(buildingId, data) { const handleBuildingUpdate = useCallback((buildingId: number, updatedData: Building) => {
apiPost(`/api/buildings/${buildingId}.json`, data) // only update current building data if the IDs match
.then(res => { if(buildingId === selectedBuildingId) {
if (res.error) { updateBuilding(Object.assign({}, building, updatedData));
console.error({ error: res.error });
} else { } else {
this.increaseRevision(res.revision_id); // otherwise, still update the latest revision ID
updateRevisionId(updatedData.revision_id);
} }
}).catch( }, [selectedBuildingId, building, updateBuilding, updateRevisionId]);
(err) => console.error({ error: err })
); const handleBuildingLikeUpdate = useCallback((buildingId: number, updatedData: boolean) => {
// only update current building data if the IDs match
if(buildingId === selectedBuildingId) {
updateBuildingLike(updatedData);
} }
}, [selectedBuildingId, updateBuildingLike]);
render() { const handleUserVerifiedUpdate = useCallback((buildingId: number, updatedData: UserVerified) => {
const mode = this.props.match.params.mode; // only update current building data if the IDs match
const viewEditMode = mode === 'multi-edit' ? undefined : mode; if(buildingId === selectedBuildingId) {
updateUserVerified(Object.assign({}, userVerified, updatedData)); // quickly show added verifications
let category = this.state.category || 'age'; reloadBuilding();
reloadUserVerified(); // but still reload from server to reflect removed verifications
const building_id = this.state.building && this.state.building.building_id; }
}, [selectedBuildingId, updateUserVerified, reloadBuilding, userVerified]);
return ( return (
<Fragment> <>
<Switch>
<PrivateRoute path="/:mode(edit|multi-edit)" /> {/* empty private route to ensure auth for editing */} <PrivateRoute path="/:mode(edit|multi-edit)" /> {/* empty private route to ensure auth for editing */}
</Switch> <Sidebar>
<Switch> <Switch>
<Route exact path="/"> <Route exact path="/">
<Sidebar>
<Welcome /> <Welcome />
</Sidebar>
</Route> </Route>
<Route exact path="/:mode/categories/:building?"> <Route exact path="/multi-edit/:cat">
<Sidebar> <MultiEdit category={displayCategory} />
<Categories mode={mode || 'view'} building_id={building_id} /> </Route>
</Sidebar> <Route path="/:mode/:cat">
<Categories mode={mode || 'view'} building_id={selectedBuildingId} />
<Switch>
<Route exact path="/:mode/:cat/:building/history">
<EditHistory building={building} />
</Route> </Route>
<Route exact path="/multi-edit/:cat" render={(props) => (
<MultiEdit
category={category}
dataString={this.getMultiEditDataString()}
user={this.props.user}
/>
)} />
<Route exact path="/:mode/:cat/:building?"> <Route exact path="/:mode/:cat/:building?">
<Sidebar>
<Categories mode={mode || 'view'} building_id={building_id} />
<BuildingView <BuildingView
mode={viewEditMode} mode={viewEditMode}
cat={category} cat={displayCategory}
building={this.state.building} building={building}
building_like={this.state.building_like} building_like={buildingLike}
user_verified={this.state.user_verified} user_verified={userVerified ?? {}}
selectBuilding={this.selectBuilding} onBuildingUpdate={handleBuildingUpdate}
user={this.props.user} onBuildingLikeUpdate={handleBuildingLikeUpdate}
onUserVerifiedUpdate={handleUserVerifiedUpdate}
/> />
</Sidebar>
</Route> </Route>
<Route exact path="/:mode/:cat/:building/history"> </Switch>
<Sidebar>
<Categories mode={mode || 'view'} building_id={building_id} />
<EditHistory building={this.state.building} />
</Sidebar>
</Route> </Route>
<Route exact path="/:mode(view|edit|multi-edit)" <Route exact path="/:mode(view|edit|multi-edit)"
render={props => (<Redirect to={`/${props.match.params.mode}/categories`} />)} render={props => (<Redirect to={`/${props.match.params.mode}/categories`} />)}
/> />
</Switch> </Switch>
</Sidebar>
<ColouringMap <ColouringMap
building={this.state.building} selectedBuildingId={selectedBuildingId}
mode={mode || 'basic'} mode={mode || 'basic'}
category={category} category={displayCategory}
revision_id={this.state.revision_id} revisionId={revisionId}
selectBuilding={this.selectBuilding} onBuildingAction={mode === 'multi-edit' ? colourBuilding : selectBuilding}
colourBuilding={this.colourBuilding}
/> />
</Fragment> </>
); );
} };
}
export default MapApp;

View File

@ -4,145 +4,10 @@ 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';
const LEGEND_CONFIG = {
location: {
title: 'Location',
description: '% data collected',
elements: [
{ color: '#084081', text: '≥80%' },
{ color: '#0868ac', text: '6080%' },
{ color: '#43a2ca', text: '4060%' },
{ color: '#7bccc4', text: '2040%' },
{ color: '#bae4bc', text: '<20%' }
]
},
use: {
title: 'Land Use',
elements: [
{ color: '#e5050d', text: 'Mixed Use' },
{ subtitle: 'Single use:'},
{ color: '#4a54a6', text: 'Residential' },
{ color: '#ff8c00', text: 'Retail' },
{ color: '#f5f58f', text: 'Industry & Business' },
{ color: '#73ccd1', text: 'Community Services' },
{ color: '#ffbfbf', text: 'Recreation & Leisure' },
{ color: '#b3de69', text: 'Transport' },
{ color: '#cccccc', text: 'Utilities & Infrastructure' },
{ color: '#898944', text: 'Defence' },
{ color: '#fa667d', text: 'Agriculture' },
{ color: '#53f5dd', text: 'Minerals' },
{ color: '#ffffff', text: 'Vacant & Derelict' }
]
},
type: {
title: 'Type',
elements: [
{ color: "#f2a2b9", text: "Detached" },
{ color: "#ab8fb0", text: "Semi-Detached" },
{ color: "#3891d1", text: "End-Terrace" },
{ color: "#226291", text: "Mid-Terrace" }
]
},
age: {
title: 'Age',
elements: [
{ color: '#fff9b8', text: '>2020' },
{ color: '#fae269', text: '2000-2019' },
{ color: '#fbaf27', text: '1980-1999' },
{ color: '#e6711d', text: '1960-1979' },
{ color: '#cc1212', text: '1940-1959' },
{ color: '#8f0303', text: '1920-1939' },
{ color: '#8f5385', text: '1900-1919' },
{ color: '#c3e1eb', text: '1880-1899' },
{ color: '#6a9dba', text: '1860-1879' },
{ color: '#3b74a3', text: '1840-1859' },
{ color: '#95ded8', text: '1820-1839' },
{ color: '#68aba5', text: '1800-1819' },
{ color: '#acc98f', text: '1750-1799' },
{ color: '#6d8a51', text: '1700-1749' },
{ color: '#d0c291', text: '<1700' },
]
},
size: {
title: 'Height to apex',
elements: [
{ color: '#f7f4f9', text: '0-5.55'},
{ color: '#e7e1ef', text: '5.55-7.73'},
{ color: '#d4b9da', text: '7.73-11.38'},
{ color: '#c994c7', text: '11.38-18.45'},
{ color: '#df65b0', text: '18.45-35.05'},
{ color: '#e7298a', text: '35.05-89.30'},
{ color: '#ce1256', text: '89.30-152'},
{ color: '#980043', text: '≥152'}
]
},
construction: {
title: 'Construction',
elements: [
{ color: "#96613b", text: "Wood" },
{ color: "#ffffe3", text: "Stone" },
{ color: "#f5d96b", text: "Brick" },
{ color: "#beffe8", text: "Steel" },
{ color: "#fca89d", text: "Reinforced Concrete" },
{ color: "#5c8970", text: "Other Metal" },
{ color: "#b5a859", text: "Other Natural Material" },
{ color: "#c48a85", text: "Other Man-Made Material" }
]
},
team: {
title: 'Team',
elements: []
},
sustainability: {
title: 'Sustainability',
description: 'DEC Rating',
elements: [
{ color: "#007f3d", text: 'A' },
{ color: "#2c9f29", text: 'B' },
{ color: "#9dcb3c", text: 'C' },
{ color: "#fff200", text: 'D' },
{ color: "#f7af1d", text: 'E' },
{ color: "#ed6823", text: 'F' },
{ color: "#e31d23", text: 'G' },
]
},
streetscape: {
title: 'Streetscape',
elements: []
},
planning: {
title: 'Statutory protections',
disclaimer: 'All data relating to designated buildings should be checked on the National Heritage List for England or local authority websites where used for planning or development purposes',
elements: [
{ color: '#95beba', text: 'In conservation area'},
{ color: '#c72e08', text: 'Grade I listed'},
{ color: '#e75b42', text: 'Grade II* listed'},
{ color: '#ffbea1', text: 'Grade II listed'},
{ color: '#858ed4', text: 'Locally listed'},
]
},
dynamics: {
title: 'Dynamics',
elements: []
},
community: {
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'}
]
}
};
interface LegendProps { interface LegendProps {
slug: string; legendConfig: LegendConfig;
} }
interface LegendState { interface LegendState {
@ -184,59 +49,62 @@ class Legend extends React.Component<LegendProps, LegendState> {
} }
render() { render() {
const details = LEGEND_CONFIG[this.props.slug] || {}; const {
const title = details.title || ""; title = undefined,
const elements = details.elements || []; elements = [],
description = undefined,
disclaimer = undefined
} = this.props.legendConfig ?? {};
return ( return (
<div className="map-legend"> <div className="map-legend">
<Logo variant="default" /> <Logo variant="default" />
<h4 className="h4">
{ title }
</h4>
{ {
elements.length > 0 ? title && <h4 className="h4">{title}</h4>
}
{
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={this.handleClick} >
{ {
this.state.collapseList ? this.state.collapseList ?
<UpIcon /> : <UpIcon /> :
<DownIcon /> <DownIcon />
} }
</button> : </button>
null
} }
{ {
details.description? description && <p>{description}</p>
<p>{details.description} </p>
: null
} }
{ {
elements.length? elements.length === 0 ?
<p className="data-intro">Coming soon</p> :
<ul className={this.state.collapseList ? 'collapse data-legend' : 'data-legend'} > <ul className={this.state.collapseList ? 'collapse data-legend' : 'data-legend'} >
{ {
details.disclaimer && disclaimer && <p className='legend-disclaimer'>{disclaimer}</p>
<p className='legend-disclaimer'>{details.disclaimer}</p>
} }
{ {
elements.map((item) => { elements.map((item) => {
if(item.subtitle != undefined) { let key: string,
return (<li key={item.subtitle}> content: React.ReactElement;
<h6>{item.subtitle}</h6>
</li>);
}
return ( if('subtitle' in item) {
key = item.subtitle;
<li key={item.color} > content = <h6>{item.subtitle}</h6>;
<div className="key" style={ { background: item.color, border: item.border } }></div> } else {
key = `${item.text}-${item.color}`;
content = <>
<div className="key" style={ { background: item.color, border: item.border } } />
{ item.text } { item.text }
</>;
}
return (
<li key={key}>
{content}
</li> </li>
); );
}) })
} }
</ul> </ul>
: <p className="data-intro">Coming soon</p>
} }
</div> </div>
); );

View File

@ -7,21 +7,22 @@ import './map.css';
import { apiGet } from '../apiHelpers'; import { apiGet } from '../apiHelpers';
import { HelpIcon } from '../components/icons'; import { HelpIcon } from '../components/icons';
import { Building } from '../models/building';
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 { categoryMapsConfig } from '../config/category-maps-config';
import { Category } from '../config/categories-config';
import { Building } from '../models/building';
const OS_API_KEY = 'NVUxtY5r8eA6eIfwrPTAGKrAAsoeI9E9'; const OS_API_KEY = 'NVUxtY5r8eA6eIfwrPTAGKrAAsoeI9E9';
interface ColouringMapProps { interface ColouringMapProps {
building?: Building; selectedBuildingId: number;
mode: 'basic' | 'view' | 'edit' | 'multi-edit'; mode: 'basic' | 'view' | 'edit' | 'multi-edit';
category: string; category: Category;
revision_id: number; revisionId: string;
selectBuilding: (building: Building) => void; onBuildingAction: (building: Building) => void;
colourBuilding: (building: Building) => void;
} }
interface ColouringMapState { interface ColouringMapState {
@ -58,30 +59,12 @@ class ColouringMap extends Component<ColouringMapProps, ColouringMapState> {
} }
handleClick(e) { handleClick(e) {
const mode = this.props.mode;
const { lat, lng } = e.latlng; const { lat, lng } = e.latlng;
apiGet(`/api/buildings/locate?lat=${lat}&lng=${lng}`) apiGet(`/api/buildings/locate?lat=${lat}&lng=${lng}`)
.then(data => { .then(data => {
if (data && data.length){ const building = data?.[0];
const building = data[0]; this.props.onBuildingAction(building);
if (mode === 'multi-edit') { }).catch(err => console.error(err));
// colour building directly
this.props.colourBuilding(building);
} else if (this.props.building == undefined || building.building_id !== this.props.building.building_id){
this.props.selectBuilding(building);
} else {
this.props.selectBuilding(undefined);
}
} else {
if (mode !== 'multi-edit') {
// deselect but keep/return to expected colour theme
// except if in multi-edit (never select building, only colour on click)
this.props.selectBuilding(undefined);
}
}
}).catch(
(err) => console.error(err)
);
} }
themeSwitch(e) { themeSwitch(e) {
@ -103,6 +86,8 @@ class ColouringMap extends Component<ColouringMapProps, ColouringMapState> {
} }
render() { render() {
const categoryMapDefinition = categoryMapsConfig[this.props.category];
const position: [number, number] = [this.state.lat, this.state.lng]; const position: [number, number] = [this.state.lat, this.state.lng];
// baselayer // baselayer
@ -121,55 +106,37 @@ class ColouringMap extends Component<ColouringMapProps, ColouringMapState> {
const buildingsBaseUrl = `/tiles/base_${this.state.theme}/{z}/{x}/{y}{r}.png`; const buildingsBaseUrl = `/tiles/base_${this.state.theme}/{z}/{x}/{y}{r}.png`;
const buildingBaseLayer = <TileLayer url={buildingsBaseUrl} minZoom={14} maxZoom={19}/>; const buildingBaseLayer = <TileLayer url={buildingsBaseUrl} minZoom={14} maxZoom={19}/>;
const boundaryStyleFn = () => ({color: '#bbb', fill: false});
const boundaryLayer = this.state.boundary && const boundaryLayer = this.state.boundary &&
<GeoJSON data={this.state.boundary} style={boundaryStyleFn}/>; <GeoJSON data={this.state.boundary} style={{color: '#bbb', fill: false}}/>;
// colour-data tiles const tileset = categoryMapDefinition.mapStyle;
const cat = this.props.category; const dataLayer = tileset != undefined &&
const tilesetByCat = {
age: 'date_year',
size: 'size_height',
construction: 'construction_core_material',
location: 'location',
community: 'likes',
planning: 'planning_combined',
sustainability: 'sust_dec',
type: 'building_attachment_form',
use: 'landuse'
};
const tileset = tilesetByCat[cat];
// pick revision id to bust browser cache
const rev = this.props.revision_id;
const dataLayer = tileset != undefined ?
<TileLayer <TileLayer
key={tileset} key={tileset}
url={`/tiles/${tileset}/{z}/{x}/{y}{r}.png?rev=${rev}`} url={`/tiles/${tileset}/{z}/{x}/{y}{r}.png?rev=${this.props.revisionId}`}
minZoom={9} minZoom={9}
maxZoom={19} maxZoom={19}
/> />;
: null;
// highlight // highlight
const highlightLayer = this.props.building != undefined ? const highlightLayer = this.props.selectedBuildingId != undefined &&
<TileLayer <TileLayer
key={this.props.building.building_id} key={this.props.selectedBuildingId}
url={`/tiles/highlight/{z}/{x}/{y}{r}.png?highlight=${this.props.building.building_id}&base=${tileset}`} url={`/tiles/highlight/{z}/{x}/{y}{r}.png?highlight=${this.props.selectedBuildingId}&base=${tileset}`}
minZoom={13} minZoom={13}
maxZoom={19} maxZoom={19}
zIndex={100} zIndex={100}
/> />;
: null;
const numbersLayer = <TileLayer const numbersLayer = <TileLayer
key={this.state.theme} key={this.state.theme}
url={`/tiles/number_labels/{z}/{x}/{y}{r}.png?rev=${rev}`} url={`/tiles/number_labels/{z}/{x}/{y}{r}.png?rev=${this.props.revisionId}`}
zIndex={200} zIndex={200}
minZoom={17} minZoom={17}
maxZoom={19} maxZoom={19}
/> />
const hasSelection = this.props.selectedBuildingId != undefined;
const isEdit = ['edit', 'multi-edit'].includes(this.props.mode); const isEdit = ['edit', 'multi-edit'].includes(this.props.mode);
return ( return (
@ -195,20 +162,18 @@ class ColouringMap extends Component<ColouringMapProps, ColouringMapState> {
<AttributionControl prefix=""/> <AttributionControl prefix=""/>
</Map> </Map>
{ {
this.props.mode !== 'basic'? ( this.props.mode !== 'basic' &&
<Fragment> <Fragment>
{ {
this.props.building == undefined ? !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>
: null
} }
<Legend slug={cat} /> <Legend legendConfig={categoryMapDefinition?.legend} />
<ThemeSwitcher onSubmit={this.themeSwitch} currentTheme={this.state.theme} /> <ThemeSwitcher onSubmit={this.themeSwitch} currentTheme={this.state.theme} />
<SearchBox onLocate={this.handleLocate} /> <SearchBox onLocate={this.handleLocate} />
</Fragment> </Fragment>
) : null
} }
</div> </div>
); );

View File

@ -1,12 +1,21 @@
interface Building { import { dataFields } from '../config/data-fields-config';
/**
* A type representing the types of a building's attributes.
* This is derived automatically from the "example" fields in dataFieldsConfig.
* If a TS error starting with "Type 'example' cannot be used to index type [...]" appears here,
* that means an example field is most probably missing on one of the config definitions in dataFieldsConfig.
*/
export type BuildingAttributes = {[key in keyof typeof dataFields]: (typeof dataFields)[key]['example']};
export type BuildingAttributeVerificationCounts = {[key in keyof typeof dataFields]: number};
export type UserVerified = {[key in keyof BuildingAttributes]?: BuildingAttributes[key]};
export interface Building extends BuildingAttributes {
building_id: number; building_id: number;
geometry_id: number; geometry_id: number;
revision_id: number; revision_id: string;
uprns: string[]; verified: BuildingAttributeVerificationCounts;
// TODO: add other fields as needed
} }
export {
Building
};

View File

@ -0,0 +1,16 @@
export interface UrlParamTransform<T> {
fromParam: (x: string) => T;
toParam: (x: T) => string;
}
const identity: <T>(x: T) => T = (x) => x;
export const stringParamTransform: UrlParamTransform<string> = {
fromParam: identity,
toParam: identity
};
export const intParamTransform: UrlParamTransform<number> = {
fromParam: x => parseInt(x, 10),
toParam: x => x.toString()
};

View File

@ -0,0 +1,6 @@
import { intParamTransform } from './url-param-transform';
import { useUrlParam } from './use-url-param';
export function useUrlBuildingParam() {
return useUrlParam('building', intParamTransform);
}

View File

@ -0,0 +1,14 @@
import { Category } from '../config/categories-config';
import { useUrlParam } from './use-url-param';
export function useUrlCategoryParam() {
return useUrlParam('category', {
fromParam: (x: string) => {
// TODO: add validation
return x as Category | 'categories';
},
toParam: (x: Category | 'categories') => {
return x as string;
}
});
}

View File

@ -0,0 +1,10 @@
import { useUrlParam } from './use-url-param';
type Mode = 'view' | 'edit' | 'multi-edit';
export function useUrlModeParam() {
return useUrlParam<Mode>('mode', {
fromParam: (x) => x as Mode,
toParam: (x) => x
});
}

View File

@ -0,0 +1,33 @@
import { useCallback, useEffect, useState } from 'react';
import { useHistory, useRouteMatch, generatePath } from 'react-router';
import { UrlParamTransform } from './url-param-transform';
export function useUrlParam<T>(
param: string,
paramTransform: UrlParamTransform<T>
): [T, (newParam: T) => void] {
const match = useRouteMatch();
const history = useHistory();
const [paramValue, setParamValue] = useState<T>();
useEffect(() => {
const stringValue: string = match.params[param];
setParamValue(stringValue && paramTransform.fromParam(stringValue));
}, [param, paramTransform, match.url]);
const setUrlParam = useCallback((value: T) => {
const stringValue = value == undefined ? '' : paramTransform.toParam(value);
const newPath = generatePath(match.path, {
...match.params,
...{
[param]: stringValue
}
});
history.push(newPath);
}, [param, paramTransform, match.url]);
return [paramValue, setUrlParam];
}

View File

@ -75,6 +75,7 @@ function renderHTML(context, data, req, res) {
building={data.building} building={data.building}
building_like={data.building_like} building_like={data.building_like}
revisionId={data.latestRevisionId} revisionId={data.latestRevisionId}
user_verified={data.userVerified}
/> />
</StaticRouter> </StaticRouter>
); );