Skip to content

Commit

Permalink
+ Add a new view for live quality monitoring
Browse files Browse the repository at this point in the history
  • Loading branch information
emi420 committed Dec 6, 2023
1 parent a1ccfab commit 8c1de4b
Show file tree
Hide file tree
Showing 8 changed files with 598 additions and 16 deletions.
3 changes: 2 additions & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,16 @@
"@formatjs/macro": "^0.2.8",
"@hotosm/id": "^2.21.1",
"@hotosm/iso-countries-languages": "^1.1.2",
"@hotosm/underpass-ui": "https://github.com/hotosm/underpass-ui.git",
"@mapbox/mapbox-gl-draw": "^1.4.1",
"@mapbox/mapbox-gl-geocoder": "^5.0.1",
"@mapbox/mapbox-gl-language": "^0.10.1",
"@placemarkio/geo-viewport": "^1.0.1",
"@rapideditor/rapid": "^2.1.1",
"@sentry/react": "^7.60.1",
"@tmcw/togeojson": "^4.7.0",
"@tanstack/react-query": "^4.29.7",
"@tanstack/react-query-devtools": "^4.29.7",
"@tmcw/togeojson": "^4.7.0",
"@turf/area": "^6.5.0",
"@turf/bbox": "^6.5.0",
"@turf/bbox-polygon": "^6.5.0",
Expand Down
5 changes: 5 additions & 0 deletions frontend/src/components/projectDetail/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { PermissionBox } from './permissionBox';
import { CustomButton } from '../button';
import { ProjectInfoPanel } from './infoPanel';
import { OSMChaButton } from './osmchaButton';
import { LiveViewButton } from './liveViewButton';
import { useSetProjectPageTitleTag } from '../../hooks/UseMetaTags';
import { useProjectContributionsQuery, useProjectTimelineQuery } from '../../api/projects';
import { Alert } from '../alert';
Expand Down Expand Up @@ -317,6 +318,10 @@ export const ProjectDetail = (props) => {
project={props.project}
className="bg-white blue-dark ba b--grey-light pa3"
/>
<LiveViewButton
projectId={props.project.projectId}
className="bg-white blue-dark ba b--grey-light pa3"
/>
<DownloadAOIButton
projectId={props.project.projectId}
className="bg-white blue-dark ba b--grey-light pa3"
Expand Down
20 changes: 20 additions & 0 deletions frontend/src/components/projectDetail/liveViewButton.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { FormattedMessage } from 'react-intl';

import messages from './messages';
import { CustomButton } from '../button';

export const LiveViewButton = ({ projectId, className, compact = false }: Object) => (
<Link to={`/projects/${projectId}/live`} className="pr2">
{
<CustomButton className={className}>
{compact ? (
<FormattedMessage {...messages.live} />
) : (
<FormattedMessage {...messages.liveMonitoring} />
)}
</CustomButton>
}
</Link>
);
8 changes: 8 additions & 0 deletions frontend/src/components/projectDetail/messages.js
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,14 @@ export default defineMessages({
id: 'project.detail.sections.contributions.osmcha',
defaultMessage: 'Changesets in OSMCha',
},
live: {
id: 'project.detail.sections.contributions.live',
defaultMessage: 'Live',
},
liveMonitoring: {
id: 'project.detail.sections.contributions.liveMonitoring',
defaultMessage: 'Live monitoring',
},
changesets: {
id: 'project.detail.sections.contributions.changesets',
defaultMessage: 'Changesets',
Expand Down
10 changes: 10 additions & 0 deletions frontend/src/routes.js
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,16 @@ export const router = createBrowserRouter(
}}
ErrorBoundary={FallbackComponent}
/>
<Route
path="projects/:id/live"
lazy={async () => {
const { ProjectLiveMonitoring } = await import(
'./views/projectLiveMonitoring' /* webpackChunkName: "projectLiveMonitoring" */
);
return { Component: ProjectLiveMonitoring };
}}
ErrorBoundary={FallbackComponent}
/>
<Route
path="organisations/:id/stats/"
lazy={async () => {
Expand Down
13 changes: 13 additions & 0 deletions frontend/src/views/projectLiveMonitoring.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
@import "@hotosm/underpass-ui/dist/index.css";

.maplibregl-map {
height: 100vh;
}

.top {
position: absolute;
top: 390px;
left: 20px;
z-index: 999;
}

234 changes: 234 additions & 0 deletions frontend/src/views/projectLiveMonitoring.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,234 @@
import React, { useState, useRef, useEffect } from "react";
import { UnderpassFeatureList, UnderpassMap, HOTTheme, UnderpassFeatureStats, UnderpassValidationStats } from "@hotosm/underpass-ui";
import { ProjectHeader } from '../components/projectDetail/header';
import { useSetTitleTag } from '../hooks/UseMetaTags';
import { useParams } from 'react-router-dom';
import { useFetch } from '../hooks/UseFetch';
import ReactPlaceholder from 'react-placeholder';
import centroid from '@turf/centroid';
import "./projectLiveMonitoring.css";

const config = {
API_URL: "https://underpass.hotosm.org:8000",
MAPBOX_TOKEN: "pk.eyJ1IjoiZW1pNDIwIiwiYSI6ImNqZW9leG5pZTAxYWwyeG83bHU0eHM0ZXcifQ.YWmk4Rp8FBGCLmpx_huJYw"
};

const statusList = {
ALL: "",
UNSQUARED: "badgeom",
OVERLAPPING: "overlapping",
BADVALUE: "badvalue",
}

const mappingTypesTags = {
ROADS: "highway",
BUILDINGS: "building",
WATERWAYS: "waterway"
}

export function ProjectLiveMonitoring() {
const { id } = useParams();
const [coords, setCoords] = useState([0,0]);
const [activeFeature, setActiveFeature] = useState(null);
const [tags, setTags] = useState("building");
const [hashtag, setHashtag] = useState("hotosm-project-" + id);
const [mapSource, setMapSource] = useState("osm");
const [realtimeList, setRealtimeList] = useState(false);
const [realtimeMap, setRealtimeMap] = useState(false);
const [status, setStatus] = useState(statusList.UNSQUARED);
const [area, setArea] = useState(null);
const tagsInputRef = useRef("");
const hashtagInputRef = useRef("");
const styleSelectRef = useRef();

useSetTitleTag(`Project #${id} Live Monitoring`);
const [error, loading, data] = useFetch(`projects/${id}/`, id);

const [areaOfInterest, setAreaOfInterest] = useState(null);
const [project, setProject] = useState(null);

useEffect(() => {
setProject(data);
}, [data])

useEffect(() => {
if (project && project.areaOfInterest) {
setCoords(centroid(project.areaOfInterest).geometry.coordinates.reverse());
setAreaOfInterest([
].join(",")
);
setTags(mappingTypesTags[project.mappingTypes]);
}
}, [project]);

const hottheme = HOTTheme();

const defaultMapStyle = {
waysLine: {
...hottheme.map.waysLine,
"line-opacity": .8,
},
waysFill: {
...hottheme.map.waysFill,
"fill-opacity":
[
"match",
["get", "type"],
"LineString", 0, .3
]
},
nodesSymbol: {
...hottheme.map.nodesSymbol,
"icon-opacity": [
"match",
["get", "type"],
"Point", .8, 0
],
},
};

const [demoTheme, setDemoTheme] = useState({
map: defaultMapStyle
});

const handleFilterClick = (e) => {
e.preventDefault();
setTags(tagsInputRef.current.value);
setHashtag(hashtagInputRef.current.value);
return false;
}

const handleMapSourceSelect = (e) => {
setMapSource(e.target.options[e.target.selectedIndex].value);
}

const handleMapMove = ({ bbox }) => {
setArea(bbox);
}
const handleMapLoad = ({ bbox }) => {
setArea(bbox);
}

return (
<ReactPlaceholder
showLoadingAnimation={true}
rows={26}
ready={!error && !loading}
className="pr3"
>
<div>
<div className="w-100 fl pv3 ph2 ph4-ns bg-white blue-dark">
<ProjectHeader project={project} showEditLink={true} />
</div>
<div className="flex p-2">
<div style={{flex: 2}}>
<div className="top">
<form>
<input
className="border px-2 py-2 text-sm"
type="text"
placeholder="key (ex: building=yes)"
ref={tagsInputRef}
defaultValue="building"
/>
&nbsp;
<input
className="border px-2 py-2 text-sm"
type="text"
placeholder="hashtag (ex: hotosm-project)"
ref={hashtagInputRef}
defaultValue={"hotosm-project-" + id}
/>
&nbsp;
<button className="inline-flex items-center rounded bg-primary px-2 py-2 text-sm font-medium text-white" onClick={handleFilterClick}>Search</button>
</form>
<select onChange={handleMapSourceSelect} ref={styleSelectRef} className="border mt-2 bg-white px-2 py-2 text-sm">
<option value="osm">OSM</option>
<option value="bing">Bing</option>
<option value="esri">ESRI</option>
<option value="mapbox">Mapbox</option>
<option value="oam">OAM</option>
</select>
</div>
<UnderpassMap
center={coords}
tags={tags}
hashtag={hashtag}
highlightDataQualityIssues
popupFeature={activeFeature}
source={mapSource}
config={config}
realtime={realtimeMap}
theme={demoTheme}
zoom={17}
onMove={handleMapMove}
onLoad={handleMapLoad}
/>
</div>
<div style={{
flex: 1,
padding: 10,
backgroundColor: `rgb(${hottheme.colors.white})`}}>
<div className="border-b-2 pb-5 space-y-3">
<UnderpassFeatureStats
tags={tags}
hashtag={hashtag}
apiUrl={config.API_URL}
area={areaOfInterest}
/>
<UnderpassValidationStats
tags={tags}
hashtag={hashtag}
apiUrl={config.API_URL}
status="badgeom"
area={areaOfInterest}
/>
</div>
<div className="border-b-2 py-5 mb-5">
<form className="space-x-2 mb-3">
<input onChange={() => { setRealtimeList(!realtimeList)}} name="liveListCheckbox" type="checkbox" />
<label target="liveListCheckbox">Live list</label>
<input onChange={() => { setRealtimeMap(!realtimeMap)}} name="liveMapCheckbox" type="checkbox" />
<label target="liveMapCheckbox">Live map</label>
</form>
<form className="space-x-2">
<input checked={status === statusList.ALL} onChange={() => { setStatus(statusList.ALL) }} name="allCheckbox" id="allCheckbox" type="radio" />
<label htmlFor="allCheckbox">All</label>
<input checked={status === statusList.UNSQUARED} onChange={() => { setStatus(statusList.UNSQUARED) }} name="geospatialCheckbox" id="geospatialCheckbox" type="radio" />
<label htmlFor="geospatialCheckbox">Geospatial</label>
<input checked={status === statusList.BADVALUE} onChange={() => { setStatus(statusList.BADVALUE) }} name="semanticCheckbox" id="semanticCheckbox" type="radio" />
<label htmlFor="semanticCheckbox">Semantic</label>
</form>
</div>
<div style={{ height: "512px", overflow: "hidden" }}>
<UnderpassFeatureList
tags={tags}
hashtag={hashtag}
page={0}
onSelect={(feature) => {
setCoords([feature.lat, feature.lon]);
const tags = JSON.stringify(feature.tags);
const status = feature.status;
setActiveFeature({properties: { tags, status } , ...feature});
}}
realtime={realtimeList}
config={config}
status={status}
orderBy="created_at"
onFetchFirstTime={(mostRecentFeature) => {
console.log(mostRecentFeature);
if (mostRecentFeature) {
setCoords([mostRecentFeature.lat, mostRecentFeature.lon]);
}
}}
/>
</div>
</div>
</div>
</div>
</ReactPlaceholder>
);
}

export default ProjectLiveMonitoring;

Loading

0 comments on commit 8c1de4b

Please sign in to comment.