mirror of
https://github.com/shlinkio/shlink-web-client.git
synced 2025-01-10 18:27:25 +03:00
Implemented map to show visits from every city
This commit is contained in:
parent
78745366c2
commit
4870801f8f
6 changed files with 90 additions and 33 deletions
|
@ -7,11 +7,15 @@ import { homepage } from '../package.json';
|
|||
import registerServiceWorker from './registerServiceWorker';
|
||||
import container from './container';
|
||||
import store from './container/store';
|
||||
import '../node_modules/react-datepicker/dist/react-datepicker.css';
|
||||
import '../node_modules/leaflet/dist/leaflet.css';
|
||||
import { fixLeafletIcons } from './utils/utils';
|
||||
import 'react-datepicker/dist/react-datepicker.css';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
import './common/react-tagsinput.scss';
|
||||
import './index.scss';
|
||||
|
||||
// This overwrites icons used for leaflet maps, fixing some issues caused by webpack while processing the CSS
|
||||
fixLeafletIcons();
|
||||
|
||||
const { App, ScrollToTop } = container;
|
||||
|
||||
render(
|
||||
|
|
|
@ -1,3 +1,8 @@
|
|||
import L from 'leaflet';
|
||||
import marker2x from 'leaflet/dist/images/marker-icon-2x.png';
|
||||
import marker from 'leaflet/dist/images/marker-icon.png';
|
||||
import markerShadow from 'leaflet/dist/images/marker-shadow.png';
|
||||
|
||||
const DEFAULT_TIMEOUT_DELAY = 2000;
|
||||
|
||||
export const stateFlagTimeout = (setState, flagName, initialValue = true, delay = DEFAULT_TIMEOUT_DELAY) => {
|
||||
|
@ -17,3 +22,13 @@ export const determineOrderDir = (clickedField, currentOrderField, currentOrderD
|
|||
|
||||
return currentOrderDir ? newOrderMap[currentOrderDir] : 'ASC';
|
||||
};
|
||||
|
||||
export const fixLeafletIcons = () => {
|
||||
delete L.Icon.Default.prototype._getIconUrl;
|
||||
|
||||
L.Icon.Default.mergeOptions({
|
||||
iconRetinaUrl: marker2x,
|
||||
iconUrl: marker,
|
||||
shadowUrl: markerShadow,
|
||||
});
|
||||
};
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { faCircleNotch as preloader } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { isEmpty, mapObjIndexed } from 'ramda';
|
||||
import { isEmpty, mapObjIndexed, values } from 'ramda';
|
||||
import React from 'react';
|
||||
import { Card } from 'reactstrap';
|
||||
import PropTypes from 'prop-types';
|
||||
|
@ -20,6 +20,7 @@ const ShortUrlVisits = ({
|
|||
processCountriesStats,
|
||||
processCitiesStats,
|
||||
processReferrersStats,
|
||||
processCitiesStatsForMap,
|
||||
}) => class ShortUrlVisits extends React.Component {
|
||||
static propTypes = {
|
||||
match: PropTypes.shape({
|
||||
|
@ -102,7 +103,14 @@ const ShortUrlVisits = ({
|
|||
<SortableBarGraph
|
||||
stats={processCitiesStats(visits)}
|
||||
title="Cities"
|
||||
extraHeaderContent={[ () => <OpenMapModalBtn title="Cities" /> ]}
|
||||
extraHeaderContent={[
|
||||
() => (
|
||||
<OpenMapModalBtn
|
||||
title="Cities"
|
||||
locations={values(processCitiesStatsForMap(visits))}
|
||||
/>
|
||||
),
|
||||
]}
|
||||
sortingItems={{
|
||||
name: 'City name',
|
||||
amount: 'Visits amount',
|
||||
|
|
|
@ -8,33 +8,40 @@ const propTypes = {
|
|||
toggle: PropTypes.func,
|
||||
isOpen: PropTypes.bool,
|
||||
title: PropTypes.string,
|
||||
locations: PropTypes.arrayOf(PropTypes.shape({
|
||||
city: PropTypes.string.isRequired,
|
||||
latLong: PropTypes.arrayOf(PropTypes.number).isRequired,
|
||||
count: PropTypes.number.isRequired,
|
||||
})),
|
||||
};
|
||||
const defaultProps = {
|
||||
locations: [],
|
||||
};
|
||||
|
||||
const MapModal = ({ toggle, isOpen, title }) => {
|
||||
const madridLat = 40.416775;
|
||||
const madridLong = -3.703790;
|
||||
const latLong = [ madridLat, madridLong ];
|
||||
const OpenStreetMapTile = () => (
|
||||
<TileLayer
|
||||
attribution='&copy <a href="http://osm.org/copyright">OpenStreetMap</a> contributors'
|
||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal toggle={toggle} isOpen={isOpen} className="map-modal__modal" contentClassName="map-modal__modal-content">
|
||||
<ModalHeader toggle={toggle}>{title}</ModalHeader>
|
||||
<ModalBody className="map-modal__modal-body">
|
||||
<Map center={latLong} zoom="13">
|
||||
<TileLayer
|
||||
attribution='&copy <a href="http://osm.org/copyright">OpenStreetMap</a> contributors'
|
||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
/>
|
||||
<Marker position={latLong}>
|
||||
<Popup>
|
||||
A pretty CSS3 popup. <br /> Easily customizable.
|
||||
</Popup>
|
||||
const MapModal = ({ toggle, isOpen, title, locations }) => (
|
||||
<Modal toggle={toggle} isOpen={isOpen} className="map-modal__modal" contentClassName="map-modal__modal-content">
|
||||
<ModalHeader toggle={toggle}>{title}</ModalHeader>
|
||||
<ModalBody className="map-modal__modal-body">
|
||||
<Map center={[ 0, 0 ]} zoom="3">
|
||||
<OpenStreetMapTile />
|
||||
{locations.map(({ city, latLong, count }, index) => (
|
||||
<Marker key={index} position={latLong}>
|
||||
<Popup><b>{count}</b> visit{count > 1 ? 's' : ''} from <b>{city}</b></Popup>
|
||||
</Marker>
|
||||
</Map>
|
||||
</ModalBody>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
))}
|
||||
</Map>
|
||||
</ModalBody>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
MapModal.propTypes = propTypes;
|
||||
MapModal.defaultProps = defaultProps;
|
||||
|
||||
export default MapModal;
|
||||
|
|
|
@ -9,12 +9,13 @@ import './OpenMapModalBtn.scss';
|
|||
export default class OpenMapModalBtn extends React.Component {
|
||||
static propTypes = {
|
||||
title: PropTypes.string.isRequired,
|
||||
locations: PropTypes.arrayOf(PropTypes.object),
|
||||
};
|
||||
|
||||
state = { mapIsOpened: false };
|
||||
|
||||
render() {
|
||||
const { title } = this.props;
|
||||
const { title, locations = [] } = this.props;
|
||||
const toggleMap = () => this.setState(({ mapIsOpened }) => ({ mapIsOpened: !mapIsOpened }));
|
||||
const buttonRef = React.createRef();
|
||||
|
||||
|
@ -24,7 +25,7 @@ export default class OpenMapModalBtn extends React.Component {
|
|||
<FontAwesomeIcon icon={mapIcon} />
|
||||
</button>
|
||||
<UncontrolledTooltip placement="bottom" target={() => buttonRef.current}>Show in map</UncontrolledTooltip>
|
||||
<MapModal toggle={toggleMap} isOpen={this.state.mapIsOpened} title={title} />
|
||||
<MapModal toggle={toggleMap} isOpen={this.state.mapIsOpened} title={title} locations={locations} />
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -76,15 +76,18 @@ export const processReferrersStats = (visits) =>
|
|||
visits,
|
||||
);
|
||||
|
||||
const visitLocationHasProperty = (visitLocation, propertyName) =>
|
||||
!isNil(visitLocation)
|
||||
&& !isNil(visitLocation[propertyName])
|
||||
&& !isEmpty(visitLocation[propertyName]);
|
||||
|
||||
const buildLocationStatsProcessorByProperty = (propertyName) => (visits) =>
|
||||
reduce(
|
||||
(stats, { visitLocation }) => {
|
||||
const notHasCountry = isNil(visitLocation)
|
||||
|| isNil(visitLocation[propertyName])
|
||||
|| isEmpty(visitLocation[propertyName]);
|
||||
const country = notHasCountry ? 'Unknown' : visitLocation[propertyName];
|
||||
const hasLocationProperty = visitLocationHasProperty(visitLocation, propertyName);
|
||||
const value = hasLocationProperty ? visitLocation[propertyName] : 'Unknown';
|
||||
|
||||
return assoc(country, (stats[country] || 0) + 1, stats);
|
||||
return assoc(value, (stats[value] || 0) + 1, stats);
|
||||
},
|
||||
{},
|
||||
visits,
|
||||
|
@ -93,3 +96,22 @@ const buildLocationStatsProcessorByProperty = (propertyName) => (visits) =>
|
|||
export const processCountriesStats = buildLocationStatsProcessorByProperty('countryName');
|
||||
|
||||
export const processCitiesStats = buildLocationStatsProcessorByProperty('cityName');
|
||||
|
||||
export const processCitiesStatsForMap = (visits) =>
|
||||
reduce(
|
||||
(stats, { visitLocation }) => {
|
||||
const hasCity = visitLocationHasProperty(visitLocation, 'cityName');
|
||||
const city = hasCity ? visitLocation.cityName : 'unknown';
|
||||
const currentCity = stats[city] || {
|
||||
city,
|
||||
count: 0,
|
||||
latLong: hasCity ? [ parseFloat(visitLocation.latitude), parseFloat(visitLocation.longitude) ] : [ 0, 0 ],
|
||||
};
|
||||
|
||||
currentCity.count++;
|
||||
|
||||
return assoc(city, currentCity, stats);
|
||||
},
|
||||
{},
|
||||
visits,
|
||||
);
|
||||
|
|
Loading…
Reference in a new issue