mirror of
https://github.com/shlinkio/shlink-web-client.git
synced 2026-03-14 19:43:49 +00:00
Implemented map to show visits from every city
This commit is contained in:
@@ -7,11 +7,15 @@ import { homepage } from '../package.json';
|
|||||||
import registerServiceWorker from './registerServiceWorker';
|
import registerServiceWorker from './registerServiceWorker';
|
||||||
import container from './container';
|
import container from './container';
|
||||||
import store from './container/store';
|
import store from './container/store';
|
||||||
import '../node_modules/react-datepicker/dist/react-datepicker.css';
|
import { fixLeafletIcons } from './utils/utils';
|
||||||
import '../node_modules/leaflet/dist/leaflet.css';
|
import 'react-datepicker/dist/react-datepicker.css';
|
||||||
|
import 'leaflet/dist/leaflet.css';
|
||||||
import './common/react-tagsinput.scss';
|
import './common/react-tagsinput.scss';
|
||||||
import './index.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;
|
const { App, ScrollToTop } = container;
|
||||||
|
|
||||||
render(
|
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;
|
const DEFAULT_TIMEOUT_DELAY = 2000;
|
||||||
|
|
||||||
export const stateFlagTimeout = (setState, flagName, initialValue = true, delay = DEFAULT_TIMEOUT_DELAY) => {
|
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';
|
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 { faCircleNotch as preloader } from '@fortawesome/free-solid-svg-icons';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { isEmpty, mapObjIndexed } from 'ramda';
|
import { isEmpty, mapObjIndexed, values } from 'ramda';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Card } from 'reactstrap';
|
import { Card } from 'reactstrap';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
@@ -20,6 +20,7 @@ const ShortUrlVisits = ({
|
|||||||
processCountriesStats,
|
processCountriesStats,
|
||||||
processCitiesStats,
|
processCitiesStats,
|
||||||
processReferrersStats,
|
processReferrersStats,
|
||||||
|
processCitiesStatsForMap,
|
||||||
}) => class ShortUrlVisits extends React.Component {
|
}) => class ShortUrlVisits extends React.Component {
|
||||||
static propTypes = {
|
static propTypes = {
|
||||||
match: PropTypes.shape({
|
match: PropTypes.shape({
|
||||||
@@ -102,7 +103,14 @@ const ShortUrlVisits = ({
|
|||||||
<SortableBarGraph
|
<SortableBarGraph
|
||||||
stats={processCitiesStats(visits)}
|
stats={processCitiesStats(visits)}
|
||||||
title="Cities"
|
title="Cities"
|
||||||
extraHeaderContent={[ () => <OpenMapModalBtn title="Cities" /> ]}
|
extraHeaderContent={[
|
||||||
|
() => (
|
||||||
|
<OpenMapModalBtn
|
||||||
|
title="Cities"
|
||||||
|
locations={values(processCitiesStatsForMap(visits))}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
]}
|
||||||
sortingItems={{
|
sortingItems={{
|
||||||
name: 'City name',
|
name: 'City name',
|
||||||
amount: 'Visits amount',
|
amount: 'Visits amount',
|
||||||
|
|||||||
@@ -8,33 +8,40 @@ const propTypes = {
|
|||||||
toggle: PropTypes.func,
|
toggle: PropTypes.func,
|
||||||
isOpen: PropTypes.bool,
|
isOpen: PropTypes.bool,
|
||||||
title: PropTypes.string,
|
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 OpenStreetMapTile = () => (
|
||||||
const madridLat = 40.416775;
|
<TileLayer
|
||||||
const madridLong = -3.703790;
|
attribution='&copy <a href="http://osm.org/copyright">OpenStreetMap</a> contributors'
|
||||||
const latLong = [ madridLat, madridLong ];
|
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
const MapModal = ({ toggle, isOpen, title, locations }) => (
|
||||||
<Modal toggle={toggle} isOpen={isOpen} className="map-modal__modal" contentClassName="map-modal__modal-content">
|
<Modal toggle={toggle} isOpen={isOpen} className="map-modal__modal" contentClassName="map-modal__modal-content">
|
||||||
<ModalHeader toggle={toggle}>{title}</ModalHeader>
|
<ModalHeader toggle={toggle}>{title}</ModalHeader>
|
||||||
<ModalBody className="map-modal__modal-body">
|
<ModalBody className="map-modal__modal-body">
|
||||||
<Map center={latLong} zoom="13">
|
<Map center={[ 0, 0 ]} zoom="3">
|
||||||
<TileLayer
|
<OpenStreetMapTile />
|
||||||
attribution='&copy <a href="http://osm.org/copyright">OpenStreetMap</a> contributors'
|
{locations.map(({ city, latLong, count }, index) => (
|
||||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
<Marker key={index} position={latLong}>
|
||||||
/>
|
<Popup><b>{count}</b> visit{count > 1 ? 's' : ''} from <b>{city}</b></Popup>
|
||||||
<Marker position={latLong}>
|
|
||||||
<Popup>
|
|
||||||
A pretty CSS3 popup. <br /> Easily customizable.
|
|
||||||
</Popup>
|
|
||||||
</Marker>
|
</Marker>
|
||||||
</Map>
|
))}
|
||||||
</ModalBody>
|
</Map>
|
||||||
</Modal>
|
</ModalBody>
|
||||||
);
|
</Modal>
|
||||||
};
|
);
|
||||||
|
|
||||||
MapModal.propTypes = propTypes;
|
MapModal.propTypes = propTypes;
|
||||||
|
MapModal.defaultProps = defaultProps;
|
||||||
|
|
||||||
export default MapModal;
|
export default MapModal;
|
||||||
|
|||||||
@@ -9,12 +9,13 @@ import './OpenMapModalBtn.scss';
|
|||||||
export default class OpenMapModalBtn extends React.Component {
|
export default class OpenMapModalBtn extends React.Component {
|
||||||
static propTypes = {
|
static propTypes = {
|
||||||
title: PropTypes.string.isRequired,
|
title: PropTypes.string.isRequired,
|
||||||
|
locations: PropTypes.arrayOf(PropTypes.object),
|
||||||
};
|
};
|
||||||
|
|
||||||
state = { mapIsOpened: false };
|
state = { mapIsOpened: false };
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { title } = this.props;
|
const { title, locations = [] } = this.props;
|
||||||
const toggleMap = () => this.setState(({ mapIsOpened }) => ({ mapIsOpened: !mapIsOpened }));
|
const toggleMap = () => this.setState(({ mapIsOpened }) => ({ mapIsOpened: !mapIsOpened }));
|
||||||
const buttonRef = React.createRef();
|
const buttonRef = React.createRef();
|
||||||
|
|
||||||
@@ -24,7 +25,7 @@ export default class OpenMapModalBtn extends React.Component {
|
|||||||
<FontAwesomeIcon icon={mapIcon} />
|
<FontAwesomeIcon icon={mapIcon} />
|
||||||
</button>
|
</button>
|
||||||
<UncontrolledTooltip placement="bottom" target={() => buttonRef.current}>Show in map</UncontrolledTooltip>
|
<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>
|
</React.Fragment>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,15 +76,18 @@ export const processReferrersStats = (visits) =>
|
|||||||
visits,
|
visits,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const visitLocationHasProperty = (visitLocation, propertyName) =>
|
||||||
|
!isNil(visitLocation)
|
||||||
|
&& !isNil(visitLocation[propertyName])
|
||||||
|
&& !isEmpty(visitLocation[propertyName]);
|
||||||
|
|
||||||
const buildLocationStatsProcessorByProperty = (propertyName) => (visits) =>
|
const buildLocationStatsProcessorByProperty = (propertyName) => (visits) =>
|
||||||
reduce(
|
reduce(
|
||||||
(stats, { visitLocation }) => {
|
(stats, { visitLocation }) => {
|
||||||
const notHasCountry = isNil(visitLocation)
|
const hasLocationProperty = visitLocationHasProperty(visitLocation, propertyName);
|
||||||
|| isNil(visitLocation[propertyName])
|
const value = hasLocationProperty ? visitLocation[propertyName] : 'Unknown';
|
||||||
|| isEmpty(visitLocation[propertyName]);
|
|
||||||
const country = notHasCountry ? 'Unknown' : visitLocation[propertyName];
|
|
||||||
|
|
||||||
return assoc(country, (stats[country] || 0) + 1, stats);
|
return assoc(value, (stats[value] || 0) + 1, stats);
|
||||||
},
|
},
|
||||||
{},
|
{},
|
||||||
visits,
|
visits,
|
||||||
@@ -93,3 +96,22 @@ const buildLocationStatsProcessorByProperty = (propertyName) => (visits) =>
|
|||||||
export const processCountriesStats = buildLocationStatsProcessorByProperty('countryName');
|
export const processCountriesStats = buildLocationStatsProcessorByProperty('countryName');
|
||||||
|
|
||||||
export const processCitiesStats = buildLocationStatsProcessorByProperty('cityName');
|
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,
|
||||||
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user