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 { 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 <Link /> in
* 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
user: PropTypes.object,
building: PropTypes.object,
building_like: PropTypes.bool
}
};
constructor(props) {
constructor(props: Readonly<AppProps>) {
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<any, any> { // 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 (
<Fragment>
<Header user={this.state.user} />
<main>
<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="/login.html">
<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 exact path="/privacy-policy.html" component={PrivacyPolicyPage} />
<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} />
</Switch>
</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';
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<any, ColouringMapState> { // TODO: add proper types
class ColouringMap extends Component<ColouringMapProps, ColouringMapState> { // 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<any, ColouringMapState> { // 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<any, ColouringMapState> { // 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<any, ColouringMapState> { // 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<any, ColouringMapState> { // 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 = <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
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 ?
<TileLayer
key={tileset}
url={`/tiles/${tileset}/{z}/{x}/{y}.png?rev=${rev}`}
minZoom={9} />
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 ?
<TileLayer
key={this.props.building.building_id}
url={highlight}
minZoom={14} />
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 (
<div className="map-container">
@ -154,23 +155,23 @@ class ColouringMap extends Component<any, ColouringMapState> { // TODO: add prop
attributionControl={false}
onClick={this.handleClick}
>
<TileLayer url={url} attribution={attribution} />
<TileLayer url={baseUrl} minZoom={14} />
{ baseLayer }
{ buildingBaseLayer }
{ dataLayer }
{ highlightLayer }
<ZoomControl position="topright" />
<AttributionControl prefix="" />
<AttributionControl />
</Map>
{
!isBuilding && this.props.match.url !== '/'? (
<div className="map-notice">
<HelpIcon /> {isEdit? 'Click a building to edit' : 'Click a building for details'}
</div>
) : null
}
{
this.props.match.url !== '/'? (
this.props.mode !== 'basic'? (
<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} />
<ThemeSwitcher onSubmit={this.themeSwitch} currentTheme={this.state.theme} />
<SearchBox onLocate={this.handleLocate} />