Merge pull request #666 from mz8i/feature/545-dynamics
Feature 545: simple dynamics
This commit is contained in:
commit
653282f0f3
@ -507,4 +507,34 @@
|
|||||||
<LineSymbolizer stroke="#888" stroke-width="3.0"/>
|
<LineSymbolizer stroke="#888" stroke-width="3.0"/>
|
||||||
</Rule>
|
</Rule>
|
||||||
</Style>
|
</Style>
|
||||||
|
<Style name="dynamics_demolished_count">
|
||||||
|
<Rule>
|
||||||
|
<Filter>[demolished_buildings_count] >= 7</Filter>
|
||||||
|
<PolygonSymbolizer fill="#bd0026" />
|
||||||
|
</Rule>
|
||||||
|
<Rule>
|
||||||
|
<Filter>[demolished_buildings_count] = 6</Filter>
|
||||||
|
<PolygonSymbolizer fill="#e31a1c" />
|
||||||
|
</Rule>
|
||||||
|
<Rule>
|
||||||
|
<Filter>[demolished_buildings_count] = 5</Filter>
|
||||||
|
<PolygonSymbolizer fill="#fc4e2a" />
|
||||||
|
</Rule>
|
||||||
|
<Rule>
|
||||||
|
<Filter>[demolished_buildings_count] = 4</Filter>
|
||||||
|
<PolygonSymbolizer fill="#fd8d3c" />
|
||||||
|
</Rule>
|
||||||
|
<Rule>
|
||||||
|
<Filter>[demolished_buildings_count] = 3</Filter>
|
||||||
|
<PolygonSymbolizer fill="#feb24c" />
|
||||||
|
</Rule>
|
||||||
|
<Rule>
|
||||||
|
<Filter>[demolished_buildings_count] = 2</Filter>
|
||||||
|
<PolygonSymbolizer fill="#fed976" />
|
||||||
|
</Rule>
|
||||||
|
<Rule>
|
||||||
|
<Filter>[demolished_buildings_count] = 1</Filter>
|
||||||
|
<PolygonSymbolizer fill="#ffe8a9" />
|
||||||
|
</Rule>
|
||||||
|
</Style>
|
||||||
</Map>
|
</Map>
|
||||||
|
@ -239,4 +239,10 @@ export const dataFieldsConfig = valueType<DataFieldConfig>()({ /* eslint-disable
|
|||||||
verify: false,
|
verify: false,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
demolished_buildings: {
|
||||||
|
edit: true,
|
||||||
|
verify: false,
|
||||||
|
asJson: true,
|
||||||
|
sqlCast: 'jsonb',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
@ -3,5 +3,64 @@ import { SomeJSONSchema } from 'ajv/dist/types/json-schema';
|
|||||||
import { dataFieldsConfig } from './dataFields';
|
import { dataFieldsConfig } from './dataFields';
|
||||||
|
|
||||||
export const fieldSchemaConfig: { [key in keyof typeof dataFieldsConfig]?: SomeJSONSchema} = { /*eslint-disable @typescript-eslint/camelcase */
|
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;
|
} as const;
|
||||||
|
@ -6,6 +6,9 @@ import { ArgumentError } from '../../errors/general';
|
|||||||
|
|
||||||
import { updateLandUse } from './landUse';
|
import { updateLandUse } from './landUse';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process land use classifications - derive land use order from land use groups
|
||||||
|
*/
|
||||||
async function processCurrentLandUseClassifications(buildingId: number, buildingUpdate: any): Promise<any> {
|
async function processCurrentLandUseClassifications(buildingId: number, buildingUpdate: any): Promise<any> {
|
||||||
const currentBuildingData = await getBuildingData(buildingId);
|
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<any> {
|
||||||
|
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<any> {
|
export async function processBuildingUpdate(buildingId: number, buildingUpdate: any): Promise<any> {
|
||||||
if(hasAnyOwnProperty(buildingUpdate, ['current_landuse_group'])) {
|
if(hasAnyOwnProperty(buildingUpdate, ['current_landuse_group'])) {
|
||||||
buildingUpdate = await processCurrentLandUseClassifications(buildingId, buildingUpdate);
|
buildingUpdate = await processCurrentLandUseClassifications(buildingId, buildingUpdate);
|
||||||
}
|
}
|
||||||
|
if('demolished_buildings' in buildingUpdate) {
|
||||||
|
buildingUpdate = await processDynamicsPastBuildings(buildingId, buildingUpdate);
|
||||||
|
}
|
||||||
|
|
||||||
return buildingUpdate;
|
return buildingUpdate;
|
||||||
}
|
}
|
||||||
|
11
app/src/frontend/building/data-components/field-row.css
Normal file
11
app/src/frontend/building/data-components/field-row.css
Normal file
@ -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;
|
||||||
|
}
|
11
app/src/frontend/building/data-components/field-row.tsx
Normal file
11
app/src/frontend/building/data-components/field-row.tsx
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
import './field-row.css';
|
||||||
|
|
||||||
|
export function FieldRow({children}) {
|
||||||
|
return (
|
||||||
|
<div className="field-row">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
@ -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<CategoryViewProps> = (props) => (
|
|
||||||
<Fragment>
|
|
||||||
<InfoBox msg="This is what we're planning to collect in this section" />
|
|
||||||
<DataEntryGroup collapsed={false} name="Historical constructions and demolitions" showCount={false}>
|
|
||||||
<DataEntry
|
|
||||||
title="Current building"
|
|
||||||
slug=""
|
|
||||||
value=""
|
|
||||||
mode='view'
|
|
||||||
/>
|
|
||||||
<DataEntry
|
|
||||||
title="Past buildings"
|
|
||||||
slug=""
|
|
||||||
value=""
|
|
||||||
mode='view'
|
|
||||||
/>
|
|
||||||
</DataEntryGroup>
|
|
||||||
<DataEntry
|
|
||||||
title="Historical land use change"
|
|
||||||
slug=""
|
|
||||||
value=""
|
|
||||||
mode='view'
|
|
||||||
/>
|
|
||||||
<DataEntry
|
|
||||||
title="Longitudinal historical footprints (raster) link"
|
|
||||||
slug=""
|
|
||||||
value=""
|
|
||||||
mode='view'
|
|
||||||
/>
|
|
||||||
<DataEntry
|
|
||||||
title="Longitudinal historical footprints (vector) link"
|
|
||||||
slug=""
|
|
||||||
value=""
|
|
||||||
mode='view'
|
|
||||||
/>
|
|
||||||
</Fragment>
|
|
||||||
);
|
|
||||||
const DynamicsContainer = withCopyEdit(DynamicsView);
|
|
||||||
|
|
||||||
export default DynamicsContainer;
|
|
@ -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%;
|
||||||
|
}
|
@ -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}) => (
|
||||||
|
<div className={`dynamics-building-pane ${className ?? ''}`} >
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
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<DynamicsDataRowProps> = ({
|
||||||
|
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 (
|
||||||
|
<>
|
||||||
|
<FieldRow>
|
||||||
|
<div>
|
||||||
|
<NumberRangeDataEntry
|
||||||
|
slug='year_constructed'
|
||||||
|
slugModifier={index}
|
||||||
|
title={dataFields.demolished_buildings.items.year_constructed.title}
|
||||||
|
onChange={onFieldChange}
|
||||||
|
value={value.year_constructed}
|
||||||
|
disabled={disabled}
|
||||||
|
max={value.year_demolished?.min ?? maxYear}
|
||||||
|
min={minYear}
|
||||||
|
placeholderMin='Earliest'
|
||||||
|
placeholderMax='Latest'
|
||||||
|
titleMin={`${dataFields.demolished_buildings.items.year_constructed.title}: earliest estimate`}
|
||||||
|
titleMax={`${dataFields.demolished_buildings.items.year_constructed.title}: latest estimate`}
|
||||||
|
required={required}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<NumberRangeDataEntry
|
||||||
|
slug='year_demolished'
|
||||||
|
slugModifier={index}
|
||||||
|
title={dataFields.demolished_buildings.items.year_demolished.title}
|
||||||
|
onChange={onFieldChange}
|
||||||
|
value={value.year_demolished}
|
||||||
|
disabled={disabled}
|
||||||
|
max={maxYear}
|
||||||
|
min={value.year_constructed?.max ?? minYear}
|
||||||
|
placeholderMin='Earliest'
|
||||||
|
placeholderMax='Latest'
|
||||||
|
titleMin={`${dataFields.demolished_buildings.items.year_demolished.title}: earliest estimate`}
|
||||||
|
titleMax={`${dataFields.demolished_buildings.items.year_demolished.title}: latest estimate`}
|
||||||
|
required={required}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className='lifespan-entry'>
|
||||||
|
<DataEntry
|
||||||
|
slug='lifespan'
|
||||||
|
slugModifier={index}
|
||||||
|
title={dataFields.demolished_buildings.items.lifespan.title}
|
||||||
|
value={formatRange(minLifespan, maxLifespan)}
|
||||||
|
disabled={true}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</FieldRow>
|
||||||
|
<SelectDataEntry
|
||||||
|
slug='overlap_present'
|
||||||
|
slugModifier={index}
|
||||||
|
title={dataFields.demolished_buildings.items.overlap_present.title}
|
||||||
|
onChange={onFieldChange}
|
||||||
|
value={value.overlap_present}
|
||||||
|
options={[
|
||||||
|
{value: '1%', label: '1% - almost no overlap with current site'},
|
||||||
|
'25%',
|
||||||
|
'50%',
|
||||||
|
'75%',
|
||||||
|
{value: '100%', label: '100% - fully contained in current site'}
|
||||||
|
]}
|
||||||
|
disabled={disabled}
|
||||||
|
required={required}
|
||||||
|
/>
|
||||||
|
<MultiDataEntry
|
||||||
|
slug='links'
|
||||||
|
slugModifier={index}
|
||||||
|
title={dataFields.demolished_buildings.items.links.title}
|
||||||
|
onChange={onFieldChange}
|
||||||
|
value={value.links}
|
||||||
|
disabled={disabled}
|
||||||
|
editableEntries={true}
|
||||||
|
mode={mode}
|
||||||
|
required={required}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
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<DynamicsDataEntryProps> = (props) => {
|
||||||
|
const [newValue, setNewValue] = useState<DemolishedBuilding>();
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
{
|
||||||
|
isEditing &&
|
||||||
|
<>
|
||||||
|
<h6 className="h6">Existing records for demolished buildings</h6>
|
||||||
|
<label>Please supply sources for any edits of existing records</label>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
<ul className="data-link-list">
|
||||||
|
{
|
||||||
|
values.length === 0 &&
|
||||||
|
<div className="input-group">
|
||||||
|
<input className="form-control no-entries" type="text" value="No records so far" disabled={true} />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
{
|
||||||
|
values.map((pastBuilding, id) => (
|
||||||
|
<li key={id}>
|
||||||
|
<DynamicsBuildingPane>
|
||||||
|
<label>Demolished building</label>
|
||||||
|
{
|
||||||
|
!isDisabled &&
|
||||||
|
<button type="button" className="btn btn-outline-dark delete-record-button"
|
||||||
|
title="Delete Record"
|
||||||
|
onClick={() => remove(id)}
|
||||||
|
data-index={id}
|
||||||
|
>x</button>
|
||||||
|
}
|
||||||
|
<DynamicsDataRow
|
||||||
|
value={pastBuilding}
|
||||||
|
disabled={!props.editableEntries || isDisabled}
|
||||||
|
onChange={(value) => edit(id, value)}
|
||||||
|
minYear={props.minYear}
|
||||||
|
maxYear={props.maxYear}
|
||||||
|
mode={props.mode}
|
||||||
|
required={true}
|
||||||
|
index={id}
|
||||||
|
/>
|
||||||
|
</DynamicsBuildingPane>
|
||||||
|
</li>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
{
|
||||||
|
!isDisabled &&
|
||||||
|
<div className='new-record-section'>
|
||||||
|
<h6 className="h6">Add a new demolished building record</h6>
|
||||||
|
<DynamicsBuildingPane className='new-record'>
|
||||||
|
<DynamicsDataRow
|
||||||
|
value={newValue}
|
||||||
|
onChange={setNewValue}
|
||||||
|
disabled={isDisabled}
|
||||||
|
minYear={props.minYear}
|
||||||
|
maxYear={props.maxYear}
|
||||||
|
mode={props.mode}
|
||||||
|
/>
|
||||||
|
<label>Please save all your edits before navigating away from the currently selected building - these will be erased otherwise.</label>
|
||||||
|
<button type="button"
|
||||||
|
className="btn btn-primary btn-block add-record-button"
|
||||||
|
title="Add to list"
|
||||||
|
onClick={addNew}
|
||||||
|
disabled={!valid || props.hasEdits}
|
||||||
|
>
|
||||||
|
{
|
||||||
|
props.hasEdits ?
|
||||||
|
'Save or discard edits first to add a new record' :
|
||||||
|
(isEdited && !valid) ?
|
||||||
|
'Fill in all fields to save record' :
|
||||||
|
'Save new record'
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
</DynamicsBuildingPane>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
123
app/src/frontend/building/data-containers/dynamics/dynamics.tsx
Normal file
123
app/src/frontend/building/data-containers/dynamics/dynamics.tsx
Normal file
@ -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<CategoryViewProps> = (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 (<>
|
||||||
|
<DataEntryGroup collapsed={false} name="Constructions and demolitions on this site" showCount={false}>
|
||||||
|
<DynamicsBuildingPane>
|
||||||
|
<label>Current building (age data <Link to={ageLinkUrl}>editable here</Link>)</label>
|
||||||
|
<FieldRow>
|
||||||
|
<div>
|
||||||
|
<NumericDataEntry
|
||||||
|
slug=''
|
||||||
|
title={dataFields.demolished_buildings.items.year_constructed.title}
|
||||||
|
value={currentBuildingConstructionYear}
|
||||||
|
disabled={true}
|
||||||
|
mode='view'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<NumericDataEntry
|
||||||
|
slug=''
|
||||||
|
title={dataFields.demolished_buildings.items.year_demolished.title}
|
||||||
|
value={undefined}
|
||||||
|
placeholder='---'
|
||||||
|
disabled={true}
|
||||||
|
mode='view'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{flex: '0 1 27%'}}>
|
||||||
|
<DataEntry
|
||||||
|
slug=''
|
||||||
|
title='Lifespan to date'
|
||||||
|
value={ (thisYear - currentBuildingConstructionYear) + ''}
|
||||||
|
disabled={true}
|
||||||
|
mode='view'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</FieldRow>
|
||||||
|
</DynamicsBuildingPane>
|
||||||
|
{
|
||||||
|
currentBuildingConstructionYear != undefined ?
|
||||||
|
<>
|
||||||
|
<DynamicsDataEntry
|
||||||
|
|
||||||
|
/*
|
||||||
|
Will clear the edits and new record data upon navigating to another building.
|
||||||
|
Should get a better way to do this, plus a way to actually keep unsaved edits.
|
||||||
|
*/
|
||||||
|
key={building.building_id}
|
||||||
|
|
||||||
|
value={building.demolished_buildings}
|
||||||
|
editableEntries={true}
|
||||||
|
slug='demolished_buildings'
|
||||||
|
title={undefined}
|
||||||
|
mode={props.mode}
|
||||||
|
onChange={props.onChange}
|
||||||
|
onSaveAdd={props.onSaveAdd}
|
||||||
|
hasEdits={props.edited}
|
||||||
|
maxYear={currentBuildingConstructionYear}
|
||||||
|
minYear={50}
|
||||||
|
/>
|
||||||
|
{
|
||||||
|
props.mode === 'view' &&
|
||||||
|
<InfoBox>Switch to edit mode to add/edit past building records</InfoBox>
|
||||||
|
}
|
||||||
|
</> :
|
||||||
|
<InfoBox>To add historical records, fill in the <Link to={ageLinkUrl}>Age</Link> data first.</InfoBox>
|
||||||
|
}
|
||||||
|
|
||||||
|
</DataEntryGroup>
|
||||||
|
|
||||||
|
<DataEntryGroup name="Future planned data collection" collapsed={false} showCount={false}>
|
||||||
|
<DataEntry
|
||||||
|
title="Historical land use change"
|
||||||
|
slug=""
|
||||||
|
value=""
|
||||||
|
mode='view'
|
||||||
|
/>
|
||||||
|
<DataEntry
|
||||||
|
title="Longitudinal historical footprints (raster) link"
|
||||||
|
slug=""
|
||||||
|
value=""
|
||||||
|
mode='view'
|
||||||
|
/>
|
||||||
|
<DataEntry
|
||||||
|
title="Longitudinal historical footprints (vector) link"
|
||||||
|
slug=""
|
||||||
|
value=""
|
||||||
|
mode='view'
|
||||||
|
/>
|
||||||
|
</DataEntryGroup>
|
||||||
|
<InfoBox>
|
||||||
|
This section is under development in collaboration with the historic environment sector.
|
||||||
|
Please let us know your suggestions on the <a href="https://discuss.colouring.london">discussion forum</a>! (external link - save your edits first)
|
||||||
|
</InfoBox>
|
||||||
|
</>)
|
||||||
|
};
|
||||||
|
|
||||||
|
const DynamicsContainer = withCopyEdit(DynamicsView);
|
||||||
|
|
||||||
|
export default DynamicsContainer;
|
@ -0,0 +1,7 @@
|
|||||||
|
.min-number-input {
|
||||||
|
border-right: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.max-number-input {
|
||||||
|
border-left: 1px dashed #ccc;
|
||||||
|
}
|
@ -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<NumberRangeDataEntryProps> = (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 (
|
||||||
|
<div>
|
||||||
|
<DataTitleCopyable
|
||||||
|
slug={props.slug}
|
||||||
|
slugModifier={props.slugModifier}
|
||||||
|
title={props.title}
|
||||||
|
tooltip={props.tooltip}
|
||||||
|
disabled={props.disabled || props.value == undefined}
|
||||||
|
copy={props.copy}
|
||||||
|
/>
|
||||||
|
<FieldRow>
|
||||||
|
<input
|
||||||
|
className="form-control min-number-input"
|
||||||
|
type="number"
|
||||||
|
id={slugWithModifier}
|
||||||
|
name={slugWithModifier}
|
||||||
|
value={minValue ?? ''}
|
||||||
|
step={props.step ?? 1}
|
||||||
|
max={(maxEdited ? maxValue : null) ?? props.max}
|
||||||
|
min={props.min}
|
||||||
|
disabled={isDisabled}
|
||||||
|
placeholder={props.placeholderMin}
|
||||||
|
title={props.titleMin}
|
||||||
|
required={props.required}
|
||||||
|
onChange={e => {
|
||||||
|
const val = e.target.value === '' ? null : parseFloat(e.target.value);
|
||||||
|
setMinEdited(val != undefined);
|
||||||
|
props.onChange(
|
||||||
|
props.slug,
|
||||||
|
{
|
||||||
|
min: val,
|
||||||
|
max: maxEdited ? maxValue : val
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
className="form-control max-number-input"
|
||||||
|
type="number"
|
||||||
|
id={`${props.slug}-max`}
|
||||||
|
name={`${props.slug}-max`}
|
||||||
|
value={maxValue ?? ''}
|
||||||
|
step={props.step ?? 1}
|
||||||
|
max={props.max}
|
||||||
|
min={(minEdited ? minValue : null) ?? props.min}
|
||||||
|
disabled={isDisabled}
|
||||||
|
placeholder={props.placeholderMax}
|
||||||
|
title={props.titleMax}
|
||||||
|
required={props.required}
|
||||||
|
onChange={e => {
|
||||||
|
const val = e.target.value === '' ? null : parseFloat(e.target.value);
|
||||||
|
setMaxEdited(val != undefined);
|
||||||
|
props.onChange(
|
||||||
|
props.slug,
|
||||||
|
{
|
||||||
|
min: minEdited ? minValue : val,
|
||||||
|
max: val
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</FieldRow>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
@ -115,7 +115,6 @@ export const categoriesConfig: {[key in Category]: CategoryDefinition} = {
|
|||||||
intro: "What's the building's context? Coming soon…",
|
intro: "What's the building's context? Coming soon…",
|
||||||
},
|
},
|
||||||
[Category.Dynamics]: {
|
[Category.Dynamics]: {
|
||||||
inactive: true,
|
|
||||||
slug: 'dynamics',
|
slug: 'dynamics',
|
||||||
name: 'Dynamics',
|
name: 'Dynamics',
|
||||||
aboutUrl: 'https://pages.colouring.london/dynamics',
|
aboutUrl: 'https://pages.colouring.london/dynamics',
|
||||||
|
@ -185,10 +185,34 @@ export const categoryMapsConfig: {[key in Category]: CategoryMapDefinition} = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
[Category.Dynamics]: {
|
[Category.Dynamics]: {
|
||||||
mapStyle: undefined,
|
mapStyle: 'dynamics_demolished_count',
|
||||||
legend: {
|
legend: {
|
||||||
title: 'Dynamics',
|
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',
|
||||||
|
}
|
||||||
|
],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -3,7 +3,7 @@ import { Category } from './categories-config';
|
|||||||
import AgeContainer from '../building/data-containers/age';
|
import AgeContainer from '../building/data-containers/age';
|
||||||
import CommunityContainer from '../building/data-containers/community';
|
import CommunityContainer from '../building/data-containers/community';
|
||||||
import ConstructionContainer from '../building/data-containers/construction';
|
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 LocationContainer from '../building/data-containers/location';
|
||||||
import PlanningContainer from '../building/data-containers/planning';
|
import PlanningContainer from '../building/data-containers/planning';
|
||||||
import SizeContainer from '../building/data-containers/size';
|
import SizeContainer from '../building/data-containers/size';
|
||||||
|
@ -411,4 +411,38 @@ export const dataFields = { /* eslint-disable @typescript-eslint/camelcase */
|
|||||||
title: "Total number of likes",
|
title: "Total number of likes",
|
||||||
example: 100,
|
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: ["", ""]}
|
||||||
|
]
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
@ -133,6 +133,13 @@ const LAYER_QUERIES = {
|
|||||||
buildings
|
buildings
|
||||||
WHERE
|
WHERE
|
||||||
current_landuse_order IS NOT NULL`,
|
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';
|
const GEOMETRY_FIELD = 'geometry_geom';
|
||||||
|
2
migrations/019.simple-dynamics.down.sql
Normal file
2
migrations/019.simple-dynamics.down.sql
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE buildings
|
||||||
|
DROP demolished_buildings;
|
2
migrations/019.simple-dynamics.up.sql
Normal file
2
migrations/019.simple-dynamics.up.sql
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE buildings
|
||||||
|
ADD column demolished_buildings JSONB DEFAULT '[]'::JSONB;
|
Loading…
Reference in New Issue
Block a user