Merge pull request #898 from matkoniecz/feature/display-of-planning-data

Feature/display of planning data
This commit is contained in:
Mike Simpson 2023-01-04 11:15:40 +00:00 committed by GitHub
commit df1da50a4a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
70 changed files with 15166 additions and 278 deletions

View File

@ -5,8 +5,6 @@
</Rule>
<Rule>
<LineSymbolizer stroke="#222222aa" stroke-width="0.5" />
<MaxScaleDenominator>17000</MaxScaleDenominator>
<MinScaleDenominator>0</MinScaleDenominator>
</Rule>
</Style>
<Style name="base_night">
@ -19,6 +17,61 @@
<MinScaleDenominator>0</MinScaleDenominator>
</Rule>
</Style>
<Style name="base_night_outlines">
<Rule>
<LineSymbolizer stroke="#ff0000ff" stroke-width="1" />
</Rule>
</Style>
<Style name="base_boroughs">
<Rule>
<TextSymbolizer
face-name="DejaVu Sans Condensed Bold"
fill="#eee"
size="24"
halo-radius="2"
halo-fill="#333"
wrap-width="40"
clip="false"
margin="20"
>
[name]
</TextSymbolizer>
<MinScaleDenominator>10000</MinScaleDenominator>
<MaxScaleDenominator>20000</MaxScaleDenominator>
</Rule>
<Rule>
<TextSymbolizer
face-name="DejaVu Sans Condensed Bold"
fill="#eee"
size="21"
halo-radius="2"
halo-fill="#333"
wrap-width="35"
clip="false"
margin="20"
>
[name]
</TextSymbolizer>
<MinScaleDenominator>20000</MinScaleDenominator>
<MaxScaleDenominator>80000</MaxScaleDenominator>
</Rule>
<Rule>
<TextSymbolizer
face-name="DejaVu Sans Condensed Bold"
fill="#eee"
size="15"
halo-radius="2"
halo-fill="#333"
wrap-width="35"
clip="false"
margin="20"
>
[name]
</TextSymbolizer>
<MinScaleDenominator>80000</MinScaleDenominator>
<MaxScaleDenominator>400000</MaxScaleDenominator>
</Rule>
</Style>
<Style name="number_labels">
<Rule>
<TextSymbolizer
@ -308,11 +361,114 @@
<LineSymbolizer stroke="#888" stroke-width="3.0"/>
</Rule>
</Style>
<Style name="empty_map">
</Style>
<Style name="conservation_area">
<Rule>
<PolygonSymbolizer fill="#73ebaf" />
</Rule>
</Style>
<Style name="planning_applications_status_all">
<Rule>
<Filter>[status] = "Submitted"</Filter>
<PolygonSymbolizer fill="#a040a0"/>
<LineSymbolizer stroke="#a040a0" stroke-width="1.75" />
</Rule>
<Rule>
<Filter>[status] = "Approved"</Filter>
<PolygonSymbolizer fill="#16cf15"/>
<LineSymbolizer stroke="#16cf15" stroke-width="1.75" />
</Rule>
<Rule>
<Filter>[status] = "Appeal In Progress"</Filter>
<PolygonSymbolizer fill="#fff200"/>
<LineSymbolizer stroke="#fff200" stroke-width="1.75" />
</Rule>
<Rule>
<Filter>[status] = "Rejected"</Filter>
<PolygonSymbolizer fill="#e31d23"/>
<LineSymbolizer stroke="#e31d23" stroke-width="1.75" />
</Rule>
<Rule>
<Filter>[status] = "Withdrawn"</Filter>
<PolygonSymbolizer fill="#999999"/>
<LineSymbolizer stroke="#999999" stroke-width="1.75" />
</Rule>
<Rule>
<Filter>[status] = "Withdrawn"</Filter>
<PolygonSymbolizer fill="#262626"/>
<LineSymbolizer stroke="#262626" stroke-width="1.75" />
</Rule>
<Rule>
<Filter>[status] != "Submitted" and [status] != "Approved" and [status] != "Appeal In Progress" and [status] != "Rejected" and [status] != "Withdrawn"</Filter>
<PolygonSymbolizer fill="#eacad0"/>
<LineSymbolizer stroke="#eacad0" stroke-width="1.75" />
</Rule>
</Style>
<Style name="planning_applications_status_recent">
<Rule>
<Filter>[status] = "Submitted" and [days_since_registered_with_local_authority_date] &lt; 366</Filter>
<PolygonSymbolizer fill="#a040a0"/>
<LineSymbolizer stroke="#a040a0" stroke-width="1.75" />
</Rule>
<Rule>
<Filter>[status] = "Approved" and [days_since_decision_date] &lt; 366</Filter>
<PolygonSymbolizer fill="#16cf15"/>
<LineSymbolizer stroke="#16cf15" stroke-width="1.75" />
</Rule>
<Rule>
<Filter>[status] = "Appeal In Progress" and [days_since_decision_date] &lt; 366</Filter>
<PolygonSymbolizer fill="#fff200"/>
<LineSymbolizer stroke="#fff200" stroke-width="1.75" />
</Rule>
<Rule>
<Filter>[status] = "Rejected" and [days_since_decision_date] &lt; 366</Filter>
<PolygonSymbolizer fill="#e31d23"/>
<LineSymbolizer stroke="#e31d23" stroke-width="1.75" />
</Rule>
<Rule>
<Filter>[status] = "Withdrawn" and [days_since_registered_with_local_authority_date] &lt; 366</Filter>
<PolygonSymbolizer fill="#999999"/>
<LineSymbolizer stroke="#999999" stroke-width="1.75" />
</Rule>
<Rule>
<Filter>[status] != "Submitted" and [status] != "Approved" and [status] != "Appeal In Progress" and [status] != "Rejected" and [status] != "Withdrawn" and [days_since_registered_with_local_authority_date] &lt; 366</Filter>
<PolygonSymbolizer fill="#eacad0"/>
<LineSymbolizer stroke="#eacad0" stroke-width="1.75" />
</Rule>
</Style>
<Style name="planning_applications_status_very_recent">
<Rule>
<Filter>[status] = "Submitted" and [days_since_registered_with_local_authority_date] &lt;= 30</Filter>
<PolygonSymbolizer fill="#a040a0"/>
<LineSymbolizer stroke="#a040a0" stroke-width="1.75" />
</Rule>
<Rule>
<Filter>[status] = "Approved" and [days_since_decision_date] &lt;= 30</Filter>
<PolygonSymbolizer fill="#16cf15"/>
<LineSymbolizer stroke="#16cf15" stroke-width="1.75" />
</Rule>
<Rule>
<Filter>[status] = "Appeal In Progress" and [days_since_decision_date] &lt;= 30</Filter>
<PolygonSymbolizer fill="#fff200"/>
<LineSymbolizer stroke="#fff200" stroke-width="1.75" />
</Rule>
<Rule>
<Filter>[status] = "Rejected" and [days_since_decision_date] &lt;= 30</Filter>
<PolygonSymbolizer fill="#e31d23"/>
<LineSymbolizer stroke="#e31d23" stroke-width="1.75" />
</Rule>
<Rule>
<Filter>[status] = "Withdrawn" and [days_since_registered_with_local_authority_date] &lt;= 30</Filter>
<PolygonSymbolizer fill="#999999"/>
<LineSymbolizer stroke="#999999" stroke-width="1.75" />
</Rule>
<Rule>
<Filter>[status] != "Submitted" and [status] != "Approved" and [status] != "Appeal In Progress" and [status] != "Rejected" and [status] != "Withdrawn" and [days_since_registered_with_local_authority_date] &lt;= 30</Filter>
<PolygonSymbolizer fill="#eacad0"/>
<LineSymbolizer stroke="#eacad0" stroke-width="1.75" />
</Rule>
</Style>
<Style name="planning_combined">
<Rule>
<Filter>[planning_in_conservation_area] = true</Filter>
@ -597,7 +753,7 @@
<Style name="landuse">
<Rule>
<Filter>[current_landuse_order] = "Agriculture And Fisheries"</Filter>
<PolygonSymbolizer fill="#fa667d" />
<PolygonSymbolizer fill="#73ccd1" />
</Rule>
<Rule>
<Filter>[current_landuse_order] = "Minerals"</Filter>
@ -637,7 +793,7 @@
</Rule>
<Rule>
<Filter>[current_landuse_order] = "Community Services"</Filter>
<PolygonSymbolizer fill="#73ccd1" />
<PolygonSymbolizer fill="#fa667d" />
</Rule>
<Rule>
<Filter>[current_landuse_order] = "Retail"</Filter>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -173,11 +173,15 @@ export const buildingAttributesConfig = valueType<DataFieldConfig>()({ /* eslint
edit: true,
verify: true,
},
work_on_site_is_completed_on_year: {
planning_crowdsourced_site_completion_status: {
edit: true,
verify: true,
},
planning_planning_application_id_crowdsourced: {
planning_crowdsourced_site_completion_year: {
edit: true,
verify: true,
},
planning_crowdsourced_planning_id: {
edit: true,
verify: true,
},

View File

@ -116,6 +116,23 @@ const getBuildingUPRNsById = asyncController(async (req: express.Request, res: e
}
});
// GET building planning data
const getBuildingPlanningDataById = asyncController(async (req: express.Request, res: express.Response) => {
const buildingId = processParam(req.params, 'building_id', parsePositiveIntParam, true);
try {
const result = await buildingService.getBuildingPlanningDataById(buildingId);
if (typeof (result) === 'undefined') {
return res.send({ error: 'Database error' });
}
res.send({data: result, buildingId: buildingId});
} catch(error) {
console.error(error);
res.send({ error: 'Database error' });
}
});
const getBuildingUserAttributesById = asyncController(async (req: express.Request, res: express.Response) => {
if(!req.session.user_id) {
return res.send({ error: 'Must be logged in'});
@ -202,6 +219,7 @@ export default {
getBuildingById,
updateBuildingById,
getBuildingUPRNsById,
getBuildingPlanningDataById,
getUserVerifiedAttributes,
verifyBuildingAttributes,
getBuildingEditHistoryById,

View File

@ -26,6 +26,7 @@ router.route('/:building_id.json')
// GET building UPRNs
router.get('/:building_id/uprns.json', buildingController.getBuildingUPRNsById);
router.get('/:building_id/planning_data.json', buildingController.getBuildingPlanningDataById);
// POST verify building attribute
router.route('/:building_id/verify.json')

View File

@ -56,4 +56,5 @@ export * from './edit';
export * from './history';
export * from './query';
export * from './uprn';
export * from './planningData';
export * from './verify';

View File

@ -0,0 +1,16 @@
import db from '../../../db';
export async function getBuildingPlanningDataById(id: number) {
try {
return await db.any(
'SELECT building_properties.uprn, building_properties.building_id, planning_data.description, planning_data.status, planning_data.status_before_aliasing, planning_data.status_explanation_note, planning_data.uprn, planning_data.planning_application_id, planning_application_link, to_char(planning_data.registered_with_local_authority_date, \'YYYY-MM-DD\') AS registered_with_local_authority_date, to_char(planning_data.decision_date, \'YYYY-MM-DD\') AS decision_date, to_char(planning_data.last_synced_date, \'YYYY-MM-DD\') AS last_synced_date, planning_data.data_source, planning_data.data_source_link, planning_data.address \
FROM building_properties \
INNER JOIN planning_data ON \
building_properties.uprn = planning_data.uprn WHERE building_id = $1',
[id]
);
} catch(error) {
console.error(error);
return undefined;
}
}

View File

@ -21,12 +21,14 @@ export function useBuildingData(buildingId: number, preloadedData: Building, inc
return;
}
try {
let [building, buildingUprns] = await Promise.all([
let [building, buildingUprns, planningData] = await Promise.all([
apiGet(`/api/buildings/${buildingId}.json${includeUserAttributes ? '?user_attributes=true' : ''}`),
apiGet(`/api/buildings/${buildingId}/uprns.json`)
apiGet(`/api/buildings/${buildingId}/uprns.json`),
apiGet(`/api/buildings/${buildingId}/planning_data.json`)
]);
building.uprns = buildingUprns.uprns;
building.planning_data = planningData.data;
building = Object.assign(building, {...building.user_attributes});
delete building.user_attributes;

View File

@ -6,6 +6,7 @@ import './app.css';
import { AuthRoute, PrivateRoute } from './route';
import { AuthProvider } from './auth-context';
import { DisplayPreferencesProvider } from './displayPreferences-context';
import { Header } from './header';
import { MapApp } from './map-app';
import { Building, UserVerified } from './models/building';
@ -53,6 +54,7 @@ export const App: React.FC<AppProps> = props => {
const mapAppPaths = ['/', '/:mode(view|edit|multi-edit)/:category/:building(\\d+)?/(history)?'];
return (
<DisplayPreferencesProvider>
<AuthProvider preloadedUser={props.user}>
<Switch>
<Route exact path={mapAppPaths}>
@ -89,5 +91,6 @@ export const App: React.FC<AppProps> = props => {
<Route component={NotFound} />
</Switch>
</AuthProvider>
</DisplayPreferencesProvider>
);
};

View File

@ -58,6 +58,7 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({
const [userError, setUserError] = useState<string>(undefined);
const [isLoading, setIsLoading] = useState(false);
const login = useCallback(async (data: UserLoginData, cb: (err) => void = noop) => {
if(isAuthenticated) {
return;
@ -199,7 +200,7 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({
logout,
signup,
generateApiKey,
deleteAccount
deleteAccount,
}}>
{children}
</AuthContext.Provider>

View File

@ -4,6 +4,7 @@ import { useAuth } from '../auth-context';
import { categoriesConfig, Category } from '../config/categories-config';
import { categoryUiConfig } from '../config/category-ui-config';
import { Building, UserVerified } from '../models/building';
import { BuildingMapTileset } from '../config/tileserver-config';
import BuildingNotFound from './building-not-found';
@ -14,6 +15,8 @@ interface BuildingViewProps {
user_verified?: any;
onBuildingUpdate: (buildingId: number, updatedData: Building) => void;
onUserVerifiedUpdate: (buildingId: number, updatedData: UserVerified) => void;
mapColourScale: BuildingMapTileset;
onMapColourScale: (x: BuildingMapTileset) => void;
}
/**
@ -45,6 +48,8 @@ const BuildingView: React.FunctionComponent<BuildingViewProps> = (props) => {
intro={intro}
inactive={inactive}
user={user}
mapColourScale={props.mapColourScale}
onMapColourScale={props.onMapColourScale}
/>;
};

View File

@ -0,0 +1,122 @@
import React, { Fragment } from 'react';
import InfoBox from '../../components/info-box';
import CheckboxDataEntry from '../data-components/checkbox-data-entry';
interface PlanningDataOfficialDataEntryProps {
shownData: {
uprn: string;
building_id: number;
status?: string,
status_before_aliasing?: string,
description?: string;
planning_application_link?: string;
registered_with_local_authority_date?: string;
decision_date?: string;
last_synced_date?: string;
data_source: string;
data_source_link?: string;
address?: string;
}[];
messageOnMissingData: string,
}
const {useState} = React;
const LongText = ({ content,limit}) => {
const [showAll, setShowAll] = useState(false);
const showMore = () => setShowAll(true);
const showLess = () => setShowAll(false);
if (content == null) {
return <div>{MissingData}</div>
}
if (content.length <= limit) {
return <div>{content}</div>
}
if (showAll) {
return <div>
{content}
<br/>
<b onClick={showLess}>Shorten description</b>
</div>
}
const toShow = content.substring(0, limit).trim() + "... ";
return <div>
{toShow}
<br/>
<b onClick={showMore}>Show full description</b>
</div>
}
const Disclaimer = () => { return <Fragment><i><div><u>Disclaimer</u>: Not all applications for London are displayed. Some boroughs do not yet provide planning data to the GLA. For comprehensive information on applications please visit the relevant local authority's planning website.</div></i></Fragment> }
const MissingData = "not provided by data source"
function ShowIfAvailable(data) {
return <>{data ? data.toString() : MissingData }</>
}
const LinkIfAvailable = (link) => {
return <>{link ? <a href={link.toString()}>{link.toString()}</a> : MissingData }</>
}
const StatusInfo = ({status, statusBeforeAliasing}) => {
if(status == null) {
return <>{LinkIfAvailable(null)}</>
}
if(status != statusBeforeAliasing) {
return <>{status} - status in data source was: {statusBeforeAliasing}</>
}
return <>{status}</>
}
const PlanningDataOfficialDataEntry: React.FC<PlanningDataOfficialDataEntryProps> = (props) => {
const data = props.shownData || [];
if(data.length == 0) {
return (<Fragment>
<div className={`alert alert-dark`} role="alert" style={{ fontSize: 13, backgroundColor: "#f6f8f9" }}>
<Disclaimer />
</div>
<InfoBox type='success'>
{props.messageOnMissingData}
</InfoBox>
</Fragment>);
}
return <>
<div className={`alert alert-dark`} role="alert" style={{ fontSize: 13, backgroundColor: "#f6f8f9"}}>
{/* TODO: data[0] is problematic here... Compute it from listed elements and show all distinct variants? Error if they are not distinct? Hardcode it? */}
<div>
<i>
Planning application status is streamed using live data uploaded by local authorities to {data[0]["data_source_link"] ? <a href={data[0]["data_source_link"]}>{data[0]["data_source"]}</a> : data[0]["data_source"] }.
</i>
</div>
<Disclaimer />
</div>
{data.map((item) => (
<Fragment>
<InfoBox type='success'>
<Fragment>
<div><b>Current planning application status for this site:</b> <StatusInfo
statusBeforeAliasing={item["status_before_aliasing"]}
status={item["status"]}
/></div>
{item["status_explanation_note"] ? <div><b>Explanation</b>: {item["status_explanation_note"]}</div> : <></>}
<div><b>Planning application ID:</b> {ShowIfAvailable(item["planning_application_id"])}</div>
<div><b>Date registered by the planning authority (validation date)</b>: {ShowIfAvailable(item["registered_with_local_authority_date"])}</div>
<div><b>Decision date</b>: {ShowIfAvailable(item["decision_date"])}</div>
<div><b>Planning application link</b>: {LinkIfAvailable(item["planning_application_link"])}</div>
<div><b>Description of proposed work</b>: {item["description"] ? <LongText content = {item["description"]} limit = {400}/> : MissingData}</div>
<div><b>Address of the location as provided by local authority:</b> {ShowIfAvailable(item["address"])}</div>
<div><b>Most recent update by data provider:</b> {ShowIfAvailable(item["decision_date"])}</div>
</Fragment>
</InfoBox>
</Fragment>
)
)
}</>
};
export default PlanningDataOfficialDataEntry;

