analytics/assets/js/dashboard/stats/locations/map.tsx

321 lines
8.8 KiB
TypeScript

import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import * as d3 from 'd3'
import classNames from 'classnames'
import * as api from '../../api'
import { replaceFilterByPrefix, cleanLabels } from '../../util/filters'
import { useAppNavigate } from '../../navigation/use-app-navigate'
import { numberShortFormatter } from '../../util/number-formatter'
import * as topojson from 'topojson-client'
import { useQuery } from '@tanstack/react-query'
import { useSiteContext } from '../../site-context'
import { useQueryContext } from '../../query-context'
import worldJson from 'visionscarto-world-atlas/world/110m.json'
import { UIMode, useTheme } from '../../theme-context'
import { apiPath } from '../../util/url'
import MoreLink from '../more-link'
import { countriesRoute } from '../../router'
import { MIN_HEIGHT } from '../reports/list'
import { MapTooltip } from './map-tooltip'
import { GeolocationNotice } from './geolocation-notice'
const width = 475
const height = 335
type CountryData = {
alpha_3: string
name: string
visitors: number
code: string
}
type WorldJsonCountryData = { properties: { name: string; a3: string } }
const WorldMap = ({
onCountrySelect,
afterFetchData
}: {
onCountrySelect: () => void
afterFetchData: (response: unknown) => void
}) => {
const navigate = useAppNavigate()
const { mode } = useTheme()
const site = useSiteContext()
const { query } = useQueryContext()
const svgRef = useRef<SVGSVGElement | null>(null)
const [tooltip, setTooltip] = useState<{
x: number
y: number
hoveredCountryAlpha3Code: string | null
}>({ x: 0, y: 0, hoveredCountryAlpha3Code: null })
const labels =
query.period === 'realtime'
? { singular: 'Current visitor', plural: 'Current visitors' }
: { singular: 'Visitor', plural: 'Visitors' }
const { data, refetch, isFetching, isError } = useQuery({
queryKey: ['countries', 'map', query],
placeholderData: (previousData) => previousData,
queryFn: async (): Promise<{
results: CountryData[]
}> => {
return await api.get(apiPath(site, '/countries'), query, {
limit: 300
})
}
})
useEffect(() => {
const onTickRefetchData = () => {
if (query.period === 'realtime') {
refetch()
}
}
document.addEventListener('tick', onTickRefetchData)
return () => document.removeEventListener('tick', onTickRefetchData)
}, [query.period, refetch])
useEffect(() => {
if (data) {
afterFetchData(data)
}
}, [afterFetchData, data])
const { maxValue, dataByCountryCode } = useMemo(() => {
const dataByCountryCode: Map<string, CountryData> = new Map()
let maxValue = 0
for (const { alpha_3, visitors, name, code } of data?.results || []) {
if (visitors > maxValue) {
maxValue = visitors
}
dataByCountryCode.set(alpha_3, { alpha_3, visitors, name, code })
}
return { maxValue, dataByCountryCode }
}, [data])
const onCountryClick = useCallback(
(d: WorldJsonCountryData) => {
const country = dataByCountryCode.get(d.properties.a3)
const clickable = country && country.visitors
if (clickable) {
const filters = replaceFilterByPrefix(query, 'country', [
'is',
'country',
[country.code]
])
const labels = cleanLabels(filters, query.labels, 'country', {
[country.code]: country.name
})
onCountrySelect()
navigate({ search: (search) => ({ ...search, filters, labels }) })
}
},
[navigate, query, dataByCountryCode, onCountrySelect]
)
useEffect(() => {
if (!svgRef.current) {
return
}
const svg = drawInteractiveCountries(svgRef.current, setTooltip)
return () => {
svg.selectAll('*').remove()
}
}, [])
useEffect(() => {
if (svgRef.current) {
const palette = colorScales[mode]
const getColorForValue = d3
.scaleLinear<string>()
.domain([0, maxValue])
.range(palette)
colorInCountriesWithValues(
svgRef.current,
getColorForValue,
dataByCountryCode
).on('click', (_event, countryPath) => {
onCountryClick(countryPath as unknown as WorldJsonCountryData)
})
}
}, [mode, maxValue, dataByCountryCode, onCountryClick])
const hoveredCountryData = tooltip.hoveredCountryAlpha3Code
? dataByCountryCode.get(tooltip.hoveredCountryAlpha3Code)
: undefined
return (
<div className="flex flex-col relative" style={{ minHeight: MIN_HEIGHT }}>
<div className="mt-4" />
<div
className="relative mx-auto w-full"
style={{ height: height, maxWidth: width }}
>
<svg
ref={svgRef}
viewBox={`0 0 ${width} ${height}`}
className="w-full"
/>
{!!hoveredCountryData && (
<MapTooltip
x={tooltip.x}
y={tooltip.y}
name={hoveredCountryData.name}
value={numberShortFormatter(hoveredCountryData.visitors)}
label={
labels[hoveredCountryData.visitors === 1 ? 'singular' : 'plural']
}
/>
)}
{isFetching ||
(isError && (
<div className="absolute inset-0 flex justify-center items-center">
<div className="loading">
<div />
</div>
</div>
))}
</div>
<MoreLink
list={data?.results ?? []}
linkProps={{
path: countriesRoute.path,
search: (search: Record<string, unknown>) => search
}}
className={undefined}
onClick={undefined}
/>
{site.isDbip && <GeolocationNotice />}
</div>
)
}
const colorScales = {
[UIMode.dark]: ['#2e3954', '#6366f1'],
[UIMode.light]: ['#f5f3ff', '#a78bfa']
}
const sharedCountryClass = classNames('transition-colors')
const countryClass = classNames(
sharedCountryClass,
'stroke-1',
'fill-[#fafafa]',
'stroke-[#dae1e7]',
'dark:fill-[#323236]',
'dark:stroke-[#18181b]'
)
const highlightedCountryClass = classNames(
sharedCountryClass,
'stroke-2',
'fill-[#f4f4f5]',
'stroke-[#a78bfa]',
'dark:fill-[#3f3f46]',
'dark:stroke-[#6366f1]'
)
/**
* Used to color the countries
* @returns the svg elements represeting countries
*/
function colorInCountriesWithValues(
element: SVGSVGElement,
getColorForValue: d3.ScaleLinear<string, string, never>,
dataByCountryCode: Map<string, CountryData>
) {
function getCountryByCountryPath(countryPath: unknown) {
return dataByCountryCode.get(
(countryPath as unknown as WorldJsonCountryData).properties.a3
)
}
const svg = d3.select(element)
return svg
.selectAll('path')
.style('fill', (countryPath) => {
const country = getCountryByCountryPath(countryPath)
if (!country?.visitors) {
return null
}
return getColorForValue(country.visitors)
})
.style('cursor', (countryPath) => {
const country = getCountryByCountryPath(countryPath)
if (!country?.visitors) {
return null
}
return 'pointer'
})
}
/** @returns the d3 selected svg element */
function drawInteractiveCountries(
element: SVGSVGElement,
setTooltip: React.Dispatch<
React.SetStateAction<{
x: number
y: number
hoveredCountryAlpha3Code: string | null
}>
>
) {
const path = setupProjetionPath()
const data = parseWorldTopoJsonToGeoJsonFeatures()
const svg = d3.select(element)
svg
.selectAll('path')
.data(data)
.enter()
.append('path')
.attr('class', countryClass)
.attr('d', path as never)
.on('mouseover', function (event, country) {
const [x, y] = d3.pointer(event, svg.node()?.parentNode)
setTooltip({ x, y, hoveredCountryAlpha3Code: country.properties.a3 })
// brings country to front
this.parentNode?.appendChild(this)
d3.select(this).attr('class', highlightedCountryClass)
})
.on('mousemove', function (event) {
const [x, y] = d3.pointer(event, svg.node()?.parentNode)
setTooltip((currentState) => ({ ...currentState, x, y }))
})
.on('mouseout', function () {
setTooltip({ x: 0, y: 0, hoveredCountryAlpha3Code: null })
d3.select(this).attr('class', countryClass)
})
return svg
}
function setupProjetionPath() {
const projection = d3
.geoMercator()
.scale(75)
.translate([width / 2, height / 1.5])
const path = d3.geoPath().projection(projection)
return path
}
function parseWorldTopoJsonToGeoJsonFeatures(): Array<WorldJsonCountryData> {
const collection = topojson.feature(
// @ts-expect-error strings in worldJson not recongizable as the enum values declared in library
worldJson,
worldJson.objects.countries
)
// @ts-expect-error topojson.feature return type incorrectly inferred as not a collection
return collection.features
}
export default WorldMap