Add regional stats to homepage
This commit is contained in:
parent
e38bc9bd76
commit
fac793e3a0
|
@ -4,12 +4,12 @@ from typing import Optional
|
||||||
from operator import and_
|
from operator import and_
|
||||||
from functools import reduce
|
from functools import reduce
|
||||||
|
|
||||||
from sqlalchemy import select, func
|
from sqlalchemy import select, func, desc
|
||||||
|
|
||||||
from sanic.response import json
|
from sanic.response import json
|
||||||
|
|
||||||
from obs.api.app import api
|
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
|
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)
|
||||||
|
|
83
frontend/src/components/RegionStats/index.tsx
Normal file
83
frontend/src/components/RegionStats/index.tsx
Normal file
|
@ -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 (
|
||||||
|
<>
|
||||||
|
<Header as="h2">Top Regions</Header>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Loader active={stats == null} />
|
||||||
|
|
||||||
|
<Table celled>
|
||||||
|
<Table.Header>
|
||||||
|
<Table.Row>
|
||||||
|
<Table.HeaderCell>Region name</Table.HeaderCell>
|
||||||
|
<Table.HeaderCell>Event count</Table.HeaderCell>
|
||||||
|
</Table.Row>
|
||||||
|
</Table.Header>
|
||||||
|
|
||||||
|
<Table.Body>
|
||||||
|
{stats
|
||||||
|
?.slice((page - 1) * PER_PAGE, page * PER_PAGE)
|
||||||
|
?.map((area) => (
|
||||||
|
<Table.Row key={area.id}>
|
||||||
|
<Table.Cell>{area.name}</Table.Cell>
|
||||||
|
<Table.Cell>{area.overtaking_event_count}</Table.Cell>
|
||||||
|
</Table.Row>
|
||||||
|
))}
|
||||||
|
</Table.Body>
|
||||||
|
|
||||||
|
{pageCount > 1 && <Table.Footer>
|
||||||
|
<Table.Row>
|
||||||
|
<Table.HeaderCell colSpan="2">
|
||||||
|
<Pagination
|
||||||
|
floated="right"
|
||||||
|
activePage={page}
|
||||||
|
totalPages={pageCount}
|
||||||
|
onPageChange={(e, data) => setPage(data.activePage as number)}
|
||||||
|
/>
|
||||||
|
</Table.HeaderCell>
|
||||||
|
</Table.Row>
|
||||||
|
</Table.Footer>}
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
|
@ -1,4 +1,5 @@
|
||||||
export {default as Avatar} from './Avatar'
|
export {default as Avatar} from './Avatar'
|
||||||
|
export {default as Chart} from './Chart'
|
||||||
export {default as ColorMapLegend, DiscreteColorMapLegend} from './ColorMapLegend'
|
export {default as ColorMapLegend, DiscreteColorMapLegend} from './ColorMapLegend'
|
||||||
export {default as FileDrop} from './FileDrop'
|
export {default as FileDrop} from './FileDrop'
|
||||||
export {default as FileUploadField} from './FileUploadField'
|
export {default as FileUploadField} from './FileUploadField'
|
||||||
|
@ -6,6 +7,6 @@ export {default as FormattedDate} from './FormattedDate'
|
||||||
export {default as LoginButton} from './LoginButton'
|
export {default as LoginButton} from './LoginButton'
|
||||||
export {default as Map} from './Map'
|
export {default as Map} from './Map'
|
||||||
export {default as Page} from './Page'
|
export {default as Page} from './Page'
|
||||||
|
export {default as RegionStats} from './RegionStats'
|
||||||
export {default as Stats} from './Stats'
|
export {default as Stats} from './Stats'
|
||||||
export {default as StripMarkdown} from './StripMarkdown'
|
export {default as StripMarkdown} from './StripMarkdown'
|
||||||
export {default as Chart} from './Chart'
|
|
||||||
|
|
|
@ -1,42 +1,44 @@
|
||||||
import React from 'react'
|
import React from "react";
|
||||||
import {Link} from 'react-router-dom'
|
import { Link } from "react-router-dom";
|
||||||
import {Message, Grid, Loader, Header, Item} from 'semantic-ui-react'
|
import { Message, Grid, Loader, Header, Item } from "semantic-ui-react";
|
||||||
import {useObservable} from 'rxjs-hooks'
|
import { useObservable } from "rxjs-hooks";
|
||||||
import {of, from} from 'rxjs'
|
import { of, from } from "rxjs";
|
||||||
import {map, switchMap} from 'rxjs/operators'
|
import { map, switchMap } from "rxjs/operators";
|
||||||
|
|
||||||
import api from 'api'
|
import api from "api";
|
||||||
import {Stats, Page, Map} from 'components'
|
import { RegionStats, Stats, Page, Map } from "components";
|
||||||
|
|
||||||
import {TrackListItem} from './TracksPage'
|
import { TrackListItem } from "./TracksPage";
|
||||||
import styles from './HomePage.module.less'
|
import styles from "./HomePage.module.less";
|
||||||
|
|
||||||
function MostRecentTrack() {
|
function MostRecentTrack() {
|
||||||
const track: Track | null = useObservable(
|
const tracks: Track[] | null = useObservable(
|
||||||
() =>
|
() =>
|
||||||
of(null).pipe(
|
of(null).pipe(
|
||||||
switchMap(() => from(api.fetch('/tracks?limit=1'))),
|
switchMap(() => from(api.fetch("/tracks?limit=3"))),
|
||||||
map((response) => response?.tracks?.[0])
|
map((response) => response?.tracks)
|
||||||
),
|
),
|
||||||
null,
|
null,
|
||||||
[]
|
[]
|
||||||
)
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Header as="h2">Most recent track</Header>
|
<Header as="h2">Most recent tracks</Header>
|
||||||
<Loader active={track === null} />
|
<Loader active={tracks === null} />
|
||||||
{track === undefined ? (
|
{tracks?.length === 0 ? (
|
||||||
<Message>
|
<Message>
|
||||||
No public tracks yet. <Link to="/upload">Upload the first!</Link>
|
No public tracks yet. <Link to="/upload">Upload the first!</Link>
|
||||||
</Message>
|
</Message>
|
||||||
) : track ? (
|
) : tracks ? (
|
||||||
<Item.Group>
|
<Item.Group>
|
||||||
<TrackListItem track={track} />
|
{tracks.map((track) => (
|
||||||
|
<TrackListItem key={track.id} track={track} />
|
||||||
|
))}
|
||||||
</Item.Group>
|
</Item.Group>
|
||||||
) : null}
|
) : null}
|
||||||
</>
|
</>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
|
@ -46,12 +48,13 @@ export default function HomePage() {
|
||||||
<Grid.Row>
|
<Grid.Row>
|
||||||
<Grid.Column width={8}>
|
<Grid.Column width={8}>
|
||||||
<Stats />
|
<Stats />
|
||||||
|
<MostRecentTrack />
|
||||||
</Grid.Column>
|
</Grid.Column>
|
||||||
<Grid.Column width={8}>
|
<Grid.Column width={8}>
|
||||||
<MostRecentTrack />
|
<RegionStats />
|
||||||
</Grid.Column>
|
</Grid.Column>
|
||||||
</Grid.Row>
|
</Grid.Row>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Page>
|
</Page>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue