diff --git a/dashboard/.indium b/dashboard/.indium
new file mode 100644
index 00000000..e69de29b
diff --git a/dashboard/LICENSE b/dashboard/LICENSE
new file mode 100644
index 00000000..5f031604
--- /dev/null
+++ b/dashboard/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2017-2018, City of Helsinki
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/dashboard/package.json b/dashboard/package.json
index bad72a06..35874e76 100644
--- a/dashboard/package.json
+++ b/dashboard/package.json
@@ -2,10 +2,32 @@
"name": "parkkidash",
"version": "0.1.0",
"private": true,
+ "license": "MIT",
"dependencies": {
+ "axios": "^0.17.1",
+ "bootstrap": "^4.0.0",
+ "chart.js": "^2.7.1",
+ "chroma-js": "^1.3.4",
+ "font-awesome": "^4.7.0",
+ "jquery": "^3.3.1",
+ "leaflet": "^1.3.1",
+ "lodash": "^4.17.4",
+ "moment": "^2.20.1",
+ "popper.js": "^1.12.9",
+ "prop-types": "^15.6.0",
"react": "^16.2.0",
+ "react-chartjs-2": "^2.7.0",
+ "react-datetime": "^2.10.3",
"react-dom": "^16.2.0",
- "react-scripts-ts": "2.13.0"
+ "react-leaflet": "^1.8.0",
+ "react-redux": "^5.0.6",
+ "react-scripts-ts": "2.13.0",
+ "react-select": "^1.2.1",
+ "react-table": "^6.7.6",
+ "reactstrap": "^5.0.0-beta",
+ "redux": "^3.7.2",
+ "redux-logger": "^3.0.6",
+ "redux-thunk": "^2.2.0"
},
"scripts": {
"start": "react-scripts-ts start",
@@ -14,10 +36,21 @@
"eject": "react-scripts-ts eject"
},
"devDependencies": {
+ "@types/axios": "^0.14.0",
+ "@types/chart.js": "^2.7.6",
+ "@types/chroma-js": "^1.3.3",
"@types/jest": "^22.1.1",
+ "@types/leaflet": "^1.2.5",
+ "@types/lodash": "^4.14.98",
"@types/node": "^9.4.0",
"@types/react": "^16.0.35",
"@types/react-dom": "^16.0.3",
+ "@types/react-leaflet": "^1.1.4",
+ "@types/react-redux": "^5.0.14",
+ "@types/react-select": "^1.2.0",
+ "@types/react-table": "^6.7.2",
+ "@types/reactstrap": "^5.0.12",
+ "@types/redux-logger": "^3.0.5",
"typescript": "^2.6.2"
}
}
diff --git a/dashboard/public/index.html b/dashboard/public/index.html
index ed0ebafa..251d409c 100644
--- a/dashboard/public/index.html
+++ b/dashboard/public/index.html
@@ -19,7 +19,7 @@
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`.
-->
-
React App
+ Parkkidash
diff --git a/dashboard/src/App.css b/dashboard/src/App.css
deleted file mode 100644
index c5c6e8a6..00000000
--- a/dashboard/src/App.css
+++ /dev/null
@@ -1,28 +0,0 @@
-.App {
- text-align: center;
-}
-
-.App-logo {
- animation: App-logo-spin infinite 20s linear;
- height: 80px;
-}
-
-.App-header {
- background-color: #222;
- height: 150px;
- padding: 20px;
- color: white;
-}
-
-.App-title {
- font-size: 1.5em;
-}
-
-.App-intro {
- font-size: large;
-}
-
-@keyframes App-logo-spin {
- from { transform: rotate(0deg); }
- to { transform: rotate(360deg); }
-}
diff --git a/dashboard/src/App.tsx b/dashboard/src/App.tsx
deleted file mode 100644
index 921bb811..00000000
--- a/dashboard/src/App.tsx
+++ /dev/null
@@ -1,22 +0,0 @@
-import * as React from 'react';
-import './App.css';
-
-const logo = require('./logo.svg');
-
-class App extends React.Component {
- render() {
- return (
-
-
-
- Welcome to React
-
-
- To get started, edit src/App.tsx
and save to reload.
-
-
- );
- }
-}
-
-export default App;
diff --git a/dashboard/src/actions.ts b/dashboard/src/actions.ts
new file mode 100644
index 00000000..58eb65ad
--- /dev/null
+++ b/dashboard/src/actions.ts
@@ -0,0 +1,65 @@
+import { Moment } from 'moment';
+
+import { RegionList, RegionStatsList } from './api/types';
+import { MapViewport } from './components/types';
+
+interface SetMapViewportAction {
+ type: 'SET_MAP_VIEWPORT';
+ viewport: MapViewport;
+}
+export function setMapViewport(viewport: MapViewport): SetMapViewportAction {
+ return {type: 'SET_MAP_VIEWPORT', viewport};
+}
+
+interface SetDataTimeAction {
+ type: 'SET_DATA_TIME';
+ time: Moment;
+}
+export function setDataTime(time: Moment): SetDataTimeAction {
+ return {type: 'SET_DATA_TIME', time};
+}
+
+interface SetAutoUpdateAction {
+ type: 'SET_AUTO_UPDATE';
+ value: boolean;
+}
+export function setAutoUpdate(value: boolean): SetAutoUpdateAction {
+ return {type: 'SET_AUTO_UPDATE', value};
+}
+
+interface SetSelectedRegionAction {
+ type: 'SET_SELECTED_REGION';
+ regionId: string|null;
+}
+export function setSelectedRegion(regionId: string|null):
+SetSelectedRegionAction {
+ return {type: 'SET_SELECTED_REGION', regionId};
+}
+
+interface ReceiveRegionStatsAction {
+ type: 'RECEIVE_REGION_STATS';
+ data: RegionStatsList;
+ time: Moment;
+}
+export function receiveRegionStats(
+ data: RegionStatsList,
+ time: Moment
+): ReceiveRegionStatsAction {
+ return {type: 'RECEIVE_REGION_STATS', data, time};
+}
+
+interface ReceiveRegionInfoAction {
+ type: 'RECEIVE_REGION_INFO';
+ data: RegionList;
+}
+export function receiveRegionInfo(data: RegionList): ReceiveRegionInfoAction {
+ return {type: 'RECEIVE_REGION_INFO', data};
+}
+
+export type Action =
+ SetMapViewportAction |
+ SetDataTimeAction |
+ SetAutoUpdateAction |
+ SetSelectedRegionAction |
+ ReceiveRegionStatsAction |
+ ReceiveRegionInfoAction;
diff --git a/dashboard/src/api/index.ts b/dashboard/src/api/index.ts
new file mode 100644
index 00000000..03eda678
--- /dev/null
+++ b/dashboard/src/api/index.ts
@@ -0,0 +1,66 @@
+import * as axios from 'axios';
+import { Moment } from 'moment';
+
+import { RegionList, RegionStatsList } from './types';
+
+interface SuccessCallback {
+ (response: axios.AxiosResponse): void;
+}
+
+interface ErrorHandler {
+ (error: axios.AxiosError): void;
+}
+
+export class Api {
+ public endpoints = {
+ regions: '/monitoring/v1/region/',
+ regionStats: '/monitoring/v1/region_statistics/',
+ };
+
+ private axios: axios.AxiosInstance;
+
+ constructor(baseUrl?: string) {
+ this.axios = axios.default.create({baseURL: baseUrl});
+ }
+
+ setBaseUrl(baseUrl: string) {
+ this.axios.defaults.baseURL = baseUrl;
+ }
+
+ fetchRegions(
+ callback: SuccessCallback,
+ errorHandler: ErrorHandler
+ ) {
+ this._fetchAllPages(this.endpoints.regions, callback, errorHandler);
+ }
+
+ fetchRegionStats(
+ time: Moment,
+ callback: SuccessCallback,
+ errorHandler: ErrorHandler
+ ) {
+ const timeParam = (time) ? '?time=' + time.toISOString() : '';
+ this._fetchAllPages(this.endpoints.regionStats + timeParam,
+ callback, errorHandler);
+ }
+
+ private _fetchAllPages(
+ url: string,
+ callback: SuccessCallback<{}>,
+ errorHandler: ErrorHandler
+ ) {
+ this.axios.get(url)
+ .then((response) => {
+ callback(response);
+ const nextUrl = response.data.next;
+ if (nextUrl) {
+ this._fetchAllPages(nextUrl, callback, errorHandler);
+ }
+ })
+ .catch(errorHandler);
+ }
+}
+
+const api = new Api();
+
+export default api;
diff --git a/dashboard/src/api/types.ts b/dashboard/src/api/types.ts
new file mode 100644
index 00000000..557d97cd
--- /dev/null
+++ b/dashboard/src/api/types.ts
@@ -0,0 +1,60 @@
+import * as geojson from 'geojson';
+
+//////////////////////////////////////////////////////////////////////
+// Primitive types
+
+type Url = string;
+type ParkingAreaId = string;
+type RegionId = string;
+
+//////////////////////////////////////////////////////////////////////
+// Helper interfaces
+
+interface PaginatedList {
+ count: number;
+ next: Url|null;
+ previous: Url|null;
+}
+
+// Type of the Features returned by the API
+interface ApiFeature extends
+geojson.Feature {
+ type: 'Feature';
+ geometry: geojson.MultiPolygon;
+ id: I;
+ properties: P|null;
+}
+
+// Type of the Feature Collections returned by the API
+interface ApiFeatureCollection extends
+geojson.FeatureCollection {
+ type: 'FeatureCollection';
+ features: Array>;
+}
+
+//////////////////////////////////////////////////////////////////////
+// Exported API interfaces
+
+export type Region = ApiFeature;
+
+export interface RegionList extends
+ApiFeatureCollection,
+PaginatedList {
+}
+
+export interface RegionProperties {
+ name: string;
+ capacity_estimate: number;
+ area_km2: number;
+ spots_per_km2: number;
+ parking_areas: ParkingAreaId[];
+}
+
+export interface RegionStatsList extends PaginatedList {
+ results: RegionStats[];
+}
+
+export interface RegionStats {
+ id: RegionId;
+ parking_count: number;
+}
diff --git a/dashboard/src/components/CalendarContainer.d.ts b/dashboard/src/components/CalendarContainer.d.ts
new file mode 100644
index 00000000..b79be166
--- /dev/null
+++ b/dashboard/src/components/CalendarContainer.d.ts
@@ -0,0 +1,12 @@
+declare module 'react-datetime/src/CalendarContainer' {
+ import * as React from 'react';
+
+ interface Props {
+ view: string; // 'years'|'months'|'days'|'time';
+ viewProps: any;
+ onClickOutside: () => void;
+ }
+
+ export default class CalendarContainer extends React.Component {
+ }
+}
diff --git a/dashboard/src/components/ParkingRegionsMap.css b/dashboard/src/components/ParkingRegionsMap.css
new file mode 100644
index 00000000..8575fb24
--- /dev/null
+++ b/dashboard/src/components/ParkingRegionsMap.css
@@ -0,0 +1,3 @@
+.leaflet-container {
+ min-height: 10rem;
+}
diff --git a/dashboard/src/components/ParkingRegionsMap.tsx b/dashboard/src/components/ParkingRegionsMap.tsx
new file mode 100644
index 00000000..58f21f55
--- /dev/null
+++ b/dashboard/src/components/ParkingRegionsMap.tsx
@@ -0,0 +1,199 @@
+import * as chroma from 'chroma-js';
+import * as geojson from 'geojson';
+import * as Leaflet from 'leaflet';
+import * as React from 'react';
+import * as ReactLeaflet from 'react-leaflet';
+
+import { MapViewport, Point, Region, RegionProperties } from './types';
+
+import './ParkingRegionsMap.css';
+
+// Add viewport props to the allowed props for Map component, since
+// they are missing from the latest libdefs available.
+// See https://github.com/DefinitelyTyped/DefinitelyTyped/issues/22125
+interface Viewport { center: Point; zoom: number; }
+interface MapProps extends ReactLeaflet.MapProps {
+ onViewportChange?: (viewport: Viewport) => void;
+ onViewportChanged?: (viewport: Viewport) => void;
+ viewport?: Viewport;
+}
+class Map extends ReactLeaflet.Map {}
+
+export interface Props {
+ center: Point;
+ zoom: number;
+ regions?: Region[];
+ onRegionClicked?: (region: Region) => void;
+ onViewportChanged?: (viewport: MapViewport) => void;
+}
+
+export default class ParkingRegionsMap extends React.Component {
+ private map?: ReactLeaflet.Map;
+
+ render() {
+ const geoJsonElement = this.props.regions ? (
+
+ ) : null;
+ const osmAttribution = (
+ '© OpenStreetMap '
+ + ' contributors');
+ return (
+ {
+ this.map = component; }}
+ >
+
+
+ {geoJsonElement}
+ );
+ }
+
+ private bindPopupToRegion = (
+ region: Region,
+ layer: Leaflet.Layer
+ ): void => {
+ const props = region.properties;
+ if (!props) {
+ return;
+ }
+ const capacityEstimateFromArea = Math.round(props.areaKm2 * 1000);
+ const capacity = props.capacityEstimate || capacityEstimateFromArea;
+ const count = props.parkingCount || 0;
+ const parkingsPerKm2 = count / props.areaKm2;
+ const usagePercentage = 100 * count / capacity;
+ layer.on('click', (event) => {
+ if (this.props.onRegionClicked) {
+ this.props.onRegionClicked(region);
+ }
+ });
+ layer.bindTooltip(props.name);
+ const text = `
+ ${props.name}
+ Pinta-ala :
+ ${props.areaKm2.toFixed(2)} km2
+ Arvioitu kapasiteetti :
+ ${props.capacityEstimate}
+ Arvioitu kapasiteetti (pinta-ala) :
+ ${capacityEstimateFromArea}
+ Paikkoja per km2 :
+ ${props.spotsPerKm2.toFixed(2)}
+
+ Pysäköintejä :
+ ${props.parkingCount || 0}
+ Pysäköintejä per km2 :
+ ${parkingsPerKm2.toFixed(2)}
+ Käyttöaste :
+ ${usagePercentage.toFixed(2)} %
+ `;
+ layer.bindPopup(text);
+ }
+
+ private handleViewportChange = (viewport: Viewport) => {
+ if (!this.map || !this.props.onViewportChanged) {
+ return;
+ }
+
+ const boundsObj = this.map.leafletElement.getBounds();
+ const ne = boundsObj.getNorthEast();
+ const sw = boundsObj.getSouthWest();
+ const bounds = {
+ neLat: ne.lat, neLng: ne.lng,
+ swLat: sw.lat, swLng: sw.lng};
+ const {center, zoom} = viewport;
+ this.props.onViewportChanged({bounds, center, zoom});
+ }
+}
+
+function getKeyForRegions(regions: Region[]): string {
+ if (!regions) {
+ return '';
+ }
+ if (!regions.length) {
+ return 'empty';
+ }
+ let lengthSum = 0;
+ let selectedId = '';
+ for (let region of regions) {
+ if (region.properties) {
+ lengthSum += Object.keys(region.properties).length || 0;
+ lengthSum += region.properties.parkingCount || 0;
+ if (region.properties.isSelected) {
+ selectedId = region.id;
+ }
+ }
+ }
+ const lastRegion = regions[regions.length - 1];
+ return `${regions.length}/${lengthSum}/${lastRegion.id}/${selectedId}`;
+}
+
+interface RegionCollection extends
+geojson.FeatureCollection {
+ features: Array;
+}
+
+function getFeatureCollection(regions: Region[]): RegionCollection {
+ return {type: 'FeatureCollection', features: regions};
+}
+
+function getStyleForRegion(region: Region) {
+ const props = region.properties;
+ const isSelected = (props && props.isSelected);
+ const borderWeight = (isSelected) ? 3 : 1;
+ const borderColor = (isSelected) ? '#0000ff' : '#000000';
+ const borderOpacity = (isSelected) ? 0.8 : 0.6;
+ const fillColor = getColorForRegion(props);
+ const fillOpacity = getOpacityForRegion(props);
+ return {
+ dashArray: (isSelected) ? undefined : '3',
+ weight: borderWeight,
+ color: borderColor,
+ opacity: borderOpacity,
+ fillColor: fillColor,
+ fillOpacity: fillOpacity,
+ };
+}
+
+function getColorForRegion(props?: RegionProperties|null): string|undefined {
+ const usage = getUsageFactorForRegion(props);
+ return (usage != null) ? getColorFromGreenToRed(usage) : undefined;
+}
+
+function getOpacityForRegion(props?: RegionProperties|null): number {
+ const usageFactor = getUsageFactorForRegion(props);
+ if (usageFactor == null) {
+ return 0;
+ }
+ const usageFactor0To1 = Math.min(Math.max(usageFactor, 0), 1);
+ return 0.25 + (usageFactor0To1 / 2);
+}
+
+function getUsageFactorForRegion(
+ props?: RegionProperties|null
+): number|undefined {
+ if (!props || !props.parkingCount || !props.areaKm2) {
+ return undefined;
+ }
+
+ const parkingsPerKm2 = props.parkingCount / props.areaKm2;
+ return parkingsPerKm2 / 100.0;
+}
+
+function getColorFromGreenToRed(ratio: number): string {
+ const truncated = Math.min(Math.max(ratio, 0), 1);
+ return chroma.scale(['#6d1', '#c21'])(truncated).hex();
+}
diff --git a/dashboard/src/components/RegionSelector.css b/dashboard/src/components/RegionSelector.css
new file mode 100644
index 00000000..fa412b16
--- /dev/null
+++ b/dashboard/src/components/RegionSelector.css
@@ -0,0 +1,5 @@
+.region-selector .Select-menu-outer {
+ /* Make sure the select dropdown is over the Leaflet map container,
+ * which uses very high z-index values for its components. */
+ z-index: 1000000;
+}
diff --git a/dashboard/src/components/RegionSelector.tsx b/dashboard/src/components/RegionSelector.tsx
new file mode 100644
index 00000000..45913b0e
--- /dev/null
+++ b/dashboard/src/components/RegionSelector.tsx
@@ -0,0 +1,47 @@
+import * as React from 'react';
+import Select from 'react-select';
+
+import './RegionSelector.css';
+
+type RegionId = string;
+type RegionTuple = [RegionId, string]; // id, name
+
+interface Item {
+ value: RegionId;
+ label: string;
+}
+
+export interface Props {
+ regions: RegionTuple[];
+ selectedRegion?: RegionId;
+ onRegionChanged?: (regionId: RegionId|null, name: string|null) => void;
+}
+
+class RegionSelect extends Select {
+}
+
+export default class RegionSelector extends React.Component {
+ render() {
+ const options = this.props.regions.map(([id, name]) => {
+ return {value: id, label: name};
+ });
+ return (
+ );
+ }
+
+ private handleItemChange = (item: Item) => {
+ if (this.props.onRegionChanged) {
+ if (item) {
+ this.props.onRegionChanged(item.value, item.label);
+ } else {
+ this.props.onRegionChanged(null, null);
+ }
+ }
+ }
+}
diff --git a/dashboard/src/components/TimeSelect.css b/dashboard/src/components/TimeSelect.css
new file mode 100644
index 00000000..1f9985be
--- /dev/null
+++ b/dashboard/src/components/TimeSelect.css
@@ -0,0 +1,26 @@
+.time-select .rdt .form-control {
+ min-width: 10em;
+ max-width: 10em;
+}
+
+.time-select .rdt .input-group {
+ justify-content: center;
+}
+
+.time-select .rdt .rdtPicker {
+ top: 2rem;
+}
+
+@media screen and (max-width: 28rem) {
+ .time-select .update-button .text {
+ display: none;
+ }
+}
+
+.time-select .shift-buttons .btn {
+ min-width: 3.6em;
+}
+
+.time-select .shift-buttons .btn-group {
+ margin: 0.5rem 0.2rem;
+}
diff --git a/dashboard/src/components/TimeSelect.tsx b/dashboard/src/components/TimeSelect.tsx
new file mode 100644
index 00000000..dc795968
--- /dev/null
+++ b/dashboard/src/components/TimeSelect.tsx
@@ -0,0 +1,197 @@
+import * as moment from 'moment';
+import * as ReactDatetime from 'react-datetime';
+import CalendarContainer from 'react-datetime/src/CalendarContainer';
+import * as React from 'react';
+import { Button, ButtonGroup, Input, InputGroup,
+ InputGroupButton, Row, Col } from 'reactstrap';
+
+import 'react-datetime/css/react-datetime.css';
+
+import './TimeSelect.css';
+
+export interface Props extends ReactDatetime.DatetimepickerProps {
+ autoUpdate?: boolean;
+ onAutoUpdateChange?: (newValue: boolean) => void;
+}
+
+// The implementation of AutoUpdatingDatetime needs to know more
+// internals of the ReactDatetime than is exported by the TS type
+// definitions. Make them visible by definining a couple extended
+// interfaces.
+interface ExtendedReactDatetime extends ReactDatetime {
+ getInitialState: () => {};
+ getStateFromProps: (props: Props) => {};
+ onInputKey: (event: React.KeyboardEvent) => void;
+ onInputChange: (event: React.ChangeEvent) => void;
+ handleClickOutside: () => void;
+}
+interface ReactDatetimeInt {
+ openCalendar?: (event: React.SyntheticEvent) => void;
+ onInputChange?: (event: React.ChangeEvent) => void;
+}
+
+const ReactDatetimeProto = ReactDatetime.prototype as ExtendedReactDatetime;
+
+class AutoUpdatingDatetime extends ReactDatetime {
+ static defaultProps = {
+ className: '',
+ defaultValue: '',
+ inputProps: {},
+ input: true,
+ onFocus: () => undefined,
+ onBlur: () => undefined,
+ onChange: () => undefined,
+ onViewModeChange: () => undefined,
+ timeFormat: true,
+ timeConstraints: {},
+ dateFormat: true,
+ strictParsing: true,
+ closeOnSelect: false,
+ closeOnTab: true,
+ utc: false,
+
+ onAutoUpdateChange: (newValue: boolean) => undefined,
+ autoUpdate: false,
+ };
+
+ getInitialState() {
+ return {
+ ...ReactDatetimeProto.getInitialState.call(this),
+ autoUpdate: AutoUpdatingDatetime.defaultProps.autoUpdate,
+ };
+ }
+
+ getStateFromProps = (props: Props) => (
+ this.getStateFromPropsOverride(props))
+
+ getStateFromPropsOverride(props: Props) {
+ return {
+ ...ReactDatetimeProto.getStateFromProps.call(this, props),
+ autoUpdate: props.autoUpdate,
+ };
+ }
+
+ handleUpdateButtonClick = (
+ event: React.SyntheticEvent
+ ) => {
+ const newAutoUpdate = !(this.state as {autoUpdate?: boolean}).autoUpdate;
+ this.setState({autoUpdate: newAutoUpdate} as {}, () => {
+ const props = (this.props as Props);
+ if (props.onAutoUpdateChange) {
+ return props.onAutoUpdateChange(newAutoUpdate);
+ }
+ });
+ }
+
+ handleInputKey(event: React.KeyboardEvent) {
+ if (event.key === 'Enter') {
+ const {inputValue, inputFormat} = this.state;
+ // Parse the input value to moment object in non-strict mode
+ const time = moment(inputValue, inputFormat, false);
+ if (time.isValid()) {
+ this.setTimeValue(time);
+ }
+ }
+ ReactDatetimeProto.onInputKey.call(this, event);
+ }
+
+ setTimeValue(time: moment.Moment) {
+ const event = {target: {value: time.format(this.state.inputFormat)}};
+ ReactDatetimeProto.onInputChange.call(this, event);
+ }
+
+ render() {
+ let className = 'rdt' + ((this.state.open) ? ' rdtOpen' : '');
+ const icon = ((this.state as Props).autoUpdate) ? 'clock-o' : 'circle-o';
+ const iconClass = 'fa fa-' + icon;
+ const inputProps = {
+ key: 'i',
+ type: 'text' as 'text',
+ className: 'form-control',
+ onFocus: (this as ReactDatetimeInt).openCalendar,
+ onChange: (this as ReactDatetimeInt).onInputChange,
+ onKeyDown: this.handleInputKey.bind(this),
+ value: this.state.inputValue,
+ };
+
+ const getComponentProps = (this as {getComponentProps?: () => {}}).getComponentProps;
+ const componentProps = (getComponentProps) ? getComponentProps() : {};
+ const handleClickOutside = ReactDatetimeProto.handleClickOutside.bind(this);
+
+ return (
+
+
+
+
+
+
+
+
+
+ Pidä ajan tasalla
+
+
+
+
+ );
+ }
+}
+
+export default class TimeSelect extends React.Component {
+ datetime?: AutoUpdatingDatetime;
+
+ shiftTime(minutes: number) {
+ if (this.datetime) {
+ const currentTime = this.datetime.props.value;
+ if (currentTime != null && moment.isMoment(currentTime)) {
+ const newTime = currentTime.clone().add(minutes, 'minutes');
+ this.datetime.setTimeValue(newTime);
+ }
+ }
+ }
+
+ render() {
+ const timeShiftButton = (label: string, minutes: number) => (
+
+ {label}
+ );
+ const datetimeProps = this.props;
+ return (
+
+
+
+ {
+ this.datetime = component; }}
+ />
+
+
+
+
+
+ {timeShiftButton('-1 vk', -7 * 24 * 60)}
+ {timeShiftButton('-1 pv', -1 * 24 * 60)}
+ {timeShiftButton('-1 t', -60)}
+
+
+ {timeShiftButton('+1 t', 60)}
+ {timeShiftButton('+1 pv', 1 * 24 * 60)}
+ {timeShiftButton('+1 vk', 7 * 24 * 60)}
+
+
+
+
+ );
+ }
+}
diff --git a/dashboard/src/components/types.ts b/dashboard/src/components/types.ts
new file mode 100644
index 00000000..d9168ed4
--- /dev/null
+++ b/dashboard/src/components/types.ts
@@ -0,0 +1,29 @@
+import * as geojson from 'geojson';
+
+export type Point = [number, number];
+
+export interface MapViewport {
+ center: Point;
+ zoom: number;
+ bounds?: {
+ neLat: number;
+ neLng: number;
+ swLat: number;
+ swLng: number;
+ };
+}
+
+export interface Region extends
+geojson.Feature {
+ id: string;
+}
+
+export interface RegionProperties {
+ name: string;
+ capacityEstimate: number;
+ areaKm2: number;
+ spotsPerKm2: number;
+ parkingAreas?: string[];
+ parkingCount?: number;
+ isSelected?: boolean;
+}
diff --git a/dashboard/src/components/utils.ts b/dashboard/src/components/utils.ts
new file mode 100644
index 00000000..d3a81159
--- /dev/null
+++ b/dashboard/src/components/utils.ts
@@ -0,0 +1,5 @@
+import { Region } from './types';
+
+export function getRegionName(region: Region) {
+ return (region.properties) ? region.properties.name : '';
+}
diff --git a/dashboard/src/config.ts b/dashboard/src/config.ts
new file mode 100644
index 00000000..381f2bed
--- /dev/null
+++ b/dashboard/src/config.ts
@@ -0,0 +1,6 @@
+export const isDev: boolean = (process.env.NODE_ENV === 'development');
+
+export const apiBaseUrl: string = process.env.REACT_APP_API_URL || (
+ (isDev)
+ ? 'http://localhost:8000/'
+ : 'https://api.parkkiopas.fi/');
diff --git a/dashboard/src/containers/App.tsx b/dashboard/src/containers/App.tsx
new file mode 100644
index 00000000..9bdf598e
--- /dev/null
+++ b/dashboard/src/containers/App.tsx
@@ -0,0 +1,9 @@
+import * as React from 'react';
+
+import Dashboard from './Dashboard';
+
+export default class App extends React.Component<{}> {
+ render() {
+ return ( );
+ }
+}
diff --git a/dashboard/src/containers/Dashboard.css b/dashboard/src/containers/Dashboard.css
new file mode 100644
index 00000000..87b2f592
--- /dev/null
+++ b/dashboard/src/containers/Dashboard.css
@@ -0,0 +1,19 @@
+.dashboard .card-body {
+ padding: 0.5rem;
+}
+
+.dashboard .map-card .card-body {
+ padding: 0;
+}
+
+.map-card .leaflet-container {
+ height: 60vh;
+}
+
+.dashboard .card {
+ margin: 0.5rem 0;
+}
+
+.dashboard .parking-histogram {
+ height: 40vh;
+}
diff --git a/dashboard/src/containers/Dashboard.tsx b/dashboard/src/containers/Dashboard.tsx
new file mode 100644
index 00000000..7a87e40e
--- /dev/null
+++ b/dashboard/src/containers/Dashboard.tsx
@@ -0,0 +1,261 @@
+import { Dispatch } from 'redux';
+import * as React from 'react';
+import { connect } from 'react-redux';
+
+import { Bar } from 'react-chartjs-2';
+import { Card, CardHeader, CardBody, Container, Row, Col } from 'reactstrap';
+
+import * as dispatchers from '../dispatchers';
+import { RootState } from '../types';
+import ParkingRegionsMap from './ParkingRegionsMap';
+import TimeSelect from './TimeSelect';
+import RegionSelector from './RegionSelector';
+
+import ReactTable from 'react-table';
+import 'react-table/react-table.css';
+
+import './Dashboard.css';
+
+const bar = {
+ labels: ['16', '18', '20', '22', '0', '2', '4', '6', '8', '10', '12', '14'],
+ datasets: [
+ {
+ backgroundColor: 'rgba(255,99,132,0.2)',
+ borderColor: 'rgba(255,99,132,1)',
+ borderWidth: 1,
+ hoverBackgroundColor: 'rgba(255,99,132,0.4)',
+ hoverBorderColor: 'rgba(255,99,132,1)',
+ data: [108, 95, 75, 35, 25, 21, 28, 35, 89, 81, 92, 99]
+ }
+ ]
+};
+
+interface Props {
+ autoUpdate: boolean;
+ onUpdate: () => void;
+}
+
+type TimerId = number;
+
+class Dashboard extends React.Component {
+ timer: TimerId|null = null;
+ timerInterval: number = 1000; // 1 second
+
+ componentDidMount() {
+ if (this.props.autoUpdate && !this.timer) {
+ this.enableAutoUpdate();
+ }
+ }
+
+ componentWillReceiveProps(nextProps: Props) {
+ if (nextProps.autoUpdate && !this.timer) {
+ this.enableAutoUpdate();
+ }
+ if (!nextProps.autoUpdate && this.timer) {
+ this.disableAutoUpdate();
+ }
+ }
+
+ enableAutoUpdate() {
+ this.autoUpdate();
+ if (this.timer) {
+ return; // Was already enabled
+ }
+ this.timer = window.setInterval(
+ this.autoUpdate.bind(this), this.timerInterval);
+ }
+
+ disableAutoUpdate() {
+ if (!this.timer) {
+ return; // Was already disabled
+ }
+ window.clearInterval(this.timer);
+ this.timer = null;
+ }
+
+ autoUpdate() {
+ if (this.props.onUpdate) {
+ this.props.onUpdate();
+ }
+ }
+
+ render() {
+
+ const data = [
+ {
+ 'id': '49df2003-ffac-4cf9-82d4-ef152bb0f539',
+ 'operator': 'Lippuautomaatit',
+ 'zone': 1,
+ 'area': 'Parking Area 3526',
+ 'terminal': 620,
+ 'start': '2.5.2018 14:18:05',
+ 'end': '2.5.2018, 15:48:05',
+ 'regnum': 'ABC-123',
+ 'created': '2.5.2018 14:18:12',
+ 'modified': '2.5.2018 14:18:12',
+ },
+ {
+ 'id': 'c0e45595-aecd-4c83-b80e-6940dcfee667',
+ 'operator': 'Lippuautomaatit',
+ 'zone': 2,
+ 'area': 'Parking Area 4012',
+ 'terminal': 120,
+ 'start': '2.5.2018 14:08:35',
+ 'end': '2.5.2018, 14:28:35',
+ 'regnum': 'CBA-456',
+ 'created': '2.5.2018 14:08:42',
+ 'modified': '2.5.2018 14:08:42',
+ },
+ {
+ 'id': 'b68d7e75-c4d9-49e1-9767-7816b7a24b13',
+ 'operator': 'EasyPark',
+ 'zone': 1,
+ 'area': '-',
+ 'terminal': undefined,
+ 'start': '2.5.2018 14:02:55',
+ 'end': '2.5.2018, 16:32:55',
+ 'regnum': 'XYZ-987',
+ 'created': '2.5.2018 14:02:59',
+ 'modified': '2.5.2018 14:03:05',
+ },
+ {
+ 'id': '051dabe8-ae2e-459f-97fe-cde4daf2de3b',
+ 'operator': 'Lippuautomaatit',
+ 'zone': 1,
+ 'area': 'Parking Area 2359',
+ 'terminal': 95,
+ 'start': '2.5.2018 14:01:22',
+ 'end': '2.5.2018, 14:21:22',
+ 'regnum': 'KKK-333',
+ 'created': '2.5.2018 14:01:23',
+ 'modified': '2.5.2018 14:01:23',
+ },
+ {
+ 'id': 'e8cd6df7-11d4-4fa2-9343-33450dd29e48',
+ 'operator': 'Witrafi',
+ 'zone': 2,
+ 'area': 'Parking Area 3374',
+ 'terminal': undefined,
+ 'start': '2.5.2018 13:59:45',
+ 'end': '2.5.2018, 15:28:06',
+ 'regnum': 'ABC-123',
+ 'created': '2.5.2018 13:59:46',
+ 'modified': '2.5.2018 15:28:07',
+ },
+ {
+ 'id': '78fff1e1-9a76-4870-8637-be32561b6aed',
+ 'operator': 'ParkMan',
+ 'zone': 3,
+ 'area': '-',
+ 'terminal': undefined,
+ 'start': '2.5.2018 13:59:32',
+ 'end': '2.5.2018, 14:38:35',
+ 'regnum': 'ZZZ-444',
+ 'created': '2.5.2018 13:59:32',
+ 'modified': '2.5.2018 14:38:36',
+ },
+ ];
+
+ const columns = [
+ {
+ Header: 'Tunniste',
+ accessor: 'id',
+ }, {
+ Header: 'Rekisterinumero',
+ accessor: 'regnum',
+ }, {
+ Header: 'Operaattori',
+ accessor: 'operator',
+ }, {
+ Header: 'Maksuvyöhyke',
+ accessor: 'zone',
+ }, {
+ Header: 'Pysäköintialue',
+ accessor: 'area',
+ }, {
+ Header: 'Lippuautomaatin numero',
+ accessor: 'terminal',
+ }, {
+ Header: 'Aloitusaika',
+ accessor: 'start',
+ }, {
+ Header: 'Loppumisaika',
+ accessor: 'end',
+ }, {
+ Header: 'Luotu',
+ accessor: 'created',
+ }, {
+ Header: 'Päivitetty',
+ accessor: 'modified',
+ },
+ ];
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ Pysäköintimäärät, viimeiset 24 t
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Viimeiset pysäköinnit
+
+
+
+
+
+
+
+
+ );
+ }
+}
+
+function mapStateToProps(state: RootState): Partial {
+ return {
+ autoUpdate: state.autoUpdate,
+ };
+}
+
+function mapDispatchToProps(dispatch: Dispatch): Partial {
+ return {
+ onUpdate: () => dispatch(dispatchers.updateData()),
+ };
+}
+
+const ConnectedDashboard = connect(
+ mapStateToProps, mapDispatchToProps)(Dashboard);
+
+export default ConnectedDashboard;
diff --git a/dashboard/src/containers/ParkingRegionsMap.ts b/dashboard/src/containers/ParkingRegionsMap.ts
new file mode 100644
index 00000000..1d0085c3
--- /dev/null
+++ b/dashboard/src/containers/ParkingRegionsMap.ts
@@ -0,0 +1,48 @@
+import * as _ from 'lodash';
+import { connect } from 'react-redux';
+import { Dispatch } from 'redux';
+
+import ParkingRegionsMap, { Props } from '../components/ParkingRegionsMap';
+import { Region, MapViewport } from '../components/types';
+import * as dispatchers from '../dispatchers';
+import { RootState } from '../types';
+
+function mapStateToProps(state: RootState): Partial {
+ const viewState = state.views.parkingRegionMap;
+ return {
+ center: viewState.center,
+ zoom: viewState.zoom,
+ regions: getRegions(state),
+ };
+}
+
+function getRegions(state: RootState): Region[] {
+ const {dataTime, regions, regionUsageHistory} = state;
+ const currentRegionUsageMap = (
+ (dataTime != null) ? regionUsageHistory[dataTime] : {}) || {};
+ const enrichedRegions = _.entries(regions).map(
+ ([id, region]: [string, Region]): Region => {
+ const properties = (region.properties) ? {
+ ...region.properties,
+ ...currentRegionUsageMap[id],
+ isSelected: (id === state.selectedRegion),
+ } : null;
+ return {...region, properties};
+ });
+ return enrichedRegions;
+}
+
+function mapDispatchToProps(dispatch: Dispatch): Partial {
+ return {
+ onRegionClicked: (region: Region) => {
+ dispatch(dispatchers.setSelectedRegion(region.id));
+ },
+ onViewportChanged: (viewport: MapViewport) => (
+ dispatch(dispatchers.setMapViewport(viewport))),
+ };
+}
+
+const ConnectedParkingRegionsMap = connect(
+ mapStateToProps, mapDispatchToProps)(ParkingRegionsMap);
+
+export default ConnectedParkingRegionsMap;
diff --git a/dashboard/src/containers/RegionSelector.ts b/dashboard/src/containers/RegionSelector.ts
new file mode 100644
index 00000000..a6d73122
--- /dev/null
+++ b/dashboard/src/containers/RegionSelector.ts
@@ -0,0 +1,35 @@
+import { Dispatch } from 'redux';
+import { connect } from 'react-redux';
+import * as _ from 'lodash';
+
+import RegionSelector, { Props } from '../components/RegionSelector';
+import { Region } from '../components/types';
+import { getRegionName } from '../components/utils';
+import * as dispatchers from '../dispatchers';
+import { RootState } from '../types';
+
+function mapStateToProps(state: RootState): Partial {
+ function getRegionPair([id, region]: [string, Region]): [string, string] {
+ return [id, getRegionName(region)];
+ }
+
+ return {
+ regions: _.sortBy(
+ _.entries(state.regions).map(getRegionPair),
+ [([id, name]: [string, string]) => ([!name, name])]),
+ selectedRegion: state.selectedRegion || undefined,
+ };
+}
+
+function mapDispatchToProps(dispatch: Dispatch): Partial {
+ return {
+ onRegionChanged: (id: string, name: string) => {
+ return dispatch(dispatchers.setSelectedRegion(id));
+ },
+ };
+}
+
+const ConnectedRegionSelector = connect(
+ mapStateToProps, mapDispatchToProps)(RegionSelector);
+
+export default ConnectedRegionSelector;
diff --git a/dashboard/src/containers/TimeSelect.ts b/dashboard/src/containers/TimeSelect.ts
new file mode 100644
index 00000000..c478207e
--- /dev/null
+++ b/dashboard/src/containers/TimeSelect.ts
@@ -0,0 +1,35 @@
+import * as moment from 'moment';
+import { Dispatch } from 'redux';
+import { connect } from 'react-redux';
+
+import TimeSelect, { Props } from '../components/TimeSelect';
+import * as dispatchers from '../dispatchers';
+import { RootState } from '../types';
+
+function mapStateToProps(state: RootState): Partial {
+ return {
+ value: (state.dataTime) ? moment(state.dataTime) : undefined,
+ autoUpdate: state.autoUpdate,
+ timeConstraints: {
+ minutes: {min: 0, max: 59, step: 5},
+ seconds: undefined
+ },
+ };
+}
+
+function mapDispatchToProps(dispatch: Dispatch): Partial {
+ return {
+ onChange: (time: moment.Moment) => {
+ dispatch(dispatchers.setAutoUpdate(false));
+ return dispatch(dispatchers.setDataTime(time));
+ },
+ onAutoUpdateChange: (value) => {
+ return dispatch(dispatchers.setAutoUpdate(value));
+ },
+ };
+}
+
+const ConnectedTimeSelect = connect(
+ mapStateToProps, mapDispatchToProps)(TimeSelect);
+
+export default ConnectedTimeSelect;
diff --git a/dashboard/src/converters.ts b/dashboard/src/converters.ts
new file mode 100644
index 00000000..02e96742
--- /dev/null
+++ b/dashboard/src/converters.ts
@@ -0,0 +1,30 @@
+/** Functions for converting API data to UI types. */
+
+import * as api from './api/types';
+import * as uic from './components/types';
+import * as ui from './types';
+
+export function convertRegion(region: api.Region): uic.Region {
+ const p = region.properties;
+ const properties = (p) ? {
+ name: p.name,
+ capacityEstimate: p.capacity_estimate,
+ areaKm2: p.area_km2,
+ spotsPerKm2: p.spots_per_km2,
+ parkingAreas: p.parking_areas,
+ } : null;
+ return {
+ id: region.id,
+ type: region.type,
+ geometry: region.geometry,
+ properties
+ };
+}
+
+export function convertRegionStats(
+ regionStats: api.RegionStats
+): ui.RegionUsageInfo {
+ return {
+ parkingCount: regionStats.parking_count,
+ };
+}
diff --git a/dashboard/src/dispatchers.ts b/dashboard/src/dispatchers.ts
new file mode 100644
index 00000000..2b3623db
--- /dev/null
+++ b/dashboard/src/dispatchers.ts
@@ -0,0 +1,86 @@
+import { Dispatch } from 'redux';
+import * as moment from 'moment';
+
+import * as actions from './actions';
+import api from './api';
+import { MapViewport } from './components/types';
+import { RootState } from './types';
+
+const updateInterval = 5 * 60 * 1000; // 5 minutes in ms
+
+export function setMapViewport(viewport: MapViewport) {
+ return (dispatch: Dispatch) => {
+ dispatch(actions.setMapViewport(viewport));
+ };
+}
+
+export function updateData() {
+ return (dispatch: Dispatch) => {
+ dispatch(setDataTime(moment()));
+ };
+}
+
+export function setDataTime(time?: moment.Moment) {
+ return (dispatch: Dispatch, getState: () => RootState) => {
+ if (time && typeof time === 'object' && time.isValid()) {
+ const {dataTime} = getState();
+ const roundedTime = roundTime(time, updateInterval);
+ if (!dataTime || roundedTime.valueOf() !== dataTime) {
+ dispatch(actions.setDataTime(roundedTime));
+ dispatch(fetchRegionStats(roundedTime));
+ }
+ }
+ };
+}
+
+function roundTime(time: moment.Moment, precision: number): moment.Moment {
+ return moment(Math.floor(time.valueOf() / precision) * precision);
+}
+
+export function setAutoUpdate(value: boolean) {
+ return (dispatch: Dispatch) => {
+ dispatch(actions.setAutoUpdate(value));
+ };
+}
+
+export function setSelectedRegion(regionId: string) {
+ return (dispatch: Dispatch) => {
+ dispatch(actions.setSelectedRegion(regionId));
+ };
+}
+
+export function fetchRegionStats(time: moment.Moment) {
+ return (dispatch: Dispatch, getState: () => RootState) => {
+ const {regions, regionUsageHistory} = getState();
+ const timestamp = time.valueOf();
+ if (timestamp in regionUsageHistory) {
+ return;
+ }
+ api.fetchRegionStats(
+ time,
+ (response) => {
+ const results = response.data.results || [];
+ const needsRegion = results.some(
+ (x: {id: string}) => (!(x.id in regions)));
+ if (needsRegion) {
+ dispatch(fetchRegions());
+ }
+ dispatch(actions.receiveRegionStats(response.data, time));
+ },
+ (error) => {
+ alert('Region statistics fetch failed: ' + error);
+ });
+ };
+}
+
+export function fetchRegions() {
+ return (dispatch: Dispatch) => {
+ api.fetchRegions(
+ (response) => {
+ dispatch(actions.receiveRegionInfo(response.data));
+ },
+ (error) => {
+ alert('Region fetch failed: ' + error);
+ });
+ };
+}
diff --git a/dashboard/src/global.d.ts b/dashboard/src/global.d.ts
new file mode 100644
index 00000000..98422abb
--- /dev/null
+++ b/dashboard/src/global.d.ts
@@ -0,0 +1,3 @@
+declare interface Window {
+ __REDUX_DEVTOOLS_EXTENSION_COMPOSE__?: any;
+}
diff --git a/dashboard/src/index.tsx b/dashboard/src/index.tsx
index 1c66245a..f513abf9 100644
--- a/dashboard/src/index.tsx
+++ b/dashboard/src/index.tsx
@@ -1,11 +1,48 @@
+import * as Leaflet from 'leaflet';
import * as React from 'react';
import * as ReactDOM from 'react-dom';
-import App from './App';
-import registerServiceWorker from './registerServiceWorker';
+import * as ReactRedux from 'react-redux';
+import * as Redux from 'redux';
+import * as ReduxLogger from 'redux-logger';
+import thunkMiddleware from 'redux-thunk';
+
+// Configure Moment.js to use Finnish locale
+import 'moment/locale/fi';
+
import './index.css';
+import api from './api';
+import * as config from './config';
+import App from './containers/App';
+import rootReducer from './reducers';
+import registerServiceWorker from './registerServiceWorker';
+
+import 'bootstrap/dist/css/bootstrap.css';
+import 'font-awesome/css/font-awesome.min.css';
+import 'react-select/dist/react-select.css';
+
+import 'leaflet/dist/leaflet.css';
+
+Leaflet.Icon.Default.imagePath =
+ '//cdnjs.cloudflare.com/ajax/libs/leaflet/1.3.1/images/';
+
+// Construct store with middlewares and enchancers
+const middlewares: Redux.Middleware[] = [thunkMiddleware];
+if (config.isDev) {
+ middlewares.push(ReduxLogger.createLogger());
+}
+const composeEnhancers = (
+ (config.isDev && window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__)
+ || Redux.compose);
+const storeEnhancer = Redux.applyMiddleware(...middlewares);
+const store = Redux.createStore(rootReducer, composeEnhancers(storeEnhancer));
+
+// Configure API base URL
+api.setBaseUrl(config.apiBaseUrl);
ReactDOM.render(
- ,
- document.getElementById('root') as HTMLElement
-);
+
+
+ ,
+ document.getElementById('root') as HTMLElement);
+
registerServiceWorker();
diff --git a/dashboard/src/logo.svg b/dashboard/src/logo.svg
deleted file mode 100644
index 6b60c104..00000000
--- a/dashboard/src/logo.svg
+++ /dev/null
@@ -1,7 +0,0 @@
-
-
-
-
-
-
-
diff --git a/dashboard/src/reducers.ts b/dashboard/src/reducers.ts
new file mode 100644
index 00000000..ca2c59a2
--- /dev/null
+++ b/dashboard/src/reducers.ts
@@ -0,0 +1,107 @@
+import * as _ from 'lodash';
+import { combineReducers } from 'redux';
+
+import { Action } from './actions';
+import * as conv from './converters';
+import {
+ ParkingRegionMapState, RegionsMap, RegionUsageHistory,
+ RootState, ViewState } from './types';
+
+// View state reducers ///////////////////////////////////////////////
+
+const initialParkingRegionMapState: ParkingRegionMapState = {
+ bounds: undefined,
+ center: [60.17, 24.94],
+ zoom: 12,
+};
+
+function parkingRegionMap(
+ state: ParkingRegionMapState = initialParkingRegionMapState,
+ action: Action
+): ParkingRegionMapState {
+ if (action.type === 'SET_MAP_VIEWPORT') {
+ return {...state, ...action.viewport};
+ }
+ return state;
+}
+
+const views: ((state: ViewState, action: Action) => ViewState) =
+ combineReducers({
+ parkingRegionMap,
+ });
+
+// Helper for creating simple data reducer
+function makeReducer(
+ actionType: string,
+ valueKey: string,
+ defaultValue: T,
+): ((state: T, action: Action) => T) {
+ function reducer(state: T = defaultValue, action: Action): T {
+ if (action.type === actionType) {
+ return action[valueKey];
+ }
+ return state;
+ }
+ return reducer;
+}
+
+// Data reducers /////////////////////////////////////////////////////
+
+function dataTime(state: number|null = null, action: Action) {
+ if (action.type === 'SET_DATA_TIME') {
+ return action.time.valueOf();
+ }
+ return state;
+}
+
+const autoUpdate = makeReducer('SET_AUTO_UPDATE', 'value', true);
+const selectedRegion = makeReducer(
+ 'SET_SELECTED_REGION', 'regionId', null);
+
+function regions(state: RegionsMap = {}, action: Action): RegionsMap {
+ if (action.type === 'RECEIVE_REGION_INFO') {
+ const newRegions = mapByIdAndApply(
+ action.data.features, conv.convertRegion);
+ return {...state, ...newRegions};
+ }
+ return state;
+}
+
+function regionUsageHistory(
+ state: RegionUsageHistory = {},
+ action: Action
+): RegionUsageHistory {
+ if (action.type === 'RECEIVE_REGION_STATS') {
+ const timestamp = action.time.valueOf();
+ const oldStats = state[timestamp] || {};
+ const newStats = mapByIdAndApply(
+ action.data.results, conv.convertRegionStats);
+ return {...state, ...{[timestamp]: {...oldStats, ...newStats}}};
+ }
+ return state;
+}
+
+interface Mapped {
+ [id: string]: T;
+}
+
+function mapByIdAndApply(
+ objects: ReadonlyArray<{id: string}>,
+ converter: ((x: {id: string}) => T)
+): Mapped {
+ return _.assign({}, ...objects.map((x) => ({[x.id]: converter(x)})));
+}
+
+// Root reducer //////////////////////////////////////////////////////
+
+const rootReducer: ((state: RootState, action: Action) => RootState) =
+ combineReducers({
+ views,
+ dataTime,
+ autoUpdate,
+ selectedRegion,
+ regions,
+ regionUsageHistory,
+ });
+
+export default rootReducer;
diff --git a/dashboard/src/types.ts b/dashboard/src/types.ts
new file mode 100644
index 00000000..2e51abf3
--- /dev/null
+++ b/dashboard/src/types.ts
@@ -0,0 +1,37 @@
+import { Region, MapViewport } from './components/types';
+
+export interface RootState {
+ views: ViewState;
+
+ dataTime: number|null; // milliseconds
+ autoUpdate: boolean;
+
+ selectedRegion: string|null; // regionId
+
+ regions: RegionsMap;
+
+ regionUsageHistory: RegionUsageHistory;
+}
+
+export interface ViewState {
+ parkingRegionMap: ParkingRegionMapState;
+}
+
+export interface ParkingRegionMapState extends MapViewport {
+}
+
+export interface RegionsMap {
+ [regionId: string]: Region;
+}
+
+export interface RegionUsageHistory {
+ [time: number]: RegionUsageMap;
+}
+
+export interface RegionUsageMap {
+ [regionId: string]: RegionUsageInfo;
+}
+
+export interface RegionUsageInfo {
+ parkingCount: number;
+}
diff --git a/dashboard/tsconfig.json b/dashboard/tsconfig.json
index 85973541..bfc6196f 100644
--- a/dashboard/tsconfig.json
+++ b/dashboard/tsconfig.json
@@ -1,6 +1,7 @@
{
"compilerOptions": {
"outDir": "build/dist",
+ "baseUrl": ".",
"module": "esnext",
"target": "es5",
"lib": ["es6", "dom"],
diff --git a/dashboard/yarn.lock b/dashboard/yarn.lock
index cd70ad48..9fa7e19b 100644
--- a/dashboard/yarn.lock
+++ b/dashboard/yarn.lock
@@ -2,10 +2,38 @@
# yarn lockfile v1
+"@types/axios@^0.14.0":
+ version "0.14.0"
+ resolved "https://registry.yarnpkg.com/@types/axios/-/axios-0.14.0.tgz#ec2300fbe7d7dddd7eb9d3abf87999964cafce46"
+ dependencies:
+ axios "*"
+
+"@types/chart.js@^2.7.6":
+ version "2.7.6"
+ resolved "https://registry.yarnpkg.com/@types/chart.js/-/chart.js-2.7.6.tgz#144f2da83b0e39e3cfa388c62aa6245d71d16109"
+
+"@types/chroma-js@^1.3.3":
+ version "1.3.3"
+ resolved "https://registry.yarnpkg.com/@types/chroma-js/-/chroma-js-1.3.3.tgz#2f8ddbaed996166c23305f8c6ff0784bea5c2a87"
+
+"@types/geojson@*":
+ version "7946.0.1"
+ resolved "https://registry.yarnpkg.com/@types/geojson/-/geojson-7946.0.1.tgz#1fc41280e42f08f0d568401a556bc97c34f5262e"
+
"@types/jest@^22.1.1":
version "22.1.1"
resolved "https://registry.yarnpkg.com/@types/jest/-/jest-22.1.1.tgz#231d7c60ed130200af9e96c82469ed25b59a7ea2"
+"@types/leaflet@*", "@types/leaflet@^1.2.5":
+ version "1.2.5"
+ resolved "https://registry.yarnpkg.com/@types/leaflet/-/leaflet-1.2.5.tgz#b172502c298849cadd034314dabfc936beff7636"
+ dependencies:
+ "@types/geojson" "*"
+
+"@types/lodash@^4.14.98":
+ version "4.14.98"
+ resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.98.tgz#aaf012ae443e657e7885e605a4c1b340db160609"
+
"@types/node@*", "@types/node@^9.4.0":
version "9.4.0"
resolved "https://registry.yarnpkg.com/@types/node/-/node-9.4.0.tgz#b85a0bcf1e1cc84eb4901b7e96966aedc6f078d1"
@@ -17,10 +45,48 @@
"@types/node" "*"
"@types/react" "*"
+"@types/react-leaflet@^1.1.4":
+ version "1.1.4"
+ resolved "https://registry.yarnpkg.com/@types/react-leaflet/-/react-leaflet-1.1.4.tgz#75b46ae80282883614962be5d59d1774e90cab25"
+ dependencies:
+ "@types/leaflet" "*"
+ "@types/react" "*"
+
+"@types/react-redux@^5.0.14":
+ version "5.0.14"
+ resolved "https://registry.yarnpkg.com/@types/react-redux/-/react-redux-5.0.14.tgz#f3fc30dcbb2d20455a714f591cc27f77b4df09bb"
+ dependencies:
+ "@types/react" "*"
+ redux "^3.6.0"
+
+"@types/react-select@^1.2.0":
+ version "1.2.0"
+ resolved "https://registry.yarnpkg.com/@types/react-select/-/react-select-1.2.0.tgz#2f29154fabbc530fd83858c7f24a34cf4e3d3869"
+ dependencies:
+ "@types/react" "*"
+
+"@types/react-table@^6.7.2":
+ version "6.7.2"
+ resolved "https://registry.yarnpkg.com/@types/react-table/-/react-table-6.7.2.tgz#7f2d6ed219e80f452bcb162bb27d3e289268f8b1"
+ dependencies:
+ "@types/react" "*"
+
"@types/react@*", "@types/react@^16.0.35":
version "16.0.35"
resolved "https://registry.yarnpkg.com/@types/react/-/react-16.0.35.tgz#7ce8a83cad9690fd965551fc513217a74fc9e079"
+"@types/reactstrap@^5.0.12":
+ version "5.0.12"
+ resolved "https://registry.yarnpkg.com/@types/reactstrap/-/reactstrap-5.0.12.tgz#6c8f011dc160be8454fe7504bd14fd76b2d16998"
+ dependencies:
+ "@types/react" "*"
+
+"@types/redux-logger@^3.0.5":
+ version "3.0.5"
+ resolved "https://registry.yarnpkg.com/@types/redux-logger/-/redux-logger-3.0.5.tgz#d1a02758f90845899cd304aa0912daeba2028eb6"
+ dependencies:
+ redux "^3.6.0"
+
abab@^1.0.3:
version "1.0.4"
resolved "https://registry.yarnpkg.com/abab/-/abab-1.0.4.tgz#5faad9c2c07f60dd76770f71cf025b62a63cfd4e"
@@ -307,6 +373,13 @@ aws4@^1.2.1, aws4@^1.6.0:
version "1.6.0"
resolved "https://registry.yarnpkg.com/aws4/-/aws4-1.6.0.tgz#83ef5ca860b2b32e4a0deedee8c771b9db57471e"
+axios@*, axios@^0.17.1:
+ version "0.17.1"
+ resolved "https://registry.yarnpkg.com/axios/-/axios-0.17.1.tgz#2d8e3e5d0bdbd7327f91bc814f5c57660f81824d"
+ dependencies:
+ follow-redirects "^1.2.5"
+ is-buffer "^1.1.5"
+
babel-code-frame@6.26.0, babel-code-frame@^6.11.0, babel-code-frame@^6.22.0, babel-code-frame@^6.26.0:
version "6.26.0"
resolved "https://registry.yarnpkg.com/babel-code-frame/-/babel-code-frame-6.26.0.tgz#63fd43f7dc1e3bb7ce35947db8fe369a3f58c74b"
@@ -555,6 +628,10 @@ boom@5.x.x:
dependencies:
hoek "4.x.x"
+bootstrap@^4.0.0:
+ version "4.0.0"
+ resolved "https://registry.yarnpkg.com/bootstrap/-/bootstrap-4.0.0.tgz#ceb03842c145fcc1b9b4e15da2a05656ba68469a"
+
boxen@^1.2.1:
version "1.3.0"
resolved "https://registry.yarnpkg.com/boxen/-/boxen-1.3.0.tgz#55c6c39a8ba58d9c61ad22cd877532deb665a20b"
@@ -768,6 +845,10 @@ center-align@^0.1.1:
align-text "^0.1.3"
lazy-cache "^1.0.3"
+chain-function@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/chain-function/-/chain-function-1.0.0.tgz#0d4ab37e7e18ead0bdc47b920764118ce58733dc"
+
chalk@1.1.3, chalk@^1.1.3:
version "1.1.3"
resolved "https://registry.yarnpkg.com/chalk/-/chalk-1.1.3.tgz#a8115c55e4a702fe4d150abd3872822a7e09fc98"
@@ -790,6 +871,26 @@ chardet@^0.4.0:
version "0.4.2"
resolved "https://registry.yarnpkg.com/chardet/-/chardet-0.4.2.tgz#b5473b33dc97c424e5d98dc87d55d4d8a29c8bf2"
+chart.js@^2.7.1:
+ version "2.7.1"
+ resolved "https://registry.yarnpkg.com/chart.js/-/chart.js-2.7.1.tgz#ae90b4aa4ff1f02decd6b1a2a8dabfd73c9f9886"
+ dependencies:
+ chartjs-color "~2.2.0"
+ moment "~2.18.0"
+
+chartjs-color-string@^0.5.0:
+ version "0.5.0"
+ resolved "https://registry.yarnpkg.com/chartjs-color-string/-/chartjs-color-string-0.5.0.tgz#8d3752d8581d86687c35bfe2cb80ac5213ceb8c1"
+ dependencies:
+ color-name "^1.0.0"
+
+chartjs-color@~2.2.0:
+ version "2.2.0"
+ resolved "https://registry.yarnpkg.com/chartjs-color/-/chartjs-color-2.2.0.tgz#84a2fb755787ed85c39dd6dd8c7b1d88429baeae"
+ dependencies:
+ chartjs-color-string "^0.5.0"
+ color-convert "^0.5.3"
+
chokidar@^1.6.0, chokidar@^1.7.0:
version "1.7.0"
resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-1.7.0.tgz#798e689778151c8076b4b360e5edd28cda2bb468"
@@ -805,6 +906,10 @@ chokidar@^1.6.0, chokidar@^1.7.0:
optionalDependencies:
fsevents "^1.0.0"
+chroma-js@^1.3.4:
+ version "1.3.6"
+ resolved "https://registry.yarnpkg.com/chroma-js/-/chroma-js-1.3.6.tgz#22dd7220ef6b55dcfcb8ef92982baaf55dced45d"
+
ci-info@^1.0.0:
version "1.1.2"
resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-1.1.2.tgz#03561259db48d0474c8bdc90f5b47b068b6bbfb4"
@@ -822,6 +927,10 @@ clap@^1.0.9:
dependencies:
chalk "^1.1.3"
+classnames@^2.2.3, classnames@^2.2.4, classnames@^2.2.5:
+ version "2.2.5"
+ resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.2.5.tgz#fb3801d453467649ef3603c7d61a02bd129bde6d"
+
clean-css@4.1.x:
version "4.1.9"
resolved "https://registry.yarnpkg.com/clean-css/-/clean-css-4.1.9.tgz#35cee8ae7687a49b98034f70de00c4edd3826301"
@@ -876,6 +985,10 @@ code-point-at@^1.0.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/code-point-at/-/code-point-at-1.1.0.tgz#0d070b4d043a5bea33a2f1a40e2edb3d9a4ccf77"
+color-convert@^0.5.3:
+ version "0.5.3"
+ resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-0.5.3.tgz#bdb6c69ce660fadffe0b0007cc447e1b9f7282bd"
+
color-convert@^1.3.0, color-convert@^1.9.0:
version "1.9.1"
resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.1.tgz#c1261107aeb2f294ebffec9ed9ecad529a6097ed"
@@ -1058,6 +1171,14 @@ create-hmac@^1.1.0, create-hmac@^1.1.2, create-hmac@^1.1.4:
safe-buffer "^5.0.1"
sha.js "^2.4.8"
+create-react-class@^15.5.2:
+ version "15.6.3"
+ resolved "https://registry.yarnpkg.com/create-react-class/-/create-react-class-15.6.3.tgz#2d73237fb3f970ae6ebe011a9e66f46dbca80036"
+ dependencies:
+ fbjs "^0.8.9"
+ loose-envify "^1.3.1"
+ object-assign "^4.1.1"
+
cross-spawn@5.1.0, cross-spawn@^5.0.1:
version "5.1.0"
resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-5.1.0.tgz#e8bd0efee58fcff6f8f94510a0a554bbfa235449"
@@ -1238,6 +1359,10 @@ decamelize@^1.0.0, decamelize@^1.1.1, decamelize@^1.1.2:
version "1.2.0"
resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290"
+deep-diff@^0.3.5:
+ version "0.3.8"
+ resolved "https://registry.yarnpkg.com/deep-diff/-/deep-diff-0.3.8.tgz#c01de63efb0eec9798801d40c7e0dae25b582c84"
+
deep-equal@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/deep-equal/-/deep-equal-1.0.1.tgz#f5d260292b660e084eff4cdbc9f08ad3247448b5"
@@ -1377,6 +1502,10 @@ dom-converter@~0.1:
dependencies:
utila "~0.3"
+dom-helpers@^3.2.0:
+ version "3.3.1"
+ resolved "https://registry.yarnpkg.com/dom-helpers/-/dom-helpers-3.3.1.tgz#fc1a4e15ffdf60ddde03a480a9c0fece821dd4a6"
+
dom-serializer@0:
version "0.1.0"
resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-0.1.0.tgz#073c697546ce0780ce23be4a28e293e40bc30c82"
@@ -1811,7 +1940,7 @@ fb-watchman@^2.0.0:
dependencies:
bser "^2.0.0"
-fbjs@^0.8.16:
+fbjs@^0.8.16, fbjs@^0.8.9:
version "0.8.16"
resolved "https://registry.yarnpkg.com/fbjs/-/fbjs-0.8.16.tgz#5e67432f550dc41b572bf55847b8aca64e5337db"
dependencies:
@@ -1889,6 +2018,16 @@ flatten@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/flatten/-/flatten-1.0.2.tgz#dae46a9d78fbe25292258cc1e780a41d95c03782"
+follow-redirects@^1.2.5:
+ version "1.4.1"
+ resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.4.1.tgz#d8120f4518190f55aac65bb6fc7b85fcd666d6aa"
+ dependencies:
+ debug "^3.1.0"
+
+font-awesome@^4.7.0:
+ version "4.7.0"
+ resolved "https://registry.yarnpkg.com/font-awesome/-/font-awesome-4.7.0.tgz#8fa8cf0411a1a31afd07b06d2902bb9fc815a133"
+
for-in@^1.0.1:
version "1.0.2"
resolved "https://registry.yarnpkg.com/for-in/-/for-in-1.0.2.tgz#81068d295a8142ec0ac726c6e2200c30fb6d5e80"
@@ -2261,6 +2400,10 @@ hoek@4.x.x:
version "4.2.0"
resolved "https://registry.yarnpkg.com/hoek/-/hoek-4.2.0.tgz#72d9d0754f7fe25ca2d01ad8f8f9a9449a89526d"
+hoist-non-react-statics@^2.2.1:
+ version "2.3.1"
+ resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-2.3.1.tgz#343db84c6018c650778898240135a1420ee22ce0"
+
home-or-tmp@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/home-or-tmp/-/home-or-tmp-2.0.0.tgz#e36c3f2d2cae7d746a857e38d18d5f32a7882db8"
@@ -2482,7 +2625,7 @@ interpret@^1.0.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/interpret/-/interpret-1.1.0.tgz#7ed1b1410c6a0e0f78cf95d3b8440c63f78b8614"
-invariant@^2.2.2:
+invariant@^2.0.0, invariant@^2.2.2:
version "2.2.2"
resolved "https://registry.yarnpkg.com/invariant/-/invariant-2.2.2.tgz#9e1f56ac0acdb6bf303306f338be3b204ae60360"
dependencies:
@@ -3002,6 +3145,10 @@ jest@20.0.4:
dependencies:
jest-cli "^20.0.4"
+jquery@^3.3.1:
+ version "3.3.1"
+ resolved "https://registry.yarnpkg.com/jquery/-/jquery-3.3.1.tgz#958ce29e81c9790f31be7792df5d4d95fc57fbca"
+
js-base64@^2.1.9:
version "2.4.3"
resolved "https://registry.yarnpkg.com/js-base64/-/js-base64-2.4.3.tgz#2e545ec2b0f2957f41356510205214e98fad6582"
@@ -3159,6 +3306,10 @@ lcid@^1.0.0:
dependencies:
invert-kv "^1.0.0"
+leaflet@^1.3.1:
+ version "1.3.1"
+ resolved "https://registry.yarnpkg.com/leaflet/-/leaflet-1.3.1.tgz#86f336d2fb0e2d0ff446677049a5dc34cf0ea60e"
+
leven@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/leven/-/leven-2.1.0.tgz#c2e7a9f772094dee9d34202ae8acce4687875580"
@@ -3217,6 +3368,10 @@ locate-path@^2.0.0:
p-locate "^2.0.0"
path-exists "^3.0.0"
+lodash-es@^4.0.0, lodash-es@^4.2.0, lodash-es@^4.2.1:
+ version "4.17.4"
+ resolved "https://registry.yarnpkg.com/lodash-es/-/lodash-es-4.17.4.tgz#dcc1d7552e150a0640073ba9cb31d70f032950e7"
+
lodash._reinterpolate@~3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/lodash._reinterpolate/-/lodash._reinterpolate-3.0.0.tgz#0ccf2d89166af03b3663c796538b75ac6e114d9d"
@@ -3237,6 +3392,10 @@ lodash.isfunction@^3.0.8:
version "3.0.8"
resolved "https://registry.yarnpkg.com/lodash.isfunction/-/lodash.isfunction-3.0.8.tgz#4db709fc81bc4a8fd7127a458a5346c5cdce2c6b"
+lodash.isobject@^3.0.2:
+ version "3.0.2"
+ resolved "https://registry.yarnpkg.com/lodash.isobject/-/lodash.isobject-3.0.2.tgz#3c8fb8d5b5bf4bf90ae06e14f2a530a4ed935e1d"
+
lodash.isstring@^4.0.1:
version "4.0.1"
resolved "https://registry.yarnpkg.com/lodash.isstring/-/lodash.isstring-4.0.1.tgz#d527dfb5456eca7cc9bb95d5daeaf88ba54a5451"
@@ -3262,11 +3421,15 @@ lodash.templatesettings@^4.0.0:
dependencies:
lodash._reinterpolate "~3.0.0"
+lodash.tonumber@^4.0.3:
+ version "4.0.3"
+ resolved "https://registry.yarnpkg.com/lodash.tonumber/-/lodash.tonumber-4.0.3.tgz#0b96b31b35672793eb7f5a63ee791f1b9e9025d9"
+
lodash.uniq@^4.5.0:
version "4.5.0"
resolved "https://registry.yarnpkg.com/lodash.uniq/-/lodash.uniq-4.5.0.tgz#d0225373aeb652adc1bc82e4945339a842754773"
-"lodash@>=3.5 <5", lodash@^4.14.0, lodash@^4.17.2, lodash@^4.17.3, lodash@^4.17.4, lodash@^4.3.0:
+"lodash@>=3.5 <5", lodash@^4.0.0, lodash@^4.14.0, lodash@^4.17.2, lodash@^4.17.3, lodash@^4.17.4, lodash@^4.2.0, lodash@^4.2.1, lodash@^4.3.0:
version "4.17.4"
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.4.tgz#78203a4d1c328ae1d86dca6460e369b57f4055ae"
@@ -3470,6 +3633,14 @@ mkdirp@0.5.x, "mkdirp@>=0.5 0", mkdirp@^0.5.1, mkdirp@~0.5.0, mkdirp@~0.5.1:
dependencies:
minimist "0.0.8"
+moment@^2.20.1:
+ version "2.20.1"
+ resolved "https://registry.yarnpkg.com/moment/-/moment-2.20.1.tgz#d6eb1a46cbcc14a2b2f9434112c1ff8907f313fd"
+
+moment@~2.18.0:
+ version "2.18.1"
+ resolved "https://registry.yarnpkg.com/moment/-/moment-2.18.1.tgz#c36193dd3ce1c2eed2adb7c802dbbc77a81b1c0f"
+
ms@2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8"
@@ -3657,6 +3828,10 @@ object-assign@4.1.1, object-assign@^4.0.1, object-assign@^4.1.0, object-assign@^
version "4.1.1"
resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863"
+object-assign@^3.0.0:
+ version "3.0.0"
+ resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-3.0.0.tgz#9bedd5ca0897949bca47e7ff408062d549f587f2"
+
object-keys@^1.0.8:
version "1.0.11"
resolved "https://registry.yarnpkg.com/object-keys/-/object-keys-1.0.11.tgz#c54601778ad560f1142ce0e01bcca8b56d13426d"
@@ -3939,6 +4114,10 @@ pkg-dir@^2.0.0:
dependencies:
find-up "^2.1.0"
+popper.js@^1.12.5, popper.js@^1.12.9:
+ version "1.12.9"
+ resolved "https://registry.yarnpkg.com/popper.js/-/popper.js-1.12.9.tgz#0dfbc2dff96c451bb332edcfcfaaf566d331d5b3"
+
portfinder@^1.0.9:
version "1.0.13"
resolved "https://registry.yarnpkg.com/portfinder/-/portfinder-1.0.13.tgz#bb32ecd87c27104ae6ee44b5a3ccbf0ebb1aede9"
@@ -4285,7 +4464,7 @@ promise@^7.1.1:
dependencies:
asap "~2.0.3"
-prop-types@^15.6.0:
+prop-types@^15.5.10, prop-types@^15.5.7, prop-types@^15.5.8, prop-types@^15.6.0:
version "15.6.0"
resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.6.0.tgz#ceaf083022fc46b4a35f69e13ef75aed0d639856"
dependencies:
@@ -4409,6 +4588,22 @@ rc@^1.0.1, rc@^1.1.6, rc@^1.1.7:
minimist "^1.2.0"
strip-json-comments "~2.0.1"
+react-chartjs-2@^2.7.0:
+ version "2.7.0"
+ resolved "https://registry.yarnpkg.com/react-chartjs-2/-/react-chartjs-2-2.7.0.tgz#14925143f7e5c59dc631d0d8279255b031c5561a"
+ dependencies:
+ lodash "^4.17.4"
+ prop-types "^15.5.8"
+
+react-datetime@^2.10.3:
+ version "2.12.0"
+ resolved "https://registry.yarnpkg.com/react-datetime/-/react-datetime-2.12.0.tgz#9226a11730b7be1273c12f018a4d5613496e831c"
+ dependencies:
+ create-react-class "^15.5.2"
+ object-assign "^3.0.0"
+ prop-types "^15.5.7"
+ react-onclickoutside "^6.5.0"
+
react-dev-utils@4.2.1:
version "4.2.1"
resolved "https://registry.yarnpkg.com/react-dev-utils/-/react-dev-utils-4.2.1.tgz#9f2763e7bafa1a1b9c52254d2a479deec280f111"
@@ -4445,6 +4640,42 @@ react-error-overlay@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/react-error-overlay/-/react-error-overlay-3.0.0.tgz#c2bc8f4d91f1375b3dad6d75265d51cd5eeaf655"
+react-input-autosize@^2.1.2:
+ version "2.2.1"
+ resolved "https://registry.yarnpkg.com/react-input-autosize/-/react-input-autosize-2.2.1.tgz#ec428fa15b1592994fb5f9aa15bb1eb6baf420f8"
+ dependencies:
+ prop-types "^15.5.8"
+
+react-leaflet@^1.8.0:
+ version "1.8.0"
+ resolved "https://registry.yarnpkg.com/react-leaflet/-/react-leaflet-1.8.0.tgz#e33ba704910e2ad86dd29b5a4a52acb7030fe2c4"
+ dependencies:
+ lodash "^4.0.0"
+ lodash-es "^4.0.0"
+ warning "^3.0.0"
+
+react-onclickoutside@^6.5.0:
+ version "6.7.1"
+ resolved "https://registry.yarnpkg.com/react-onclickoutside/-/react-onclickoutside-6.7.1.tgz#6a5b5b8b4eae6b776259712c89c8a2b36b17be93"
+
+react-popper@^0.7.2:
+ version "0.7.5"
+ resolved "https://registry.yarnpkg.com/react-popper/-/react-popper-0.7.5.tgz#71c25946f291db381231281f6b95729e8b801596"
+ dependencies:
+ popper.js "^1.12.5"
+ prop-types "^15.5.10"
+
+react-redux@^5.0.6:
+ version "5.0.6"
+ resolved "https://registry.yarnpkg.com/react-redux/-/react-redux-5.0.6.tgz#23ed3a4f986359d68b5212eaaa681e60d6574946"
+ dependencies:
+ hoist-non-react-statics "^2.2.1"
+ invariant "^2.0.0"
+ lodash "^4.2.0"
+ lodash-es "^4.2.0"
+ loose-envify "^1.1.0"
+ prop-types "^15.5.10"
+
react-scripts-ts@2.13.0:
version "2.13.0"
resolved "https://registry.yarnpkg.com/react-scripts-ts/-/react-scripts-ts-2.13.0.tgz#d5493035e6d521030f0d3e5e8e5b5872f7df1a28"
@@ -4482,6 +4713,31 @@ react-scripts-ts@2.13.0:
optionalDependencies:
fsevents "1.1.2"
+react-select@^1.2.1:
+ version "1.2.1"
+ resolved "https://registry.yarnpkg.com/react-select/-/react-select-1.2.1.tgz#a2fe58a569eb14dcaa6543816260b97e538120d1"
+ dependencies:
+ classnames "^2.2.4"
+ prop-types "^15.5.8"
+ react-input-autosize "^2.1.2"
+
+react-table@^6.7.6:
+ version "6.7.6"
+ resolved "https://registry.yarnpkg.com/react-table/-/react-table-6.7.6.tgz#3f9e3b308328ceb04ef93a47babefaba2619c905"
+ dependencies:
+ classnames "^2.2.5"
+
+react-transition-group@^2.2.0:
+ version "2.2.1"
+ resolved "https://registry.yarnpkg.com/react-transition-group/-/react-transition-group-2.2.1.tgz#e9fb677b79e6455fd391b03823afe84849df4a10"
+ dependencies:
+ chain-function "^1.0.0"
+ classnames "^2.2.5"
+ dom-helpers "^3.2.0"
+ loose-envify "^1.3.1"
+ prop-types "^15.5.8"
+ warning "^3.0.0"
+
react@^16.2.0:
version "16.2.0"
resolved "https://registry.yarnpkg.com/react/-/react-16.2.0.tgz#a31bd2dab89bff65d42134fa187f24d054c273ba"
@@ -4491,6 +4747,18 @@ react@^16.2.0:
object-assign "^4.1.1"
prop-types "^15.6.0"
+reactstrap@^5.0.0-beta:
+ version "5.0.0-beta"
+ resolved "https://registry.yarnpkg.com/reactstrap/-/reactstrap-5.0.0-beta.tgz#0372acae7665ec3396abe87e5cdddf3077e98995"
+ dependencies:
+ classnames "^2.2.3"
+ lodash.isfunction "^3.0.8"
+ lodash.isobject "^3.0.2"
+ lodash.tonumber "^4.0.3"
+ prop-types "^15.5.8"
+ react-popper "^0.7.2"
+ react-transition-group "^2.2.0"
+
read-pkg-up@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/read-pkg-up/-/read-pkg-up-1.0.1.tgz#9d63c13276c065918d57f002a57f40a1b643fb02"
@@ -4578,6 +4846,25 @@ reduce-function-call@^1.0.1:
dependencies:
balanced-match "^0.4.2"
+redux-logger@^3.0.6:
+ version "3.0.6"
+ resolved "https://registry.yarnpkg.com/redux-logger/-/redux-logger-3.0.6.tgz#f7555966f3098f3c88604c449cf0baf5778274bf"
+ dependencies:
+ deep-diff "^0.3.5"
+
+redux-thunk@^2.2.0:
+ version "2.2.0"
+ resolved "https://registry.yarnpkg.com/redux-thunk/-/redux-thunk-2.2.0.tgz#e615a16e16b47a19a515766133d1e3e99b7852e5"
+
+redux@^3.6.0, redux@^3.7.2:
+ version "3.7.2"
+ resolved "https://registry.yarnpkg.com/redux/-/redux-3.7.2.tgz#06b73123215901d25d065be342eb026bc1c8537b"
+ dependencies:
+ lodash "^4.2.1"
+ lodash-es "^4.2.1"
+ loose-envify "^1.1.0"
+ symbol-observable "^1.0.3"
+
regenerate@^1.2.1:
version "1.3.3"
resolved "https://registry.yarnpkg.com/regenerate/-/regenerate-1.3.3.tgz#0c336d3980553d755c39b586ae3b20aa49c82b7f"
@@ -5235,6 +5522,10 @@ sw-toolbox@^3.4.0:
path-to-regexp "^1.0.1"
serviceworker-cache-polyfill "^4.0.0"
+symbol-observable@^1.0.3:
+ version "1.2.0"
+ resolved "https://registry.yarnpkg.com/symbol-observable/-/symbol-observable-1.2.0.tgz#c22688aed4eab3cdc2dfeacbb561660560a00804"
+
symbol-tree@^3.2.1:
version "3.2.2"
resolved "https://registry.yarnpkg.com/symbol-tree/-/symbol-tree-3.2.2.tgz#ae27db38f660a7ae2e1c3b7d1bc290819b8519e6"
@@ -5643,6 +5934,12 @@ walker@~1.0.5:
dependencies:
makeerror "1.0.x"
+warning@^3.0.0:
+ version "3.0.0"
+ resolved "https://registry.yarnpkg.com/warning/-/warning-3.0.0.tgz#32e5377cb572de4ab04753bdf8821c01ed605b7c"
+ dependencies:
+ loose-envify "^1.0.0"
+
watch@~0.10.0:
version "0.10.0"
resolved "https://registry.yarnpkg.com/watch/-/watch-0.10.0.tgz#77798b2da0f9910d595f1ace5b0c2258521f21dc"