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:
commit
727b5fa6a9
@ -9,7 +9,7 @@ describe('<App />', () => {
|
||||
const div = document.createElement('div');
|
||||
ReactDOM.render(
|
||||
<MemoryRouter>
|
||||
<App revisionId={0} />
|
||||
<App revisionId="0" />
|
||||
</MemoryRouter>,
|
||||
div
|
||||
);
|
||||
|
@ -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}) =>
|
||||
<Route exact path={mapAppPaths} >
|
||||
<MapApp
|
||||
{...routeProps}
|
||||
building={props.building}
|
||||
building_like={props.building_like}
|
||||
user_verified={props.user_verified}
|
||||
user={user}
|
||||
revisionId={props.revisionId}
|
||||
/>
|
||||
}
|
||||
</AuthContext.Consumer>
|
||||
)} />
|
||||
</Route>
|
||||
<Route component={NotFound} />
|
||||
</Switch>
|
||||
</AuthProvider>
|
||||
|
@ -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 & 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:
|
||||
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;
|
||||
|
@ -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 & 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}
|
||||
{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>
|
||||
);
|
||||
|
||||
|
@ -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>
|
||||
|
@ -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';
|
||||
|
@ -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});
|
||||
|
@ -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';
|
||||
|
@ -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;
|
||||
|
@ -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';
|
||||
|
||||
|
@ -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';
|
||||
|
@ -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';
|
||||
|
@ -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';
|
||||
|
@ -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';
|
||||
|
@ -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';
|
||||
|
@ -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';
|
||||
|
@ -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}
|
||||
|
@ -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';
|
||||
|
||||
@ -19,10 +19,10 @@ interface CategoryEditSummaryProps {
|
||||
}
|
||||
|
||||
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 {
|
||||
name: categoryName = 'Unknown category',
|
||||
slug: categorySlug = 'categories'
|
||||
} = categoriesConfig[props.category] ?? {};
|
||||
|
||||
return (
|
||||
<div className='edit-history-category-summary'>
|
||||
|
@ -1,80 +1,54 @@
|
||||
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>
|
||||
<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'>
|
||||
@ -83,7 +57,6 @@ const MultiEdit: React.FC<MultiEditProps> = (props) => {
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
</Sidebar>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -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>
|
||||
{
|
||||
(props.msg)?
|
||||
(
|
||||
|
||||
return props.msg ?
|
||||
<div className="alert alert-danger" role="alert">
|
||||
{
|
||||
typeof props.msg === 'string' ?
|
||||
props.msg
|
||||
: 'Unexpected error'
|
||||
}
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
</Fragment>
|
||||
);
|
||||
</div> :
|
||||
null;
|
||||
};
|
||||
|
||||
export default ErrorBox;
|
||||
|
124
app/src/frontend/config/categories-config.ts
Normal file
124
app/src/frontend/config/categories-config.ts
Normal 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?'
|
||||
},
|
||||
};
|
195
app/src/frontend/config/category-maps-config.ts
Normal file
195
app/src/frontend/config/category-maps-config.ts
Normal 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: '60–80%' },
|
||||
{ color: '#43a2ca', text: '40–60%' },
|
||||
{ color: '#7bccc4', text: '20–40%' },
|
||||
{ color: '#bae4bc', text: '<20%' }
|
||||
]
|
||||
},
|
||||
},
|
||||
[Category.Community]: {
|
||||
mapStyle: 'likes',
|
||||
legend: {
|
||||
title: 'Like Me',
|
||||
elements: [
|
||||
{ color: '#bd0026', text: '👍👍👍👍 100+' },
|
||||
{ color: '#e31a1c', text: '👍👍👍 50–99' },
|
||||
{ color: '#fc4e2a', text: '👍👍 20–49' },
|
||||
{ color: '#fd8d3c', text: '👍👍 10–19' },
|
||||
{ color: '#feb24c', text: '👍 3–9' },
|
||||
{ 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: []
|
||||
},
|
||||
}
|
||||
|
||||
};
|
32
app/src/frontend/config/category-ui-config.ts
Normal file
32
app/src/frontend/config/category-ui-config.ts
Normal 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,
|
||||
};
|
||||
|
@ -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,
|
||||
},
|
||||
};
|
54
app/src/frontend/hooks/use-building-data.ts
Normal file
54
app/src/frontend/hooks/use-building-data.ts
Normal 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];
|
||||
}
|
43
app/src/frontend/hooks/use-building-like-data.ts
Normal file
43
app/src/frontend/hooks/use-building-like-data.ts
Normal 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];
|
||||
}
|
11
app/src/frontend/hooks/use-last-not-empty.ts
Normal file
11
app/src/frontend/hooks/use-last-not-empty.ts
Normal 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;
|
||||
}
|
27
app/src/frontend/hooks/use-multi-edit-data.ts
Normal file
27
app/src/frontend/hooks/use-multi-edit-data.ts
Normal 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];
|
||||
}
|
6
app/src/frontend/hooks/use-query.ts
Normal file
6
app/src/frontend/hooks/use-query.ts
Normal 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);
|
||||
}
|
33
app/src/frontend/hooks/use-revision.tsx
Normal file
33
app/src/frontend/hooks/use-revision.tsx
Normal 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];
|
||||
}
|
40
app/src/frontend/hooks/use-user-verified-data.ts
Normal file
40
app/src/frontend/hooks/use-user-verified-data.ts
Normal 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)];
|
||||
}
|
@ -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);
|
||||
}
|
||||
|
||||
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);
|
||||
/** 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 {
|
||||
const data = parseJsonOrDefault(this.getMultiEditDataString());
|
||||
|
||||
|
||||
if (data != undefined && !Object.values(data).some(x => x == undefined)) {
|
||||
this.updateBuilding(building.building_id, data);
|
||||
}
|
||||
return newValue;
|
||||
}
|
||||
}
|
||||
|
||||
likeBuilding(buildingId) {
|
||||
apiPost(`/api/buildings/${buildingId}/like.json`, { like: true })
|
||||
.then(res => {
|
||||
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 {
|
||||
this.increaseRevision(res.revision_id);
|
||||
updateRevisionId(res.revision_id);
|
||||
}
|
||||
}).catch(
|
||||
(err) => console.error({ error: err })
|
||||
);
|
||||
} catch(error) {
|
||||
console.error({ error });
|
||||
}
|
||||
}
|
||||
}, [multiEditError, multiEditData, currentCategory]);
|
||||
|
||||
updateBuilding(buildingId, data) {
|
||||
apiPost(`/api/buildings/${buildingId}.json`, data)
|
||||
.then(res => {
|
||||
if (res.error) {
|
||||
console.error({ error: res.error });
|
||||
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 {
|
||||
this.increaseRevision(res.revision_id);
|
||||
// otherwise, still update the latest revision ID
|
||||
updateRevisionId(updatedData.revision_id);
|
||||
}
|
||||
}).catch(
|
||||
(err) => console.error({ error: err })
|
||||
);
|
||||
}, [selectedBuildingId, building, updateBuilding, updateRevisionId]);
|
||||
|
||||
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 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;
|
||||
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]);
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<Switch>
|
||||
<>
|
||||
<PrivateRoute path="/:mode(edit|multi-edit)" /> {/* empty private route to ensure auth for editing */}
|
||||
</Switch>
|
||||
<Sidebar>
|
||||
<Switch>
|
||||
<Route exact path="/">
|
||||
<Sidebar>
|
||||
<Welcome />
|
||||
</Sidebar>
|
||||
</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 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="/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}
|
||||
cat={displayCategory}
|
||||
building={building}
|
||||
building_like={buildingLike}
|
||||
user_verified={userVerified ?? {}}
|
||||
onBuildingUpdate={handleBuildingUpdate}
|
||||
onBuildingLikeUpdate={handleBuildingLikeUpdate}
|
||||
onUserVerifiedUpdate={handleUserVerifiedUpdate}
|
||||
/>
|
||||
</Sidebar>
|
||||
</Route>
|
||||
<Route exact path="/:mode/:cat/:building/history">
|
||||
<Sidebar>
|
||||
<Categories mode={mode || 'view'} building_id={building_id} />
|
||||
<EditHistory building={this.state.building} />
|
||||
</Sidebar>
|
||||
</Switch>
|
||||
</Route>
|
||||
<Route exact path="/:mode(view|edit|multi-edit)"
|
||||
render={props => (<Redirect to={`/${props.match.params.mode}/categories`} />)}
|
||||
/>
|
||||
</Switch>
|
||||
</Sidebar>
|
||||
<ColouringMap
|
||||
building={this.state.building}
|
||||
selectedBuildingId={selectedBuildingId}
|
||||
mode={mode || 'basic'}
|
||||
category={category}
|
||||
revision_id={this.state.revision_id}
|
||||
selectBuilding={this.selectBuilding}
|
||||
colourBuilding={this.colourBuilding}
|
||||
category={displayCategory}
|
||||
revisionId={revisionId}
|
||||
onBuildingAction={mode === 'multi-edit' ? colourBuilding : selectBuilding}
|
||||
/>
|
||||
</Fragment>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default MapApp;
|
||||
};
|
||||
|
@ -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: '60–80%' },
|
||||
{ color: '#43a2ca', text: '40–60%' },
|
||||
{ color: '#7bccc4', text: '20–40%' },
|
||||
{ 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: '👍👍👍 50–99' },
|
||||
{ color: '#fc4e2a', text: '👍👍 20–49' },
|
||||
{ color: '#fd8d3c', text: '👍👍 10–19' },
|
||||
{ color: '#feb24c', text: '👍 3–9' },
|
||||
{ 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>);
|
||||
}
|
||||
let key: string,
|
||||
content: React.ReactElement;
|
||||
|
||||
return (
|
||||
|
||||
<li key={item.color} >
|
||||
<div className="key" style={ { background: item.color, border: item.border } }></div>
|
||||
if('subtitle' in item) {
|
||||
key = item.subtitle;
|
||||
content = <h6>{item.subtitle}</h6>;
|
||||
} else {
|
||||
key = `${item.text}-${item.color}`;
|
||||
content = <>
|
||||
<div className="key" style={ { background: item.color, border: item.border } } />
|
||||
{ item.text }
|
||||
</>;
|
||||
}
|
||||
return (
|
||||
<li key={key}>
|
||||
{content}
|
||||
</li>
|
||||
|
||||
);
|
||||
})
|
||||
}
|
||||
</ul>
|
||||
: <p className="data-intro">Coming soon…</p>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
|
@ -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)
|
||||
);
|
||||
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'? (
|
||||
this.props.mode !== 'basic' &&
|
||||
<Fragment>
|
||||
{
|
||||
this.props.building == undefined ?
|
||||
!hasSelection &&
|
||||
<div className="map-notice">
|
||||
<HelpIcon /> {isEdit ? 'Click a building to edit' : 'Click a building for details'}
|
||||
</div>
|
||||
: null
|
||||
}
|
||||
<Legend slug={cat} />
|
||||
<Legend legendConfig={categoryMapDefinition?.legend} />
|
||||
<ThemeSwitcher onSubmit={this.themeSwitch} currentTheme={this.state.theme} />
|
||||
<SearchBox onLocate={this.handleLocate} />
|
||||
</Fragment>
|
||||
) : null
|
||||
}
|
||||
</div>
|
||||
);
|
||||
|
@ -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
|
||||
};
|
||||
|
16
app/src/frontend/nav/url-param-transform.ts
Normal file
16
app/src/frontend/nav/url-param-transform.ts
Normal 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()
|
||||
};
|
7
app/src/frontend/nav/use-url-building-param.ts
Normal file
7
app/src/frontend/nav/use-url-building-param.ts
Normal 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});
|
||||
}
|
14
app/src/frontend/nav/use-url-category-param.ts
Normal file
14
app/src/frontend/nav/use-url-category-param.ts
Normal 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;
|
||||
}
|
||||
});
|
||||
}
|
10
app/src/frontend/nav/use-url-mode-param.ts
Normal file
10
app/src/frontend/nav/use-url-mode-param.ts
Normal 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
|
||||
});
|
||||
}
|
32
app/src/frontend/nav/use-url-param.ts
Normal file
32
app/src/frontend/nav/use-url-param.ts
Normal 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];
|
||||
}
|
@ -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>
|
||||
);
|
||||
|
Loading…
Reference in New Issue
Block a user