Commit e570f4b3 authored by Samuel Mergenthaler's avatar Samuel Mergenthaler
Browse files

add entry for bike sharing dashboard

parent 99cc083c
Pipeline #5868 passed with stages
in 15 seconds
import StatusCodes from 'http-status-codes'
import { Request, Response, Router } from 'express'
import { ApiError, paramMissingError } from '@shared/responseTypes'
import { IBikeTripDurationCount } from '@entities/BikeTripDurationCount'
import { bikeTripsCollectionName, dbClient, dbName } from '@server'
const router = Router()
const {OK} = StatusCodes
interface IBikeTripDurationsResponse {
bikeTripDurationCounts: IBikeTripDurationCount[]
}
interface QueryParams {
classSize: string
from: string
to: string
}
/***************************************************************************************
* Get Bike Trip Duration Statistics in range - "GET /api/bike-trip-durations"
***************************************************************************************/
router.get('/', async (req: Request<any, any, any, QueryParams>, res: Response<IBikeTripDurationsResponse | ApiError>) => {
// class size in seconds
const durationClassSize = Number(req.query.classSize)
const from = Number(req.query.from)
const to = Number(req.query.to)
if (!durationClassSize || !from || !to) {
return res.status(StatusCodes.BAD_REQUEST).json({error: paramMissingError})
}
// create classes between 0 and 90 minutes
const maxInterestingDuration = 90 * 60
const numberOfDurationClasses = Math.floor(maxInterestingDuration / durationClassSize)
// efficient mongodb aggregation query, consists of multiple 'stages'
// _id will be the max duration (exclusive) of the according class after the aggregation
const durationClassCountsFromDB = await dbClient.db(dbName).collection(bikeTripsCollectionName).aggregate<{ _id: number, count: number }>([
// only use data of correct time range
{$match: {startDate: {$gte: from, $lt: to}}},
{$match: {duration: {$gte: 0}}},
// count for different trip duration intervals, how many trips fall into them
// _id will container the upper limit of the respective duration interval
{$group: {
_id: {
$switch: {
branches: range(durationClassSize, maxInterestingDuration, durationClassSize).map(maxDurationOfClass => ({
case: {$lt: ['$duration', maxDurationOfClass]},
then: maxDurationOfClass
})),
default: (numberOfDurationClasses + 1) * durationClassSize
}
},
count: {$sum: 1}
}},
{$sort: {_id: 1}},
]).toArray()
// MongoDB query result might not contain all duration classes, if there were no bike trips with that duration
const necessaryClasses: { _id: number, count: number }[] = []
// add values to all duration classes, if none set to 0
for (let i = 0; i < numberOfDurationClasses + 1; i++) {
const currentClassToCheck = (i + 1) * durationClassSize
necessaryClasses[i] = {_id: currentClassToCheck, count: 0}
for (let n = 0; n < durationClassCountsFromDB.length; n++) {
if (durationClassCountsFromDB[n]._id == currentClassToCheck) {
necessaryClasses[i].count = durationClassCountsFromDB[n].count
break
}
}
}
// prepare output or diagram, with duration class labels
const bikeTripDurationCounts = necessaryClasses.map((durationClassCount, index) => {
const fromValue = durationClassCount._id - durationClassSize
const toValue = durationClassCount._id
let classLabel = `${fromValue / 60}-${toValue / 60}`
if (index === necessaryClasses.length - 1) {
classLabel = `${fromValue / 60}+`
}
return {
classLabel: classLabel,
count: durationClassCount.count
} as IBikeTripDurationCount
})
return res.status(OK).json({bikeTripDurationCounts})
})
// Sequence generator function
const range = (start: number, stop: number, step: number) => Array.from({length: (stop - start) / step + 1}, (_, i) => start + (i * step))
/******************************************************************************
* Export
******************************************************************************/
export default router
import StatusCodes from 'http-status-codes'
import { Request, Response, Router } from 'express'
import { ApiError, notFoundError, paramMissingError } from '@shared/responseTypes'
import { IBikeTrip } from '@entities/BikeTrip'
import { bikeTripsCollectionName, dbClient, dbName } from '@server'
const router = Router()
const { NOT_FOUND, OK } = StatusCodes
interface QueryParams {
from: string
to: string
}
interface IBikeTripsInRangeResponse {
bikeTrips: IBikeTrip[]
}
interface IBikeTripResponse {
bikeTrip: IBikeTrip
}
/******************************************************************************
* Get Bike Trips in range - "GET /api/bike-trips/all-in-range"
******************************************************************************/
router.get('/all-in-range', async (req: Request, res: Response<IBikeTripsInRangeResponse | ApiError>) => {
const from = Number(req.query.from)
const to = Number(req.query.to)
if (!from || !to) {
return res.status(StatusCodes.BAD_REQUEST).json({error: paramMissingError})
}
const bikeTripsCursor = dbClient.db(dbName).collection(bikeTripsCollectionName).aggregate<IBikeTrip>([
{$match: {startDate: {$gte: from, $lt: to}}}
])
return res.status(OK).json({bikeTrips: await bikeTripsCursor.toArray()})
})
/******************************************************************************
* Get single Bike Trip by rentalId - "GET /api/bike-trips/:rentalId"
******************************************************************************/
router.get('/:rentalId', async (req: Request, res: Response<IBikeTripResponse | ApiError>) => {
const { rentalId } = req.params
if (!rentalId) {
return res.status(StatusCodes.BAD_REQUEST).json({error: paramMissingError})
}
const bikeTrip = await dbClient.db(dbName).collection(bikeTripsCollectionName).findOne({_id: rentalId})
if (!bikeTrip) {
return res.status(NOT_FOUND).json({error: notFoundError})
}
return res.status(OK).json({bikeTrip: bikeTrip})
})
/******************************************************************************
* Export
******************************************************************************/
export default router
import { Router } from 'express'
import BikeTripRouter from './BikeTrips'
import BikePointRouter from './BikePoints'
import BikeTripDurationRouter from './BikeTripDurations'
import BikePointsActivityRouter from './BikePointsActivity'
import BikePointDetailsRouter from './BikePointDetails'
// Init router and path
const router = Router()
// Add sub-routes
router.use('/bike-trips', BikeTripRouter)
router.use('/bike-points', BikePointRouter)
router.use('/bike-trip-durations', BikeTripDurationRouter)
router.use('/bike-points-activity', BikePointsActivityRouter)
router.use('/bike-point-details', BikePointDetailsRouter)
// Export the base-router
export default router
/**
* Setup the jet-logger.
*
* Documentation: https://github.com/seanpmaxwell/jet-logger
*/
import Logger from 'jet-logger'
const logger = new Logger()
export default logger
import logger from './Logger'
export const pErr = (err: Error) => {
if (err) {
logger.err(err)
}
}
export const getRandomInt = () => {
return Math.floor(Math.random() * 1_000_000_000_000)
}
export async function asyncForEach<T>(array: Array<T>, callback: (item: T, index: number) => void) {
for (let index = 0; index < array.length; index++) {
await callback(array[index], index)
}
}
export const paramMissingError = 'One or more of the required parameters was missing.'
export const notFoundError = 'No resource found for given parameters.'
export interface ApiError {
error: string
}
{
"compilerOptions": {
/* Basic Options */
"target": "es6", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */
"module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */
"outDir": "dist", /* Redirect output structure to the directory. */
/* Strict Type-Checking Options */
"strict": true, /* Enable all strict type-checking options. */
"noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */
/* Module Resolution Options */
"baseUrl": "./", /* Base directory to resolve non-absolute module names. */
"esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */
"resolveJsonModule": true, /* Allow import of json files. */
/* Advanced Options */
"skipLibCheck": true, /* Skip type checking of declaration files. */
"forceConsistentCasingInFileNames": true, /* Disallow inconsistently-cased references to the same file. */
"paths": {
"@daos/*": [
"src/daos/*"
],
"@entities/*": [
"src/entities/*"
],
"@shared/*": [
"src/shared/*"
],
"@server": [
"src/Server"
]
}
},
"include": [
"src/**/*.ts"
],
"exclude": [
"src/public/"
]
}
\ No newline at end of file
{
"extends": "./tsconfig.json",
"compilerOptions": {
"sourceMap": false
},
"exclude": [
"src/public/"
]
}
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# production
/build
# lint
.eslintcache
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Dashboard for bike sharing data in London
## Overview
Our bike sharing dashboard visualizes bike sharing data on three different pages, defined in `src/pages`:
- landing page `/`
- bike points map `/map`
- bike point details `/bike-point-details/:bikePointId`
## Start frontend locally
To run this dashboard for local development: Start the backend first!
Then, run `npm install` to download all dependencies.
Run `npm start` to start the frontend. It will be available on port 3000.
## Helpful documentation
- [leaflet-react docs](https://react-leaflet.js.org/docs/example-popup-marker)
- [recharts docs](https://recharts.org/en-US/api)
- [find leaflet map providers](https://leaflet-extras.github.io/leaflet-providers/preview/)
{
"name": "bike-sharing-dashboard",
"version": "0.1.0",
"private": true,
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"dependencies": {
"@testing-library/jest-dom": "^5.11.6",
"@testing-library/react": "^11.2.2",
"@testing-library/user-event": "^12.5.0",
"@types/chroma-js": "^2.1.3",
"@types/jest": "^26.0.16",
"@types/leaflet": "^1.5.19",
"@types/node": "^12.19.8",
"@types/react": "^16.14.2",
"@types/react-datepicker": "^3.1.2",
"@types/react-dom": "^16.9.10",
"@types/react-router-dom": "^5.1.6",
"@types/recharts": "^1.8.18",
"@types/styled-components": "^5.1.4",
"chroma-js": "^2.1.0",
"leaflet": "^1.7.1",
"react": "^17.0.1",
"react-datepicker": "^3.3.0",
"react-dom": "^17.0.1",
"react-leaflet": "^3.0.5",
"react-markdown": "^5.0.3",
"react-router": "^5.2.0",
"react-router-dom": "^5.2.0",
"react-scripts": "4.0.1",
"react-spinners": "^0.10.4",
"recharts": "^1.8.5",
"styled-components": "^5.2.1",
"typescript": "^4.1.2",
"web-vitals": "^0.2.4"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
}
}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="Visualisations for bike sharing in London"
/>
<link
rel="stylesheet"
href="https://unpkg.com/leaflet@1.7.1/dist/leaflet.css"
integrity="sha512-xodZBNTC5n17Xt2atTPuE1HxjVMSvLVW9ocqUKLsCC5CXdbqCmblAshOMAS6/keqq/sMZMZ19scR4PsZChSR7A=="
crossorigin=""
/>
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>Bike sharing</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
</html>
{
"short_name": "React App",
"name": "Create React App Sample",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:
import React from 'react'
import styled, { ThemeProvider } from 'styled-components'
import { GlobalStyle } from './style/Global'
import { BrowserRouter as Router, Route, Switch } from 'react-router-dom'
import { darkTheme } from './style/theme'
import { ErrorPage } from './pages/errorPage/ErrorPage'
import { Home } from './pages/home/Home'
import { BikeSharingMap } from "./pages/map/BikeSharingMap"
import { BikePointDetails } from './pages/bikePointDetails/BikePointDetails'
const App = () => {
return (
<ThemeProvider theme={darkTheme}>
<GlobalStyle/>
<Router>
<SiteWrapper>
<Switch>
<Route path='/' exact component={Home}/>
<Route path='/map' exact component={BikeSharingMap}/>
<Route path='/bike-point-details/:bikePointId' exact component={BikePointDetails}/>
<Route component={ErrorPage}/>
</Switch>
</SiteWrapper>
</Router>
</ThemeProvider>
)
}
const SiteWrapper = styled.div`
display: flex;
flex-flow: column nowrap;
justify-content: flex-start;
`
export default App
import styled from 'styled-components'
import { fonts } from 'style/fonts'
import { distance, maxScreenWidthMobile } from 'style/sizes'
const Button = styled.button.attrs({type: 'button'})`
font-family: ${fonts.default};
display: block;
appearance: none;
background: ${({theme}) => theme.colors.secondary};
color: ${({theme}) => theme.colors.backgroundSecondary};
border: none;
margin-bottom: ${distance.large};
text-transform: uppercase;
padding: ${distance.small};
border-radius: 4px;
-webkit-appearance: none;
opacity: ${({disabled}) => disabled ? 0.3 : 1};
@media screen and (min-width: ${maxScreenWidthMobile}px) {
&:hover {
background: ${({theme}) => theme.colors.primary};
transform: scale(1.05);
}
}
&:active {
opacity: 0.7;
}
`
export default Button
\ No newline at end of file
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment