Rework split between app/map-app/map

This commit is contained in:
Maciej Ziarkowski 2019-09-09 01:09:05 +01:00
parent 6625099c03
commit b9648c47af
3 changed files with 322 additions and 230 deletions

View File

@ -1,31 +1,30 @@
import React, { Fragment } from 'react'; import React, { Fragment } from 'react';
import { Route, Switch, Link } from 'react-router-dom'; import { Route, Switch, Link } from 'react-router-dom';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { parse } from 'query-string';
import '../../node_modules/bootstrap/dist/css/bootstrap.min.css'; import '../../node_modules/bootstrap/dist/css/bootstrap.min.css';
import './app.css'; import './app.css';
import BuildingView from './building/building-view';
import ColouringMap from './map/map';
import Header from './header'; 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 AboutPage from './pages/about';
import ContributorAgreementPage from './pages/contributor-agreement'; import ContributorAgreementPage from './pages/contributor-agreement';
import PrivacyPolicyPage from './pages/privacy-policy'; import PrivacyPolicyPage from './pages/privacy-policy';
import Welcome from './pages/welcome';
import Login from './user/login'; import Login from './user/login';
import MyAccountPage from './user/my-account'; import MyAccountPage from './user/my-account';
import SignUp from './user/signup'; import SignUp from './user/signup';
import ForgottenPassword from './user/forgotten-password'; import ForgottenPassword from './user/forgotten-password';
import PasswordReset from './user/password-reset'; 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 * 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 <Link /> in * map or other pages are rendered, based on the URL. Use a react-router-dom <Link /> in
* child components to navigate without a full page reload. * child components to navigate without a full page reload.
*/ */
class App extends React.Component<any, any> { // TODO: add proper types class App extends React.Component<AppProps, any> { // TODO: add proper types
static propTypes = { // TODO: generate propTypes from TS static propTypes = { // TODO: generate propTypes from TS
user: PropTypes.object, user: PropTypes.object,
building: PropTypes.object, building: PropTypes.object,
building_like: PropTypes.bool building_like: PropTypes.bool
} };
constructor(props) { constructor(props: Readonly<AppProps>) {
super(props); super(props);
// set building revision id, default 0
const rev = (props.building)? +props.building.revision_id : 0;
this.state = { this.state = {
user: props.user, user: props.user
building: props.building,
building_like: props.building_like,
revision_id: rev
}; };
this.login = this.login.bind(this); this.login = this.login.bind(this);
this.updateUser = this.updateUser.bind(this); this.updateUser = this.updateUser.bind(this);
this.logout = this.logout.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) { login(user) {
if (user.error) { if (user.error) {
this.logout(); this.logout();
return return;
} }
this.setState({user: user}); this.setState({user: user});
} }
@ -80,171 +72,12 @@ class App extends React.Component<any, any> { // TODO: add proper types
this.setState({user: undefined}); 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() { 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 ( return (
<Fragment> <Fragment>
<Header user={this.state.user} /> <Header user={this.state.user} />
<main> <main>
<Switch> <Switch>
<Route exact path="/">
<Welcome />
</Route>
<Route exact path="/view/categories.html">
<Sidebar>
<Categories mode="view" building_id={building_id} />
</Sidebar>
</Route>
<Route exact path="/edit/categories.html">
<Sidebar>
<Categories mode="edit" building_id={building_id} />
</Sidebar>
</Route>
<Route exact path="/multi-edit/:cat.html" render={(props) => (
<MultiEdit
{...props}
user={this.state.user}
/>
) } />
<Route exact path="/:mode/:cat/building/:building.html" render={(props) => (
<BuildingView
mode={props.match.params.mode}
cat={props.match.params.cat}
building={this.state.building}
building_like={this.state.building_like}
selectBuilding={this.selectBuilding}
user={this.state.user}
/>
) } />
</Switch>
<Switch>
<Route exact path="/(multi-edit.*|edit.*|view.*)?" render={(props) => (
<ColouringMap
{...props}
building={this.state.building}
revision_id={this.state.revision_id}
selectBuilding={this.selectBuilding}
colourBuilding={this.colourBuilding}
/>
) } />
<Route exact path="/about.html" component={AboutPage} /> <Route exact path="/about.html" component={AboutPage} />
<Route exact path="/login.html"> <Route exact path="/login.html">
<Login user={this.state.user} login={this.login} /> <Login user={this.state.user} login={this.login} />
@ -263,6 +96,14 @@ class App extends React.Component<any, any> { // TODO: add proper types
</Route> </Route>
<Route exact path="/privacy-policy.html" component={PrivacyPolicyPage} /> <Route exact path="/privacy-policy.html" component={PrivacyPolicyPage} />
<Route exact path="/contributor-agreement.html" component={ContributorAgreementPage} /> <Route exact path="/contributor-agreement.html" component={ContributorAgreementPage} />
<Route exact path="/:mode?/:category?/:building?" render={(props) => (
<MapApp
{...props}
building={this.props.building}
building_like={this.props.building_like}
user={this.state.user}
/>
)} />
<Route component={NotFound} /> <Route component={NotFound} />
</Switch> </Switch>
</main> </main>

View File

@ -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<MapAppRouteParams> {
building: any;
building_like: boolean;
user: any;
}
interface MapAppState {
category: string;
revision_id: number;
building: any;
building_like: boolean;
}
class MapApp extends React.Component<MapAppProps, MapAppState> {
static propTypes = {
category: PropTypes.string,
revision_id: PropTypes.number,
building: PropTypes.object,
building_like: PropTypes.bool,
user: PropTypes.object
};
constructor(props: Readonly<MapAppProps>) {
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<MapAppProps>) {
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 (
<Fragment>
<Switch>
<Route exact path="/">
<Welcome />
</Route>
<Route exact path="/:mode/categories/:building?">
<Sidebar>
<Categories mode={mode} building_id={building_id} />
</Sidebar>
</Route>
<Route exact path="/multi-edit/:cat" render={(props) => (
<MultiEdit
{...props}
user={this.props.user}
/>
)} />
<Route exact path="/:mode/:cat/:building?">
<Sidebar>
<BuildingView
mode={mode}
cat={category}
building={this.state.building}
building_like={this.state.building_like}
selectBuilding={this.selectBuilding}
user={this.props.user}
/>
</Sidebar>
</Route>
<Route exact path="/(view|edit|multi-edit)">
<Redirect to="/view/categories" />
</Route>
</Switch>
<ColouringMap
building={this.state.building}
mode={mode}
category={category}
revision_id={this.state.revision_id}
selectBuilding={this.selectBuilding}
colourBuilding={this.colourBuilding}
/>
</Fragment>
);
}
}
export default MapApp;

View File

@ -14,6 +14,15 @@ import ThemeSwitcher from './theme-switcher';
const OS_API_KEY = 'NVUxtY5r8eA6eIfwrPTAGKrAAsoeI9E9'; 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 { interface ColouringMapState {
theme: 'light' | 'night'; theme: 'light' | 'night';
lat: number; lat: number;
@ -23,14 +32,14 @@ interface ColouringMapState {
/** /**
* Map area * Map area
*/ */
class ColouringMap extends Component<any, ColouringMapState> { // TODO: add proper types class ColouringMap extends Component<ColouringMapProps, ColouringMapState> { // TODO: add proper types
static propTypes = { // TODO: generate propTypes from TS static propTypes = { // TODO: generate propTypes from TS
building: PropTypes.object, building: PropTypes.object,
mode: PropTypes.string,
category: PropTypes.string,
revision_id: PropTypes.number, revision_id: PropTypes.number,
selectBuilding: PropTypes.func, selectBuilding: PropTypes.func,
colourBuilding: PropTypes.func, colourBuilding: PropTypes.func
match: PropTypes.object,
history: PropTypes.object
}; };
constructor(props) { constructor(props) {
@ -44,7 +53,6 @@ class ColouringMap extends Component<any, ColouringMapState> { // TODO: add prop
this.handleClick = this.handleClick.bind(this); this.handleClick = this.handleClick.bind(this);
this.handleLocate = this.handleLocate.bind(this); this.handleLocate = this.handleLocate.bind(this);
this.themeSwitch = this.themeSwitch.bind(this); this.themeSwitch = this.themeSwitch.bind(this);
this.getMode = this.getMode.bind(this);
} }
handleLocate(lat, lng, zoom){ handleLocate(lat, lng, zoom){
@ -52,21 +60,13 @@ class ColouringMap extends Component<any, ColouringMapState> { // TODO: add prop
lat: lat, lat: lat,
lng: lng, lng: lng,
zoom: zoom 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) { handleClick(e) {
const mode = this.getMode() const mode = this.props.mode;
const lat = e.latlng.lat const lat = e.latlng.lat;
const lng = e.latlng.lng const lng = e.latlng.lng;
const newCat = parseCategoryURL(this.props.match.url);
const mapCat = newCat || 'age';
fetch( fetch(
'/api/buildings/locate?lat='+lat+'&lng='+lng '/api/buildings/locate?lat='+lat+'&lng='+lng
).then( ).then(
@ -74,17 +74,15 @@ class ColouringMap extends Component<any, ColouringMapState> { // TODO: add prop
).then(function(data){ ).then(function(data){
if (data && data.length){ if (data && data.length){
const building = data[0]; const building = data[0];
if (mode === 'multi') { if (mode === 'multi-edit') {
// colour building directly // colour building directly
this.props.colourBuilding(building); this.props.colourBuilding(building);
} else { } else {
this.props.selectBuilding(building); this.props.selectBuilding(building);
this.props.history.push(`/${mode}/${mapCat}/building/${building.building_id}.html`);
} }
} else { } else {
// deselect but keep/return to expected colour theme // deselect but keep/return to expected colour theme
this.props.selectBuilding(undefined); this.props.selectBuilding(undefined);
this.props.history.push(`/${mode}/${mapCat}.html`);
} }
}.bind(this)).catch( }.bind(this)).catch(
(err) => console.error(err) (err) => console.error(err)
@ -101,46 +99,49 @@ class ColouringMap extends Component<any, ColouringMapState> { // TODO: add prop
const position: LatLngExpression = [this.state.lat, this.state.lng]; const position: LatLngExpression = [this.state.lat, this.state.lng];
// baselayer // baselayer
const key = OS_API_KEY const key = OS_API_KEY;
const tilematrixSet = 'EPSG:3857' const tilematrixSet = 'EPSG:3857';
const layer = (this.state.theme === 'light')? 'Light 3857' : 'Night 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 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 attribution = 'Building attribute data is © Colouring London contributors. Maps contain OS data © Crown copyright: OS Maps baselayers and building outlines.';
const baseLayer = <TileLayer
url={baseUrl}
attribution={attribution}
/>;
const buildingsBaseUrl = `/tiles/base_${this.state.theme}/{z}/{x}/{y}.png`;
const buildingBaseLayer = <TileLayer url={buildingsBaseUrl} minZoom={14} />;
// colour-data tiles // colour-data tiles
const isBuilding = /building/.test(this.props.match.url); const cat = this.props.category;
const isEdit = /edit/.test(this.props.match.url);
const cat = parseCategoryURL(this.props.match.url);
const tilesetByCat = { const tilesetByCat = {
age: 'date_year', age: 'date_year',
size: 'size_storeys', size: 'size_storeys',
location: 'location', location: 'location',
like: 'likes', like: 'likes',
planning: 'conservation_area', planning: 'conservation_area',
} };
const tileset = tilesetByCat[cat]; const tileset = tilesetByCat[cat];
// pick revision id to bust browser cache // pick revision id to bust browser cache
const rev = this.props.revision_id; const rev = this.props.revision_id;
const dataLayer = tileset? const dataLayer = tileset != undefined ?
<TileLayer <TileLayer
key={tileset} key={tileset}
url={`/tiles/${tileset}/{z}/{x}/{y}.png?rev=${rev}`} url={`/tiles/${tileset}/{z}/{x}/{y}.png?rev=${rev}`}
minZoom={9} /> minZoom={9}
/>
: null; : null;
// highlight // highlight
const geometryId = (this.props.building) ? this.props.building.geometry_id : undefined; const highlightLayer = this.props.building != undefined ?
const highlight = `/tiles/highlight/{z}/{x}/{y}.png?highlight=${geometryId}`
const highlightLayer = (isBuilding && this.props.building) ?
<TileLayer <TileLayer
key={this.props.building.building_id} key={this.props.building.building_id}
url={highlight} url={`/tiles/highlight/{z}/{x}/{y}.png?highlight=${this.props.building.geometry_id}`}
minZoom={14} /> minZoom={14}
/>
: null; : null;
const baseUrl = (this.state.theme === 'light')? const isEdit = ['edit', 'multi-edit'].includes(this.props.mode);
'/tiles/base_light/{z}/{x}/{y}.png'
: '/tiles/base_night/{z}/{x}/{y}.png'
return ( return (
<div className="map-container"> <div className="map-container">
@ -154,23 +155,23 @@ class ColouringMap extends Component<any, ColouringMapState> { // TODO: add prop
attributionControl={false} attributionControl={false}
onClick={this.handleClick} onClick={this.handleClick}
> >
<TileLayer url={url} attribution={attribution} /> { baseLayer }
<TileLayer url={baseUrl} minZoom={14} /> { buildingBaseLayer }
{ dataLayer } { dataLayer }
{ highlightLayer } { highlightLayer }
<ZoomControl position="topright" /> <ZoomControl position="topright" />
<AttributionControl prefix="" /> <AttributionControl />
</Map> </Map>
{ {
!isBuilding && this.props.match.url !== '/'? ( this.props.mode !== 'basic'? (
<div className="map-notice">
<HelpIcon /> {isEdit? 'Click a building to edit' : 'Click a building for details'}
</div>
) : null
}
{
this.props.match.url !== '/'? (
<Fragment> <Fragment>
{
this.props.building == undefined ?
<div className="map-notice">
<HelpIcon /> {isEdit ? 'Click a building to edit' : 'Click a building for details'}
</div>
: null
}
<Legend slug={cat} /> <Legend slug={cat} />
<ThemeSwitcher onSubmit={this.themeSwitch} currentTheme={this.state.theme} /> <ThemeSwitcher onSubmit={this.themeSwitch} currentTheme={this.state.theme} />
<SearchBox onLocate={this.handleLocate} /> <SearchBox onLocate={this.handleLocate} />