321 lines
8.8 KiB
TypeScript
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
|