Merge pull request #664 from mz8i/data-edit-refactor

Mapp app hooks refactor:
- Refactor app and map app with hooks and React context
- Separate categories and data fields config from generic app code
This commit is contained in:
Maciej Ziarkowski 2021-02-24 08:26:18 +00:00 committed by GitHub
commit 727b5fa6a9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
41 changed files with 1096 additions and 912 deletions

View File

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

View File

@ -5,10 +5,10 @@ import 'bootstrap/dist/css/bootstrap.min.css';
import './app.css';
import { AuthRoute, PrivateRoute } from './route';
import { AuthContext, AuthProvider } from './auth-context';
import { AuthProvider } from './auth-context';
import { Header } from './header';
import MapApp from './map-app';
import { Building } from './models/building';
import { MapApp } from './map-app';
import { Building, UserVerified } from './models/building';
import { User } from './models/user';
import AboutPage from './pages/about';
import ChangesPage from './pages/changes';
@ -33,8 +33,8 @@ interface AppProps {
user?: User;
building?: Building;
building_like?: boolean;
user_verified?: object;
revisionId: number;
user_verified?: UserVerified;
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="/leaderboard.html" component={LeaderboardPage} />
<Route exact path="/history.html" component={ChangesPage} />
<Route exact path={mapAppPaths} render={(routeProps) => (
<AuthContext.Consumer>
{({user}) =>
<MapApp
{...routeProps}
building={props.building}
building_like={props.building_like}
user_verified={props.user_verified}
user={user}
revisionId={props.revisionId}
/>
}
</AuthContext.Consumer>
)} />
<Route exact path={mapAppPaths} >
<MapApp
building={props.building}
building_like={props.building_like}
user_verified={props.user_verified}
revisionId={props.revisionId}
/>
</Route>
<Route component={NotFound} />
</Switch>
</AuthProvider>

View File

@ -1,30 +1,21 @@
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 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 {
cat: string;
cat: Category;
mode: 'view' | 'edit';
building?: Building;
building_like?: boolean;
user?: any;
selectBuilding: (building: Building) => void;
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
*/
const BuildingView: React.FunctionComponent<BuildingViewProps> = (props) => {
switch (props.cat) {
case 'location':
return <LocationContainer
{...props}
title="Location"
help="https://pages.colouring.london/location"
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" />;
const { user } = useAuth();
const DataContainer = categoryUiConfig[props.cat];
const categoryConfig = categoriesConfig[props.cat];
if(categoryConfig == undefined) {
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;

View File

@ -4,6 +4,7 @@ import { CategoryLink } from './category-link';
import { ListWrapper } from '../components/list-wrapper';
import './categories.css';
import { categoriesOrder, categoriesConfig } from '../config/categories-config';
interface CategoriesProps {
mode: 'view' | 'edit' | 'multi-edit';
@ -12,102 +13,24 @@ interface CategoriesProps {
const Categories: React.FC<CategoriesProps> = (props) => (
<ListWrapper className='data-category-list'>
<CategoryLink
title="Location"
slug="location"
help="https://pages.colouring.london/location"
inactive={false}
mode={props.mode}
building_id={props.building_id}
/>
<CategoryLink
title="Current Use"
slug="use"
help="https://pages.colouring.london/use"
inactive={true}
mode={props.mode}
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}
building_id={props.building_id}
/>
{categoriesOrder.map(category => {
const {
name,
slug,
aboutUrl,
inactive = false
} = categoriesConfig[category];
return <CategoryLink
key={category}
title={name}
slug={slug}
help={aboutUrl}
inactive={inactive}
mode={props.mode}
building_id={props.building_id}
/>
})}
</ListWrapper>
);

View File

@ -2,6 +2,7 @@ import React, { Fragment } from 'react';
import { NavLink } from 'react-router-dom';
import Tooltip from '../../components/tooltip';
import { Category } from '../../config/categories-config';
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." />
<div className="icon-buttons">
<NavLink
to={`/multi-edit/like?data=${data_string}`}
to={`/multi-edit/${Category.Community}?data=${data_string}`}
className="icon-button like">
Like more
</NavLink>

View File

@ -1,7 +1,7 @@
import React, { Component, Fragment } from 'react';
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 NumericDataEntry from './numeric-data-entry';

View File

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

View File

@ -1,6 +1,6 @@
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 NumericDataEntry from '../data-components/numeric-data-entry';
import SelectDataEntry from '../data-components/select-data-entry';

View File

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

View File

@ -1,6 +1,6 @@
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 withCopyEdit from '../data-container';

View File

@ -1,7 +1,7 @@
import React, { Fragment } from 'react';
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 NumericDataEntry from '../data-components/numeric-data-entry';
import UPRNsDataEntry from '../data-components/uprns-data-entry';

View File

@ -1,7 +1,7 @@
import React, { Fragment } from 'react';
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 DataEntry from '../data-components/data-entry';
import { DataEntryGroup } from '../data-components/data-entry-group';

View File

@ -1,6 +1,6 @@
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 NumericDataEntry from '../data-components/numeric-data-entry';
import SelectDataEntry from '../data-components/select-data-entry';

View File

@ -1,6 +1,6 @@
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 SelectDataEntry from '../data-components/select-data-entry';
import Verification from '../data-components/verification';

View File

@ -1,6 +1,6 @@
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 NumericDataEntry from '../data-components/numeric-data-entry';
import SelectDataEntry from '../data-components/select-data-entry';

View File

@ -1,7 +1,7 @@
import React, { Fragment } from 'react';
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 MultiDataEntry from '../data-components/multi-data-entry/multi-data-entry';
import withCopyEdit from '../data-container';

View File

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

View File

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

View File

@ -1,89 +1,62 @@
import React from 'react';
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 InfoBox from '../components/info-box';
import { dataFields } from '../data_fields';
import { User } from '../models/user';
import { dataFields } from '../config/data-fields-config';
import DataEntry from './data-components/data-entry';
import Sidebar from './sidebar';
import Categories from './categories';
import { Category } from '../config/categories-config';
interface MultiEditProps {
user?: User;
category: string;
dataString: string;
}
const MultiEdit: React.FC<MultiEditProps> = (props) => {
if (props.category === 'like') {
// 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!' />
const [data, error] = useMultiEditData();
<Link to='/view/like' className='btn btn-secondary'>Back to view</Link>
<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 = {};
}
const isLike = props.category === Category.Community;
return (
<Sidebar>
<Categories mode={'view'} />
<section className='data-section'>
<header className={`section-header view ${props.category} background-${props.category}`}>
<h2 className="h2">Copy {props.category} data</h2>
</header>
<div className="section-body">
<form>
<section className='data-section'>
<header className={`section-header view ${props.category} background-${props.category}`}>
<h2 className="h2">{
isLike ?
<>Like Me!</> :
<>Copy {props.category} data</>
}</h2>
</header>
<div className="section-body">
<form>
{
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 => {
const info = dataFields[key] || {};
return (
!isLike && data &&
Object.keys(data).map((key => (
<DataEntry
title={info.title || `Unknown field (${key})`}
key={key}
title={dataFields[key]?.title ?? `Unknown field (${key})`}
slug={key}
disabled={true}
value={data[key]}
/>
);
}))
/>
)))
}
</form>
<form className='buttons-container'>
<Link to={`/view/${props.category}`} className='btn btn-secondary'>Back to view</Link>
<Link to={`/edit/${props.category}`} className='btn btn-secondary'>Back to edit</Link>
</form>
</div>
</section>
</Sidebar>
</form>
<form className='buttons-container'>
<Link to={`/view/${props.category}`} className='btn btn-secondary'>Back to view</Link>
<Link to={`/edit/${props.category}`} className='btn btn-secondary'>Back to edit</Link>
</form>
</div>
</section>
);
};

View File

@ -1,4 +1,4 @@
import React, { Fragment } from 'react';
import React from 'react';
interface ErrorBoxProps {
msg: string;
@ -8,22 +8,16 @@ const ErrorBox: React.FC<ErrorBoxProps> = (props) => {
if (props.msg) {
console.error(props.msg);
}
return (
<Fragment>
return props.msg ?
<div className="alert alert-danger" role="alert">
{
(props.msg)?
(
<div className="alert alert-danger" role="alert">
{
typeof props.msg === 'string' ?
props.msg
: 'Unexpected error'
}
</div>
) : null
typeof props.msg === 'string' ?
props.msg
: 'Unexpected error'
}
</Fragment>
);
</div> :
null;
};
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 {
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,
];
import { Category } from './categories-config';
/**
* 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;
title: 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: {
category: Category.Location,
title: "Building Name",
tooltip: "May not be needed for many buildings.",
example: "The Cruciform",
},
location_number: {
category: Category.Location,
title: "Building number",
example: 12,
},
location_street: {
category: Category.Location,
title: "Street",
example: "Gower Street",
//tooltip: ,
},
location_line_two: {
category: Category.Location,
title: "Address line 2",
example: "Flat 21",
//tooltip: ,
},
location_town: {
category: Category.Location,
title: "Town",
example: "London",
//tooltip: ,
},
location_postcode: {
category: Category.Location,
title: "Postcode",
example: "W1W 6TR",
//tooltip: ,
},
ref_toid: {
category: Category.Location,
title: "TOID",
tooltip: "Ordnance Survey Topography Layer ID (to be filled automatically)",
example: "",
},
/**
@ -136,43 +64,51 @@ export const dataFields = {
uprns: {
category: Category.Location,
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: {
category: Category.Location,
title: "OSM ID",
tooltip: "OpenStreetMap feature ID",
example: "",
},
location_latitude: {
category: Category.Location,
title: "Latitude",
example: 12.4564,
},
location_longitude: {
category: Category.Location,
title: "Longitude",
example: 0.12124,
},
current_landuse_group: {
category: Category.LandUse,
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: {
category: Category.LandUse,
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: {
category: Category.Type,
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)",
example: "",
},
date_change_building_use: {
category: Category.Type,
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",
example: 1920,
},
/**
* original_building_use does not exist in database yet.
@ -182,101 +118,121 @@ export const dataFields = {
category: Category.Type,
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",
example: "",
},
date_year: {
category: Category.Age,
title: "Year built (best estimate)"
title: "Year built (best estimate)",
example: 1924,
},
date_lower : {
category: Category.Age,
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: {
category: Category.Age,
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: {
category: Category.Age,
title: "Facade year",
tooltip: "Best estimate"
tooltip: "Best estimate",
example: 1900,
},
date_source: {
category: Category.Age,
title: "Source of information",
tooltip: "Source for the main start date"
tooltip: "Source for the main start date",
example: "",
},
date_source_detail: {
category: Category.Age,
title: "Source details",
tooltip: "References for date source (max 500 characters)"
tooltip: "References for date source (max 500 characters)",
example: "",
},
date_link: {
category: Category.Age,
title: "Text and Image Links",
tooltip: "URL for age and date reference",
example: ["", "", ""],
},
size_storeys_core: {
category: Category.SizeShape,
title: "Core storeys",
tooltip: "How many storeys between the pavement and start of roof?",
example: 10,
},
size_storeys_attic: {
category: Category.SizeShape,
title: "Attic storeys",
tooltip: "How many storeys above start of roof?",
example: 1,
},
size_storeys_basement: {
category: Category.SizeShape,
title: "Basement storeys",
tooltip: "How many storeys below pavement level?",
example: 1,
},
size_height_apex: {
category: Category.SizeShape,
title: "Height to apex (m)",
example: 100.5,
//tooltip: ,
},
size_height_eaves: {
category: Category.SizeShape,
title: "Height to eaves (m)",
example: 20.33,
//tooltip: ,
},
size_floor_area_ground: {
category: Category.SizeShape,
title: "Ground floor area (m²)",
example: 1245.6,
//tooltip: ,
},
size_floor_area_total: {
category: Category.SizeShape,
title: "Total floor area (m²)",
example: 2001.7,
//tooltip: ,
},
size_width_frontage: {
category: Category.SizeShape,
title: "Frontage Width (m)",
example: 12.2,
//tooltip: ,
},
size_plot_area_total: {
category: Category.SizeShape,
title: "Total area of plot (m²)",
example: 123.02,
//tooltip: ,
},
size_far_ratio: {
category: Category.SizeShape,
title: "FAR ratio (percentage of plot covered by building)",
example: 0.1,
//tooltip: ,
},
size_configuration: {
category: Category.SizeShape,
title: "Configuration (semi/detached, end/terrace)",
example: "",
//tooltip: ,
},
size_roof_shape: {
category: Category.SizeShape,
title: "Roof shape",
example: "",
//tooltip: ,
},
@ -284,155 +240,172 @@ export const dataFields = {
category: Category.Construction,
title: "Core Material",
tooltip:"The main structural material",
example: "",
},
construction_secondary_materials: {
category: Category.Construction,
title: "Secondary Construction Material/s",
tooltip:"Other construction materials",
example: "",
},
construction_roof_covering: {
category: Category.Construction,
title: "Main Roof Covering",
tooltip:'Main roof covering material',
example: "",
},
sust_breeam_rating: {
category: Category.Sustainability,
title: "BREEAM Rating",
tooltip: "(Building Research Establishment Environmental Assessment Method) May not be present for many buildings",
example: "",
},
sust_dec: {
category: Category.Sustainability,
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",
example: "G",
},
sust_aggregate_estimate_epc: {
category: Category.Sustainability,
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",
example: "",
},
sust_retrofit_date: {
category: Category.Sustainability,
title: "Last significant retrofit",
tooltip: "Date of last major building refurbishment",
example: 1920,
},
sust_life_expectancy: {
category: Category.Sustainability,
title: "Expected lifespan for typology",
example: 123,
//tooltip: ,
},
planning_portal_link: {
category: Category.Planning,
title: "Planning portal link",
example: "",
//tooltip: ,
},
planning_in_conservation_area: {
category: Category.Planning,
title: "In a conservation area?",
example: true,
//tooltip: ,
},
planning_conservation_area_name: {
category: Category.Planning,
title: "Conservation area name",
example: "",
//tooltip: ,
},
planning_in_list: {
category: Category.Planning,
title: "Is listed on the National Heritage List for England?",
example: true,
//tooltip: ,
},
planning_list_id: {
category: Category.Planning,
title: "National Heritage List for England list id",
example: "121436",
//tooltip: ,
},
planning_list_cat: {
category: Category.Planning,
title: "National Heritage List for England list type",
example: "",
//tooltip: ,
},
planning_list_grade: {
category: Category.Planning,
title: "Listing grade",
example: "II",
//tooltip: ,
},
planning_heritage_at_risk_id: {
category: Category.Planning,
title: "Heritage at risk list id",
example: "",
//tooltip: ,
},
planning_world_list_id: {
category: Category.Planning,
title: "World heritage list id",
example: "",
//tooltip: ,
},
planning_in_glher: {
category: Category.Planning,
title: "In the Greater London Historic Environment Record?",
example: true,
//tooltip: ,
},
planning_glher_url: {
category: Category.Planning,
title: "Greater London Historic Environment Record link",
example: "",
//tooltip: ,
},
planning_in_apa: {
category: Category.Planning,
title: "In an Architectural Priority Area?",
example: true,
//tooltip: ,
},
planning_apa_name: {
category: Category.Planning,
title: "Architectural Priority Area name",
example: "",
//tooltip: ,
},
planning_apa_tier: {
category: Category.Planning,
title: "Architectural Priority Area tier",
example: "2",
//tooltip: ,
},
planning_in_local_list: {
category: Category.Planning,
title: "Is locally listed?",
example: true,
//tooltip: ,
},
planning_local_list_url: {
category: Category.Planning,
title: "Local list link",
example: "",
//tooltip: ,
},
planning_in_historic_area_assessment: {
category: Category.Planning,
title: "Within a historic area assessment?",
example: true,
//tooltip: ,
},
planning_historic_area_assessment_url: {
category: Category.Planning,
title: "Historic area assessment link",
example: "",
//tooltip: ,
},
planning_demolition_proposed: {
category: Category.Planning,
title: "Is the building proposed for demolition?",
//tooltip: ,
},
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",
example: true,
//tooltip: ,
},
likes_total: {
category: Category.Like,
title: "Total number of likes"
}
category: Category.Community,
title: "Total number of likes",
example: 100,
},
};

View File

@ -0,0 +1,54 @@
import { useCallback, useEffect, useState } from 'react';
import { Building, BuildingAttributeVerificationCounts } 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]);
const updateData = useCallback((building: Building) => {
if(building.verified == undefined) {
building.verified = {} as BuildingAttributeVerificationCounts;
}
setBuildingData(building);
}, []);
useEffect(() => {
return () => {
setIsOld(true);
};
}, [buildingId]);
useEffect(() => {
if(isOld) {
fetchData();
}
}, [isOld]);
const reloadData = useCallback(() => setIsOld(true), []);
return [buildingData, updateData, 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,11 @@
import { useEffect, useState } from 'react';
export function useLastNotEmpty<T>(value: T): T {
const [notEmpty, setNotEmpty] = useState(value);
useEffect(() => {
if(value != undefined) {
setNotEmpty(value);
}
}, [value]);
return notEmpty;
}

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,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,172 @@
import { parse as parseQuery } from 'query-string';
import React, { Fragment } from 'react';
import { Redirect, Route, RouteComponentProps, Switch } from 'react-router-dom';
import React, { useCallback, useEffect, useState } from 'react';
import { Redirect, Route, Switch } from 'react-router-dom';
import { parseJsonOrDefault } from '../helpers';
import { strictParseInt } from '../parse';
import { apiGet, apiPost } from './apiHelpers';
import { useRevisionId } from './hooks/use-revision';
import { useBuildingData } from './hooks/use-building-data';
import { useBuildingLikeData } from './hooks/use-building-like-data';
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 Categories from './building/categories';
import { EditHistory } from './building/edit-history/edit-history';
import MultiEdit from './building/multi-edit';
import Sidebar from './building/sidebar';
import ColouringMap from './map/map';
import { Building } from './models/building';
import { Building, UserVerified } from './models/building';
import Welcome from './pages/welcome';
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 {
mode: 'view' | 'edit' | 'multi-edit';
category: string;
building?: string;
}
interface MapAppProps extends RouteComponentProps<MapAppRouteParams> {
interface MapAppProps {
building?: Building;
building_like?: boolean;
user?: any;
revisionId?: number;
revisionId?: string;
user_verified?: object;
}
interface MapAppState {
category: string;
revision_id: number;
building: Building;
building_like: boolean;
user_verified: object;
/** Returns first argument, unless it's equal to the second argument - then returns undefined */
function unless<V extends string, U extends V>(value: V, unlessValue: U): Exclude<V, U> {
return value === unlessValue ? undefined : value as Exclude<V, U>;
}
class MapApp extends React.Component<MapAppProps, MapAppState> {
constructor(props: Readonly<MapAppProps>) {
super(props);
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);
/** Returns the new value, unless it is equal to the current value - then returns undefined */
function setOrToggle<T>(currentValue: T, newValue: T): T {
if(newValue == undefined || newValue === currentValue){
return undefined;
} else {
return newValue;
}
}
componentWillReceiveProps(props: Readonly<MapAppProps>) {
const newCategory = this.getCategory(props.match.params.category);
if (newCategory != undefined) {
this.setState({ category: newCategory });
export const MapApp: React.FC<MapAppProps> = props => {
const [categoryUrlParam] = useUrlCategoryParam();
const [currentCategory, setCategory] = useState<Category>();
useEffect(() => setCategory(unless(categoryUrlParam, 'categories')), [categoryUrlParam]);
const displayCategory = useLastNotEmpty(currentCategory) ?? defaultMapCategory;
const [selectedBuildingId, setSelectedBuildingId] = useUrlBuildingParam('view', displayCategory);
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 [mode] = useUrlModeParam();
const viewEditMode = unless(mode, 'multi-edit');
const [multiEditData, multiEditError] = useMultiEditData();
const selectBuilding = useCallback((selectedBuilding: Building) => {
const currentId = selectedBuildingId;
updateBuilding(selectedBuilding);
setSelectedBuildingId(setOrToggle(currentId, 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) {
console.error({ error: res.error });
} else {
updateRevisionId(res.revision_id);
}
} catch(error) {
console.error({ error });
}
}
}
}, [multiEditError, multiEditData, currentCategory]);
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);
const handleBuildingUpdate = useCallback((buildingId: number, updatedData: Building) => {
// only update current building data if the IDs match
if(buildingId === selectedBuildingId) {
updateBuilding(Object.assign({}, building, updatedData));
} else {
const data = parseJsonOrDefault(this.getMultiEditDataString());
if (data != undefined && !Object.values(data).some(x => x == undefined)) {
this.updateBuilding(building.building_id, data);
}
// otherwise, still update the latest revision ID
updateRevisionId(updatedData.revision_id);
}
}
}, [selectedBuildingId, building, updateBuilding, updateRevisionId]);
likeBuilding(buildingId) {
apiPost(`/api/buildings/${buildingId}/like.json`, { like: true })
.then(res => {
if (res.error) {
console.error({ error: res.error });
} else {
this.increaseRevision(res.revision_id);
}
}).catch(
(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]);
updateBuilding(buildingId, data) {
apiPost(`/api/buildings/${buildingId}.json`, data)
.then(res => {
if (res.error) {
console.error({ error: res.error });
} else {
this.increaseRevision(res.revision_id);
}
}).catch(
(err) => console.error({ error: err })
);
}
const handleUserVerifiedUpdate = useCallback((buildingId: number, updatedData: UserVerified) => {
// only update current building data if the IDs match
if(buildingId === selectedBuildingId) {
updateUserVerified(Object.assign({}, userVerified, updatedData)); // quickly show added verifications
reloadBuilding();
reloadUserVerified(); // but still reload from server to reflect removed verifications
}
}, [selectedBuildingId, updateUserVerified, reloadBuilding, userVerified]);
render() {
const mode = this.props.match.params.mode;
const viewEditMode = mode === 'multi-edit' ? undefined : mode;
let category = this.state.category || 'age';
const building_id = this.state.building && this.state.building.building_id;
return (
<Fragment>
<Switch>
<PrivateRoute path="/:mode(edit|multi-edit)" /> {/* empty private route to ensure auth for editing */}
</Switch>
return (
<>
<PrivateRoute path="/:mode(edit|multi-edit)" /> {/* empty private route to ensure auth for editing */}
<Sidebar>
<Switch>
<Route exact path="/">
<Sidebar>
<Welcome />
</Sidebar>
<Welcome />
</Route>
<Route exact path="/:mode/categories/:building?">
<Sidebar>
<Categories mode={mode || 'view'} building_id={building_id} />
</Sidebar>
<Route exact path="/multi-edit/:cat">
<MultiEdit category={displayCategory} />
</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?">
<Sidebar>
<Categories mode={mode || 'view'} building_id={building_id} />
<BuildingView
mode={viewEditMode}
cat={category}
building={this.state.building}
building_like={this.state.building_like}
user_verified={this.state.user_verified}
selectBuilding={this.selectBuilding}
user={this.props.user}
/>
</Sidebar>
</Route>
<Route exact path="/:mode/:cat/:building/history">
<Sidebar>
<Categories mode={mode || 'view'} building_id={building_id} />
<EditHistory building={this.state.building} />
</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 exact path="/:mode/:cat/:building?">
<BuildingView
mode={viewEditMode}
cat={displayCategory}
building={building}
building_like={buildingLike}
user_verified={userVerified ?? {}}
onBuildingUpdate={handleBuildingUpdate}
onBuildingLikeUpdate={handleBuildingLikeUpdate}
onUserVerifiedUpdate={handleUserVerifiedUpdate}
/>
</Route>
</Switch>
</Route>
<Route exact path="/:mode(view|edit|multi-edit)"
render={props => (<Redirect to={`/${props.match.params.mode}/categories`} />)}
/>
</Switch>
<ColouringMap
building={this.state.building}
mode={mode || 'basic'}
category={category}
revision_id={this.state.revision_id}
selectBuilding={this.selectBuilding}
colourBuilding={this.colourBuilding}
/>
</Fragment>
);
}
}
export default MapApp;
</Sidebar>
<ColouringMap
selectedBuildingId={selectedBuildingId}
mode={mode || 'basic'}
category={displayCategory}
revisionId={revisionId}
onBuildingAction={mode === 'multi-edit' ? colourBuilding : selectBuilding}
/>
</>
);
};

View File

@ -4,145 +4,10 @@ import './legend.css';
import { DownIcon, UpIcon } from '../components/icons';
import { Logo } from '../components/logo';
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'}
]
}
};
import { LegendConfig } from '../config/category-maps-config';
interface LegendProps {
slug: string;
legendConfig: LegendConfig;
}
interface LegendState {
@ -184,59 +49,62 @@ class Legend extends React.Component<LegendProps, LegendState> {
}
render() {
const details = LEGEND_CONFIG[this.props.slug] || {};
const title = details.title || "";
const elements = details.elements || [];
const {
title = undefined,
elements = [],
description = undefined,
disclaimer = undefined
} = this.props.legendConfig ?? {};
return (
<div className="map-legend">
<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} >
{
this.state.collapseList ?
<UpIcon /> :
<DownIcon />
}
</button> :
null
</button>
}
{
details.description?
<p>{details.description} </p>
: null
description && <p>{description}</p>
}
{
elements.length?
elements.length === 0 ?
<p className="data-intro">Coming soon</p> :
<ul className={this.state.collapseList ? 'collapse data-legend' : 'data-legend'} >
{
details.disclaimer &&
<p className='legend-disclaimer'>{details.disclaimer}</p>
disclaimer && <p className='legend-disclaimer'>{disclaimer}</p>
}
{
elements.map((item) => {
if(item.subtitle != undefined) {
return (<li key={item.subtitle}>
<h6>{item.subtitle}</h6>
</li>);
}
return (
<li key={item.color} >
<div className="key" style={ { background: item.color, border: item.border } }></div>
let key: string,
content: React.ReactElement;
if('subtitle' in item) {
key = item.subtitle;
content = <h6>{item.subtitle}</h6>;
} else {
key = `${item.text}-${item.color}`;
content = <>
<div className="key" style={ { background: item.color, border: item.border } } />
{ item.text }
</li>
</>;
}
return (
<li key={key}>
{content}
</li>
);
})
}
</ul>
: <p className="data-intro">Coming soon</p>
}
</div>
);

View File

@ -7,21 +7,22 @@ import './map.css';
import { apiGet } from '../apiHelpers';
import { HelpIcon } from '../components/icons';
import { Building } from '../models/building';
import Legend from './legend';
import SearchBox from './search-box';
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';
interface ColouringMapProps {
building?: Building;
selectedBuildingId: number;
mode: 'basic' | 'view' | 'edit' | 'multi-edit';
category: string;
revision_id: number;
selectBuilding: (building: Building) => void;
colourBuilding: (building: Building) => void;
category: Category;
revisionId: string;
onBuildingAction: (building: Building) => void;
}
interface ColouringMapState {
@ -58,30 +59,12 @@ class ColouringMap extends Component<ColouringMapProps, ColouringMapState> {
}
handleClick(e) {
const mode = this.props.mode;
const { lat, lng } = e.latlng;
apiGet(`/api/buildings/locate?lat=${lat}&lng=${lng}`)
.then(data => {
if (data && data.length){
const building = data[0];
if (mode === 'multi-edit') {
// 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)
);
.then(data => {
const building = data?.[0];
this.props.onBuildingAction(building);
}).catch(err => console.error(err));
}
themeSwitch(e) {
@ -103,6 +86,8 @@ class ColouringMap extends Component<ColouringMapProps, ColouringMapState> {
}
render() {
const categoryMapDefinition = categoryMapsConfig[this.props.category];
const position: [number, number] = [this.state.lat, this.state.lng];
// baselayer
@ -121,55 +106,37 @@ class ColouringMap extends Component<ColouringMapProps, ColouringMapState> {
const buildingsBaseUrl = `/tiles/base_${this.state.theme}/{z}/{x}/{y}{r}.png`;
const buildingBaseLayer = <TileLayer url={buildingsBaseUrl} minZoom={14} maxZoom={19}/>;
const boundaryStyleFn = () => ({color: '#bbb', fill: false});
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 cat = this.props.category;
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 ?
const tileset = categoryMapDefinition.mapStyle;
const dataLayer = tileset != undefined &&
<TileLayer
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}
maxZoom={19}
/>
: null;
/>;
// highlight
const highlightLayer = this.props.building != undefined ?
const highlightLayer = this.props.selectedBuildingId != undefined &&
<TileLayer
key={this.props.building.building_id}
url={`/tiles/highlight/{z}/{x}/{y}{r}.png?highlight=${this.props.building.building_id}&base=${tileset}`}
key={this.props.selectedBuildingId}
url={`/tiles/highlight/{z}/{x}/{y}{r}.png?highlight=${this.props.selectedBuildingId}&base=${tileset}`}
minZoom={13}
maxZoom={19}
zIndex={100}
/>
: null;
/>;
const numbersLayer = <TileLayer
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}
minZoom={17}
maxZoom={19}
/>
const hasSelection = this.props.selectedBuildingId != undefined;
const isEdit = ['edit', 'multi-edit'].includes(this.props.mode);
return (
@ -195,20 +162,18 @@ class ColouringMap extends Component<ColouringMapProps, ColouringMapState> {
<AttributionControl prefix=""/>
</Map>
{
this.props.mode !== 'basic'? (
<Fragment>
{
this.props.building == undefined ?
<div className="map-notice">
<HelpIcon /> {isEdit ? 'Click a building to edit' : 'Click a building for details'}
</div>
: null
}
<Legend slug={cat} />
<ThemeSwitcher onSubmit={this.themeSwitch} currentTheme={this.state.theme} />
<SearchBox onLocate={this.handleLocate} />
</Fragment>
) : null
this.props.mode !== 'basic' &&
<Fragment>
{
!hasSelection &&
<div className="map-notice">
<HelpIcon /> {isEdit ? 'Click a building to edit' : 'Click a building for details'}
</div>
}
<Legend legendConfig={categoryMapDefinition?.legend} />
<ThemeSwitcher onSubmit={this.themeSwitch} currentTheme={this.state.theme} />
<SearchBox onLocate={this.handleLocate} />
</Fragment>
}
</div>
);

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;
geometry_id: number;
revision_id: number;
revision_id: string;
uprns: string[];
// TODO: add other fields as needed
verified: BuildingAttributeVerificationCounts;
}
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,7 @@
import { Category } from '../config/categories-config';
import { intParamTransform } from './url-param-transform';
import { useUrlParam } from './use-url-param';
export function useUrlBuildingParam(defaultMode: 'view' | 'edit' | 'multi-edit', defaultCategory: Category | 'categories' = 'categories') {
return useUrlParam('building', intParamTransform, '/:mode/:category/:building?', {mode: defaultMode, category: defaultCategory});
}

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,32 @@
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>,
pathPattern?: string,
defaultParams: { [key: string]: string} = {}
): [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 newParams = Object.assign({}, defaultParams, match.params);
newParams[param] = value == undefined ? undefined : paramTransform.toParam(value);
const newPath = generatePath(pathPattern ?? match.path, newParams);
history.push(newPath);
}, [param, paramTransform, pathPattern, defaultParams, match.url]);
return [paramValue, setUrlParam];
}

View File

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