View File

@ -9,6 +9,7 @@ import ErrorBox from '../components/error-box';
import InfoBox from '../components/info-box';
import { compareObjects } from '../helpers';
import { Building, BuildingEdits, BuildingUserAttributes, UserVerified } from '../models/building';
import { BuildingMapTileset } from '../config/tileserver-config';
import { User } from '../models/user';
import ContainerHeader from './container-header';
@ -34,6 +35,9 @@ interface DataContainerProps {
user_verified?: any;
onBuildingUpdate: (buildingId: number, updatedData: Building) => void;
onUserVerifiedUpdate: (buildingId: number, updatedData: UserVerified) => void;
mapColourScale: BuildingMapTileset;
onMapColourScale: (x: BuildingMapTileset) => void;
}
interface DataContainerState {
@ -43,6 +47,8 @@ interface DataContainerState {
currentBuildingId: number;
currentBuildingRevisionId: number;
buildingEdits: BuildingEdits;
mapColourScale: BuildingMapTileset;
onMapColourScale: (x: BuildingMapTileset) => void;
}
export type DataContainerType = React.ComponentType<DataContainerProps>;
@ -66,7 +72,9 @@ const withCopyEdit: (wc: React.ComponentType<CategoryViewProps>) => DataContaine
keys_to_copy: {},
buildingEdits: {},
currentBuildingId: undefined,
currentBuildingRevisionId: undefined
currentBuildingRevisionId: undefined,
mapColourScale: undefined,
onMapColourScale: undefined
};
this.handleChange = this.handleChange.bind(this);
@ -108,7 +116,9 @@ const withCopyEdit: (wc: React.ComponentType<CategoryViewProps>) => DataContaine
keys_to_copy: categoryKeys,
buildingEdits: {},
currentBuildingId: newBuildingId,
currentBuildingRevisionId: newBuildingRevisionId
currentBuildingRevisionId: newBuildingRevisionId,
mapColourScale: props.mapColourScale,
onMapColourScale: props.onMapColourScale
};
}
@ -361,6 +371,8 @@ const withCopyEdit: (wc: React.ComponentType<CategoryViewProps>) => DataContaine
onSaveAdd={undefined}
onSaveChange={undefined}
user_verified={[]}
mapColourScale={undefined}
onMapColourScale={undefined}
/>
</Fragment> :
this.props.building != undefined ?
@ -413,6 +425,8 @@ const withCopyEdit: (wc: React.ComponentType<CategoryViewProps>) => DataContaine
onSaveChange={this.handleSaveChange}
user_verified={this.props.user_verified}
user={this.props.user}
mapColourScale={this.props.mapColourScale}
onMapColourScale={this.props.onMapColourScale}
/>
</form> :
<InfoBox msg="Select a building to view data"></InfoBox>

View File

