ApiRenderers.tsx635 lines · main
| 1 | import { geoCentroid } from 'd3-geo' |
| 2 | import sumBy from 'lodash/sumBy' |
| 3 | import { ChevronRight } from 'lucide-react' |
| 4 | import { useTheme } from 'next-themes' |
| 5 | import { Fragment, useRef, useState, type ReactNode } from 'react' |
| 6 | import { ComposableMap, Geographies, Geography, Marker, ZoomableGroup } from 'react-simple-maps' |
| 7 | import { |
| 8 | Alert, |
| 9 | AlertDescription, |
| 10 | AlertTitle, |
| 11 | Button, |
| 12 | Collapsible, |
| 13 | CollapsibleContent, |
| 14 | CollapsibleTrigger, |
| 15 | WarningIcon, |
| 16 | } from 'ui' |
| 17 | import * as z from 'zod' |
| 18 | |
| 19 | import { queryParamsToObject } from '../Reports.utils' |
| 20 | import { ReportWidgetProps, ReportWidgetRendererProps } from '../ReportWidget' |
| 21 | import { COUNTRY_LAT_LON } from '@/components/interfaces/ProjectCreation/ProjectCreation.constants' |
| 22 | import { |
| 23 | buildCountsByIso2, |
| 24 | computeMarkerRadius, |
| 25 | extractIso2FromFeatureProps, |
| 26 | getFillColor, |
| 27 | getFillOpacity, |
| 28 | isKnownCountryCode, |
| 29 | isMicroCountry, |
| 30 | iso2ToCountryName, |
| 31 | MAP_CHART_THEME, |
| 32 | } from '@/components/interfaces/Reports/utils/geo' |
| 33 | import { |
| 34 | jsonSyntaxHighlight, |
| 35 | TextFormatter, |
| 36 | } from '@/components/interfaces/Settings/Logs/LogsFormatters' |
| 37 | import Table from '@/components/to-be-cleaned/Table' |
| 38 | import AlertError from '@/components/ui/AlertError' |
| 39 | import BarChart from '@/components/ui/Charts/BarChart' |
| 40 | import { DataTableColumnStatusCode } from '@/components/ui/DataTable/DataTableColumn/DataTableColumnStatusCode' |
| 41 | import { useFillTimeseriesSorted } from '@/hooks/analytics/useFillTimeseriesSorted' |
| 42 | import { BASE_PATH } from '@/lib/constants' |
| 43 | import type { ResponseError } from '@/types' |
| 44 | |
| 45 | export const NetworkTrafficRenderer = ( |
| 46 | props: ReportWidgetProps<{ |
| 47 | timestamp: string |
| 48 | ingress: number |
| 49 | egress: number |
| 50 | }> |
| 51 | ) => { |
| 52 | const { data, error, isError } = useFillTimeseriesSorted({ |
| 53 | data: props.data, |
| 54 | timestampKey: 'timestamp', |
| 55 | valueKey: ['ingress_mb', 'egress_mb'], |
| 56 | defaultValue: 0, |
| 57 | startDate: props.params?.iso_timestamp_start, |
| 58 | endDate: props.params?.iso_timestamp_end, |
| 59 | }) |
| 60 | |
| 61 | const totalIngress = sumBy(props.data, 'ingress_mb') |
| 62 | const totalEgress = sumBy(props.data, 'egress_mb') |
| 63 | |
| 64 | function determinePrecision(valueInMb: number) { |
| 65 | return valueInMb < 0.001 ? 7 : totalIngress > 1 ? 2 : 4 |
| 66 | } |
| 67 | |
| 68 | if (!!props.error) { |
| 69 | const error = ( |
| 70 | typeof props.error === 'string' ? { message: props.error } : props.error |
| 71 | ) as ResponseError |
| 72 | return <AlertError subject="Failed to retrieve network traffic" error={error} /> |
| 73 | } else if (isError) { |
| 74 | return ( |
| 75 | <Alert variant="warning"> |
| 76 | <WarningIcon /> |
| 77 | <AlertTitle>Failed to retrieve network traffic</AlertTitle> |
| 78 | <AlertDescription>{error?.message ?? 'Unknown error'}</AlertDescription> |
| 79 | </Alert> |
| 80 | ) |
| 81 | } |
| 82 | |
| 83 | return ( |
| 84 | <div className="flex flex-col gap-12 w-full"> |
| 85 | <BarChart |
| 86 | size="small" |
| 87 | title="Ingress" |
| 88 | highlightedValue={sumBy(props.data, 'ingress_mb')} |
| 89 | format="MB" |
| 90 | className="w-full" |
| 91 | valuePrecision={determinePrecision(totalIngress)} |
| 92 | data={data} |
| 93 | yAxisKey="ingress_mb" |
| 94 | xAxisKey="timestamp" |
| 95 | displayDateInUtc |
| 96 | /> |
| 97 | |
| 98 | <BarChart |
| 99 | size="small" |
| 100 | title="Egress" |
| 101 | highlightedValue={totalEgress} |
| 102 | format="MB" |
| 103 | valuePrecision={determinePrecision(totalEgress)} |
| 104 | className="w-full" |
| 105 | data={data} |
| 106 | yAxisKey="egress_mb" |
| 107 | xAxisKey="timestamp" |
| 108 | displayDateInUtc |
| 109 | /> |
| 110 | </div> |
| 111 | ) |
| 112 | } |
| 113 | |
| 114 | export const TotalRequestsChartRenderer = ( |
| 115 | props: ReportWidgetProps<{ |
| 116 | timestamp: string |
| 117 | count: number |
| 118 | }> |
| 119 | ) => { |
| 120 | const total = props.data.reduce((acc, datum) => { |
| 121 | return acc + datum.count |
| 122 | }, 0) |
| 123 | const { data, error, isError } = useFillTimeseriesSorted({ |
| 124 | data: props.data, |
| 125 | timestampKey: 'timestamp', |
| 126 | valueKey: 'count', |
| 127 | defaultValue: 0, |
| 128 | startDate: props.params?.iso_timestamp_start, |
| 129 | endDate: props.params?.iso_timestamp_end, |
| 130 | }) |
| 131 | |
| 132 | if (!!props.error) { |
| 133 | const error = ( |
| 134 | typeof props.error === 'string' ? { message: props.error } : props.error |
| 135 | ) as ResponseError |
| 136 | return <AlertError subject="Failed to retrieve total requests" error={error} /> |
| 137 | } else if (isError) { |
| 138 | return ( |
| 139 | <Alert variant="warning"> |
| 140 | <WarningIcon /> |
| 141 | <AlertTitle>Failed to retrieve total requests</AlertTitle> |
| 142 | <AlertDescription>{error?.message ?? 'Unknown error'}</AlertDescription> |
| 143 | </Alert> |
| 144 | ) |
| 145 | } |
| 146 | |
| 147 | return ( |
| 148 | <BarChart |
| 149 | size="small" |
| 150 | minimalHeader |
| 151 | highlightedValue={total} |
| 152 | className="w-full" |
| 153 | data={data} |
| 154 | yAxisKey="count" |
| 155 | xAxisKey="timestamp" |
| 156 | displayDateInUtc |
| 157 | /> |
| 158 | ) |
| 159 | } |
| 160 | |
| 161 | export const TopApiRoutesRenderer = ( |
| 162 | props: ReportWidgetRendererProps<{ |
| 163 | method: string |
| 164 | // shown for error table but not all requests table |
| 165 | status_code?: number |
| 166 | path: string |
| 167 | search: string |
| 168 | count: number |
| 169 | // used for response speed table only |
| 170 | avg?: number |
| 171 | }> |
| 172 | ) => { |
| 173 | const [showMore, setShowMore] = useState(false) |
| 174 | |
| 175 | const headerClasses = 'text-xs! py-2! p-0 font-bold bg-surface-200! border-x-0! rounded-none!' |
| 176 | const cellClasses = 'text-xs! py-2! border-x-0! rounded-none! align-middle' |
| 177 | |
| 178 | if (props.data.length === 0) return null |
| 179 | |
| 180 | return ( |
| 181 | <> |
| 182 | <Table |
| 183 | className="rounded-t-none" |
| 184 | containerClassName="overflow-x-auto" |
| 185 | head={ |
| 186 | <> |
| 187 | <Table.th className={headerClasses}>Request</Table.th> |
| 188 | <Table.th className={headerClasses + ' text-right'}>Count</Table.th> |
| 189 | {props.data[0].avg !== undefined && ( |
| 190 | <Table.th className={headerClasses + ' text-right'}>Avg</Table.th> |
| 191 | )} |
| 192 | </> |
| 193 | } |
| 194 | body={ |
| 195 | <> |
| 196 | {props.data.map((datum, index) => ( |
| 197 | <Fragment key={index + datum.method + datum.path + (datum.search || '')}> |
| 198 | <Table.tr |
| 199 | className={[ |
| 200 | 'p-0 transition transform cursor-pointer hover:bg-surface-200', |
| 201 | showMore && index >= 3 ? 'w-full h-full opacity-100' : '', |
| 202 | !showMore && index >= 3 ? ' w-0 h-0 translate-y-10 opacity-0' : '', |
| 203 | ].join(' ')} |
| 204 | > |
| 205 | {(!showMore && index < 3) || showMore ? ( |
| 206 | <> |
| 207 | <Table.td className={[cellClasses].join(' ')}> |
| 208 | <RouteTdContent {...datum} /> |
| 209 | </Table.td> |
| 210 | <Table.td className={[cellClasses, 'text-right'].join(' ')}> |
| 211 | {datum.count} |
| 212 | </Table.td> |
| 213 | {props.data[0].avg !== undefined && ( |
| 214 | <Table.td className={[cellClasses, 'text-right'].join(' ')}> |
| 215 | {Number(datum.avg).toFixed(2)}ms |
| 216 | </Table.td> |
| 217 | )} |
| 218 | </> |
| 219 | ) : null} |
| 220 | </Table.tr> |
| 221 | </Fragment> |
| 222 | ))} |
| 223 | </> |
| 224 | } |
| 225 | /> |
| 226 | <div className="flex flex-row justify-end w-full gap-2 p-1"> |
| 227 | <Button |
| 228 | type="text" |
| 229 | onClick={() => setShowMore(!showMore)} |
| 230 | className={[ |
| 231 | 'transition', |
| 232 | showMore ? 'text-foreground' : 'text-foreground-lighter', |
| 233 | props.data.length <= 3 ? 'hidden' : '', |
| 234 | ].join(' ')} |
| 235 | > |
| 236 | {!showMore ? 'Show more' : 'Show less'} |
| 237 | </Button> |
| 238 | </div> |
| 239 | </> |
| 240 | ) |
| 241 | } |
| 242 | |
| 243 | export const ErrorCountsChartRenderer = ( |
| 244 | props: ReportWidgetProps<{ |
| 245 | timestamp: string |
| 246 | count: number |
| 247 | }> |
| 248 | ) => { |
| 249 | const total = props.data.reduce((acc, datum) => { |
| 250 | return acc + datum.count |
| 251 | }, 0) |
| 252 | |
| 253 | const { data, error, isError } = useFillTimeseriesSorted({ |
| 254 | data: props.data, |
| 255 | timestampKey: 'timestamp', |
| 256 | valueKey: 'count', |
| 257 | defaultValue: 0, |
| 258 | startDate: props.params?.iso_timestamp_start, |
| 259 | endDate: props.params?.iso_timestamp_end, |
| 260 | }) |
| 261 | |
| 262 | if (!!props.error) { |
| 263 | const error = ( |
| 264 | typeof props.error === 'string' ? { message: props.error } : props.error |
| 265 | ) as ResponseError |
| 266 | return <AlertError subject="Failed to retrieve request errors" error={error} /> |
| 267 | } else if (isError) { |
| 268 | return ( |
| 269 | <Alert variant="warning"> |
| 270 | <WarningIcon /> |
| 271 | <AlertTitle>Failed to retrieve request errors</AlertTitle> |
| 272 | <AlertDescription>{error?.message ?? 'Unknown error'}</AlertDescription> |
| 273 | </Alert> |
| 274 | ) |
| 275 | } |
| 276 | |
| 277 | return ( |
| 278 | <BarChart |
| 279 | size="small" |
| 280 | minimalHeader |
| 281 | className="w-full" |
| 282 | highlightedValue={total} |
| 283 | data={data} |
| 284 | yAxisKey="count" |
| 285 | xAxisKey="timestamp" |
| 286 | displayDateInUtc |
| 287 | /> |
| 288 | ) |
| 289 | } |
| 290 | |
| 291 | export const ResponseSpeedChartRenderer = ( |
| 292 | props: ReportWidgetProps<{ |
| 293 | timestamp: string |
| 294 | avg: number |
| 295 | }> |
| 296 | ) => { |
| 297 | const transformedData = props.data.map((datum) => ({ |
| 298 | timestamp: datum.timestamp, |
| 299 | avg: datum.avg, |
| 300 | })) |
| 301 | |
| 302 | const { data, error, isError } = useFillTimeseriesSorted({ |
| 303 | data: transformedData, |
| 304 | timestampKey: 'timestamp', |
| 305 | valueKey: 'avg', |
| 306 | defaultValue: 0, |
| 307 | startDate: props.params?.iso_timestamp_start, |
| 308 | endDate: props.params?.iso_timestamp_end, |
| 309 | }) |
| 310 | |
| 311 | const lastAvg = props.data[props.data.length - 1]?.avg |
| 312 | |
| 313 | if (!!props.error) { |
| 314 | const error = ( |
| 315 | typeof props.error === 'string' ? { message: props.error } : props.error |
| 316 | ) as ResponseError |
| 317 | return <AlertError subject="Failed to retrieve response speeds" error={error} /> |
| 318 | } else if (isError) { |
| 319 | return ( |
| 320 | <Alert variant="warning"> |
| 321 | <WarningIcon /> |
| 322 | <AlertTitle>Failed to retrieve response speeds</AlertTitle> |
| 323 | <AlertDescription>{error?.message ?? 'Unknown error'}</AlertDescription> |
| 324 | </Alert> |
| 325 | ) |
| 326 | } |
| 327 | |
| 328 | return ( |
| 329 | <BarChart |
| 330 | size="small" |
| 331 | highlightedValue={lastAvg} |
| 332 | format="ms" |
| 333 | minimalHeader |
| 334 | className="w-full" |
| 335 | data={data} |
| 336 | yAxisKey="avg" |
| 337 | xAxisKey="timestamp" |
| 338 | displayDateInUtc |
| 339 | /> |
| 340 | ) |
| 341 | } |
| 342 | |
| 343 | interface RouteTdContentProps { |
| 344 | method: string |
| 345 | status_code?: number |
| 346 | path: string |
| 347 | search: string |
| 348 | } |
| 349 | const RouteTdContent = (datum: RouteTdContentProps) => ( |
| 350 | <Collapsible> |
| 351 | <CollapsibleTrigger asChild> |
| 352 | <div className="flex gap-2 items-center"> |
| 353 | <Button asChild type="text" className=" py-0! p-1!" title="Show more route details"> |
| 354 | <span> |
| 355 | <ChevronRight |
| 356 | size={14} |
| 357 | className="transition data-open-parent:rotate-90 data-closed-parent:rotate-0" |
| 358 | /> |
| 359 | </span> |
| 360 | </Button> |
| 361 | <TextFormatter |
| 362 | className="w-10 h-4 text-center rounded-sm bg-surface-300" |
| 363 | value={datum.method} |
| 364 | /> |
| 365 | {datum.status_code && ( |
| 366 | <DataTableColumnStatusCode |
| 367 | value={datum.status_code} |
| 368 | level={String(Math.floor(datum.status_code / 100))} |
| 369 | /> |
| 370 | )} |
| 371 | <div className=" truncate max-w-sm lg:max-w-lg"> |
| 372 | <TextFormatter className="text-foreground-light" value={datum.path} /> |
| 373 | <TextFormatter |
| 374 | className="max-w-sm text-foreground-lighter truncate " |
| 375 | value={decodeURIComponent(datum.search || '')} |
| 376 | /> |
| 377 | </div> |
| 378 | </div> |
| 379 | </CollapsibleTrigger> |
| 380 | <CollapsibleContent className="pt-2"> |
| 381 | {datum.search ? ( |
| 382 | <pre className="syntax-highlight overflow-auto whitespace-pre-wrap wrap-break-word rounded-sm bg-surface-100 p-2 text-xs! [&_span]:whitespace-pre-wrap!"> |
| 383 | <div |
| 384 | className="text-wrap" |
| 385 | dangerouslySetInnerHTML={{ |
| 386 | __html: jsonSyntaxHighlight(queryParamsToObject(datum.search)), |
| 387 | }} |
| 388 | /> |
| 389 | </pre> |
| 390 | ) : ( |
| 391 | <p className="text-xs text-foreground-lighter">No query parameters in this request</p> |
| 392 | )} |
| 393 | </CollapsibleContent> |
| 394 | </Collapsible> |
| 395 | ) |
| 396 | export const RequestsByCountryMapRenderer = ( |
| 397 | props: ReportWidgetProps<{ |
| 398 | country: string | null |
| 399 | count: number |
| 400 | }> |
| 401 | ) => { |
| 402 | const WORLD_TOPO_URL = `${BASE_PATH}/json/worldmap.json` |
| 403 | const containerRef = useRef<HTMLDivElement | null>(null) |
| 404 | const [hoverInfo, setHoverInfo] = useState<{ |
| 405 | x: number |
| 406 | y: number |
| 407 | title: string |
| 408 | subtitle: string |
| 409 | visible: boolean |
| 410 | }>({ x: 0, y: 0, title: '', subtitle: '', visible: false }) |
| 411 | |
| 412 | const countsByIso2 = buildCountsByIso2(props.data) |
| 413 | const max = Object.values(countsByIso2).reduce((m, v) => (v > m ? v : m), 0) |
| 414 | const { resolvedTheme } = useTheme() |
| 415 | const theme = resolvedTheme === 'dark' ? MAP_CHART_THEME.dark : MAP_CHART_THEME.light |
| 416 | |
| 417 | if (!!props.error) { |
| 418 | const AlertErrorSchema = z.object({ message: z.string() }) |
| 419 | const parsed = |
| 420 | typeof props.error === 'string' |
| 421 | ? { success: true, data: { message: props.error } } |
| 422 | : AlertErrorSchema.safeParse(props.error) |
| 423 | const alertError = parsed.success ? parsed.data : null |
| 424 | return <AlertError subject="Failed to retrieve requests by geography" error={alertError} /> |
| 425 | } |
| 426 | |
| 427 | return ( |
| 428 | <div ref={containerRef} className="w-full h-[420px] relative border-t"> |
| 429 | <ComposableMap |
| 430 | projection="geoMercator" |
| 431 | projectionConfig={{ scale: 155 }} |
| 432 | className="w-full h-full" |
| 433 | style={{ backgroundColor: theme.oceanFill }} |
| 434 | > |
| 435 | <ZoomableGroup minZoom={1} maxZoom={5} zoom={1.3}> |
| 436 | <Geographies geography={WORLD_TOPO_URL}> |
| 437 | {({ geographies }) => ( |
| 438 | <> |
| 439 | {geographies.map((geo) => { |
| 440 | const title = |
| 441 | (geo.properties?.name as string) || |
| 442 | (geo.properties?.NAME as string) || |
| 443 | 'Unknown' |
| 444 | const iso2 = extractIso2FromFeatureProps( |
| 445 | (geo.properties || undefined) as Record<string, unknown> | undefined |
| 446 | ) |
| 447 | const value = iso2 ? countsByIso2[iso2] || 0 : 0 |
| 448 | const baseOpacity = getFillOpacity(value, max, theme) |
| 449 | const tooltipTitle = title |
| 450 | const tooltipSubtitle = `${value.toLocaleString()} requests` |
| 451 | return ( |
| 452 | <Geography |
| 453 | key={geo.rsmKey} |
| 454 | geography={geo} |
| 455 | onMouseMove={(e) => { |
| 456 | const rect = containerRef.current?.getBoundingClientRect() |
| 457 | const x = (rect ? e.clientX - rect.left : e.clientX) + 12 |
| 458 | const y = (rect ? e.clientY - rect.top : e.clientY) + 12 |
| 459 | setHoverInfo({ |
| 460 | x, |
| 461 | y, |
| 462 | title: tooltipTitle, |
| 463 | subtitle: tooltipSubtitle, |
| 464 | visible: true, |
| 465 | }) |
| 466 | }} |
| 467 | onMouseEnter={(e) => { |
| 468 | const rect = containerRef.current?.getBoundingClientRect() |
| 469 | const x = (rect ? e.clientX - rect.left : e.clientX) + 12 |
| 470 | const y = (rect ? e.clientY - rect.top : e.clientY) + 12 |
| 471 | setHoverInfo({ |
| 472 | x, |
| 473 | y, |
| 474 | title: tooltipTitle, |
| 475 | subtitle: tooltipSubtitle, |
| 476 | visible: true, |
| 477 | }) |
| 478 | }} |
| 479 | onMouseLeave={() => setHoverInfo((prev) => ({ ...prev, visible: false }))} |
| 480 | style={{ |
| 481 | default: { |
| 482 | fill: getFillColor(value, max, theme), |
| 483 | stroke: theme.boundaryStroke, |
| 484 | strokeWidth: 0.4, |
| 485 | opacity: baseOpacity, |
| 486 | outline: 'none', |
| 487 | cursor: 'default', |
| 488 | }, |
| 489 | hover: { |
| 490 | fill: getFillColor(value, max, theme), |
| 491 | stroke: 'transparent', |
| 492 | strokeWidth: 0, |
| 493 | opacity: Math.max(0, baseOpacity * 0.8), |
| 494 | outline: 'none', |
| 495 | cursor: 'default', |
| 496 | }, |
| 497 | pressed: { |
| 498 | fill: getFillColor(value, max, theme), |
| 499 | stroke: 'transparent', |
| 500 | strokeWidth: 0, |
| 501 | opacity: Math.max(0, baseOpacity * 0.8), |
| 502 | outline: 'none', |
| 503 | cursor: 'default', |
| 504 | }, |
| 505 | }} |
| 506 | aria-label={`${tooltipTitle} — ${tooltipSubtitle}`} |
| 507 | /> |
| 508 | ) |
| 509 | })} |
| 510 | |
| 511 | {geographies.map((geo) => { |
| 512 | const title = |
| 513 | (geo.properties?.name as string) || |
| 514 | (geo.properties?.NAME as string) || |
| 515 | 'Unknown' |
| 516 | if (!isMicroCountry(title)) return null |
| 517 | const iso2 = extractIso2FromFeatureProps( |
| 518 | (geo.properties || undefined) as Record<string, unknown> | undefined |
| 519 | ) |
| 520 | const value = iso2 ? countsByIso2[iso2] || 0 : 0 |
| 521 | if (value <= 0) return null |
| 522 | const [lon, lat] = geoCentroid(geo) |
| 523 | const r = computeMarkerRadius(value, max) |
| 524 | const tooltipTitle = title |
| 525 | const tooltipSubtitle = `${value.toLocaleString()} requests` |
| 526 | return ( |
| 527 | <Marker |
| 528 | key={`marker-${geo.rsmKey}`} |
| 529 | coordinates={[lon, lat]} |
| 530 | onMouseMove={(e) => { |
| 531 | const rect = containerRef.current?.getBoundingClientRect() |
| 532 | const x = (rect ? e.clientX - rect.left : e.clientX) + 12 |
| 533 | const y = (rect ? e.clientY - rect.top : e.clientY) + 12 |
| 534 | setHoverInfo({ |
| 535 | x, |
| 536 | y, |
| 537 | title: tooltipTitle, |
| 538 | subtitle: tooltipSubtitle, |
| 539 | visible: true, |
| 540 | }) |
| 541 | }} |
| 542 | onMouseEnter={(e) => { |
| 543 | const rect = containerRef.current?.getBoundingClientRect() |
| 544 | const x = (rect ? e.clientX - rect.left : e.clientX) + 12 |
| 545 | const y = (rect ? e.clientY - rect.top : e.clientY) + 12 |
| 546 | setHoverInfo({ |
| 547 | x, |
| 548 | y, |
| 549 | title: tooltipTitle, |
| 550 | subtitle: tooltipSubtitle, |
| 551 | visible: true, |
| 552 | }) |
| 553 | }} |
| 554 | onMouseLeave={() => setHoverInfo((prev) => ({ ...prev, visible: false }))} |
| 555 | > |
| 556 | <circle r={r} fill={theme.markerFill} /> |
| 557 | </Marker> |
| 558 | ) |
| 559 | })} |
| 560 | |
| 561 | {(() => { |
| 562 | const present = new Set<string>() |
| 563 | for (const g of geographies) { |
| 564 | const code = extractIso2FromFeatureProps( |
| 565 | (g.properties || undefined) as Record<string, unknown> | undefined |
| 566 | ) |
| 567 | if (code) present.add(code) |
| 568 | } |
| 569 | |
| 570 | const markers: ReactNode[] = [] |
| 571 | for (const iso2 in countsByIso2) { |
| 572 | const count = countsByIso2[iso2] |
| 573 | if (count <= 0) continue |
| 574 | // Do not render Antarctica |
| 575 | if (iso2.toUpperCase() === 'AQ') continue |
| 576 | if (present.has(iso2)) continue |
| 577 | if (!isKnownCountryCode(iso2)) continue |
| 578 | const ll = COUNTRY_LAT_LON[iso2] |
| 579 | const r = computeMarkerRadius(count, max) |
| 580 | const tooltipTitle = iso2ToCountryName(iso2) |
| 581 | const tooltipSubtitle = `${count.toLocaleString()} requests` |
| 582 | markers.push( |
| 583 | <Marker |
| 584 | key={`fallback-${iso2}`} |
| 585 | coordinates={[ll.lon, ll.lat]} |
| 586 | onMouseMove={(e) => { |
| 587 | const rect = containerRef.current?.getBoundingClientRect() |
| 588 | const x = (rect ? e.clientX - rect.left : e.clientX) + 12 |
| 589 | const y = (rect ? e.clientY - rect.top : e.clientY) + 12 |
| 590 | setHoverInfo({ |
| 591 | x, |
| 592 | y, |
| 593 | title: tooltipTitle, |
| 594 | subtitle: tooltipSubtitle, |
| 595 | visible: true, |
| 596 | }) |
| 597 | }} |
| 598 | onMouseEnter={(e) => { |
| 599 | const rect = containerRef.current?.getBoundingClientRect() |
| 600 | const x = (rect ? e.clientX - rect.left : e.clientX) + 12 |
| 601 | const y = (rect ? e.clientY - rect.top : e.clientY) + 12 |
| 602 | setHoverInfo({ |
| 603 | x, |
| 604 | y, |
| 605 | title: tooltipTitle, |
| 606 | subtitle: tooltipSubtitle, |
| 607 | visible: true, |
| 608 | }) |
| 609 | }} |
| 610 | onMouseLeave={() => setHoverInfo((prev) => ({ ...prev, visible: false }))} |
| 611 | > |
| 612 | <circle r={r} fill={theme.markerFill} /> |
| 613 | </Marker> |
| 614 | ) |
| 615 | } |
| 616 | |
| 617 | return markers |
| 618 | })()} |
| 619 | </> |
| 620 | )} |
| 621 | </Geographies> |
| 622 | </ZoomableGroup> |
| 623 | </ComposableMap> |
| 624 | {hoverInfo.visible && ( |
| 625 | <div |
| 626 | className="pointer-events-none absolute z-10 rounded-sm bg-surface-100 p-1.5 border border-surface-200 text-sm" |
| 627 | style={{ left: hoverInfo.x, top: hoverInfo.y }} |
| 628 | > |
| 629 | <h3 className="text-foreground-lighter text-sm">{hoverInfo.title}</h3> |
| 630 | <p className="text-foreground text-sm">{hoverInfo.subtitle}</p> |
| 631 | </div> |
| 632 | )} |
| 633 | </div> |
| 634 | ) |
| 635 | } |