From 28bad44220000cedc826e37cbe2a24469aa102ec Mon Sep 17 00:00:00 2001 From: Maciej Ziarkowski Date: Thu, 11 Mar 2021 19:15:58 +0000 Subject: [PATCH 1/4] Add simple dynamics API support --- app/src/api/config/dataFields.ts | 6 ++ app/src/api/config/fieldSchemaConfig.ts | 59 +++++++++++++++++++ .../domainLogic/processBuildingUpdate.ts | 19 ++++++ migrations/019.simple-dynamics.down.sql | 2 + migrations/019.simple-dynamics.up.sql | 2 + 5 files changed, 88 insertions(+) create mode 100644 migrations/019.simple-dynamics.down.sql create mode 100644 migrations/019.simple-dynamics.up.sql diff --git a/app/src/api/config/dataFields.ts b/app/src/api/config/dataFields.ts index e7ce695a..9e805fbc 100644 --- a/app/src/api/config/dataFields.ts +++ b/app/src/api/config/dataFields.ts @@ -239,4 +239,10 @@ export const dataFieldsConfig = valueType()({ /* eslint-disable verify: false, }, + demolished_buildings: { + edit: true, + verify: false, + asJson: true, + sqlCast: 'jsonb', + }, }); diff --git a/app/src/api/config/fieldSchemaConfig.ts b/app/src/api/config/fieldSchemaConfig.ts index c042896f..ea530d51 100644 --- a/app/src/api/config/fieldSchemaConfig.ts +++ b/app/src/api/config/fieldSchemaConfig.ts @@ -3,5 +3,64 @@ import { SomeJSONSchema } from 'ajv/dist/types/json-schema'; import { dataFieldsConfig } from './dataFields'; export const fieldSchemaConfig: { [key in keyof typeof dataFieldsConfig]?: SomeJSONSchema} = { /*eslint-disable @typescript-eslint/camelcase */ + + demolished_buildings: { + type: 'array', + items: { + type: 'object', + required: ['year_constructed', 'year_demolished', 'overlap_present', 'links'], + properties: { + year_constructed: { + type: 'object', + required: ['min', 'max'], + additionalProperties: false, + properties: { + min: { + type: 'integer' + }, + max: { + type: 'integer' + } + } + }, + year_demolished: { + type: 'object', + required: ['min', 'max'], + additionalProperties: false, + properties: { + min: { + type: 'integer' + }, + max: { + type: 'integer' + } + } + }, + overlap_present: { + type: 'string', + enum: ['1%', '25%', '50%', '75%', '100%'] + }, + links: { + type: 'array', + items: { + type: 'string' + }, + minItems: 1 + } + }, + additionalProperties: false, + } + } as JSONSchemaType<{ + year_constructed: { + min: number; + max: number; + }; + year_demolished: { + min: number; + max: number; + } + overlap_present: string; + links: string[]; + }[]>, } as const; diff --git a/app/src/api/services/domainLogic/processBuildingUpdate.ts b/app/src/api/services/domainLogic/processBuildingUpdate.ts index 81e6b95a..bf85d0ac 100644 --- a/app/src/api/services/domainLogic/processBuildingUpdate.ts +++ b/app/src/api/services/domainLogic/processBuildingUpdate.ts @@ -6,6 +6,9 @@ import { ArgumentError } from '../../errors/general'; import { updateLandUse } from './landUse'; +/** + * Process land use classifications - derive land use order from land use groups + */ async function processCurrentLandUseClassifications(buildingId: number, buildingUpdate: any): Promise { const currentBuildingData = await getBuildingData(buildingId); @@ -31,10 +34,26 @@ async function processCurrentLandUseClassifications(buildingId: number, building } } + +/** + * Process Dynamics data - sort past buildings by construction date + */ +async function processDynamicsPastBuildings(buildingId: number, buildingUpdate: any): Promise { + buildingUpdate.demolished_buildings = buildingUpdate.demolished_buildings.sort((a, b) => b.year_constructed.min - a.year_constructed.min); + return buildingUpdate; +} + + +/** + * Define any custom processing logic for specific building attributes + */ export async function processBuildingUpdate(buildingId: number, buildingUpdate: any): Promise { if(hasAnyOwnProperty(buildingUpdate, ['current_landuse_group'])) { buildingUpdate = await processCurrentLandUseClassifications(buildingId, buildingUpdate); } + if('demolished_buildings' in buildingUpdate) { + buildingUpdate = await processDynamicsPastBuildings(buildingId, buildingUpdate); + } return buildingUpdate; } diff --git a/migrations/019.simple-dynamics.down.sql b/migrations/019.simple-dynamics.down.sql new file mode 100644 index 00000000..051a4931 --- /dev/null +++ b/migrations/019.simple-dynamics.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE buildings +DROP demolished_buildings; \ No newline at end of file diff --git a/migrations/019.simple-dynamics.up.sql b/migrations/019.simple-dynamics.up.sql new file mode 100644 index 00000000..01307579 --- /dev/null +++ b/migrations/019.simple-dynamics.up.sql @@ -0,0 +1,2 @@ +ALTER TABLE buildings +ADD column demolished_buildings JSONB DEFAULT '[]'::JSONB; \ No newline at end of file From 542fe6634be57bc731f8dd0aa32897c414e8dc99 Mon Sep 17 00:00:00 2001 From: Maciej Ziarkowski Date: Thu, 11 Mar 2021 19:16:37 +0000 Subject: [PATCH 2/4] Add simple dynamics map definition --- app/map_styles/polygon.xml | 30 ++++++++++++++++++++++++++++++ app/src/tiles/dataDefinition.ts | 7 +++++++ 2 files changed, 37 insertions(+) diff --git a/app/map_styles/polygon.xml b/app/map_styles/polygon.xml index d122482d..1debdc17 100644 --- a/app/map_styles/polygon.xml +++ b/app/map_styles/polygon.xml @@ -507,4 +507,34 @@ + diff --git a/app/src/tiles/dataDefinition.ts b/app/src/tiles/dataDefinition.ts index fa3913b6..45227491 100644 --- a/app/src/tiles/dataDefinition.ts +++ b/app/src/tiles/dataDefinition.ts @@ -133,6 +133,13 @@ const LAYER_QUERIES = { buildings WHERE current_landuse_order IS NOT NULL`, + dynamics_demolished_count: ` + SELECT + geometry_id, + jsonb_array_length(demolished_buildings) as demolished_buildings_count + FROM + buildings + WHERE jsonb_array_length(demolished_buildings) > 0`, }; const GEOMETRY_FIELD = 'geometry_geom'; From 8a42f93dd2f37ab97bf4cdf406fb3e9b76d487b2 Mon Sep 17 00:00:00 2001 From: Maciej Ziarkowski Date: Thu, 11 Mar 2021 19:19:20 +0000 Subject: [PATCH 3/4] Add a field row component --- .../frontend/building/data-components/field-row.css | 11 +++++++++++ .../frontend/building/data-components/field-row.tsx | 11 +++++++++++ 2 files changed, 22 insertions(+) create mode 100644 app/src/frontend/building/data-components/field-row.css create mode 100644 app/src/frontend/building/data-components/field-row.tsx diff --git a/app/src/frontend/building/data-components/field-row.css b/app/src/frontend/building/data-components/field-row.css new file mode 100644 index 00000000..a630fd63 --- /dev/null +++ b/app/src/frontend/building/data-components/field-row.css @@ -0,0 +1,11 @@ +.field-row { + display: flex; + flex-direction: row; + justify-content: space-evenly; + justify-items: left; +} + +/* make all row elements the same width */ +.field-row * { + flex: 1 1 0px; +} \ No newline at end of file diff --git a/app/src/frontend/building/data-components/field-row.tsx b/app/src/frontend/building/data-components/field-row.tsx new file mode 100644 index 00000000..71513594 --- /dev/null +++ b/app/src/frontend/building/data-components/field-row.tsx @@ -0,0 +1,11 @@ +import React from 'react' + +import './field-row.css'; + +export function FieldRow({children}) { + return ( +
+ {children} +
+ ) +} From 81b92fe6fa3413c0e80b9e2a9562819661f9444c Mon Sep 17 00:00:00 2001 From: Maciej Ziarkowski Date: Thu, 11 Mar 2021 19:33:45 +0000 Subject: [PATCH 4/4] Add simple dynamics frontend --- .../building/data-containers/dynamics.tsx | 52 ---- .../dynamics/dynamics-data-entry.css | 36 +++ .../dynamics/dynamics-data-entry.tsx | 283 ++++++++++++++++++ .../data-containers/dynamics/dynamics.tsx | 123 ++++++++ .../dynamics/number-range-data-entry.css | 7 + .../dynamics/number-range-data-entry.tsx | 98 ++++++ app/src/frontend/config/categories-config.ts | 1 - .../frontend/config/category-maps-config.ts | 28 +- app/src/frontend/config/category-ui-config.ts | 2 +- app/src/frontend/config/data-fields-config.ts | 34 +++ 10 files changed, 608 insertions(+), 56 deletions(-) delete mode 100644 app/src/frontend/building/data-containers/dynamics.tsx create mode 100644 app/src/frontend/building/data-containers/dynamics/dynamics-data-entry.css create mode 100644 app/src/frontend/building/data-containers/dynamics/dynamics-data-entry.tsx create mode 100644 app/src/frontend/building/data-containers/dynamics/dynamics.tsx create mode 100644 app/src/frontend/building/data-containers/dynamics/number-range-data-entry.css create mode 100644 app/src/frontend/building/data-containers/dynamics/number-range-data-entry.tsx diff --git a/app/src/frontend/building/data-containers/dynamics.tsx b/app/src/frontend/building/data-containers/dynamics.tsx deleted file mode 100644 index 53baab63..00000000 --- a/app/src/frontend/building/data-containers/dynamics.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import React, { Fragment } from 'react'; -import InfoBox from '../../components/info-box'; -import DataEntry from '../data-components/data-entry'; -import { DataEntryGroup } from '../data-components/data-entry-group'; - -import withCopyEdit from '../data-container'; - -import { CategoryViewProps } from './category-view-props'; - -/** -* Dynamics view/edit section -*/ -const DynamicsView: React.FunctionComponent = (props) => ( - - - - - - - - - - -); -const DynamicsContainer = withCopyEdit(DynamicsView); - -export default DynamicsContainer; diff --git a/app/src/frontend/building/data-containers/dynamics/dynamics-data-entry.css b/app/src/frontend/building/data-containers/dynamics/dynamics-data-entry.css new file mode 100644 index 00000000..e59e0e51 --- /dev/null +++ b/app/src/frontend/building/data-containers/dynamics/dynamics-data-entry.css @@ -0,0 +1,36 @@ +.dynamics-building-pane { + position: relative; + background-color: #f1f1f1; + border: 1px solid #cccccc; + padding: 10px; + border-radius: 5px; + margin-top: 10px; + margin-bottom: 15px; +} + +.dynamics-building-pane.new-record { + background-color: #f6f6f6; + border: 1px dashed #cccccc; +} + +.h6 { + margin-top: 40px; +} + +.delete-record-button { + position: absolute; + top:10px; + right: 10px; +} + +.add-record-button { + margin-top: 20px; +} + +.field-row * { + margin-right: 1px; +} + +.lifespan-entry { + flex: 0 1 27%; +} \ No newline at end of file diff --git a/app/src/frontend/building/data-containers/dynamics/dynamics-data-entry.tsx b/app/src/frontend/building/data-containers/dynamics/dynamics-data-entry.tsx new file mode 100644 index 00000000..3bcaa6a3 --- /dev/null +++ b/app/src/frontend/building/data-containers/dynamics/dynamics-data-entry.tsx @@ -0,0 +1,283 @@ +import React, { useCallback, useState } from 'react' +import * as _ from 'lodash'; + +import { BuildingAttributes } from '../../../models/building'; +import { FieldRow } from '../../data-components/field-row'; +import DataEntry, { BaseDataEntryProps } from '../../data-components/data-entry'; +import { dataFields } from '../../../config/data-fields-config'; +import SelectDataEntry from '../../data-components/select-data-entry'; +import MultiDataEntry from '../../data-components/multi-data-entry/multi-data-entry'; +import { NumberRangeDataEntry } from './number-range-data-entry'; + +import './dynamics-data-entry.css'; + +type DemolishedBuilding = (BuildingAttributes['demolished_buildings'][number]); + +export const DynamicsBuildingPane: React.FC<{className?: string}> = ({children, className}) => ( +
+ {children} +
+); + +function lifespan(a: number, b: number): number { + if(a == undefined || b == undefined) return undefined; + + const diff = a - b; + + return Math.max(diff, 0); +} + +function formatRange(minSpan: number, maxSpan: number): string { + if(minSpan == undefined || maxSpan == undefined) return ''; + + if(minSpan === maxSpan) return minSpan + ''; + + return `${minSpan}-${maxSpan}`; +} + +interface DynamicsDataRowProps { + value: DemolishedBuilding; + onChange?: (value: DemolishedBuilding) => void; + disabled?: boolean; + maxYear?: number; + minYear?: number; + mode?: 'view' | 'edit' | 'multi-edit'; + required?: boolean; + validateForm?: boolean; + index?: number; +} +const DynamicsDataRow: React.FC = ({ + value = {} as DemolishedBuilding, + onChange, + disabled = false, + maxYear, + minYear, + mode, + required = false, + validateForm = false, + index +}) => { + + const onFieldChange = useCallback((key: string, val: any) => { + const changedValue = {...value}; + changedValue[key] = val; + onChange(changedValue); + }, [value, onChange]); + + const maxLifespan = lifespan(value.year_demolished?.max, value.year_constructed?.min); + const minLifespan = lifespan(value.year_demolished?.min, value.year_constructed?.max); + + return ( + <> + +
+ +
+
+ +
+
+ +
+
+ + + + ) +}; + +interface DynamicsDataEntryProps extends BaseDataEntryProps { + value: DemolishedBuilding[]; + editableEntries: boolean; + maxYear: number; + minYear: number; + onSaveAdd: (slug: string, newItem: any) => void; + hasEdits: boolean; +} + +function isValid(val: DemolishedBuilding) { + if(val == undefined) return false; + + if(typeof val.year_constructed?.min !== 'number') return false; + if(typeof val.year_constructed?.max !== 'number') return false; + + if(typeof val.year_demolished?.min !== 'number') return false; + if(typeof val.year_demolished?.max !== 'number') return false; + + if(val.overlap_present == undefined) return false; + + if(val.links == undefined || val.links.length < 1) return false; + + return true; +} + +export const DynamicsDataEntry: React.FC = (props) => { + const [newValue, setNewValue] = useState(); + + const values: DemolishedBuilding[] = props.value ?? []; + const isEditing = props.mode === 'edit'; + const isDisabled = !isEditing || props.disabled; + + const isEdited = !_.isEmpty(newValue); + const valid = isValid(newValue); + + const addNew = useCallback(() => { + const val = {...newValue}; + + setNewValue(undefined); + props.onSaveAdd(props.slug, val); + }, [values, newValue]); + + const edit = useCallback((id: number, val: DemolishedBuilding) => { + const editedValues = [...values]; + editedValues.splice(id, 1, val); + + props.onChange(props.slug, editedValues); + }, [values]); + + const remove = useCallback((id: number) => { + const editedValues = [...values]; + editedValues.splice(id, 1); + + props.onChange(props.slug, editedValues); + }, [values]); + + return ( + <> +
+ { + isEditing && + <> +
Existing records for demolished buildings
+ + + } +
    + { + values.length === 0 && +
    + +
    + } + { + values.map((pastBuilding, id) => ( +
  • + + + { + !isDisabled && + + } + edit(id, value)} + minYear={props.minYear} + maxYear={props.maxYear} + mode={props.mode} + required={true} + index={id} + /> + +
  • + )) + } +
+ { + !isDisabled && +
+
Add a new demolished building record
+ + + + + +
+ } +
+ + ); +}; diff --git a/app/src/frontend/building/data-containers/dynamics/dynamics.tsx b/app/src/frontend/building/data-containers/dynamics/dynamics.tsx new file mode 100644 index 00000000..25e03d18 --- /dev/null +++ b/app/src/frontend/building/data-containers/dynamics/dynamics.tsx @@ -0,0 +1,123 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; +import InfoBox from '../../../components/info-box'; + +import { Category } from '../../../config/categories-config'; +import { dataFields } from '../../../config/data-fields-config'; + +import DataEntry from '../../data-components/data-entry'; +import { DataEntryGroup } from '../../data-components/data-entry-group'; +import { DynamicsBuildingPane, DynamicsDataEntry } from './dynamics-data-entry'; +import { FieldRow } from '../../data-components/field-row'; +import NumericDataEntry from '../../data-components/numeric-data-entry'; +import withCopyEdit from '../../data-container'; + +import { CategoryViewProps } from '../category-view-props'; + +/** +* Dynamics view/edit section +*/ +const DynamicsView: React.FunctionComponent = (props) => { + const building = props.building; + const thisYear = (new Date()).getFullYear(); + const currentBuildingConstructionYear = building.date_year || undefined; + + const ageLinkUrl = `/${props.mode}/${Category.Age}/${props.building.building_id}`; + + return (<> + + + + +
+ +
+
+ +
+
+ +
+
+
+ { + currentBuildingConstructionYear != undefined ? + <> + + { + props.mode === 'view' && + Switch to edit mode to add/edit past building records + } + : + To add historical records, fill in the Age data first. + } + +
+ + + + + + + + This section is under development in collaboration with the historic environment sector. + Please let us know your suggestions on the discussion forum! (external link - save your edits first) + + ) +}; + +const DynamicsContainer = withCopyEdit(DynamicsView); + +export default DynamicsContainer; diff --git a/app/src/frontend/building/data-containers/dynamics/number-range-data-entry.css b/app/src/frontend/building/data-containers/dynamics/number-range-data-entry.css new file mode 100644 index 00000000..950da68d --- /dev/null +++ b/app/src/frontend/building/data-containers/dynamics/number-range-data-entry.css @@ -0,0 +1,7 @@ +.min-number-input { + border-right: none; +} + +.max-number-input { + border-left: 1px dashed #ccc; +} \ No newline at end of file diff --git a/app/src/frontend/building/data-containers/dynamics/number-range-data-entry.tsx b/app/src/frontend/building/data-containers/dynamics/number-range-data-entry.tsx new file mode 100644 index 00000000..b6fbe83f --- /dev/null +++ b/app/src/frontend/building/data-containers/dynamics/number-range-data-entry.tsx @@ -0,0 +1,98 @@ +import React, { useCallback, useState } from 'react'; + +import { BaseDataEntryProps } from '../../data-components/data-entry'; +import { DataTitleCopyable } from '../../data-components/data-title'; +import { FieldRow } from '../../data-components/field-row'; + +import './number-range-data-entry.css'; + +interface NumberRangeDataEntryProps extends BaseDataEntryProps { + value?: { + min: number; + max: number; + }; + placeholderMin?: string; + placeholderMax?: string; + titleMin?: string; + titleMax?: string; + step?: number; + min?: number; + max?: number; +} + +export const NumberRangeDataEntry: React.FC = (props) => { + const {min: minValue, max: maxValue} = props.value ?? {}; + const [minEdited, setMinEdited] = useState(minValue != undefined); + const [maxEdited, setMaxEdited] = useState(maxValue != undefined); + + const isDisabled = props.mode === 'view' || props.disabled; + + const slugWithModifier = props.slug + (props.slugModifier ?? ''); + + return ( +
+ + + { + const val = e.target.value === '' ? null : parseFloat(e.target.value); + setMinEdited(val != undefined); + props.onChange( + props.slug, + { + min: val, + max: maxEdited ? maxValue : val + } + ); + } + } + /> + { + const val = e.target.value === '' ? null : parseFloat(e.target.value); + setMaxEdited(val != undefined); + props.onChange( + props.slug, + { + min: minEdited ? minValue : val, + max: val + } + ); + } + } + /> + +
+ ); +}; \ No newline at end of file diff --git a/app/src/frontend/config/categories-config.ts b/app/src/frontend/config/categories-config.ts index 1800361e..02a37d66 100644 --- a/app/src/frontend/config/categories-config.ts +++ b/app/src/frontend/config/categories-config.ts @@ -115,7 +115,6 @@ export const categoriesConfig: {[key in Category]: CategoryDefinition} = { intro: "What's the building's context? Coming soon…", }, [Category.Dynamics]: { - inactive: true, slug: 'dynamics', name: 'Dynamics', aboutUrl: 'https://pages.colouring.london/dynamics', diff --git a/app/src/frontend/config/category-maps-config.ts b/app/src/frontend/config/category-maps-config.ts index 3478b4e2..165edf07 100644 --- a/app/src/frontend/config/category-maps-config.ts +++ b/app/src/frontend/config/category-maps-config.ts @@ -185,10 +185,34 @@ export const categoryMapsConfig: {[key in Category]: CategoryMapDefinition} = { }, }, [Category.Dynamics]: { - mapStyle: undefined, + mapStyle: 'dynamics_demolished_count', legend: { title: 'Dynamics', - elements: [] + description: 'Past (demolished) buildings on site', + elements: [ + { + text: '7+', + color: '#bd0026', + }, { + text: '6', + color: '#e31a1c', + }, { + text: '5', + color: '#fc4e2a', + }, { + text: '4', + color: '#fd8d3c', + }, { + text: '3', + color: '#feb24c', + }, { + text: '2', + color: '#fed976', + }, { + text: '1', + color: '#ffe8a9', + } + ], }, } diff --git a/app/src/frontend/config/category-ui-config.ts b/app/src/frontend/config/category-ui-config.ts index 7f905512..ac41ba2d 100644 --- a/app/src/frontend/config/category-ui-config.ts +++ b/app/src/frontend/config/category-ui-config.ts @@ -3,7 +3,7 @@ import { Category } from './categories-config'; import AgeContainer from '../building/data-containers/age'; import CommunityContainer from '../building/data-containers/community'; import ConstructionContainer from '../building/data-containers/construction'; -import DynamicsContainer from '../building/data-containers/dynamics'; +import DynamicsContainer from '../building/data-containers/dynamics/dynamics'; import LocationContainer from '../building/data-containers/location'; import PlanningContainer from '../building/data-containers/planning'; import SizeContainer from '../building/data-containers/size'; diff --git a/app/src/frontend/config/data-fields-config.ts b/app/src/frontend/config/data-fields-config.ts index 1c47cdf0..f5b911cc 100644 --- a/app/src/frontend/config/data-fields-config.ts +++ b/app/src/frontend/config/data-fields-config.ts @@ -411,4 +411,38 @@ export const dataFields = { /* eslint-disable @typescript-eslint/camelcase */ title: "Total number of likes", example: 100, }, + + demolished_buildings: { + category: Category.Dynamics, + title: 'Past (demolished) buildings on this site', + items: { + year_constructed: { + title: 'Construction year', + example: { min: 1989, max: 1991 }, + }, + year_demolished: { + title: 'Demolition year', + example: { min: 1993, max: 1994 }, + }, + lifespan: { + title: 'Lifespan', + example: "2-5", + }, + overlap_present: { + title: 'Roughly what percentage of this building was inside the current site boundary?', + tooltip: '', + example: "25%" + }, + links: { + title: 'Links / sources', + example: ["", ""] + } + }, + example: [ + { + year_constructed: { min: 1989, max: 1991 }, + year_demolished: { min: 1993, max: 1994 }, + lifespan: "2-5", overlap_present: "50%", links: ["", ""]} + ] + } };