@ -1,4 +1,5 @@
import { Building, BuildingAttributes } from '../../models/building';
import { BuildingMapTileset } from '../config/tileserver-config';
interface CopyProps {
copying: boolean;
@ -38,6 +39,9 @@ interface CategoryViewProps {
user_verified: any;
user?: any;
mapColourScale: BuildingMapTileset;
onMapColourScale: (x: BuildingMapTileset) => void;
}
export {

View File

@ -18,6 +18,22 @@ import { MultiDataEntry } from '../data-components/multi-data-entry/multi-data-e
* Community view/edit section
*/
const CommunityView: React.FunctionComponent<CategoryViewProps> = (props) => {
const switchToLikesMapStyle = (e) => {
e.preventDefault();
props.onMapColourScale('likes')
}
const switchToLocalSignificanceMapStyle = (e) => {
e.preventDefault();
props.onMapColourScale('community_local_significance_total')
}
const switchToExpectedApplicationMapStyle = (e) => {
e.preventDefault();
props.onMapColourScale('community_expected_planning_application_total')
}
const switchToPublicOwnershipMapStyle = (e) => {
e.preventDefault();
props.onMapColourScale('community_in_public_ownership')
}
const worthKeepingReasonsNonEmpty = Object.values(props.building.community_type_worth_keeping_reasons ?? {}).some(x => x);
return <>
<InfoBox type='warning'>
@ -38,6 +54,9 @@ const CommunityView: React.FunctionComponent<CategoryViewProps> = (props) => {
copy={props.copy}
/>
<button className="map-switcher-inline btn btn-outline btn-outline-dark" onClick={switchToLikesMapStyle}>
{'Click here to switch map key to this info'}
</button>
<LogicalDataEntry
slug='community_type_worth_keeping'
title={buildingUserFields.community_type_worth_keeping.title}
@ -81,7 +100,9 @@ const CommunityView: React.FunctionComponent<CategoryViewProps> = (props) => {
mode={props.mode}
copy={props.copy}
/>
<button className="map-switcher-inline btn btn-outline btn-outline-dark" onClick={switchToLocalSignificanceMapStyle}>
{'Click here to switch map key to this info'}
</button>
<UserOpinionEntry
slug='community_expected_planning_application'
title={buildingUserFields.community_expected_planning_application.title}
@ -92,6 +113,10 @@ const CommunityView: React.FunctionComponent<CategoryViewProps> = (props) => {
mode={props.mode}
copy={props.copy}
/>
<button className="map-switcher-inline btn btn-outline btn-outline-dark" onClick={switchToExpectedApplicationMapStyle}>
{'Click here to switch map key to this info'}
</button>
<InfoBox>You can click and colour any other building on the map as well.</InfoBox>
</div>
<InfoBox>Can you help add information on community use of buildings?</InfoBox>
@ -149,6 +174,9 @@ const CommunityView: React.FunctionComponent<CategoryViewProps> = (props) => {
mode={props.mode}
copy={props.copy}
/>
<button className="map-switcher-inline btn btn-outline btn-outline-dark" onClick={switchToPublicOwnershipMapStyle}>
{'Click here to switch map key to this info'}
</button>
<Verification
slug="community_public_ownership"
allow_verify={props.user !== undefined && props.building.community_public_ownership !== null && !props.edited}

View File

@ -0,0 +1,18 @@
.map-switcher-inline {
border-radius: 4px;
background: #FFC0CB;
margin: 12px;
min-width: 400px;
}
.map-switcher-inline.night {
background: #FFC0CB;
color: #fff;
background-color: #343a40;
border-color: #ff6065;
}
.map-switcher-inline.night .btn:hover {
color: #343a40;
background-color: transparent;
background-image: none;
border-color: #ff6065;
}

View File

@ -1,137 +1,180 @@
import React, { Fragment } from 'react';
import './map-switcher-inline.css'; // import in a proper place
import { Link } from 'react-router-dom';
import InfoBox from '../../components/info-box';
import { dataFields } from '../../config/data-fields-config';
import CheckboxDataEntry from '../data-components/checkbox-data-entry';
import NumericDataEntryWithFormattedLink from '../data-components/numeric-data-entry-with-formatted-link';
import { buildingUserFields, dataFields } from '../../config/data-fields-config';
import NumericDataEntry from '../data-components/numeric-data-entry';
import UserOpinionEntry from '../data-components/user-opinion-data-entry';
import DataEntry from '../data-components/data-entry';
import { LogicalDataEntry } from '../data-components/logical-data-entry/logical-data-entry';
import NumericDataEntryWithFormattedLink from '../data-components/numeric-data-entry-with-formatted-link';import DataEntry from '../data-components/data-entry';
import { DataEntryGroup } from '../data-components/data-entry-group';
import SelectDataEntry from '../data-components/select-data-entry';
import Verification from '../data-components/verification';
import withCopyEdit from '../data-container';
import PlanningDataOfficialDataEntry from '../data-components/planning-data-entry';
import { CategoryViewProps } from './category-view-props';
import { Category } from '../../config/categories-config';
import { useDisplayPreferences } from '../../displayPreferences-context';
import { processParam } from '../../../api/parameters';
/**
* Planning view/edit section
*/
const PlanningView: React.FunctionComponent<CategoryViewProps> = (props) => (
const currentTimestamp = new Date().valueOf();
const milisecondsInYear = 1000 * 60 * 60 * 24 * 365;
// there is already "parseDate" in helpers
// but it is using timestamp as input, while this one
// uses lower accuracy (as actual data is using the same accuracy)
function parseDateSpecifiedWithDailyAccuracy(isoUtcDate: string): Date {
const [year, month, day] = isoUtcDate.match(/^(\d{4})-(\d\d)-(\d\d)$/)
.splice(1)
.map(x => parseInt(x, 10));
return new Date(Date.UTC(year, month-1, day));
}
function isArchived(item) {
const decisionDate = item.decision_date;
if(decisionDate != null) {
if ((currentTimestamp - parseDateSpecifiedWithDailyAccuracy(decisionDate).valueOf()) > milisecondsInYear) {
return true;
}
}
if(item.registered_with_local_authority_date != null) {
if ((currentTimestamp - parseDateSpecifiedWithDailyAccuracy(item.registered_with_local_authority_date).valueOf()) > milisecondsInYear) {
return true;
}
}
return false;
}
const PlanningView: React.FunctionComponent<CategoryViewProps> = (props) => {
const switchToExpectedApplicationMapStyle = (e) => {
e.preventDefault();
props.onMapColourScale('community_expected_planning_application_total')
}
const switchToBuildingProtectionMapStyle = (e) => {
e.preventDefault();
props.onMapColourScale('planning_combined')
}
const switchToEmptyMapStyle = (e) => {
e.preventDefault();
props.onMapColourScale('empty_map')
}
const { flood, floodSwitchOnClick, housing, housingSwitchOnClick, creative, creativeSwitchOnClick, vista, vistaSwitchOnClick, parcel, parcelSwitchOnClick, conservation, conservationSwitchOnClick } = useDisplayPreferences();
const communityLinkUrl = `/${props.mode}/${Category.Community}/${props.building.building_id}`;
return (
<Fragment>
<InfoBox type='warning'>
This section is under development as part of the project CLPV Tool. For more details and progress <a href="https://github.com/colouring-cities/manual/wiki/G2.-Data-capture-(2).-Live-streaming-and-automated-methods">read here</a>.
</InfoBox>
<DataEntryGroup name="Planning application information">
<CheckboxDataEntry
title="Is a planning application live for this site?"
slug="planning_live_application"
value={null}
disabled={false}
/>
<CheckboxDataEntry
title={dataFields.planning_demolition_proposed.title}
slug="planning_demolition_proposed"
value={props.building.planning_demolition_proposed}
mode={props.mode}
copy={props.copy}
onChange={props.onChange}
disabled={false}
/>
<CheckboxDataEntry
title="Has this application recently been been approved/refused?"
slug="planning_recent_outcome"
value={null}
disabled={false}
/>
<CheckboxDataEntry
title="Has the work been carried out?"
slug="planning_carried_out"
value={null}
disabled={false}
/>
<InfoBox msg="For historical planning applications see Planning Portal link" />
{/*
Move to Demolition:
<DataEntryGroup name="Planning application information" collapsed={true} >
<DataEntryGroup name="Current/active applications (official data)" collapsed={false} >
<InfoBox>
To see planning applications visualised for different periods click on the map key dropdown.
<CheckboxDataEntry
title={dataFields.planning_demolition_complete.title}
slug="planning_demolition_complete"
value={props.building.planning_demolition_complete}
To comment on an application follow the application link if provided, or visit the relevant local authority's planning page.
</InfoBox>
{props.building.planning_data ?
<PlanningDataOfficialDataEntry
shownData={props.building.planning_data.filter(item => isArchived(item) == false)}
messageOnMissingData={
props.building.planning_data.length > 0 ?
"Only past application data is currently available for this site"
:
"No live planning data are currently available for this building from the Planning London DataHub."
}
/>
: <></>
}
</DataEntryGroup>
<DataEntryGroup name="Past applications (official data)" collapsed={true} >
<InfoBox>
This section provides data on past applications where available from the GLA, including those with no decision in over a year
</InfoBox>
{props.building.planning_data ?
<PlanningDataOfficialDataEntry
shownData={props.building.planning_data.filter(item => isArchived(item))}
messageOnMissingData={
props.building.planning_data.length > 0 ?
"Only current application data is currently available for this site"
:
"No live planning data are currently available for this building from the Planning London DataHub."
}
/>
: <></>
}
</DataEntryGroup>
<DataEntryGroup name="Possible future applications (crowdsourced data)" collapsed={true} >
<InfoBox type='info'>Click and colour buildings here if you think they may be subject to a future planning application involving demolition. To add your opinion on how well this building works, please also visit the <Link to={communityLinkUrl}>Community</Link> section.</InfoBox>
{
props.mapColourScale != "community_expected_planning_application_total" ?
<button className="map-switcher-inline btn btn-outline btn-outline-dark" onClick={switchToExpectedApplicationMapStyle}>
{'Click here to view possible locations of future applications'}
</button>
:
<button className="map-switcher-inline btn btn-outline btn-outline-dark" onClick={switchToEmptyMapStyle}>
{'Click here to hide possible locations of future applications'}
</button>
}
<UserOpinionEntry
slug='community_expected_planning_application'
title={buildingUserFields.community_expected_planning_application.title}
userValue={props.building.community_expected_planning_application}
onChange={props.onSaveChange}
mode={props.mode}
copy={props.copy}
onChange={props.onChange}
disabled={false}
/>
<DataEntry
title={dataFields.planning_demolition_history.title}
slug="planning_demolition_history"
value={props.building.planning_demolition_history}
mode={props.mode}
copy={props.copy}
onChange={props.onChange}
disabled={false}
/>
*/}
</DataEntryGroup>
</DataEntryGroup>
<DataEntryGroup name="Planning zones" collapsed={true} >
<InfoBox type='success'>
Data in this section comes from the Greater London Authority's Planning London Datahub. Please check the original GLA source when using for planning purposes.
<InfoBox>
To view planning zone data for London click the pink buttons. You may need to <u>zoom out</u>.
Information on whether a specific building is in a zone will be added automatically in future.
</InfoBox>
<div className={`alert alert-dark`} role="alert" style={{ fontSize: 13, backgroundColor: "#f6f8f9" }}>
<i>
Data in this section comes from the Greater London Authority's Planning London Datahub. Please check the original GLA source when using for planning purposes.
</i>
</div>
<LogicalDataEntry
title="Is the building inside a flood zone?"
title="Is the building inside a Flood Zone?"
slug="planning_live_application"
value={null}
disabled={true}
tooltip={"GLA official description: \"All areas with more than a 1 in 1,000 annual probability of either river or sea flooding.\""}
/>
{/*
<form className={`layer-switcher-inline`}>
<button className="btn btn-outline btn-outline-dark"
type="submit">
Click to see the data mapped
<button className="map-switcher-inline btn btn-outline btn-outline-dark" onClick={floodSwitchOnClick}>
{(flood === 'enabled')? 'Click to hide Flood Zones' : 'Click to see Flood Zones mapped'}
</button>
</form>
*/}
<LogicalDataEntry
title="Is the building in a strategic development zone for housing?"
title="Is the building in a Housing Zone?"
slug="planning_live_application"
value={null}
disabled={true}
tooltip={"GLA official description: \"Housing zones are areas funded by the Mayor and government to attract developers and relevant partners to build new homes.\""}
/>
{/*
<form className={`layer-switcher-inline`}>
<button className="btn btn-outline btn-outline-dark"
type="submit">
Click to see the data mapped
<button className="map-switcher-inline btn btn-outline btn-outline-dark" onClick={housingSwitchOnClick}>
{(housing === 'enabled')? 'Click to hide Housing Zones' : 'Click to see Housing Zones mapped'}
</button>
</form>
*/}
<LogicalDataEntry
title="Is the building in a strategic development zone for commerce or industry?"
title="Is the building in a Creative Enterprise Zone?"
slug="planning_live_application"
value={null}
disabled={true}
tooltip={"GLA official description: \"Creative Enterprise Zones are a new Mayoral initiative to designate areas of London where artists and creative businesses can find permanent affordable space to work; are supported to start-up and grow; and where local people are helped to learn creative sector skills and find new jobs.\""}
/>
{/*
<form className={`layer-switcher-inline`}>
<button className="btn btn-outline btn-outline-dark"
type="submit">
Click to see the data mapped
<button className="map-switcher-inline btn btn-outline btn-outline-dark" onClick={creativeSwitchOnClick}>
{(creative === 'enabled')? 'Click to hide Creative Enterprise Zones' : 'Click to see Creative Enterprise Zones'}
</button>
</form>
*/}
<LogicalDataEntry
title="Is the building within a protected sightline?"
title="Is the building within a Protected Vista?"
slug="planning_live_application"
value={null}
disabled={true}
tooltip={"GLA official description: \"The Protected Vistas are established in the London Plan with more detailed guidance provided in the London View Management Framework (LVMF). The London Plan seeks to protect the significant views which help to define London, including the panoramas, linear views and townscape views in this layer.\""}
/>
{/*
<form className={`layer-switcher-inline`}>
<button className="btn btn-outline btn-outline-dark"
type="submit">
Click to see the data mapped
<button className="map-switcher-inline btn btn-outline btn-outline-dark" onClick={vistaSwitchOnClick}>
{(vista === 'enabled')? 'Click to hide Protected Vistas' : 'Click to see Protected Vistas'}
</button>
</form>
*/}
{/*
<DataEntry
title={dataFields.planning_glher_url.title}
@ -153,10 +196,23 @@ const PlanningView: React.FunctionComponent<CategoryViewProps> = (props) => (
/>
*/}
</DataEntryGroup>
<DataEntryGroup name="Building protection" collapsed={true} >
<InfoBox type='success'>
This section provides information on heritage assets and building protection. To produce the most accurate spatial map possible we need to combine official data with crowdsourced data. Help us create this map together by checking, verifying and adding information.
<DataEntryGroup name="Heritage assets and building protection" collapsed={true} >
<InfoBox>
Help us produce the most accurate map possible for London's designated/protected buildings. Please add data if missing or click "Verify" where entries are correct.
</InfoBox>
{
props.mapColourScale != "planning_combined" ?
<button className="map-switcher-inline btn btn-outline btn-outline-dark" onClick={switchToBuildingProtectionMapStyle}>
{'Click to see individual protected buildings mapped'}
</button>
:
<button className="map-switcher-inline btn btn-outline btn-outline-dark" onClick={switchToEmptyMapStyle}>
{'Click to hide individual protected buildings on map'}
</button>
}
<button className="map-switcher-inline btn btn-outline btn-outline-dark" onClick={conservationSwitchOnClick}>
{(conservation === 'enabled')? 'Click to hide Convervation Areas' : 'Click to see Convervation Areas'}
</button>
<NumericDataEntryWithFormattedLink
title={dataFields.planning_list_id.title}
slug="planning_list_id"
@ -164,7 +220,7 @@ const PlanningView: React.FunctionComponent<CategoryViewProps> = (props) => (
mode={props.mode}
copy={props.copy}
onChange={props.onChange}
placeholder="If yes, add ID here"
placeholder="add ID here"
linkTargetFunction={(id: String) => { return "https://historicengland.org.uk/listing/the-list/list-entry/" + id + "?section=official-list-entry" } }
linkDescriptionFunction={(id: String) => { return "ID Link" } }
/>
@ -224,7 +280,7 @@ const PlanningView: React.FunctionComponent<CategoryViewProps> = (props) => (
mode={props.mode}
copy={props.copy}
onChange={props.onChange}
placeholder="If yes, add ID here"
placeholder="add ID here"
linkTargetFunction={(id: String) => { return "https://whc.unesco.org/en/list/" + id } }
linkDescriptionFunction={(id: String) => { return "ID Link" } }
/>
@ -339,10 +395,94 @@ const PlanningView: React.FunctionComponent<CategoryViewProps> = (props) => (
verified_count={props.building.verified.planning_in_apa_url}
/>
</DataEntryGroup>
<DataEntryGroup name="Forthcoming data (sections to be activated)" collapsed={true} >
<DataEntryGroup name="Active application info (crowdsourced)" collapsed={true} >
{/* will be titled "Other active application info (crowdsourced data)" once active" */}
<InfoBox type='warning'>
This category is not yet activated.
</InfoBox>
{/* that is placeholder display, will be replaced by actual code */}
<div className="data-title">
<div className="data-title-text">
<ul>
<li>Year of completion if known</li>
<li>If you know of a planning application that has been recently submitted for this site, and is not listed in the blue box above, please enter its planning application ID below:</li>
<li>If any of the active planning applications are not mapped onto the correct site, please tick here</li>
</ul>
</div>
</div>
{
/*
<NumericDataEntry
title={dataFields.planning_crowdsourced_site_completion_year.title}
slug="planning_crowdsourced_site_completion_year"
value={props.building.planning_crowdsourced_site_completion_year}
mode={props.mode}
copy={props.copy}
onChange={props.onChange}
disabled={true}
/>
<Verification
slug="planning_crowdsourced_site_completion_year"
allow_verify={false}
onVerify={props.onVerify}
user_verified={props.user_verified.hasOwnProperty("planning_crowdsourced_site_completion_year")}
user_verified_as={props.user_verified.planning_crowdsourced_site_completion_year}
verified_count={props.building.verified.planning_crowdsourced_site_completion_year}
/>
<DataEntry
title={dataFields.planning_crowdsourced_planning_id.title}
slug="planning_crowdsourced_planning_id"
value={props.building.planning_crowdsourced_planning_id}
mode={props.mode}
copy={props.copy}
onChange={props.onChange}
disabled={true}
/>
<Verification
slug="planning_crowdsourced_planning_id"
allow_verify={false && props.user !== undefined && props.building.planning_crowdsourced_planning_id !== null && !props.edited}
onVerify={props.onVerify}
user_verified={props.user_verified.hasOwnProperty("planning_crowdsourced_planning_id")}
user_verified_as={props.user_verified.planning_crowdsourced_planning_id}
verified_count={props.building.verified.planning_crowdsourced_planning_id}
/>
<LogicalDataEntry
slug='community_expected_planning_application_is_inaccurate'
title={"If any of the active planning applications are not mapped onto the correct site, please tick here"}
value={null}
onChange={props.onSaveChange}
mode={props.mode}
copy={props.copy}
disabled={true}
/>
on enabling switch it to UserOpinionEntry, remove value and restore userValue
*/
}
</DataEntryGroup>
<DataEntryGroup name="Land ownership type" collapsed={true} >
<InfoBox type='success'>
<InfoBox type='warning'>
This category is not yet activated.
</InfoBox>
<InfoBox>
This section is designed to provide information on land parcels and their ownership type. Can you help us to crowdsource this information?
</InfoBox>
<button className="map-switcher-inline btn btn-outline btn-outline-dark" onClick={parcelSwitchOnClick}>
{(parcel === 'enabled')? 'Click to hide sample of parcel data (in City)' : 'Click to see sample of parcel data (in City) mapped'}
</button>
<div className="data-title">
<div className="data-title-text">
<ul>
<li>What type of owner owns this land parcel?</li>
</ul>
</div>
</div>
{/*
<SelectDataEntry
slug='community_public_ownership'
title={"What type of owner owns this land parcel? "}
@ -367,9 +507,12 @@ const PlanningView: React.FunctionComponent<CategoryViewProps> = (props) => (
user_verified_as={props.user_verified.community_public_ownership}
verified_count={props.building.verified.community_public_ownership}
/>
*/
}
</DataEntryGroup>
</DataEntryGroup>
</Fragment>
);
)};
const PlanningContainer = withCopyEdit(PlanningView);
export default PlanningContainer;

View File

@ -150,6 +150,9 @@
position: static;
margin-right: 0.5em;
}
.data-section a {
overflow-wrap: break-word;
}
.data-list dd {
margin: 0 0 0.5rem;
line-height: 1.5;

View File

@ -2,7 +2,9 @@ import React from 'react';
interface InfoBoxProps {
msg?: string;
type?: 'info' | 'warning' | 'success'
// https://react-bootstrap.github.io/components/alerts/
// predefined valid values
type?: 'info' | 'warning' | 'success' | 'danger' | 'dark'
}
const InfoBox: React.FC<InfoBoxProps> = ({msg, children, type = 'info'}) => (

View File

@ -142,8 +142,8 @@ export const categoryMapsConfig: {[key in Category]: CategoryMapDefinition[]} =
{
mapStyle: 'community_expected_planning_application_total',
legend: {
title: 'Expected planning application',
description: 'Sites identified by users as likely to be subject to planning application over the next six months',
title: 'Expected planning applications',
disclaimer: 'Sites identified by users as likely to be subject to planning application over the next six months',
elements: [
{ color: '#bd0026', text: '100+' },
{ color: '#e31a1c', text: '5099' },
@ -167,10 +167,75 @@ export const categoryMapsConfig: {[key in Category]: CategoryMapDefinition[]} =
}
}
],
[Category.Planning]: [{
[Category.Planning]: [
{
// this database commad allows to see statistics about decision dates per year
// SELECT COUNT(*), date_part('year', decision_date) as year from planning_data WHERE decision_date IS NOT NULL GROUP BY year ORDER BY year ASC;
// SELECT COUNT(*), date_part('year', registered_with_local_authority_date) as year from planning_data WHERE decision_date IS NOT NULL GROUP BY year ORDER BY year ASC;
mapStyle: 'planning_applications_status_all',
legend: {
title: 'All planning applications available from GLA (official data)',
disclaimer: 'The map shows official data available from the GLA Planning London Datahub. What you are looking at is mainly applications from 2019 onwards.',
elements: [
{ color: '#a040a0', text: 'Submitted, awaiting decision' },
{ color: '#fff200', text: 'Appeal In Progress' },
{ color: '#16cf15', text: 'Approved' },
{ color: '#e31d23', text: 'Rejected' },
{ color: '#999999', text: 'Withdrawn' },
{ color: '#eacad0', text: 'Other' },
]
}
},
{
mapStyle: 'planning_applications_status_recent',
legend: {
title: 'Last 12 months - planning applications submissions/decisions (official data)',
disclaimer: 'The map shows applications where the submission or decision data falls within last 12 months.',
elements: [
{ color: '#a040a0', text: 'Submitted, awaiting decision' },
{ color: '#fff200', text: 'Appeal In Progress' },
{ color: '#16cf15', text: 'Approved' },
{ color: '#e31d23', text: 'Rejected' },
{ color: '#999999', text: 'Withdrawn' },
{ color: '#eacad0', text: 'Other' },
]
}
},
{
mapStyle: 'planning_applications_status_very_recent',
legend: {
title: 'Last 30 days - planning applications submissions/decisions (official data)',
disclaimer: 'The map shows applications where the submission or decision data falls within last 30 days.',
elements: [
{ color: '#a040a0', text: 'Submitted, awaiting decision' },
{ color: '#fff200', text: 'Appeal In Progress' },
{ color: '#16cf15', text: 'Approved' },
{ color: '#e31d23', text: 'Rejected' },
{ color: '#999999', text: 'Withdrawn' },
{ color: '#eacad0', text: 'Other' },
]
}
},
{
mapStyle: 'community_expected_planning_application_total',
legend: {
title: 'Expected planning applications (crowdsourced data)',
disclaimer: 'Sites identified by users as likely to be subject to planning application over the next six months',
elements: [
{ color: '#bd0026', text: '100+' },
{ color: '#e31a1c', text: '5099' },
{ color: '#fc4e2a', text: '2049' },
{ color: '#fd8d3c', text: '1019' },
{ color: '#feb24c', text: '39' },
{ color: '#fed976', text: '2' },
{ color: '#ffe8a9', text: '1'}
]
}
},
{
mapStyle: 'planning_combined',
legend: {
title: 'Designation/protection',
title: 'Designation/protection (official and crowdsourced data)',
disclaimer: 'All data relating to designated buildings should be checked against the National Heritage List for England and local authority websites. Designation data is currently incomplete. We are aiming for 100% coverage by April 2023.',
elements: [
{ color: '#95beba', text: 'In Conservation Area'},
@ -178,12 +243,22 @@ export const categoryMapsConfig: {[key in Category]: CategoryMapDefinition[]} =
{ color: '#e75b42', text: 'Grade II* Listed'},
{ color: '#ffbea1', text: 'Grade II Listed'},
{ color: '#85ffd4', text: 'Heritage at Risk'},
{ color: '#858ed4', text: 'Locally Listed'},
{ color: '#858eff', text: 'In World Heritage Site'},
{ color: '#8500d4', text: 'In Archaeological Priority Area'},
{ color: '#858ed4', text: 'Locally Listed'},
]
},
}],
},
{
mapStyle: 'empty_map',
legend: {
title: 'Empty map',
disclaimer: 'This is an empty map to see overlays without distraction.',
elements: [
]
},
}
],
[Category.Sustainability]: [{
mapStyle: 'sust_dec',
legend: {
@ -223,12 +298,12 @@ export const categoryMapsConfig: {[key in Category]: CategoryMapDefinition[]} =
{ color: '#7025a6', text: 'Residential (verified)' },
{ color: '#ff8c00', text: 'Retail' },
{ color: '#f5f58f', text: 'Industry & Business' },
{ color: '#73ccd1', text: 'Community Services' },
{ color: '#fa667d', 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: '#73ccd1', text: 'Agriculture' },
{ color: '#45cce3', text: 'Minerals' },
{ color: '#ffffff', text: 'Vacant & Derelict' },
{ color: '#6c6f8e', text: 'Unclassified, presumed non-residential' }

View File

@ -50,6 +50,8 @@ export interface DataFieldDefinition {
*
* Making it semantically correct is useful but not necessary.
* E.g. for building attachment form, you could use "Detached" as example
*
* This field is later processed by AttributesBasedOnExample
*/
example: any;
@ -109,7 +111,7 @@ export const buildingUserFields = {
community_expected_planning_application: {
perUser: true,
category: Category.Community,
title: "Select any building that you think may be subject to a planning application over the next six months and tick the box below to colour it.",
title: "Do you think that this building may be subject to a planning application, involving demolition, over the next six months?",
example: true
}
};
@ -170,6 +172,16 @@ export const dataFields = { /* eslint-disable @typescript-eslint/camelcase */
example: [{uprn: "", parent_uprn: "" }, {uprn: "", parent_uprn: "" }],
},
planning_data: {
category: Category.Location,
title: "PLANNING DATA",
tooltip: "PLANNING DATA",
example: [{uprn: "", building_id: 1, data_source: ""},
{uprn: "", building_id: 1, data_source: "", status: "", status_before_aliasing: "", decision_date: "", description: "", planning_application_link: "", registered_with_local_authority_date: "", last_synced_date: "", data_source_link: "", address: ""},
],
},
ref_osm_id: {
category: Category.Location,
title: "OSM ID",
@ -440,9 +452,17 @@ export const dataFields = { /* eslint-disable @typescript-eslint/camelcase */
//tooltip: ,
},
edit_history: {
category: Category.Planning,
title: "PLANNING DATA",
tooltip: "PLANNING DATA",
example: [{}],
},
planning_portal_link: {
category: Category.Planning,
title: "Planning portal link",
title: "Local authority planning application link",
example: "",
//tooltip: ,
},
@ -452,6 +472,24 @@ export const dataFields = { /* eslint-disable @typescript-eslint/camelcase */
example: "",
//tooltip: ,
},
planning_crowdsourced_site_completion_status: {
category: Category.Planning,
title: "Has the work on this site been completed?",
example: true,
//tooltip: ,
},
planning_crowdsourced_site_completion_year: {
category: Category.Planning,
title: "Year of completion if known",
example: 2022,
//tooltip: ,
},
planning_crowdsourced_planning_id: {
category: Category.Planning,
title: "If you know of a planning application that has been recently submitted for this site, and is not listed in the blue box above, please enter its planning application ID below:",
example: "1112/QWERTY",
//tooltip: ,
},
planning_in_conservation_area_id: {
category: Category.Planning,
title: "Conservation Area identifier",
@ -466,7 +504,7 @@ export const dataFields = { /* eslint-disable @typescript-eslint/camelcase */
},
planning_list_id: {
category: Category.Planning,
title: "Is the building on the <a href=\"https://historicengland.org.uk/advice/hpg/heritage-assets/nhle/\" target=\"_blank\">National Heritage List for England</a>?",
title: "If the building is on the <a href=\"https://historicengland.org.uk/advice/hpg/heritage-assets/nhle/\" target=\"_blank\">National Heritage List for England (NHLE)</a> please add the ID:",
example: "121436",
//tooltip: ,
},
@ -478,13 +516,13 @@ export const dataFields = { /* eslint-disable @typescript-eslint/camelcase */
},
planning_heritage_at_risk_url: {
category: Category.Planning,
title: "Is it on the <a href=\"https://historicengland.org.uk/advice/heritage-at-risk/search-register/\" target=\"_blank\">Heritage at Risk</a> register?",
title: "If the building is on the <a href=\"https://historicengland.org.uk/advice/heritage-at-risk/search-register/\" target=\"_blank\">Heritage at Risk</a> register please add the ID:",
example: "",
//tooltip: ,
},
planning_world_list_id: {
category: Category.Planning,
title: "Is it within a <a href=\"https://historicengland.org.uk/advice/hpg/has/whs/\" target=\"_blank\">World Heritage Site</a>?",
title: "If the building is on a <a href=\"https://historicengland.org.uk/advice/hpg/has/whs/\" target=\"_blank\">World Heritage Site</a> please add the ID:",
example: "488",
//tooltip: ,
},

View File

@ -8,9 +8,13 @@ export const initialMapViewport: MapViewport = {
zoom: 16,
};
export type MapTheme = 'light' | 'night';
export type MapTheme = 'light' | 'night' | 'night_outlines' | 'boroughs';
export type LayerEnablementState = 'enabled' | 'disabled';
export const mapBackgroundColor: Record<MapTheme, string> = {
light: '#F0EEEB',
night: '#162639'
night: '#162639',
night_outlines: '#162639',
boroughs: '#ff0000',
};

View File

@ -11,13 +11,17 @@ export type BuildingMapTileset = 'date_year' |
'community_local_significance_total' |
'community_expected_planning_application_total' |
'community_in_public_ownership' |
'planning_applications_status_all' |
'planning_applications_status_recent' |
'planning_applications_status_very_recent' |
'planning_combined' |
'empty_map' |
'sust_dec' |
'building_attachment_form' |
'landuse' |
'dynamics_demolished_count' |
'team';
export type SpecialMapTileset = 'base_light' | 'base_night' | 'highlight' | 'number_labels';
export type SpecialMapTileset = 'base_light' | 'base_night' | 'base_night_outlines' | 'highlight' | 'number_labels' | 'base_boroughs';
export type MapTileset = BuildingMapTileset | SpecialMapTileset;

View File

@ -0,0 +1,274 @@
import React, { createContext, useCallback, useContext, useEffect, useState } from 'react';
import { LayerEnablementState, MapTheme } from './config/map-config';
interface DisplayPreferencesContextState {
vista: LayerEnablementState;
vistaSwitch: (e: React.FormEvent<HTMLFormElement>) => void;
vistaSwitchOnClick: React.MouseEventHandler<HTMLButtonElement>;
flood: LayerEnablementState;
floodSwitch: (e: React.FormEvent<HTMLFormElement>) => void;
floodSwitchOnClick: React.MouseEventHandler<HTMLButtonElement>;
creative: LayerEnablementState;
creativeSwitch: (e: React.FormEvent<HTMLFormElement>) => void;
creativeSwitchOnClick: React.MouseEventHandler<HTMLButtonElement>;
housing: LayerEnablementState;
housingSwitch: (e: React.FormEvent<HTMLFormElement>) => void;
housingSwitchOnClick: React.MouseEventHandler<HTMLButtonElement>;
conservation: LayerEnablementState;
conservationSwitch: (e: React.FormEvent<HTMLFormElement>) => void;
conservationSwitchOnClick: React.MouseEventHandler<HTMLButtonElement>;
parcel: LayerEnablementState;
parcelSwitch: (e: React.FormEvent<HTMLFormElement>) => void;
parcelSwitchOnClick: React.MouseEventHandler<HTMLButtonElement>;
borough: LayerEnablementState;
boroughSwitch: (e: React.FormEvent<HTMLFormElement>) => void;
boroughSwitchOnClick: React.MouseEventHandler<HTMLButtonElement>;
historicData: LayerEnablementState;
historicDataSwitch: (e: React.FormEvent<HTMLFormElement>) => void;
historicDataSwitchOnClick: React.MouseEventHandler<HTMLButtonElement>;
darkLightTheme: MapTheme;
darkLightThemeSwitch: (e: React.FormEvent<HTMLFormElement>) => void;
darkLightThemeSwitchOnClick: React.MouseEventHandler<HTMLButtonElement>;
}
const stub = (): never => {
throw new Error('DisplayPreferencesProvider not set up');
};
export const DisplayPreferencesContext = createContext<DisplayPreferencesContextState>({
vista: undefined,
vistaSwitch: stub,
vistaSwitchOnClick: undefined,
flood: undefined,
floodSwitch: stub,
floodSwitchOnClick: undefined,
creative: undefined,
creativeSwitch: stub,
creativeSwitchOnClick: undefined,
housing: undefined,
housingSwitch: stub,
housingSwitchOnClick: undefined,
conservation: undefined,
conservationSwitch: stub,
conservationSwitchOnClick: undefined,
parcel: undefined,
parcelSwitch: stub,
parcelSwitchOnClick: undefined,
borough: undefined,
boroughSwitch: stub,
boroughSwitchOnClick: undefined,
historicData: undefined,
historicDataSwitch: stub,
historicDataSwitchOnClick: undefined,
darkLightTheme: undefined,
darkLightThemeSwitch: stub,
darkLightThemeSwitchOnClick: undefined,
});
const noop = () => {};
export const DisplayPreferencesProvider: React.FC<{}> = ({children}) => {
const [vista, setVista] = useState<LayerEnablementState>('disabled');
const [flood, setFlood] = useState<LayerEnablementState>('disabled');
const [creative, setCreative] = useState<LayerEnablementState>('disabled');
const [housing, setHousing] = useState<LayerEnablementState>('disabled');
const [borough, setBorough] = useState<LayerEnablementState>('enabled');
const [parcel, setParcel] = useState<LayerEnablementState>('disabled');
const [conservation, setConservation] = useState<LayerEnablementState>('disabled');
const [historicData, setHistoricData] = useState<LayerEnablementState>('disabled');
const [darkLightTheme, setDarkLightTheme] = useState<MapTheme>('night');
const vistaSwitch = useCallback(
(e) => {
e.preventDefault();
const newVista = (vista === 'enabled')? 'disabled' : 'enabled';
setVista(newVista);
},
[vista],
)
const vistaSwitchOnClick = (e) => {
e.preventDefault();
const newVista = (vista === 'enabled')? 'disabled' : 'enabled';
setVista(newVista);
}
const floodSwitch = useCallback(
(e) => {
e.preventDefault();
const newFlood = (flood === 'enabled')? 'disabled' : 'enabled';
setFlood(newFlood);
},
[flood],
)
const floodSwitchOnClick = (e) => {
e.preventDefault();
const newFlood = (flood === 'enabled')? 'disabled' : 'enabled';
setFlood(newFlood);
}
const housingSwitch = useCallback(
(e) => {
e.preventDefault();
const newHousing = (housing === 'enabled')? 'disabled' : 'enabled';
setHousing(newHousing);
},
[housing],
)
const housingSwitchOnClick = (e) => {
e.preventDefault();
const newHousing = (housing === 'enabled')? 'disabled' : 'enabled';
setHousing(newHousing);
}
const creativeSwitch = useCallback(
(e) => {
e.preventDefault();
const newCreative = (creative === 'enabled')? 'disabled' : 'enabled';
setCreative(newCreative);
},
[creative],
)
const creativeSwitchOnClick = (e) => {
e.preventDefault();
const newCreative = (creative === 'enabled')? 'disabled' : 'enabled';
setCreative(newCreative);
}
const boroughSwitch = useCallback(
(e) => {
flipBorough(e)
},
[borough],
)
const boroughSwitchOnClick = (e) => {
flipBorough(e)
}
function flipBorough(e) {
e.preventDefault();
const newBorough = (borough === 'enabled')? 'disabled' : 'enabled';
setBorough(newBorough);
}
const parcelSwitch = useCallback(
(e) => {
flipParcel(e)
},
[parcel],
)
const parcelSwitchOnClick = (e) => {
flipParcel(e)
}
function flipParcel(e) {
e.preventDefault();
const newParcel = (parcel === 'enabled')? 'disabled' : 'enabled';
setParcel(newParcel);
}
const conservationSwitch = useCallback(
(e) => {
flipConservation(e)
},
[conservation],
)
const conservationSwitchOnClick = (e) => {
flipConservation(e)
}
function flipConservation(e) {
e.preventDefault();
const newConservation = (conservation === 'enabled')? 'disabled' : 'enabled';
setConservation(newConservation);
}
const historicDataSwitch = useCallback(
(e) => {
flipHistoricData(e)
},
[historicData],
)
const historicDataSwitchOnClick = (e) => {
flipHistoricData(e)
}
function flipHistoricData(e) {
e.preventDefault();
const newHistoric = (historicData === 'enabled')? 'disabled' : 'enabled';
setHistoricData(newHistoric);
}
const darkLightThemeSwitch = useCallback(
(e) => {
flipDarkLightTheme(e)
},
[darkLightTheme],
)
const darkLightThemeSwitchOnClick = (e) => {
flipDarkLightTheme(e)
}
function flipDarkLightTheme(e) {
e.preventDefault();
const newDarkLightTheme = (darkLightTheme === 'light')? 'night' : 'light';
setDarkLightTheme(newDarkLightTheme);
}
return (
<DisplayPreferencesContext.Provider value={{
vista,
vistaSwitch,
vistaSwitchOnClick,
flood,
floodSwitch,
floodSwitchOnClick,
creative,
creativeSwitch,
creativeSwitchOnClick,
housing,
housingSwitch,
housingSwitchOnClick,
conservation,
conservationSwitch,
conservationSwitchOnClick,
parcel,
parcelSwitch,
parcelSwitchOnClick,
borough,
boroughSwitch,
boroughSwitchOnClick,
historicData,
historicDataSwitch,
historicDataSwitchOnClick,
darkLightTheme,
darkLightThemeSwitch,
darkLightThemeSwitchOnClick
}}>
{children}
</DisplayPreferencesContext.Provider>
);
};
export const useDisplayPreferences = (): DisplayPreferencesContextState => {
return useContext(DisplayPreferencesContext);
};

View File

@ -0,0 +1,13 @@
type UserContextType = {
context: string | null,
setContext: React.Dispatch<React.SetStateAction<string | null>>
}
const iUserContextState = {
context: null,
setContext: () => {}
}
const UserContext = createContext<UserContextType>(iUserContextState)
export default UserContext

View File

@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useState } from 'react';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Redirect, Route, Switch } from 'react-router-dom';
import loadable from '@loadable/component';
@ -18,7 +18,8 @@ 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 { BuildingMapTileset } from './config/tileserver-config';
import { defaultMapCategory, categoryMapsConfig } from './config/category-maps-config';
import { useMultiEditData } from './hooks/use-multi-edit-data';
import { useAuth } from './auth-context';
import { sendBuildingUpdate } from './api-data/building-update';
@ -59,6 +60,15 @@ function setOrToggle<T>(currentValue: T, newValue: T): T {
}
}
function useStateWithOptions<T>(defaultValue: T, options: T[]): [T, (x: T) => void] {
const [value, setValue] = useState(defaultValue);
const effectiveValue = options.includes(value) ? value : options[0];
const handleChange = useCallback((x) => setValue(x), []);
return [effectiveValue, handleChange];
}
export const MapApp: React.FC<MapAppProps> = props => {
const { user } = useAuth();
const [categoryUrlParam] = useUrlCategoryParam();
@ -121,6 +131,11 @@ export const MapApp: React.FC<MapAppProps> = props => {
}
}, [selectedBuildingId, updateUserVerified, reloadBuilding, userVerified]);
const categoryMapDefinitions = useMemo(() => categoryMapsConfig[displayCategory], [displayCategory]);
const availableMapStyles = useMemo(() => categoryMapDefinitions.map(x => x.mapStyle), [categoryMapDefinitions]);
const [mapColourScale, setMapColourScale] = useStateWithOptions<BuildingMapTileset>(undefined, availableMapStyles);
return (
<>
<PrivateRoute path="/:mode(edit|multi-edit)" /> {/* empty private route to ensure auth for editing */}
@ -146,6 +161,8 @@ export const MapApp: React.FC<MapAppProps> = props => {
user_verified={userVerified ?? {}}
onBuildingUpdate={handleBuildingUpdate}
onUserVerifiedUpdate={handleUserVerifiedUpdate}
mapColourScale={mapColourScale}
onMapColourScale={setMapColourScale}
/>
</Route>
</Switch>
@ -158,9 +175,11 @@ export const MapApp: React.FC<MapAppProps> = props => {
<ColouringMap
selectedBuildingId={selectedBuildingId}
mode={mode || 'basic'}
category={displayCategory}
revisionId={revisionId}
onBuildingAction={mode === 'multi-edit' ? colourBuilding : selectBuilding}
mapColourScale={mapColourScale}
onMapColourScale={setMapColourScale}
categoryMapDefinitions={categoryMapDefinitions}
/>
</>
);

View File

@ -0,0 +1,16 @@
import React from 'react';
import './map-button.css';
import { useDisplayPreferences } from '../displayPreferences-context';
export const BoroughSwitcher: React.FC<{}> = () => {
const { borough, boroughSwitch, darkLightTheme } = useDisplayPreferences();
return (
<form className={`borough-switcher map-button ${darkLightTheme}`} onSubmit={boroughSwitch}>
<button className="btn btn-outline btn-outline-dark"
type="submit">
{(borough === 'enabled')? 'Switch off Borough Boundaries' : 'Switch on Borough Boundaries'}
</button>
</form>
);
}

View File

@ -0,0 +1,16 @@
import React from 'react';
import './map-button.css';
import { useDisplayPreferences } from '../displayPreferences-context';
export const ConservationAreaSwitcher: React.FC<{}> = (props) => {
const { conservation, conservationSwitch, darkLightTheme } = useDisplayPreferences();
return (
<form className={`conservation-switcher map-button ${darkLightTheme}`} onSubmit={conservationSwitch}>
<button className="btn btn-outline btn-outline-dark"
type="submit">
{(conservation === 'enabled')? 'Switch off Conservation Areas' : 'Switch on Conservation Areas'}
</button>
</form>
);
}

View File

@ -0,0 +1,16 @@
import React from 'react';
import './map-button.css';
import { useDisplayPreferences } from '../displayPreferences-context';
export const CreativeSwitcher: React.FC<{}> = () => {
const { creative, creativeSwitch, darkLightTheme } = useDisplayPreferences();
return (
<form className={`creative-switcher map-button ${darkLightTheme}`} onSubmit={creativeSwitch}>
<button className="btn btn-outline btn-outline-dark"
type="submit">
{(creative === 'enabled')? 'Switch off Creative Enterprise Zones' : 'Switch on Creative Enterprise Zones'}
</button>
</form>
);
}

View File

@ -0,0 +1,23 @@
import React from 'react';
import './map-button.css';
import { useDisplayPreferences } from '../displayPreferences-context';
interface DataLayerSwitcherProps {
currentDisplay: string;
onSubmit: (e: React.FormEvent<HTMLFormElement>) => void;
}
const DataLayerSwitcher: React.FC<DataLayerSwitcherProps> = (props) => {
const { darkLightTheme } = useDisplayPreferences();
return (
<form className={`data-switcher map-button ${darkLightTheme}`} onSubmit={props.onSubmit}>
<button className="btn btn-outline btn-outline-dark"
type="submit">
{(props.currentDisplay === 'enabled')? 'Hide layer options' : 'Show layer options'}
</button>
</form>
);
}
export default DataLayerSwitcher;

View File

@ -0,0 +1,16 @@
import React from 'react';
import './map-button.css';
import { useDisplayPreferences } from '../displayPreferences-context';
export const FloodSwitcher: React.FC<{}> = () => {
const { flood, floodSwitch, darkLightTheme } = useDisplayPreferences();
return (
<form className={`flood-switcher map-button ${darkLightTheme}`} onSubmit={floodSwitch}>
<button className="btn btn-outline btn-outline-dark"
type="submit">
{(flood === 'enabled')? 'Switch off Flood Zones' : 'Switch on Flood Zones'}
</button>
</form>
);
}

View File

@ -0,0 +1,16 @@
import React from 'react';
import './map-button.css';
import { useDisplayPreferences } from '../displayPreferences-context';
export const HistoricDataSwitcher: React.FC<{}> = (props) => {
const { historicData, historicDataSwitch, darkLightTheme } = useDisplayPreferences();
return (
<form className={`historic-data-switcher map-button ${darkLightTheme}`} onSubmit={historicDataSwitch}>
<button className="btn btn-outline btn-outline-dark"
type="submit">
{(historicData === 'enabled')? 'Switch off the OS 1890s Historical Map' : 'Switch on the OS 1890s Historical Map'}
</button>
</form>
);
}

View File

@ -0,0 +1,16 @@
import React from 'react';
import './map-button.css';
import { useDisplayPreferences } from '../displayPreferences-context';
export const HousingSwitcher: React.FC<{}> = () => {
const { housing, housingSwitch, darkLightTheme } = useDisplayPreferences();
return (
<form className={`housing-switcher map-button ${darkLightTheme}`} onSubmit={housingSwitch}>
<button className="btn btn-outline btn-outline-dark"
type="submit">
{(housing === 'enabled')? 'Switch off Housing Zones' : 'Switch on Housing Zones'}
</button>
</form>
);
}

View File

@ -0,0 +1,37 @@
import { GeoJsonObject } from 'geojson';
import React, { useEffect, useState } from 'react';
import { GeoJSON } from 'react-leaflet';
import { useDisplayPreferences } from '../../displayPreferences-context';
import { apiGet } from '../../apiHelpers';
import { BuildingBaseLayerAllZoom } from './building-base-layer-all-zoom';
export function BoroughBoundaryLayer({}) {
const [boundaryGeojson, setBoundaryGeojson] = useState<GeoJsonObject>(null);
const { borough } = useDisplayPreferences();
useEffect(() => {
apiGet('/geometries/boroughs.geojson')
.then(data => setBoundaryGeojson(data as GeoJsonObject));
}, []);
if(borough == "enabled") {
return boundaryGeojson &&
<><GeoJSON
attribution='Borough boundary from <a href=https://data.london.gov.uk/dataset/london_boroughs>London Datastore</a> Ordnance Survey Open Data - Contains public sector information licensed under the <a href=https://www.nationalarchives.gov.uk/doc/open-government-licence/version/3/>Open Government Licence v3.0</a>'
data={boundaryGeojson}
style={{color: '#f00', fill: false, weight: 1}}
/* minNativeZoom={17}*/
/><BuildingBaseLayerAllZoom theme="boroughs" /></>;
} else if (borough == "disabled") {
return <div></div>
// do not display anything
return boundaryGeojson &&
<GeoJSON
data={boundaryGeojson}
style={{color: '#0f0', fill: false, weight: 1}} />
} else {
return boundaryGeojson &&
<GeoJSON data={boundaryGeojson} style={{color: '#0f0', fill: true}}/>;
}
}

View File

@ -0,0 +1,19 @@
import * as React from 'react';
import { TileLayer } from 'react-leaflet';
import { MapTheme } from '../../config/map-config';
import { MapTileset } from '../../config/tileserver-config';
import {getTileLayerUrl } from './get-tile-layer-url';
export function BuildingBaseLayerAllZoom({ theme }: {theme: MapTheme}) {
const tileset = `base_${theme}` as const;
return <TileLayer
key={theme} /* needed because TileLayer url is not mutable in react-leaflet v3 */
url={getTileLayerUrl(tileset)}
minZoom={1}
maxZoom={109}
detectRetina={true}
/>;
}

View File

@ -0,0 +1,30 @@
import { GeoJsonObject } from 'geojson';
import React, { useEffect, useState } from 'react';
import { GeoJSON } from 'react-leaflet';
import { useDisplayPreferences } from '../../displayPreferences-context';
import { apiGet } from '../../apiHelpers';
export function ConservationAreaBoundaryLayer({}) {
const [boundaryGeojson, setBoundaryGeojson] = useState<GeoJsonObject>(null);
const { conservation } = useDisplayPreferences();
useEffect(() => {
apiGet('/geometries/conservation_areas.geojson')
.then(data => setBoundaryGeojson(data as GeoJsonObject));
}, []);
if(conservation == "enabled") {
return boundaryGeojson &&
<GeoJSON
attribution='Conservation areas by <a href=http://www.bedfordpark.net/leo/planning/>Ian Hall</a> on <a href=https://creativecommons.org/licenses/by/4.0/legalcode>CC-BY 4.0 licence</a>, contains data under <a href=https://www.nationalarchives.gov.uk/doc/open-government-licence/version/3/>the Open Government Licence v.3.0</a>'
data={boundaryGeojson}
style={{color: '#cd7090', fill: true, weight: 3, opacity: 1, fillOpacity: 0.3}}
/>;
} else if (conservation == "disabled") {
return <div></div>
} else {
return boundaryGeojson &&
<GeoJSON data={boundaryGeojson} style={{color: '#fff', fill: true}}/>;
}
}

View File

@ -0,0 +1,26 @@
import { GeoJsonObject } from 'geojson';
import React, { useEffect, useState } from 'react';
import { GeoJSON } from 'react-leaflet';
import { apiGet } from '../../apiHelpers';
import { useDisplayPreferences } from '../../displayPreferences-context';
export function CreativeBoundaryLayer() {
const [boundaryGeojson, setBoundaryGeojson] = useState<GeoJsonObject>(null);
const { creative } = useDisplayPreferences();
useEffect(() => {
apiGet('/geometries/creative_enterprise_zones.geojson')
.then(data => setBoundaryGeojson(data as GeoJsonObject));
}, []);
if(creative == "enabled") {
return boundaryGeojson &&
<GeoJSON
attribution="Creative Enterprise Zones data from <a href=https://apps.london.gov.uk/planning/?_gl=1*avicz4*_ga*MTg1MjY3MzMuMTY2NzcxMjIwMg..*_ga_PY4SWZN1RJ*MTY2NzcxMjI1NS4xLjAuMTY2NzcxMjI1NS42MC4wLjA>PLanning Datamap</a> licence: <a href=https://www.nationalarchives.gov.uk/doc/open-government-licence/version/3/>Open Government Licence v3.0</a> The boundaries are based on Ordnance Survey mapping and the data is published under Ordnance Survey's 'presumption to publish'. Contains OS data © Crown copyright and database rights 2019."
data={boundaryGeojson}
style={{color: '#f0f', fill: true, weight: 1, opacity: 0.6}}
/>;
} else if (creative == "disabled") {
return <div></div>
}
}

View File

@ -0,0 +1,27 @@
import { GeoJsonObject } from 'geojson';
import React, { useEffect, useState } from 'react';
import { GeoJSON } from 'react-leaflet';
import { apiGet } from '../../apiHelpers';
import { useDisplayPreferences } from '../../displayPreferences-context';
export function FloodBoundaryLayer() {
const [boundaryGeojson, setBoundaryGeojson] = useState<GeoJsonObject>(null);
const { flood } = useDisplayPreferences();
useEffect(() => {
apiGet('/geometries/flood_zones_simplified.geojson')
.then(data => setBoundaryGeojson(data as GeoJsonObject));
}, []);
if(flood == "enabled") {
return boundaryGeojson &&
<GeoJSON
attribution='Flood zone from <a href=https://data.london.gov.uk/dataset/flood-risk-zones>London Datastore</a>: © Environment Agency copyright and/or database right 2017. All rights reserved. Some features of this map are based on digital spatial data from the Centre for Ecology & Hydrology, © NERC (CEH) © Crown copyright and database rights 2017 Ordnance Survey 100024198'
data={boundaryGeojson}
style={{color: '#00f', fill: true, weight: 1, opacity: 0.6}}
/>;
} else if (flood == "disabled") {
return <div></div>
}
}

View File

@ -0,0 +1,18 @@
import * as React from 'react';
import { TileLayer } from 'react-leaflet';
import { LayerEnablementState } from '../../config/map-config';
import { BuildingBaseLayerAllZoom } from './building-base-layer-all-zoom';
import { useDisplayPreferences } from '../../displayPreferences-context';
export function HistoricDataLayer({}) {
const { historicData } = useDisplayPreferences();
if(historicData == "enabled") {
return <><TileLayer
url="https://mapseries-tilesets.s3.amazonaws.com/london_1890s/{z}/{x}/{y}.png"
attribution='&copy; CC BY 4.0 - Reproduced with the permission of the <a href="https://maps.nls.uk/">National Library of Scotland</a>'
/><BuildingBaseLayerAllZoom theme="night_outlines" /></>
} else {
return null;
}
}

View File

@ -0,0 +1,26 @@
import { GeoJsonObject } from 'geojson';
import React, { useEffect, useState } from 'react';
import { GeoJSON } from 'react-leaflet';
import { apiGet } from '../../apiHelpers';
import { useDisplayPreferences } from '../../displayPreferences-context';
export function HousingBoundaryLayer() {
const [boundaryGeojson, setBoundaryGeojson] = useState<GeoJsonObject>(null);
const { housing } = useDisplayPreferences();
useEffect(() => {
apiGet('/geometries/housing_zones.geojson')
.then(data => setBoundaryGeojson(data as GeoJsonObject));
}, []);
if(housing == "enabled") {
return boundaryGeojson &&
<GeoJSON
attribution="Housing Zones from <a href=https://data.london.gov.uk/dataset/housing_zones>London Datastore</a>. The boundaries are based on Ordnance Survey mapping and the data is published under Ordnance Survey's 'presumption to publish'. Contains OS data © Crown copyright and database rights 2019. Greater London Authority - Contains public sector information licensed under the <a href=https://www.nationalarchives.gov.uk/doc/open-government-licence/version/3/>Open Government Licence v3.0</a>'"
data={boundaryGeojson}
style={{color: '#FF8000', fill: true, weight: 1, opacity: 0.6}}
/>;
} else if (housing == "disabled") {
return <div></div>
}
}

View File

@ -0,0 +1,37 @@
import { GeoJsonObject } from 'geojson';
import React, { useEffect, useState } from 'react';
import { GeoJSON } from 'react-leaflet';
import { LayerEnablementState } from '../../config/map-config';
import { apiGet } from '../../apiHelpers';
import { useDisplayPreferences } from '../../displayPreferences-context';
export function ParcelBoundaryLayer() {
const [boundaryGeojson, setBoundaryGeojson] = useState<GeoJsonObject>(null);
const { parcel } = useDisplayPreferences();
useEffect(() => {
apiGet('/geometries/parcels_city_of_london.geojson')
.then(data => setBoundaryGeojson(data as GeoJsonObject));
}, []);
if(parcel == "enabled") {
return boundaryGeojson &&
<GeoJSON
attribution='Parcel boundary from <a href=https://use-land-property-data.service.gov.uk/datasets/inspire/download>Index polygons spatial data (INSPIRE)</a> - <a href=www.nationalarchives.gov.uk/doc/open-government-licence/version/3/>Open Government Licence v3</a>'
data={boundaryGeojson}
style={{color: '#ff0', fill: false, weight: 1}}
/* minNativeZoom={17}*/
/>;
} else if (parcel == "disabled") {
return <div></div>
// do not display anything
return boundaryGeojson &&
<GeoJSON
data={boundaryGeojson}
style={{color: '#0f0', fill: false, weight: 1}} />
} else {
return boundaryGeojson &&
<GeoJSON data={boundaryGeojson} style={{color: '#0f0', fill: true}}/>;
}
}

View File

@ -0,0 +1,26 @@
import { GeoJsonObject } from 'geojson';
import React, { useEffect, useState } from 'react';
import { GeoJSON } from 'react-leaflet';
import { apiGet } from '../../apiHelpers';
import { useDisplayPreferences } from '../../displayPreferences-context';
export function VistaBoundaryLayer() {
const [boundaryGeojson, setBoundaryGeojson] = useState<GeoJsonObject>(null);
const { vista } = useDisplayPreferences();
useEffect(() => {
apiGet('/geometries/protected_vistas.geojson')
.then(data => setBoundaryGeojson(data as GeoJsonObject));
}, []);
if(vista == "enabled") {
return boundaryGeojson &&
<GeoJSON
attribution=' London Views Management Framework (LVMF) Extended background vistas from <a href=https://data.london.gov.uk/dataset/london-views-management-framework-lvmf-extended-background-vistas>London Datastore</a>: <a href=https://creativecommons.org/licenses/by/4.0/legalcode>CC-BY-SA 4.0</a> by Greater London Authority (GLA)'
data={boundaryGeojson}
style={{color: '#0f0', fill: true, weight: 1, opacity: 0.6}}
/>;
} else {
return <></>
}
}

View File

@ -57,7 +57,7 @@ export const Legend : FC<LegendProps> = ({
<Logo variant="default" />
{
mapColourScaleDefinitions.length > 1 ?
<select className='style-select' onChange={e => onMapColourScale(e.target.value as BuildingMapTileset)}>
<select className='style-select' onChange={e => onMapColourScale(e.target.value as BuildingMapTileset)} value={mapColourScale}>
{
mapColourScaleDefinitions.map(def =>
<option key={def.mapStyle} value={def.mapStyle}>{def.legend.title}</option>
@ -81,7 +81,7 @@ export const Legend : FC<LegendProps> = ({
}
{
elements.length === 0 ?
<p className="data-intro">Coming soon</p> :
( disclaimer ? <ul className={collapseList ? 'collapse data-legend' : 'data-legend'} ><p className='legend-disclaimer'>{disclaimer}</p></ul> : <p className="data-intro">Coming soon</p>) :
<ul className={collapseList ? 'collapse data-legend' : 'data-legend'} >
{
disclaimer && <p className='legend-disclaimer'>{disclaimer}</p>

View File

@ -0,0 +1,82 @@
.night-theme {
filter: grayscale(100%) invert(1);
}
.light-theme {
filter: none;
}
.map-button {
z-index: 1000;
position: absolute;
right: 10px;
float: right;
background: white;
border-radius: 4px;
}
.map-button .btn {
margin: 0;
min-width: 310px;
}
.map-button.night .btn {
color: #fff;
background-color: #343a40;
border-color: #343a40;
}
.map-button.night .btn:hover {
color: #343a40;
background-color: transparent;
background-image: none;
border-color: #343a40;
}
@media (max-width: 990px){
.map-button {
visibility: hidden;
}
}
.theme-switcher {
top: 77px;
}
.theme-switcher .btn {
min-width: 340px;
}
.data-switcher {
top: 117px;
}
.data-switcher .btn {
min-width: 340px;
}
.borough-switcher {
top: 157px;
}
.housing-switcher {
top: 197px;
}
.creative-switcher {
top: 237px;
}
.flood-switcher {
top: 277px;
}
.vista-switcher {
top: 317px;
}
.conservation-switcher {
top: 357px;
}
.historic-data-switcher {
top: 397px;
}
.parcel-switcher {
top: 437px;
}

View File

@ -6,13 +6,21 @@ import './map.css';
import { apiGet } from '../apiHelpers';
import { HelpIcon } from '../components/icons';
import { categoryMapsConfig } from '../config/category-maps-config';
import { Category } from '../config/categories-config';
import { initialMapViewport, mapBackgroundColor, MapTheme } from '../config/map-config';
import { initialMapViewport, mapBackgroundColor, MapTheme, LayerEnablementState } from '../config/map-config';
import { Building } from '../models/building';
import { CityBaseMapLayer } from './layers/city-base-map-layer';
import { CityBoundaryLayer } from './layers/city-boundary-layer';
import { BoroughBoundaryLayer } from './layers/borough-boundary-layer';
import { ParcelBoundaryLayer } from './layers/parcel-boundary-layer';
import { HistoricDataLayer } from './layers/historic-data-layer';
import { FloodBoundaryLayer } from './layers/flood-boundary-layer';
import { ConservationAreaBoundaryLayer } from './layers/conservation-boundary-layer';
import { VistaBoundaryLayer } from './layers/vista-boundary-layer';
import { HousingBoundaryLayer } from './layers/housing-boundary-layer';
import { CreativeBoundaryLayer } from './layers/creative-boundary-layer';
import { BuildingBaseLayer } from './layers/building-base-layer';
import { BuildingDataLayer } from './layers/building-data-layer';
import { BuildingNumbersLayer } from './layers/building-numbers-layer';
@ -21,30 +29,44 @@ import { BuildingHighlightLayer } from './layers/building-highlight-layer';
import { Legend } from './legend';
import SearchBox from './search-box';
import ThemeSwitcher from './theme-switcher';
import DataLayerSwitcher from './data-switcher';
import { BoroughSwitcher } from './borough-switcher';
import { ParcelSwitcher } from './parcel-switcher';
import { FloodSwitcher } from './flood-switcher';
import { ConservationAreaSwitcher } from './conservation-switcher';
import { HistoricDataSwitcher } from './historic-data-switcher';
import { VistaSwitcher } from './vista-switcher';
import { CreativeSwitcher } from './creative-switcher';
import { HousingSwitcher } from './housing-switcher';
import { BuildingMapTileset } from '../config/tileserver-config';
import { useDisplayPreferences } from '../displayPreferences-context';
import { CategoryMapDefinition } from '../config/category-maps-config';
interface ColouringMapProps {
selectedBuildingId: number;
mode: 'basic' | 'view' | 'edit' | 'multi-edit';
category: Category;
revisionId: string;
onBuildingAction: (building: Building) => void;
mapColourScale: BuildingMapTileset;
onMapColourScale: (x: BuildingMapTileset) => void;
categoryMapDefinitions: CategoryMapDefinition[]
}
export const ColouringMap : FC<ColouringMapProps> = ({
category,
mode,
revisionId,
onBuildingAction,
selectedBuildingId,
mapColourScale,
onMapColourScale,
categoryMapDefinitions,
children
}) => {
const [theme, setTheme] = useState<MapTheme>('night');
const { darkLightTheme, darkLightThemeSwitch } = useDisplayPreferences();
const [dataLayers, setDataLayers] = useState<LayerEnablementState>('disabled');
const [position, setPosition] = useState(initialMapViewport.position);
const [zoom, setZoom] = useState(initialMapViewport.zoom);
const [mapColourScale, setMapColourScale] = useState<BuildingMapTileset>();
const handleLocate = useCallback(
(lat: number, lng: number, zoom: number) => {
@ -64,23 +86,15 @@ export const ColouringMap : FC<ColouringMapProps> = ({
[onBuildingAction],
)
const themeSwitch = useCallback(
const layerSwitch = useCallback(
(e) => {
e.preventDefault();
const newTheme = (theme === 'light')? 'night' : 'light';
setTheme(newTheme);
const newDisplayState = (dataLayers === 'enabled')? 'disabled' : 'enabled';
setDataLayers(newDisplayState);
},
[theme],
[dataLayers],
)
const categoryMapDefinitions = useMemo(() => categoryMapsConfig[category], [category]);
useEffect(() => {
if(!categoryMapDefinitions.some(def => def.mapStyle === mapColourScale)) {
setMapColourScale(categoryMapDefinitions[0].mapStyle);
}
}, [categoryMapDefinitions, mapColourScale]);
const hasSelection = selectedBuildingId != undefined;
const isEdit = ['edit', 'multi-edit'].includes(mode);
@ -96,16 +110,23 @@ export const ColouringMap : FC<ColouringMapProps> = ({
attributionControl={false}
>
<ClickHandler onClick={handleClick} />
<MapBackgroundColor theme={theme} />
<MapBackgroundColor theme={darkLightTheme} />
<MapViewport position={position} zoom={zoom} />
<Pane
key={theme}
key={darkLightTheme}
name={'cc-base-pane'}
style={{zIndex: 50}}
>
<CityBaseMapLayer theme={theme} />
<BuildingBaseLayer theme={theme} />
<CityBaseMapLayer theme={darkLightTheme} />
<BuildingBaseLayer theme={darkLightTheme} />
</Pane>
<Pane
name='cc-overlay-pane-shown-behind-buildings'
style={{zIndex: 199}}
>
<ConservationAreaBoundaryLayer/>
</Pane>
{
@ -120,7 +141,14 @@ export const ColouringMap : FC<ColouringMapProps> = ({
name='cc-overlay-pane'
style={{zIndex: 300}}
>
<CityBoundaryLayer />
<CityBoundaryLayer/>
<HistoricDataLayer/>
<BoroughBoundaryLayer/>
<ParcelBoundaryLayer/>
<FloodBoundaryLayer/>
<VistaBoundaryLayer/>
<HousingBoundaryLayer/>
<CreativeBoundaryLayer/>
<BuildingNumbersLayer revisionId={revisionId} />
{
selectedBuildingId &&
@ -143,8 +171,24 @@ export const ColouringMap : FC<ColouringMapProps> = ({
<HelpIcon /> {isEdit ? 'Click a building to edit' : 'Click a building for details'}
</div>
}
<Legend mapColourScaleDefinitions={categoryMapDefinitions} mapColourScale={mapColourScale} onMapColourScale={setMapColourScale}/>
<ThemeSwitcher onSubmit={themeSwitch} currentTheme={theme} />
<Legend mapColourScaleDefinitions={categoryMapDefinitions} mapColourScale={mapColourScale} onMapColourScale={onMapColourScale}/>
<ThemeSwitcher onSubmit={darkLightThemeSwitch} currentTheme={darkLightTheme} />
<DataLayerSwitcher onSubmit={layerSwitch} currentDisplay={dataLayers} />
{
(dataLayers == "enabled") ?
<>
<BoroughSwitcher/>
<ParcelSwitcher/>
<FloodSwitcher/>
<ConservationAreaSwitcher/>
<HistoricDataSwitcher/>
<VistaSwitcher />
<HousingSwitcher />
<CreativeSwitcher />
</>
: <></>
}
{/* TODO change remaining ones*/}
<SearchBox onLocate={handleLocate} />
</>
}

View File

@ -0,0 +1,16 @@
import React from 'react';
import './map-button.css';
import { useDisplayPreferences } from '../displayPreferences-context';
export const ParcelSwitcher: React.FC<{}> = () => {
const { parcel, parcelSwitch, darkLightTheme } = useDisplayPreferences();
return (
<form className={`parcel-switcher map-button ${darkLightTheme}`} onSubmit={parcelSwitch}>
<button className="btn btn-outline btn-outline-dark"
type="submit">
{(parcel === 'enabled')? 'Switch off Parcel (sample) overlay' : 'Switch on Parcel (sample) overlay'}
</button>
</form>
);
}

View File

@ -1,36 +0,0 @@
.night-theme {
filter: grayscale(100%) invert(1);
}
.light-theme {
filter: none;
}
.theme-switcher {
z-index: 1000;
position: absolute;
top: 77px;
right: 10px;
float: right;
background: white;
border-radius: 4px;
}
.theme-switcher .btn {
margin: 0;
}
.theme-switcher.night .btn {
color: #fff;
background-color: #343a40;
border-color: #343a40;
}
.theme-switcher.night .btn:hover {
color: #343a40;
background-color: transparent;
background-image: none;
border-color: #343a40;
}
@media (max-width: 990px){
.theme-switcher {
visibility: hidden;
}
}

View File

@ -1,6 +1,6 @@
import React from 'react';
import './theme-switcher.css';
import './map-button.css';
interface ThemeSwitcherProps {
currentTheme: string;
@ -8,7 +8,7 @@ interface ThemeSwitcherProps {
}
const ThemeSwitcher: React.FC<ThemeSwitcherProps> = (props) => (
<form className={`theme-switcher ${props.currentTheme}`} onSubmit={props.onSubmit}>
<form className={`theme-switcher map-button ${props.currentTheme}`} onSubmit={props.onSubmit}>
<button className="btn btn-outline btn-outline-dark"
type="submit">
Switch theme ({(props.currentTheme === 'light')? 'Light' : 'Night'})

View File

@ -0,0 +1,16 @@
import React from 'react';
import './map-button.css';
import { useDisplayPreferences } from '../displayPreferences-context';
export const VistaSwitcher: React.FC<{}> = () => {
const { vista, vistaSwitch, darkLightTheme } = useDisplayPreferences();
return (
<form className={`vista-switcher map-button ${darkLightTheme}`} onSubmit={vistaSwitch}>
<button className="btn btn-outline btn-outline-dark"
type="submit">
{(vista === 'enabled')? 'Switch off Protected Vistas' : 'Switch on Protected Vistas'}
</button>
</form>
);
}

View File

@ -7,6 +7,7 @@ import serialize from 'serialize-javascript';
import {
getBuildingById,
getBuildingUPRNsById,
getBuildingPlanningDataById,
getLatestRevisionId,
getUserVerifiedAttributes
} from './api/services/building/base';
@ -33,13 +34,14 @@ const frontendRoute = asyncController(async (req: express.Request, res: express.
}
try {
let [user, building, uprns, userVerified, latestRevisionId] = await Promise.all([
let [user, building, uprns, planningData, userVerified, latestRevisionId] = await Promise.all([
userId ? getUserById(userId) : undefined,
isBuilding ? getBuildingById(
buildingId,
{ userDataOptions: userId ? { userId, userAttributes: true } : null }
) : undefined,
isBuilding ? getBuildingUPRNsById(buildingId) : undefined,
isBuilding ? getBuildingPlanningDataById(buildingId) : undefined,
(isBuilding && userId) ? getUserVerifiedAttributes(buildingId, userId) : {},
getLatestRevisionId()
]);
@ -53,6 +55,9 @@ const frontendRoute = asyncController(async (req: express.Request, res: express.
if (data.building != null) {
data.building.uprns = uprns;
}
if (data.building != null) {
data.building.planning_data = planningData;
}
data.latestRevisionId = latestRevisionId;
renderHTML(context, data, req, res);
} catch(error) {

View File

@ -13,6 +13,17 @@ const LAYER_QUERIES = {
geometry_id
FROM
buildings`,
base_night_outlines: `
SELECT
geometry_id
FROM
buildings`,
base_boroughs: `
SELECT
geometry_id,
name
FROM
external_data_borough_boundary`,
number_labels:`
SELECT
geometry_id,
@ -136,6 +147,25 @@ const LAYER_QUERIES = {
WHERE
community_public_ownership IS NOT NULL
`,
planning_applications_status_all: `SELECT
buildings.geometry_id, building_properties.uprn, building_properties.building_id, planning_data.status AS status, planning_data.uprn
FROM building_properties
INNER JOIN planning_data ON building_properties.uprn = planning_data.uprn
INNER JOIN buildings ON building_properties.building_id = buildings.building_id`,
planning_applications_status_recent: `SELECT
buildings.geometry_id, building_properties.uprn, building_properties.building_id, planning_data.status AS status, planning_data.uprn,
1 AS days_since_decision_date,
1 AS days_since_registered_with_local_authority_date
FROM building_properties
INNER JOIN planning_data ON building_properties.uprn = planning_data.uprn
INNER JOIN buildings ON building_properties.building_id = buildings.building_id`,
planning_applications_status_very_recent: `SELECT
buildings.geometry_id, building_properties.uprn, building_properties.building_id, planning_data.status AS status, planning_data.uprn,
planning_data.days_since_decision_date_cached AS days_since_decision_date,
planning_data.days_since_registration_cached AS days_since_registered_with_local_authority_date
FROM building_properties
INNER JOIN planning_data ON building_properties.uprn = planning_data.uprn
INNER JOIN buildings ON building_properties.building_id = buildings.building_id`,
planning_combined: `
SELECT
geometry_id,
@ -161,6 +191,13 @@ const LAYER_QUERIES = {
OR planning_heritage_at_risk_url <> ''
OR planning_in_apa_url <> ''
`,
empty_map: `
SELECT
geometry_id
FROM
buildings
WHERE
sust_dec IS NOT NULL`,
conservation_area: `
SELECT
geometry_id
@ -221,6 +258,29 @@ function getDataConfig(tileset: string): DataConfig {
throw new Error('Invalid tileset requested');
}
if(tileset == 'base_boroughs') {
const query = `(
SELECT
d.*,
g.geometry_geom
FROM (
${table}
) AS d
JOIN
geometries AS g
ON d.geometry_id = g.geometry_id
JOIN
external_data_borough_boundary AS b
ON d.geometry_id = b.geometry_id
) AS data
`;
return {
geometry_field: GEOMETRY_FIELD,
table: query
};
}
const query = `(
SELECT
d.*,

View File

@ -35,11 +35,11 @@ if(!allLayersCacheSwitch) {
// cache age data and base building outlines for more zoom levels than other layers
shouldCacheFn = ({ tileset, z }: TileParams) =>
(tileset === 'date_year' && z <= 16) ||
(['base_light', 'base_night'].includes(tileset) && z <= 17) ||
(['base_light', 'base_night', 'base_night_outlines', 'base_boroughs'].includes(tileset) && z <= 17) ||
z <= 13;
} else {
shouldCacheFn = ({ tileset, z }: TileParams) =>
['base_light', 'base_night'].includes(tileset) && z <= 17;
['base_light', 'base_night', 'base_night_outlines', 'base_boroughs'].includes(tileset) && z <= 17;
}
const tileCache = new TileCache(
@ -52,8 +52,8 @@ const tileCache = new TileCache(
},
shouldCacheFn,
// don't clear base_light and base_night on bounding box cache clear
(tileset: string) => tileset !== 'base_light' && tileset !== 'base_night'
// don't clear on bounding box cache clear tilesets not affected by user-editable data
(tileset: string) => tileset !== 'base_light' && tileset !== 'base_night' && tileset !== 'base_night_outlines' && tileset !== 'base_burough' && tileset !== "planning_applications_status_recent" && tileset !== "planning_applications_status_very_recent" && tileset !== "planning_applications_status_all"
);
const renderBuildingTile = (t: TileParams, d: any) => renderDataSourceTile(t, d, getDataConfig, getLayerVariables);
@ -63,7 +63,7 @@ function cacheOrCreateBuildingTile(tileParams: TileParams, dataParams: any): Pro
}
function stitchOrRenderBuildingTile(tileParams: TileParams, dataParams: any): Promise<Tile> {
if (tileParams.z <= STITCH_THRESHOLD) {
if (tileParams.z <= STITCH_THRESHOLD && tileParams.tileset != "base_boroughs") {
// stitch tile, using cache recursively
return stitchTile(tileParams, dataParams, cacheOrCreateBuildingTile);
} else {

2
etl/planning_data/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
*.json
*.sql

View File

@ -0,0 +1,37 @@
Following instructions assume that code is placed within `~/colouring-london/etl/planning_data/`
To install necessary dependencies use `cd ~/colouring-london/etl/planning_data/ && pip3 install -r requirements.txt`
Following scripts should be scheduled to run regularly to load livestream data into database.
```
# querying API to obtain data & loading data into Colouring database
python3 obtain_livestream_data_and_load_into_database.py
# removing tile cache for planning applications status layers - note that location of cache depends on your configuration
rm /srv/colouring-london/tilecache/planning_applications_status_all/* -rf
rm /srv/colouring-london/tilecache/planning_applications_status_recent/* -rf
rm /srv/colouring-london/tilecache/planning_applications_status_very_recent/* -rf
```
As loading into databases expects environment variables to be set, one option to actually schedule it in a cron is something like
```
export $(cat ~/scripts/.env | xargs) && /usr/bin/python3 ~/colouring-london/etl/planning_data/obtain_livestream_data_and_load_into_database.py
```
with
```
~/scripts/.env
```
being in following format
```
PGHOST=localhost
PGDATABASE=colouringlondondb
PGUSER=cldbadmin
PGPASSWORD=actualpassword
PLANNNING_DATA_API_ALLOW_REQUEST_CODE=requestcode
```

View File

@ -0,0 +1,103 @@
def planning_data_entry_to_address(element):
site_name = element["_source"].get("site_name")
site_number = element["_source"].get("site_number")
street_name = element["_source"].get("street_name") # seems often misused - say "31 COPTHALL ROAD EAST" site_name getting Ickenham street_name
secondary_street_name = element["_source"].get("secondary_street_name")
return generate_address(site_name, site_number, street_name, secondary_street_name)['result']
def generate_address(site_name, site_number, street_name, secondary_street_name):
"""
this function generates address from planning data that was provided
sadly it does not always works well and relies on many heursitics as data quality is limited
"""
if site_name != None:
site_name = site_name.strip()
if site_number != None:
site_number = site_number.strip()
if street_name != None:
street_name = street_name.strip()
if secondary_street_name != None:
secondary_street_name = secondary_street_name.strip()
if site_name == "":
site_name = None
if site_number == "":
site_number = None
if street_name == "":
street_name = None
if secondary_street_name == "":
secondary_street_name = None
data = {
'site_name': site_name,
'site_number': site_number,
'street_name': street_name,
'secondary_street_name': secondary_street_name,
}
if site_name == site_number == street_name == secondary_street_name == None:
return {'result': None, 'data': data}
if secondary_street_name != None:
if street_name == None:
print('"secondary_street_name != None, street_name == None"')
show_data(site_name, site_number, street_name, secondary_street_name, "???????")
else:
street_name += " - with secondary road name: " + secondary_street_name
if site_number != None and street_name != None:
address = site_number + " " + street_name
if site_name != None:
print('"site_name != None and site_number != None and street_name != None"')
show_data(site_name, site_number, street_name, secondary_street_name, address)
return {'result': address, 'data': data}
if site_name != None:
if street_name != None:
try:
if site_number == None and int(site_name):
return {'result': site_name + " " + street_name, 'data': data}
except ValueError:
pass
if street_name in site_name:
site_name_without_street_name = site_name.replace(street_name, "").strip()
try:
house_number = int(site_name_without_street_name)
# so it appears to be case like
# site_name: 5 Warwick Road
# street_name: Warwick Road
# no other info provided
# in such case just returning site_name will work fine...
return {'result': site_name, 'data': data}
except ValueError:
pass
print('"site_name != None and street_name != None"')
show_data(site_name, site_number, street_name, secondary_street_name, site_name)
if site_number != None:
print('"site_name != None and site_number != None"')
show_data(site_name, site_number, street_name, secondary_street_name, site_name)
return {'result': site_name, 'data': data}
else:
if street_name != None:
if site_number != None:
return {'result': site_number + " " + street_name, 'data': data}
if street_name != None and site_number == None:
print('"street_name != None or site_number == None"')
show_data(site_name, site_number, street_name, secondary_street_name, None)
return {'result': None, 'data': data}
if street_name == None and site_number != None:
print('"street_name == None or site_number != None"')
show_data(site_name, site_number, street_name, secondary_street_name, None)
return {'result': None, 'data': data}
return {'result': None, 'data': data}
def show_data(site_name, site_number, street_name, secondary_street_name, address):
print("site_name:", site_name)
print("site_number:", site_number)
print("street_name:", street_name)
print("secondary_street_name:", secondary_street_name)
print("address generated based on this data:", address)
print()
print()

View File

@ -0,0 +1,310 @@
import json
import datetime
import os
import requests
import psycopg2
import address_data
def main():
connection = get_connection()
cursor = connection.cursor()
cursor.execute("TRUNCATE planning_data")
downloaded = 0
last_sort = None
search_after = []
while True:
data = query(search_after).json()
load_data_into_database(cursor, data)
for entry in data['hits']['hits']:
downloaded += 1
last_sort = entry['sort']
print("downloaded", downloaded, "last_sort", last_sort, "previous", search_after)
if search_after == last_sort:
break
search_after = last_sort
connection.commit()
def load_data_into_database(cursor, data):
if "timed_out" not in data:
print(json.dumps(data, indent=4))
print("timed_out field missing in provided data")
else:
if data['timed_out']:
raise Exception("query getting livestream data has failed")
for entry in data['hits']['hits']:
try:
description = None
if entry['_source']['description'] != None:
description = entry['_source']['description'].strip()
application_id = entry['_source']['lpa_app_no']
decision_date = parse_date_string_into_date_object(entry['_source']['decision_date'])
last_synced_date = parse_date_string_into_date_object(entry['_source']['last_synced'])
uprn = entry['_source']['uprn']
status_before_aliasing = entry['_source']['status']
status_info = process_status(status_before_aliasing, decision_date)
status = status_info["status"]
status_explanation_note = status_info["status_explanation_note"]
planning_url = obtain_entry_link(entry['_source']['url_planning_app'], application_id)
if uprn == None:
continue
try:
uprn = int(uprn)
except ValueError as e:
print(e)
continue
entry = {
"description": description,
"decision_date": decision_date,
"last_synced_date": last_synced_date,
"application_id": application_id,
"application_url": planning_url,
"registered_with_local_authority_date": parse_date_string_into_date_object(entry['_source']['valid_date']),
"uprn": uprn,
"status": status,
"status_before_aliasing": status_before_aliasing,
"status_explanation_note": status_explanation_note,
"data_source": "Greater London Authority's Planning London DataHub",
"data_source_link": None,
"address": address_data.planning_data_entry_to_address(entry),
}
if entry["address"] != None:
maximum_address_length = 300
if len(entry["address"]) > maximum_address_length:
print("address is too long, shortening", entry["address"])
entry["address"] = entry["address"][0:maximum_address_length]
if date_in_future(entry["registered_with_local_authority_date"]):
print("registered_with_local_authority_date is treated as invalid:", entry["registered_with_local_authority_date"])
# Brent-87_0946 has "valid_date": "23/04/9187"
entry["registered_with_local_authority_date"] = None
if date_in_future(entry["decision_date"]):
print("decision_date is treated as invalid:", entry["decision_date"])
entry["decision_date"] = None
if date_in_future(entry["last_synced_date"]):
print("last_synced_date is treated as invalid:", entry["last_synced_date"])
entry["last_synced_date"] = None
if "Hackney" in entry["application_id"]:
if entry["application_url"] != None:
if "https://" not in entry["application_url"]:
entry["application_url"] = "https://developmentandhousing.hackney.gov.uk" + entry["application_url"]
insert_entry(cursor, entry)
except TypeError as e:
print()
print()
print()
print(e)
print()
show_dictionary(entry)
raise e
def date_in_future(date):
if date == None:
return False
return date > datetime.datetime.now()
def query(search_after):
headers = {
'X-API-AllowRequest': os.environ['PLANNNING_DATA_API_ALLOW_REQUEST_CODE'],
# Already added when you pass json= but not when you pass data=
# 'Content-Type': 'application/json',
}
json_data = {
'size': 10000,
'sort': [
{
'last_updated': {
'order': 'desc',
'unmapped_type': 'boolean',
},
},
],
'stored_fields': [
'*',
],
'_source': {
'excludes': [],
},
'query': {
'bool': {
'must': [
{
'range': {
'valid_date': {
'gte': '01/01/1021',
},
},
},
],
},
},
}
if search_after != []:
json_data['search_after'] = search_after
print(json_data)
return requests.post('https://planningdata.london.gov.uk/api-guest/applications/_search', headers=headers, json=json_data)
def get_connection():
return psycopg2.connect(
host=os.environ['PGHOST'],
dbname=os.environ['PGDATABASE'],
user=os.environ['PGUSER'],
password=os.environ['PGPASSWORD']
)
def filepath():
return os.path.dirname(os.path.realpath(__file__)) + os.sep + "data.json"
def insert_entry(cursor, e):
try:
now = datetime.datetime.now()
application_url = None
if e["application_url"] != None:
application_url = e["application_url"]
cursor.execute('''INSERT INTO
planning_data (planning_application_id, planning_application_link, description, registered_with_local_authority_date, days_since_registration_cached, decision_date, days_since_decision_date_cached, last_synced_date, status, status_before_aliasing, status_explanation_note, data_source, data_source_link, address, uprn)
VALUES
(%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
''', (
e["application_id"],
application_url, e["description"],
date_object_into_date_string(e["registered_with_local_authority_date"]),
days_since(e["registered_with_local_authority_date"], now),
date_object_into_date_string(e["decision_date"]),
days_since(e["decision_date"], now),
date_object_into_date_string(e["last_synced_date"]),
e["status"],
e["status_before_aliasing"],
e["status_explanation_note"],
e["data_source"],
e["data_source_link"],
e["address"],
e["uprn"],
)
)
except psycopg2.errors.Error as error:
show_dictionary(e)
raise error
def show_dictionary(data):
for key in data.keys():
print(key, "=", data[key])
def days_since(date, now):
if(date == None):
return None
return (now - date).days
def date_object_into_date_string(date):
if(date == None):
return None
return datetime.datetime.strftime(date, "%Y-%m-%d")
def parse_date_string_into_date_object(incoming):
if incoming == None:
return None
date = None
try:
date = datetime.datetime.strptime(incoming, "%d/%m/%Y") # '21/07/2022'
except ValueError:
date = datetime.datetime.strptime(incoming, "%Y-%m-%dT%H:%M:%S.%fZ") # '2022-08-08T20:07:22.238Z'
return date
def obtain_entry_link(provided_link, application_id):
if provided_link != None:
if "Ealing" in application_id:
if ";" == provided_link[-1]:
return provided_link[:-1]
return provided_link
if "Hackney" in application_id:
# https://cl-staging.uksouth.cloudapp.azure.com/view/planning/1377846
# Planning application ID: Hackney-2021_2491
# https://developmentandhousing.hackney.gov.uk/planning/index.html?fa=getApplication&reference=2021/2491
ref_for_link = application_id.replace("Hackney-", "").replace("_", "/")
return "https://developmentandhousing.hackney.gov.uk/planning/index.html?fa=getApplication&reference=" + ref_for_link
if "Lambeth" in application_id:
# sadly, specific links seems impossible
return "https://planning.lambeth.gov.uk/online-applications/refineSearch.do?action=refine"
if "Barnet" in application_id:
# sadly, specific links seems impossible
return "https://publicaccess.barnet.gov.uk/online-applications/"
if "Kingston" in application_id:
# sadly, specific links seems impossible
return "https://publicaccess.kingston.gov.uk/online-applications/"
if "Sutton" in application_id:
# sadly, specific links seems impossible
return "https://publicaccess.sutton.gov.uk/online-applications/"
if "Croydon" in application_id:
# sadly, specific links seems impossible
return "https://publicaccess3.croydon.gov.uk/online-applications/"
if "Bromley" in application_id:
# sadly, specific links seems impossible
return "https://searchapplications.bromley.gov.uk/online-applications/"
if "Bexley" in application_id:
# sadly, specific links seems impossible
return "https://pa.bexley.gov.uk/online-applications/search.do?action=simple&searchType=Application"
if "Newham" in application_id:
# sadly, specific links seems impossible
return "https://pa.newham.gov.uk/online-applications/"
if "Westminster" in application_id:
# sadly, specific links seems impossible
return "https://idoxpa.westminster.gov.uk/online-applications/"
if "Enfield" in application_id:
# sadly, specific links seems impossible
return "https://planningandbuildingcontrol.enfield.gov.uk/online-applications/"
if "Southwark" in application_id:
# sadly, specific links seems impossible
return "https://planning.southwark.gov.uk/online-applications/"
if "Hammersmith" in application_id:
return "https://public-access.lbhf.gov.uk/online-applications/search.do?action=simple&searchType=Application"
if "City_of_London" in application_id:
return "https://www.planning2.cityoflondon.gov.uk/online-applications/"
return None
# Richmond is simply broken
def process_status(status, decision_date):
status_length_limit = 50 # see migrations/034.planning_livestream_data.up.sql
if status in ["Application Under Consideration", "Application Received"]:
if decision_date == None:
status = "Submitted"
if status in ["Refused", "Refusal", "Refusal (P)", "Application Invalid", "Insufficient Fee", "Dismissed"]:
status = "Rejected"
if status == "Appeal Received":
status = "Appeal In Progress"
if status in ["Completed", "Allowed", "Approval"]:
status = "Approved"
if status in [None, "NOT_MAPPED"]:
status = "Unknown"
if status in ["Lapsed"]:
status = "Withdrawn"
if len(status) > status_length_limit:
print("Status was too long and was skipped:", status)
return {"status": "Processing failed", "status_explanation_note": "status was unusally long and it was imposible to save it"}
if (status in ["Submitted", "Approved", "Rejected", "Appeal In Progress", "Withdrawn", "Unknown"]):
return {"status": status, "status_explanation_note": None}
if status in ["No Objection to Proposal (OBS only)", "Objection Raised to Proposal (OBS only)"]:
return {"status": "Approved", "status_explanation_note": "preapproved application, local authority is unable to reject it"}
print("Unexpected status " + status)
if status not in ["Not Required", "SECS", "Comment Issued", "ALL DECISIONS ISSUED", "Closed", "Declined to Determine"]:
print("New unexpected status " + status)
return {"status": status, "status_explanation_note": None}
if __name__ == '__main__':
main()

View File

@ -0,0 +1,3 @@
# Python packages for planning data import
psycopg2==2.8.6
requests==2.27.1

View File

@ -70,6 +70,9 @@ This is the main table, containing almost all data collected by Colouring London
- `sust_dec`: DEC rating
- `sust_retrofit_date`: year of last significant retrofit
- `planning_portal_link`: link to an entry on https://www.planningportal.co.uk/
- `planning_crowdsourced_site_completion_status`: status of completion of costruction at given location
- `planning_crowdsourced_site_completion_year`: year of completion of costruction at given location
- `planning_crowdsourced_planning_id`: id of planning application for a given location
- `planning_list_id`: National Heritage List for England ID
- `planning_in_conservation_area_id`: conservation area ID
- `planning_in_conservation_area_url`: conservation area appraisal link

View File

@ -0,0 +1 @@
DROP TABLE IF EXISTS planning_data;

View File

@ -0,0 +1,21 @@
CREATE TABLE IF NOT EXISTS planning_data (
-- internal unique id
planning_entry_id serial PRIMARY KEY,
-- assigned by planning authority
planning_application_id VARCHAR(50),
planning_application_link VARCHAR(260),
description VARCHAR,
registered_with_local_authority_date date,
days_since_registration_cached smallint,
decision_date date,
days_since_decision_date_cached smallint,
last_synced_date date,
status VARCHAR(50),
status_before_aliasing VARCHAR(50),
status_explanation_note VARCHAR(250),
data_source VARCHAR(70),
data_source_link VARCHAR(150),
address VARCHAR(300),
uprn bigint
);

View File

@ -0,0 +1,3 @@
ALTER TABLE buildings DROP COLUMN IF EXISTS planning_crowdsourced_site_completion_status;
ALTER TABLE buildings DROP COLUMN IF EXISTS planning_crowdsourced_site_completion_year;
ALTER TABLE buildings DROP COLUMN IF EXISTS planning_crowdsourced_planning_id;

View File

@ -0,0 +1,3 @@
ALTER TABLE buildings ADD COLUMN IF NOT EXISTS planning_crowdsourced_site_completion_status boolean;
ALTER TABLE buildings ADD COLUMN IF NOT EXISTS planning_crowdsourced_site_completion_year smallint;
ALTER TABLE buildings ADD COLUMN IF NOT EXISTS planning_crowdsourced_planning_id VARCHAR DEFAULT '';

View File

@ -0,0 +1 @@
DROP TABLE IF EXISTS external_data_borough_boundary;

File diff suppressed because one or more lines are too long