diff --git a/api/obs/api/routes/stats.py b/api/obs/api/routes/stats.py index 8f5603c..dfdbe7c 100644 --- a/api/obs/api/routes/stats.py +++ b/api/obs/api/routes/stats.py @@ -4,12 +4,12 @@ from typing import Optional from operator import and_ from functools import reduce -from sqlalchemy import select, func +from sqlalchemy import select, func, desc from sanic.response import json from obs.api.app import api -from obs.api.db import Track, OvertakingEvent, User +from obs.api.db import Track, OvertakingEvent, User, Region from obs.api.utils import round_to @@ -167,3 +167,36 @@ async def stats(req): # }); # }), # ); + + +@api.route("/stats/regions") +async def stats(req): + query = ( + select( + [ + Region.relation_id.label("id"), + Region.name, + func.count(OvertakingEvent.id).label("overtaking_event_count"), + ] + ) + .select_from(Region) + .join( + OvertakingEvent, + func.ST_Within( + func.ST_Transform(OvertakingEvent.geometry, 3857), Region.geometry + ), + ) + .where(Region.admin_level == 6) + .group_by( + Region.relation_id, + Region.name, + Region.relation_id, + Region.admin_level, + Region.geometry, + ) + .having(func.count(OvertakingEvent.id) > 0) + .order_by(desc("overtaking_event_count")) + ) + + regions = list(map(dict, (await req.ctx.db.execute(query)).all())) + return json(regions) diff --git a/frontend/src/components/RegionStats/index.tsx b/frontend/src/components/RegionStats/index.tsx new file mode 100644 index 0000000..5dac6b8 --- /dev/null +++ b/frontend/src/components/RegionStats/index.tsx @@ -0,0 +1,83 @@ +import React, { useState, useCallback } from "react"; +import { pickBy } from "lodash"; +import { + Loader, + Statistic, + Pagination, + Segment, + Header, + Menu, + Table, + Icon, +} from "semantic-ui-react"; +import { useObservable } from "rxjs-hooks"; +import { of, from, concat, combineLatest } from "rxjs"; +import { map, switchMap, distinctUntilChanged } from "rxjs/operators"; +import { Duration, DateTime } from "luxon"; + +import api from "api"; + +function formatDuration(seconds) { + return ( + Duration.fromMillis((seconds ?? 0) * 1000) + .as("hours") + .toFixed(1) + " h" + ); +} + +export default function Stats() { + const [page, setPage] = useState(1); + const PER_PAGE = 10; + const stats = useObservable( + () => + of(null).pipe( + switchMap(() => concat(of(null), from(api.get("/stats/regions")))) + ), + null + ); + + const pageCount = stats ? Math.ceil(stats.length / PER_PAGE) : 1; + + return ( + <> +
Top Regions
+ +
+ + + + + + Region name + Event count + + + + + {stats + ?.slice((page - 1) * PER_PAGE, page * PER_PAGE) + ?.map((area) => ( + + {area.name} + {area.overtaking_event_count} + + ))} + + + {pageCount > 1 && + + + setPage(data.activePage as number)} + /> + + + } +
+
+ + ); +} diff --git a/frontend/src/components/index.js b/frontend/src/components/index.js index e5e4c3f..9e4ab30 100644 --- a/frontend/src/components/index.js +++ b/frontend/src/components/index.js @@ -1,4 +1,5 @@ export {default as Avatar} from './Avatar' +export {default as Chart} from './Chart' export {default as ColorMapLegend, DiscreteColorMapLegend} from './ColorMapLegend' export {default as FileDrop} from './FileDrop' export {default as FileUploadField} from './FileUploadField' @@ -6,7 +7,7 @@ export {default as FormattedDate} from './FormattedDate' export {default as LoginButton} from './LoginButton' export {default as Map} from './Map' export {default as Page} from './Page' +export {default as RegionStats} from './RegionStats' export {default as Stats} from './Stats' export {default as StripMarkdown} from './StripMarkdown' export {default as Chart} from './Chart' -export {default as Visibility} from './Visibility' diff --git a/frontend/src/pages/HomePage.tsx b/frontend/src/pages/HomePage.tsx index b8aede0..d9a8d37 100644 --- a/frontend/src/pages/HomePage.tsx +++ b/frontend/src/pages/HomePage.tsx @@ -1,28 +1,28 @@ import React from 'react' -import {Grid, Loader, Header, Item} from 'semantic-ui-react' +import {Link} from 'react-router-dom' +import {Message, Grid, Loader, Header, Item} from 'semantic-ui-react' import {useObservable} from 'rxjs-hooks' import {of, from} from 'rxjs' import {map, switchMap} from 'rxjs/operators' -import {useTranslation} from 'react-i18next' import api from 'api' -import {Stats, Page} from 'components' -import type {Track} from 'types' +import {Stats, Page, Map} from 'components' -import {TrackListItem, NoPublicTracksMessage} from './TracksPage' +import {TrackListItem} from './TracksPage' +import styles from './HomePage.module.less' function MostRecentTrack() { const {t} = useTranslation() - const track: Track | null = useObservable( + const tracks: Track[] | null = useObservable( () => of(null).pipe( - switchMap(() => from(api.fetch('/tracks?limit=1'))), - map((response) => response?.tracks?.[0]) + switchMap(() => from(api.fetch("/tracks?limit=3"))), + map((response) => response?.tracks) ), null, [] - ) + ); return ( <> @@ -32,11 +32,13 @@ function MostRecentTrack() { ) : track ? ( - + {tracks.map((track) => ( + + ))} ) : null} - ) + ); } export default function HomePage() { @@ -46,12 +48,13 @@ export default function HomePage() { + - + - ) + ); }