diff --git a/app/src/frontend/app.tsx b/app/src/frontend/app.tsx index c2df0a9f..ad5257ae 100644 --- a/app/src/frontend/app.tsx +++ b/app/src/frontend/app.tsx @@ -1,31 +1,30 @@ import React, { Fragment } from 'react'; import { Route, Switch, Link } from 'react-router-dom'; import PropTypes from 'prop-types'; -import { parse } from 'query-string'; import '../../node_modules/bootstrap/dist/css/bootstrap.min.css'; import './app.css'; -import BuildingView from './building/building-view'; -import ColouringMap from './map/map'; import Header from './header'; -import MultiEdit from './building/multi-edit'; -import Categories from './building/categories'; -import Sidebar from './building/sidebar'; import AboutPage from './pages/about'; import ContributorAgreementPage from './pages/contributor-agreement'; import PrivacyPolicyPage from './pages/privacy-policy'; -import Welcome from './pages/welcome'; import Login from './user/login'; import MyAccountPage from './user/my-account'; import SignUp from './user/signup'; - import ForgottenPassword from './user/forgotten-password'; import PasswordReset from './user/password-reset'; -import { parseCategoryURL } from '../parse'; +import MapApp from './map-app'; + + +interface AppProps { + user?: any; + building?: any; + building_like?: boolean; +} /** * App component @@ -39,35 +38,28 @@ import { parseCategoryURL } from '../parse'; * map or other pages are rendered, based on the URL. Use a react-router-dom in * child components to navigate without a full page reload. */ -class App extends React.Component { // TODO: add proper types +class App extends React.Component { // TODO: add proper types static propTypes = { // TODO: generate propTypes from TS user: PropTypes.object, building: PropTypes.object, building_like: PropTypes.bool - } + }; - constructor(props) { + constructor(props: Readonly) { super(props); - // set building revision id, default 0 - const rev = (props.building)? +props.building.revision_id : 0; + this.state = { - user: props.user, - building: props.building, - building_like: props.building_like, - revision_id: rev + user: props.user }; this.login = this.login.bind(this); this.updateUser = this.updateUser.bind(this); this.logout = this.logout.bind(this); - this.selectBuilding = this.selectBuilding.bind(this); - this.colourBuilding = this.colourBuilding.bind(this); - this.increaseRevision = this.increaseRevision.bind(this); } login(user) { if (user.error) { this.logout(); - return + return; } this.setState({user: user}); } @@ -80,171 +72,12 @@ class App extends React.Component { // TODO: add proper types this.setState({user: undefined}); } - increaseRevision(revisionId) { - revisionId = +revisionId; - // bump revision id, only ever increasing - if (revisionId > this.state.revision_id){ - this.setState({revision_id: revisionId}) - } - } - - selectBuilding(building) { - this.increaseRevision(building.revision_id); - // get UPRNs and update - fetch(`/api/buildings/${building.building_id}/uprns.json`, { - method: 'GET', - headers:{ - 'Content-Type': 'application/json' - }, - credentials: 'same-origin' - }).then( - res => res.json() - ).then((res) => { - if (res.error) { - console.error(res); - } else { - building.uprns = res.uprns; - this.setState({building: building}); - } - }).catch((err) => { - console.error(err) - this.setState({building: building}); - }); - - // get if liked and update - fetch(`/api/buildings/${building.building_id}/like.json`, { - method: 'GET', - headers:{ - 'Content-Type': 'application/json' - }, - credentials: 'same-origin' - }).then( - res => res.json() - ).then((res) => { - if (res.error) { - console.error(res); - } else { - this.setState({building_like: res.like}); - } - }).catch((err) => { - console.error(err) - this.setState({building_like: false}); - }); - } - - /** - * Colour building - * - * Used in multi-edit mode to colour buildings on map click - * - * Pulls data from URL to form update - * - * @param {object} building - */ - colourBuilding(building) { - const cat = parseCategoryURL(window.location.pathname); - const q = parse(window.location.search); - const data = (cat === 'like')? {like: true}: JSON.parse(q.data as string); // TODO: verify what happens if data is string[] - if (cat === 'like'){ - this.likeBuilding(building.building_id) - } else { - this.updateBuilding(building.building_id, data) - } - } - - likeBuilding(buildingId) { - fetch(`/api/buildings/${buildingId}/like.json`, { - method: 'POST', - headers:{ - 'Content-Type': 'application/json' - }, - credentials: 'same-origin', - body: JSON.stringify({like: true}) - }).then( - res => res.json() - ).then(function(res){ - if (res.error) { - console.error({error: res.error}) - } else { - this.increaseRevision(res.revision_id); - } - }.bind(this)).catch( - (err) => console.error({error: err}) - ); - } - - updateBuilding(buildingId, data){ - fetch(`/api/buildings/${buildingId}.json`, { - method: 'POST', - body: JSON.stringify(data), - headers:{ - 'Content-Type': 'application/json' - }, - credentials: 'same-origin' - }).then( - res => res.json() - ).then(res => { - if (res.error) { - console.error({error: res.error}) - } else { - this.increaseRevision(res.revision_id); - } - }).catch( - (err) => console.error({error: err}) - ); - } - render() { - const building_id = (this.state.building)? - this.state.building.building_id - : 2503371 // Default to UCL main building. TODO use last selected if any - const building = this.state.building; - const building_like = this.state.building_like; return (
- - - - - - - - - - - - - - ( - - ) } /> - ( - - ) } /> - - - ( - - ) } /> @@ -263,6 +96,14 @@ class App extends React.Component { // TODO: add proper types + ( + + )} />
diff --git a/app/src/frontend/map-app.tsx b/app/src/frontend/map-app.tsx new file mode 100644 index 00000000..89306535 --- /dev/null +++ b/app/src/frontend/map-app.tsx @@ -0,0 +1,250 @@ +import React, { Fragment } from 'react'; +import { Switch, Route, RouteComponentProps, Redirect } from 'react-router-dom'; +import PropTypes from 'prop-types'; + +import Welcome from './pages/welcome'; +import Sidebar from './building/sidebar'; +import Categories from './building/categories'; +import MultiEdit from './building/multi-edit'; +import BuildingView from './building/building-view'; +import ColouringMap from './map/map'; +import { parse } from 'query-string'; + +interface MapAppRouteParams { + mode: 'view' | 'edit' | 'multi-edit'; + category: string; + building?: string; +} + +interface MapAppProps extends RouteComponentProps { + building: any; + building_like: boolean; + user: any; +} + +interface MapAppState { + category: string; + revision_id: number; + building: any; + building_like: boolean; +} + +class MapApp extends React.Component { + static propTypes = { + category: PropTypes.string, + revision_id: PropTypes.number, + building: PropTypes.object, + building_like: PropTypes.bool, + user: PropTypes.object + }; + constructor(props: Readonly) { + super(props); + + // set building revision id, default 0 + const rev = props.building != undefined ? +props.building.revision_id : 0; + + this.state = { + category: this.getCategory(props.match.params.category), + revision_id: rev, + building: props.building, + building_like: props.building_like + }; + + this.selectBuilding = this.selectBuilding.bind(this); + this.colourBuilding = this.colourBuilding.bind(this); + this.increaseRevision = this.increaseRevision.bind(this); + } + + componentWillReceiveProps(props: Readonly) { + const newCategory = this.getCategory(props.match.params.category); + if (newCategory != undefined) { + this.setState({ category: newCategory }); + } + } + + getCategory(category: string) { + if (category === 'categories') return undefined; + + return category; + } + + increaseRevision(revisionId) { + revisionId = +revisionId; + // bump revision id, only ever increasing + if (revisionId > this.state.revision_id) { + this.setState({ revision_id: revisionId }) + } + } + + selectBuilding(building) { + const mode = this.props.match.params.mode || 'view'; + const category = this.props.match.params.category || 'age'; + + if (building == undefined || + (this.state.building != undefined && + building.building_id === this.state.building.building_id)) { + this.setState({ building: undefined }); + this.props.history.push(`/${mode}/${category}`); + return; + } + + this.increaseRevision(building.revision_id); + // get UPRNs and update + fetch(`/api/buildings/${building.building_id}/uprns.json`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json' + }, + credentials: 'same-origin' + }).then( + res => res.json() + ).then((res) => { + if (res.error) { + console.error(res); + } else { + building.uprns = res.uprns; + this.setState({ building: building }); + } + }).catch((err) => { + console.error(err) + this.setState({ building: building }); + }); + + // get if liked and update + fetch(`/api/buildings/${building.building_id}/like.json`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json' + }, + credentials: 'same-origin' + }).then( + res => res.json() + ).then((res) => { + if (res.error) { + console.error(res); + } else { + this.setState({ building_like: res.like }); + this.props.history.push(`/${mode}/${category}/${building.building_id}`); + } + }).catch((err) => { + console.error(err) + this.setState({ building_like: false }); + }); + } + + /** + * Colour building + * + * Used in multi-edit mode to colour buildings on map click + * + * Pulls data from URL to form update + * + * @param {object} building + */ + colourBuilding(building) { + const cat = this.props.match.params.category; + const q = parse(window.location.search); + const data = (cat === 'like') ? { like: true } : JSON.parse(q.data as string); // TODO: verify what happens if data is string[] + if (cat === 'like') { + this.likeBuilding(building.building_id) + } else { + this.updateBuilding(building.building_id, data) + } + } + + likeBuilding(buildingId) { + fetch(`/api/buildings/${buildingId}/like.json`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + credentials: 'same-origin', + body: JSON.stringify({ like: true }) + }).then( + res => res.json() + ).then(function (res) { + if (res.error) { + console.error({ error: res.error }) + } else { + this.increaseRevision(res.revision_id); + } + }.bind(this)).catch( + (err) => console.error({ error: err }) + ); + } + + updateBuilding(buildingId, data) { + fetch(`/api/buildings/${buildingId}.json`, { + method: 'POST', + body: JSON.stringify(data), + headers: { + 'Content-Type': 'application/json' + }, + credentials: 'same-origin' + }).then( + res => res.json() + ).then(res => { + if (res.error) { + console.error({ error: res.error }) + } else { + this.increaseRevision(res.revision_id); + } + }).catch( + (err) => console.error({ error: err }) + ); + } + + render() { + const mode = this.props.match.params.mode || 'basic'; + + let category = this.state.category || 'age'; + + const building_id = this.state.building && this.state.building.building_id; + + return ( + + + + + + + + + + + ( + + )} /> + + + + + + + + + + + + ); + } +} + +export default MapApp; \ No newline at end of file diff --git a/app/src/frontend/map/map.tsx b/app/src/frontend/map/map.tsx index 5b30d475..d606e622 100644 --- a/app/src/frontend/map/map.tsx +++ b/app/src/frontend/map/map.tsx @@ -14,6 +14,15 @@ import ThemeSwitcher from './theme-switcher'; const OS_API_KEY = 'NVUxtY5r8eA6eIfwrPTAGKrAAsoeI9E9'; +interface ColouringMapProps { + building: any; + mode: 'basic' | 'view' | 'edit' | 'multi-edit'; + category: string; + revision_id: number; + selectBuilding: any; + colourBuilding: any; +} + interface ColouringMapState { theme: 'light' | 'night'; lat: number; @@ -23,14 +32,14 @@ interface ColouringMapState { /** * Map area */ -class ColouringMap extends Component { // TODO: add proper types +class ColouringMap extends Component { // TODO: add proper types static propTypes = { // TODO: generate propTypes from TS building: PropTypes.object, + mode: PropTypes.string, + category: PropTypes.string, revision_id: PropTypes.number, selectBuilding: PropTypes.func, - colourBuilding: PropTypes.func, - match: PropTypes.object, - history: PropTypes.object + colourBuilding: PropTypes.func }; constructor(props) { @@ -44,7 +53,6 @@ class ColouringMap extends Component { // TODO: add prop this.handleClick = this.handleClick.bind(this); this.handleLocate = this.handleLocate.bind(this); this.themeSwitch = this.themeSwitch.bind(this); - this.getMode = this.getMode.bind(this); } handleLocate(lat, lng, zoom){ @@ -52,21 +60,13 @@ class ColouringMap extends Component { // TODO: add prop lat: lat, lng: lng, zoom: zoom - }) - } - - getMode() { - const isEdit = this.props.match.url.match('edit') - const isMulti = this.props.match.url.match('multi') - return isEdit? (isMulti? 'multi' : 'edit') : 'view'; + }); } handleClick(e) { - const mode = this.getMode() - const lat = e.latlng.lat - const lng = e.latlng.lng - const newCat = parseCategoryURL(this.props.match.url); - const mapCat = newCat || 'age'; + const mode = this.props.mode; + const lat = e.latlng.lat; + const lng = e.latlng.lng; fetch( '/api/buildings/locate?lat='+lat+'&lng='+lng ).then( @@ -74,17 +74,15 @@ class ColouringMap extends Component { // TODO: add prop ).then(function(data){ if (data && data.length){ const building = data[0]; - if (mode === 'multi') { + if (mode === 'multi-edit') { // colour building directly this.props.colourBuilding(building); } else { this.props.selectBuilding(building); - this.props.history.push(`/${mode}/${mapCat}/building/${building.building_id}.html`); } } else { // deselect but keep/return to expected colour theme this.props.selectBuilding(undefined); - this.props.history.push(`/${mode}/${mapCat}.html`); } }.bind(this)).catch( (err) => console.error(err) @@ -101,46 +99,49 @@ class ColouringMap extends Component { // TODO: add prop const position: LatLngExpression = [this.state.lat, this.state.lng]; // baselayer - const key = OS_API_KEY - const tilematrixSet = 'EPSG:3857' + const key = OS_API_KEY; + const tilematrixSet = 'EPSG:3857'; const layer = (this.state.theme === 'light')? 'Light 3857' : 'Night 3857'; - const url = `https://api2.ordnancesurvey.co.uk/mapping_api/v1/service/zxy/${tilematrixSet}/${layer}/{z}/{x}/{y}.png?key=${key}` - const attribution = 'Building attribute data is © Colouring London contributors. Maps contain OS data © Crown copyright: OS Maps baselayers and building outlines.' + const baseUrl = `https://api2.ordnancesurvey.co.uk/mapping_api/v1/service/zxy/${tilematrixSet}/${layer}/{z}/{x}/{y}.png?key=${key}`; + const attribution = 'Building attribute data is © Colouring London contributors. Maps contain OS data © Crown copyright: OS Maps baselayers and building outlines.'; + const baseLayer = ; + + const buildingsBaseUrl = `/tiles/base_${this.state.theme}/{z}/{x}/{y}.png`; + const buildingBaseLayer = ; // colour-data tiles - const isBuilding = /building/.test(this.props.match.url); - const isEdit = /edit/.test(this.props.match.url); - const cat = parseCategoryURL(this.props.match.url); + const cat = this.props.category; const tilesetByCat = { age: 'date_year', size: 'size_storeys', location: 'location', like: 'likes', planning: 'conservation_area', - } + }; const tileset = tilesetByCat[cat]; // pick revision id to bust browser cache const rev = this.props.revision_id; - const dataLayer = tileset? + const dataLayer = tileset != undefined ? + minZoom={9} + /> : null; // highlight - const geometryId = (this.props.building) ? this.props.building.geometry_id : undefined; - const highlight = `/tiles/highlight/{z}/{x}/{y}.png?highlight=${geometryId}` - const highlightLayer = (isBuilding && this.props.building) ? + const highlightLayer = this.props.building != undefined ? + url={`/tiles/highlight/{z}/{x}/{y}.png?highlight=${this.props.building.geometry_id}`} + minZoom={14} + /> : null; - const baseUrl = (this.state.theme === 'light')? - '/tiles/base_light/{z}/{x}/{y}.png' - : '/tiles/base_night/{z}/{x}/{y}.png' + const isEdit = ['edit', 'multi-edit'].includes(this.props.mode); return (
@@ -154,23 +155,23 @@ class ColouringMap extends Component { // TODO: add prop attributionControl={false} onClick={this.handleClick} > - - + { baseLayer } + { buildingBaseLayer } { dataLayer } { highlightLayer } - + { - !isBuilding && this.props.match.url !== '/'? ( -
- {isEdit? 'Click a building to edit' : 'Click a building for details'} -
- ) : null - } - { - this.props.match.url !== '/'? ( + this.props.mode !== 'basic'? ( + { + this.props.building == undefined ? +
+ {isEdit ? 'Click a building to edit' : 'Click a building for details'} +
+ : null + }