110
common/resources/client/admin/analytics/geo-chart/geo-chart.tsx
Executable file
110
common/resources/client/admin/analytics/geo-chart/geo-chart.tsx
Executable file
@@ -0,0 +1,110 @@
|
||||
import {
|
||||
LocationDatasetItem,
|
||||
ReportMetric,
|
||||
} from '@common/admin/analytics/report-metric';
|
||||
import React, {useMemo, useRef} from 'react';
|
||||
import {useGoogleGeoChart} from './use-google-geo-chart';
|
||||
import {ChartLayout, ChartLayoutProps} from '@common/charts/chart-layout';
|
||||
import {Trans} from '@common/i18n/trans';
|
||||
import {ChartLoadingIndicator} from '@common/charts/chart-loading-indicator';
|
||||
import {Button} from '@common/ui/buttons/button';
|
||||
import {ArrowBackIcon} from '@common/icons/material/ArrowBack';
|
||||
import clsx from 'clsx';
|
||||
import {InfoDialogTrigger} from '@common/ui/overlays/dialog/info-dialog-trigger/info-dialog-trigger';
|
||||
import {FormattedCountryName} from '@common/i18n/formatted-country-name';
|
||||
|
||||
interface GeoChartData extends Partial<ChartLayoutProps> {
|
||||
data?: ReportMetric<LocationDatasetItem>;
|
||||
onCountrySelected?: (countryCode: string | undefined) => void;
|
||||
country?: string;
|
||||
}
|
||||
export function GeoChart({
|
||||
data: metricData,
|
||||
isLoading,
|
||||
onCountrySelected,
|
||||
country,
|
||||
...layoutProps
|
||||
}: GeoChartData) {
|
||||
const placeholderRef = useRef<HTMLDivElement>(null);
|
||||
const regionInteractivity = !!onCountrySelected;
|
||||
|
||||
// memo data to avoid redrawing chart on rerender
|
||||
const initialData = metricData?.datasets[0].data;
|
||||
const data = useMemo(() => {
|
||||
return initialData || [];
|
||||
}, [initialData]);
|
||||
useGoogleGeoChart({placeholderRef, data, country, onCountrySelected});
|
||||
|
||||
return (
|
||||
<ChartLayout
|
||||
{...layoutProps}
|
||||
className="min-w-500"
|
||||
title={
|
||||
<div className="flex items-center">
|
||||
<Trans message="Top Locations" />
|
||||
{country ? (
|
||||
<span className="pl-4">
|
||||
({<FormattedCountryName code={country} />})
|
||||
</span>
|
||||
) : null}
|
||||
{regionInteractivity && <InfoTrigger />}
|
||||
</div>
|
||||
}
|
||||
contentIsFlex={isLoading}
|
||||
>
|
||||
{isLoading && <ChartLoadingIndicator />}
|
||||
<div className="flex gap-24">
|
||||
<div
|
||||
ref={placeholderRef}
|
||||
className="flex-auto w-[480px] min-h-[340px]"
|
||||
/>
|
||||
<div className="w-[170px]">
|
||||
<div className="text-sm max-h-[340px] w-full flex-initial overflow-y-auto">
|
||||
{data.map(location => (
|
||||
<div
|
||||
key={location.label}
|
||||
className={clsx(
|
||||
'flex items-center gap-4 mb-4',
|
||||
regionInteractivity && 'cursor-pointer hover:underline'
|
||||
)}
|
||||
role={regionInteractivity ? 'button' : undefined}
|
||||
onClick={() => {
|
||||
onCountrySelected?.(location.code);
|
||||
}}
|
||||
>
|
||||
<div className="max-w-110 whitespace-nowrap overflow-hidden overflow-ellipsis">
|
||||
{location.label}
|
||||
</div>
|
||||
<div>({location.percentage})%</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{country && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
className="mt-14"
|
||||
startIcon={<ArrowBackIcon />}
|
||||
onClick={() => {
|
||||
onCountrySelected?.(undefined);
|
||||
}}
|
||||
>
|
||||
<Trans message="Back to countries" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ChartLayout>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoTrigger() {
|
||||
return (
|
||||
<InfoDialogTrigger
|
||||
title={<Trans message="Zooming in" />}
|
||||
body={
|
||||
<Trans message="Click on a country inside the map or country list to zoom in and see city data for that country." />
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
116
common/resources/client/admin/analytics/geo-chart/use-google-geo-chart.ts
Executable file
116
common/resources/client/admin/analytics/geo-chart/use-google-geo-chart.ts
Executable file
@@ -0,0 +1,116 @@
|
||||
import lazyLoader from '../../../utils/http/lazy-loader';
|
||||
import {useSettings} from '@common/core/settings/use-settings';
|
||||
import {RefObject, useCallback, useEffect, useRef} from 'react';
|
||||
import {useThemeSelector} from '@common/ui/themes/theme-selector-context';
|
||||
import {themeValueToHex} from '@common/ui/themes/utils/theme-value-to-hex';
|
||||
import {useTrans} from '@common/i18n/use-trans';
|
||||
import {message} from '@common/i18n/message';
|
||||
import {LocationDatasetItem} from '@common/admin/analytics/report-metric';
|
||||
|
||||
const loaderUrl = 'https://www.gstatic.com/charts/loader.js';
|
||||
|
||||
interface UseGoogleGeoChartProps {
|
||||
placeholderRef: RefObject<HTMLDivElement>;
|
||||
data: LocationDatasetItem[];
|
||||
onCountrySelected?: (countryCode: string) => void;
|
||||
country?: string;
|
||||
}
|
||||
export function useGoogleGeoChart({
|
||||
placeholderRef,
|
||||
data,
|
||||
country,
|
||||
onCountrySelected,
|
||||
}: UseGoogleGeoChartProps) {
|
||||
const {trans} = useTrans();
|
||||
const {analytics} = useSettings();
|
||||
const apiKey = analytics?.gchart_api_key;
|
||||
const {selectedTheme} = useThemeSelector();
|
||||
const geoChartRef = useRef<google.visualization.GeoChart>();
|
||||
// only allow selecting countries, not cities
|
||||
const regionInteractivity = !!onCountrySelected && !country;
|
||||
const drawGoogleChart = useCallback(() => {
|
||||
if (typeof google === 'undefined') return;
|
||||
|
||||
const seedData = data.map(location => [location.label, location.value]);
|
||||
seedData.unshift([
|
||||
country ? trans(message('City')) : trans(message('Country')),
|
||||
trans(message('Clicks')),
|
||||
]);
|
||||
|
||||
const backgroundColor = `${themeValueToHex(
|
||||
selectedTheme.values['--be-paper'],
|
||||
)}`;
|
||||
const chartColor = `${themeValueToHex(
|
||||
selectedTheme.values['--be-primary'],
|
||||
)}`;
|
||||
|
||||
const options: google.visualization.GeoChartOptions = {
|
||||
colorAxis: {colors: [chartColor]},
|
||||
backgroundColor,
|
||||
region: country ? country.toUpperCase() : undefined,
|
||||
resolution: country ? 'provinces' : 'countries',
|
||||
displayMode: country ? 'markers' : 'regions',
|
||||
enableRegionInteractivity: regionInteractivity,
|
||||
};
|
||||
|
||||
if (
|
||||
!geoChartRef.current &&
|
||||
placeholderRef.current &&
|
||||
google?.visualization?.GeoChart
|
||||
) {
|
||||
geoChartRef.current = new google.visualization.GeoChart(
|
||||
placeholderRef.current,
|
||||
);
|
||||
}
|
||||
geoChartRef.current?.draw(
|
||||
google.visualization.arrayToDataTable(seedData),
|
||||
options,
|
||||
);
|
||||
}, [
|
||||
selectedTheme,
|
||||
data,
|
||||
placeholderRef,
|
||||
trans,
|
||||
country,
|
||||
regionInteractivity,
|
||||
]);
|
||||
|
||||
const initGoogleGeoChart = useCallback(async () => {
|
||||
if (lazyLoader.isLoadingOrLoaded(loaderUrl)) return;
|
||||
await lazyLoader.loadAsset(loaderUrl, {type: 'js', id: 'google-charts-js'});
|
||||
await google.charts.load('current', {
|
||||
packages: ['geochart'],
|
||||
mapsApiKey: apiKey,
|
||||
});
|
||||
drawGoogleChart();
|
||||
}, [apiKey, drawGoogleChart]);
|
||||
|
||||
useEffect(() => {
|
||||
if (geoChartRef.current && onCountrySelected) {
|
||||
google.visualization.events.addListener(
|
||||
geoChartRef.current,
|
||||
'regionClick',
|
||||
(a: {region: string}) => onCountrySelected?.(a.region),
|
||||
);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (geoChartRef.current) {
|
||||
google.visualization.events.removeAllListeners(geoChartRef.current);
|
||||
}
|
||||
};
|
||||
// this will correctly run when geochart instance is set on ref
|
||||
}, [onCountrySelected, geoChartRef.current]);
|
||||
|
||||
// on component load: load chart library then draw, otherwise just draw
|
||||
useEffect(() => {
|
||||
initGoogleGeoChart();
|
||||
}, [initGoogleGeoChart]);
|
||||
|
||||
// redraw chart if data or theme changes
|
||||
useEffect(() => {
|
||||
drawGoogleChart();
|
||||
}, [selectedTheme, drawGoogleChart, data]);
|
||||
|
||||
return {drawGoogleChart};
|
||||
}
|
||||
Reference in New Issue
Block a user