mirror of
https://github.com/umami-software/umami.git
synced 2025-12-08 05:12:36 +01:00
Added world map component.
This commit is contained in:
parent
e4e7f5b05c
commit
a65f637df2
10 changed files with 545 additions and 79 deletions
|
|
@ -2,6 +2,7 @@ import React, { useState, useEffect, useMemo } from 'react';
|
|||
import { useSpring, animated } from 'react-spring';
|
||||
import classNames from 'classnames';
|
||||
import { get } from 'lib/web';
|
||||
import { percentFilter } from 'lib/filters';
|
||||
import styles from './RankingsChart.module.css';
|
||||
|
||||
export default function RankingsChart({
|
||||
|
|
@ -13,6 +14,7 @@ export default function RankingsChart({
|
|||
heading,
|
||||
className,
|
||||
dataFilter,
|
||||
onDataLoad = () => {},
|
||||
}) {
|
||||
const [data, setData] = useState();
|
||||
|
||||
|
|
@ -23,16 +25,17 @@ export default function RankingsChart({
|
|||
return [];
|
||||
}, [data]);
|
||||
|
||||
const total = useMemo(() => rankings?.reduce((n, { y }) => n + y, 0) || 0, [rankings]);
|
||||
|
||||
async function loadData() {
|
||||
setData(
|
||||
await get(`/api/website/${websiteId}/rankings`, {
|
||||
start_at: +startDate,
|
||||
end_at: +endDate,
|
||||
type,
|
||||
}),
|
||||
);
|
||||
const data = await get(`/api/website/${websiteId}/rankings`, {
|
||||
start_at: +startDate,
|
||||
end_at: +endDate,
|
||||
type,
|
||||
});
|
||||
|
||||
const updated = percentFilter(data);
|
||||
|
||||
setData(updated);
|
||||
onDataLoad(updated);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -51,26 +54,25 @@ export default function RankingsChart({
|
|||
<div className={styles.title}>{title}</div>
|
||||
<div className={styles.heading}>{heading}</div>
|
||||
</div>
|
||||
{rankings.map(({ x, y }) => (
|
||||
<Row key={x} label={x} value={y} total={total} />
|
||||
{rankings.map(({ x, y, z }) => (
|
||||
<Row key={x} label={x} value={y} percent={z} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const Row = ({ label, value, total }) => {
|
||||
const pct = total ? (value / total) * 100 : 0;
|
||||
const props = useSpring({ width: pct, from: { width: 0 } });
|
||||
const Row = ({ label, value, percent }) => {
|
||||
const props = useSpring({ width: percent, from: { width: 0 } });
|
||||
const valueProps = useSpring({ y: value, from: { y: 0 } });
|
||||
|
||||
return (
|
||||
<div className={styles.row}>
|
||||
<div className={styles.label}>{label}</div>
|
||||
<animated.div className={styles.value}>
|
||||
{valueProps.y.interpolate(y => y.toFixed(0))}
|
||||
{valueProps.y.interpolate(n => n.toFixed(0))}
|
||||
</animated.div>
|
||||
<div className={styles.percent}>
|
||||
<animated.div>{props.width.interpolate(y => `${y.toFixed(0)}%`)}</animated.div>
|
||||
<animated.div>{props.width.interpolate(n => `${n.toFixed(0)}%`)}</animated.div>
|
||||
<animated.div className={styles.bar} style={{ ...props }} />
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,74 +1,19 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { get } from 'lib/web';
|
||||
import WebsiteChart from './WebsiteChart';
|
||||
import RankingsChart from './RankingsChart';
|
||||
import { getDateRange } from '../lib/date';
|
||||
import WorldMap from './WorldMap';
|
||||
import { getDateRange } from 'lib/date';
|
||||
import { get } from 'lib/web';
|
||||
import { browserFilter, urlFilter, refFilter, deviceFilter, countryFilter } from 'lib/filters';
|
||||
import styles from './WebsiteDetails.module.css';
|
||||
|
||||
const pageviewClasses = 'col-md-12 col-lg-6';
|
||||
const sessionClasses = 'col-12 col-lg-4';
|
||||
|
||||
const urlFilter = data => data.filter(({ x }) => x !== '' && !x.startsWith('#'));
|
||||
|
||||
const refFilter = data =>
|
||||
data.filter(({ x }) => x !== '' && !x.startsWith('/') && !x.startsWith('#'));
|
||||
|
||||
const deviceFilter = data => {
|
||||
const devices = data.reduce(
|
||||
(obj, { x, y }) => {
|
||||
const [width] = x.split('x');
|
||||
if (width >= 1920) {
|
||||
obj.Desktop += +y;
|
||||
} else if (width >= 1024) {
|
||||
obj.Laptop += +y;
|
||||
} else if (width >= 767) {
|
||||
obj.Tablet += +y;
|
||||
} else {
|
||||
obj.Mobile += +y;
|
||||
}
|
||||
return obj;
|
||||
},
|
||||
{ Desktop: 0, Laptop: 0, Tablet: 0, Mobile: 0 },
|
||||
);
|
||||
|
||||
return Object.keys(devices).map(key => ({ x: key, y: devices[key] }));
|
||||
};
|
||||
|
||||
const browsers = {
|
||||
aol: 'AOL',
|
||||
edge: 'Edge',
|
||||
'edge-ios': 'Edge (iOS)',
|
||||
yandexbrowser: 'Yandex',
|
||||
kakaotalk: 'KKaoTalk',
|
||||
samsung: 'Samsung',
|
||||
silk: 'Silk',
|
||||
miui: 'MIUI',
|
||||
beaker: 'Beaker',
|
||||
'edge-chromium': 'Edge (Chromium)',
|
||||
chrome: 'Chrome',
|
||||
'chromium-webview': 'Chrome (webview)',
|
||||
phantomjs: 'PhantomJS',
|
||||
crios: 'Chrome (iOS)',
|
||||
firefox: 'Firefox',
|
||||
fxios: 'Firefox (iOS)',
|
||||
'opera-mini': 'Opera Mini',
|
||||
opera: 'Opera',
|
||||
ie: 'IE',
|
||||
bb10: 'BlackBerry 10',
|
||||
android: 'Android',
|
||||
ios: 'iOS',
|
||||
safari: 'Safari',
|
||||
facebook: 'Facebook',
|
||||
instagram: 'Instagram',
|
||||
'ios-webview': 'iOS (webview)',
|
||||
searchbot: 'Searchbot',
|
||||
};
|
||||
|
||||
const browserFilter = data => data.map(({ x, y }) => ({ x: browsers[x] || x, y }));
|
||||
|
||||
export default function WebsiteDetails({ websiteId, defaultDateRange = '7day' }) {
|
||||
const [data, setData] = useState();
|
||||
const [countryData, setCountryData] = useState();
|
||||
const [dateRange, setDateRange] = useState(getDateRange(defaultDateRange));
|
||||
const { startDate, endDate } = dateRange;
|
||||
|
||||
|
|
@ -151,6 +96,20 @@ export default function WebsiteDetails({ websiteId, defaultDateRange = '7day' })
|
|||
dataFilter={deviceFilter}
|
||||
/>
|
||||
</div>
|
||||
<div className="row">
|
||||
<WorldMap data={countryData} className="col-12 col-md-12 col-lg-8" />
|
||||
<RankingsChart
|
||||
title="Countries"
|
||||
type="country"
|
||||
heading="Visitors"
|
||||
className="col-12 col-md-12 col-lg-4"
|
||||
websiteId={data.website_id}
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
dataFilter={countryFilter}
|
||||
onDataLoad={data => setCountryData(data)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
71
components/WorldMap.js
Normal file
71
components/WorldMap.js
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import React, { useState } from 'react';
|
||||
import ReactTooltip from 'react-tooltip';
|
||||
import classNames from 'classnames';
|
||||
import tinycolor from 'tinycolor2';
|
||||
import { ComposableMap, Geographies, Geography, ZoomableGroup } from 'react-simple-maps';
|
||||
import styles from './WorldMap.module.css';
|
||||
|
||||
const geoUrl = '/world-110m.json';
|
||||
|
||||
export default function WorldMap({
|
||||
data,
|
||||
className,
|
||||
baseColor = '#e9f3fd',
|
||||
fillColor = '#f5f5f5',
|
||||
strokeColor = '#2680eb',
|
||||
hoverColor = '#2680eb',
|
||||
}) {
|
||||
const [tooltip, setTooltip] = useState();
|
||||
|
||||
function getFillColor(code) {
|
||||
if (code === 'AQ') return '#ffffff';
|
||||
const country = data?.find(({ x }) => x === code);
|
||||
return country ? tinycolor(baseColor).darken(country.z) : fillColor;
|
||||
}
|
||||
|
||||
function getStrokeColor(code) {
|
||||
return code === 'AQ' ? '#ffffff' : strokeColor;
|
||||
}
|
||||
|
||||
function getHoverColor(code) {
|
||||
return code === 'AQ' ? '#ffffff' : hoverColor;
|
||||
}
|
||||
|
||||
function handleHover({ ISO_A2: code, NAME: name }) {
|
||||
const country = data?.find(({ x }) => x === code);
|
||||
setTooltip(`${name}: ${country?.y || 0} visitors`);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={classNames(styles.container, className)}>
|
||||
<ComposableMap data-tip="" projection="geoMercator">
|
||||
<ZoomableGroup zoom={0.8} minZoom={0.7} center={[0, 40]}>
|
||||
<Geographies geography={geoUrl}>
|
||||
{({ geographies }) => {
|
||||
return geographies.map(geo => {
|
||||
const code = geo.properties.ISO_A2;
|
||||
|
||||
return (
|
||||
<Geography
|
||||
key={geo.rsmKey}
|
||||
geography={geo}
|
||||
fill={getFillColor(code)}
|
||||
stroke={getStrokeColor(code)}
|
||||
style={{
|
||||
default: { outline: 'none' },
|
||||
hover: { outline: 'none', fill: getHoverColor(code) },
|
||||
pressed: { outline: 'none' },
|
||||
}}
|
||||
onMouseOver={() => handleHover(geo.properties)}
|
||||
onMouseOut={() => setTooltip(null)}
|
||||
/>
|
||||
);
|
||||
});
|
||||
}}
|
||||
</Geographies>
|
||||
</ZoomableGroup>
|
||||
</ComposableMap>
|
||||
<ReactTooltip>{tooltip}</ReactTooltip>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
5
components/WorldMap.module.css
Normal file
5
components/WorldMap.module.css
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
.container {
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
border-top: 1px solid #e1e1e1;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue