SPB Git forge

spb/countryatlas

Public
20commits 1branches 0releases
268.3 MBsize
maindefault branch
12 days agolast push
TypeScript 57% Python 38.6% JavaScript 3.6% CSS 0.6%
8.7 KB · 206 lines tsx
Raw Blame History
1'use client';2import { useRouter } from 'next/navigation';3import { useCallback, useId, useState, type ReactNode } from 'react';4import { t } from '@/i18n';5import { cn } from '@/lib/cn';6import { formatValue } from '@/lib/format';7import { MAP_HEIGHT, MAP_WIDTH } from '@/lib/map-geo';8import { routes } from '@/lib/site';9import type { FormatSpec as Spec } from '@/lib/types';10import { seqVar } from './palette';1112export interface ChoroplethFeature {13  iso3: string | null;14  name: string;15  slug: string | null;16  flag: string | null;17  d: string;18  value: number | null;19  cls: number | null;20}2122/** Map a class index (0..k-1) onto the 7-step sequential ramp. */23export function stepFor(cls: number, k: number): number {24  if (k <= 1) return 4;25  const start = k >= 6 ? 1 : 2;26  const end = 7;27  return Math.round(start + (cls / (k - 1)) * (end - start));28}2930/** Legend items (class → label) from API quantile breaks; shared by every choropleth wrapper. */31export function legendFromBreaks(breaks: number[], min: number | null, max: number | null, spec: Spec): Array<{ cls: number; label: string }> {32  const k = breaks.length + 1;33  return Array.from({ length: k }, (_, i) => {34    const lo = i === 0 ? min : breaks[i - 1]!;35    const hi = i === k - 1 ? max : breaks[i]!;36    return { cls: i, label: `${formatValue(lo, spec)} – ${formatValue(hi, spec)}` };37  });38}3940/** Class index 0..k for a value against sorted quantile breaks. */41export function classFor(value: number, breaks: number[]): number {42  let i = 0;43  while (i < breaks.length && value >= breaks[i]!) i++;44  return i;45}4647/**48 * Interactive SVG world map: hover (mouse) shows the floating label; on touch the first tap selects and49 * shows the label with an "Open" link, a click with a mouse navigates (or calls `onSelect` when given).50 * Explicit hatched fill for no data. `selectedId` outlines one country; `renderLabel` customises the tooltip.51 */52export function ChoroplethView({53  features,54  sphere,55  legend,56  k,57  spec,58  summary,59  title,60  height,61  compact,62  className,63  onSelect,64  selectedId,65  renderLabel,66  showLegend = true,67  showHint = true,68  fillFor,69}: {70  features: ChoroplethFeature[];71  sphere: string;72  legend: Array<{ cls: number; label: string }>;73  k: number;74  spec: Spec;75  summary: string;76  title: string;77  height?: number;78  compact?: boolean;79  className?: string;80  /** When given, clicking / "Open" calls this instead of navigating to the country page. */81  onSelect?: (f: ChoroplethFeature) => void;82  selectedId?: string | null;83  renderLabel?: (f: ChoroplethFeature) => ReactNode;84  showLegend?: boolean;85  showHint?: boolean;86  /** Override the class → colour mapping (e.g. a diverging ramp). */87  fillFor?: (f: ChoroplethFeature) => string;88}) {89  const router = useRouter();90  const id = useId();91  const [active, setActive] = useState<{ f: ChoroplethFeature; x: number; y: number; sticky: boolean } | null>(null);9293  const place = useCallback((e: React.PointerEvent<SVGPathElement>, f: ChoroplethFeature, sticky: boolean) => {94    const box = e.currentTarget.ownerSVGElement?.parentElement?.getBoundingClientRect();95    if (!box) return;96    setActive({ f, x: e.clientX - box.left, y: e.clientY - box.top, sticky });97  }, []);9899  const open = (f: ChoroplethFeature) => {100    if (onSelect) {101      onSelect(f);102      setActive(null);103      return;104    }105    if (f.slug) router.push(routes.country(f.slug));106  };107  const fill = (f: ChoroplethFeature) => (fillFor ? fillFor(f) : f.cls != null ? seqVar(stepFor(f.cls, k)) : `url(#${id}-hatch)`);108  const selected = selectedId ? features.find((f) => f.iso3 === selectedId) ?? null : null;109110  return (111    <figure className={cn('min-w-0', className)}>112      <div className="relative w-full" style={{ aspectRatio: `${MAP_WIDTH} / ${MAP_HEIGHT}`, maxHeight: height }} onPointerLeave={() => setActive((a) => (a?.sticky ? a : null))}>113        <svg viewBox={`0 0 ${MAP_WIDTH} ${MAP_HEIGHT}`} className="h-full w-full" role="img" aria-label={summary}>114          <title>{title}</title>115          <desc>{summary}</desc>116          <defs>117            <pattern id={`${id}-hatch`} width="6" height="6" patternUnits="userSpaceOnUse" patternTransform="rotate(45)">118              <rect width="6" height="6" fill="var(--nodata)" />119              <line x1="0" y1="0" x2="0" y2="6" stroke="var(--rule-strong)" strokeWidth="1.5" />120            </pattern>121          </defs>122          <path d={sphere} fill="var(--map-water)" stroke="var(--rule)" strokeWidth={1} />123          <g stroke="var(--map-stroke)" strokeWidth={0.6} strokeLinejoin="round">124            {features.map((f, i) => {125              const isActive = active?.f === f;126              const interactive = !!f.slug || (!!onSelect && !!f.iso3);127              return (128                <path129                  key={f.iso3 ?? `${f.name}-${i}`}130                  d={f.d}131                  fill={fill(f)}132                  className={cn(interactive && 'cursor-pointer', 'transition-[fill-opacity] duration-100')}133                  fillOpacity={isActive ? 0.75 : 1}134                  tabIndex={interactive ? 0 : -1}135                  role={interactive ? (onSelect ? 'button' : 'link') : undefined}136                  aria-label={`${f.name}: ${formatValue(f.value, spec)}`}137                  onPointerMove={(e) => {138                    if (e.pointerType === 'mouse') place(e, f, false);139                  }}140                  onPointerDown={(e) => {141                    if (e.pointerType !== 'mouse') {142                      e.preventDefault();143                      place(e, f, true);144                    }145                  }}146                  onClick={(e) => {147                    // Mouse: open. Touch: the label carries the action (first tap selects).148                    if ((e.nativeEvent as PointerEvent).pointerType === 'mouse' || (e as unknown as { detail: number }).detail === 0) open(f);149                  }}150                  onKeyDown={(e) => {151                    if (e.key === 'Enter' && interactive) open(f);152                  }}153                  onFocus={(e) => {154                    const b = e.currentTarget.getBBox();155                    setActive({ f, x: ((b.x + b.width / 2) / MAP_WIDTH) * (e.currentTarget.ownerSVGElement?.clientWidth ?? MAP_WIDTH), y: (b.y / MAP_HEIGHT) * (e.currentTarget.ownerSVGElement?.clientHeight ?? MAP_HEIGHT), sticky: false });156                  }}157                >158                  <title>{`${f.name}: ${formatValue(f.value, spec)}`}</title>159                </path>160              );161            })}162            {selected ? <path d={selected.d} fill="none" stroke="var(--ink)" strokeWidth={1.6} pointerEvents="none" /> : null}163          </g>164        </svg>165        {active ? (166          <div className="pointer-events-none absolute z-10 rounded-sm border border-rule bg-surface px-2.5 py-1.5 text-xs shadow-pop" style={{ left: Math.max(0, Math.min(active.x + 10, (typeof window !== 'undefined' ? window.innerWidth : 9999) - 240)), top: Math.max(0, active.y - 44), maxWidth: 230 }}>167            <div className="flex items-center gap-1.5 font-medium text-ink">168              {active.f.flag ? <span aria-hidden>{active.f.flag}</span> : null}169              <span className="truncate">{active.f.name}</span>170            </div>171            {renderLabel ? renderLabel(active.f) : <div className="tnum text-ink-2">{formatValue(active.f.value, spec)}</div>}172            {active.sticky && (active.f.slug || onSelect) ? (173              <button type="button" className="pointer-events-auto mt-1 min-h-[32px] text-accent underline" onClick={() => open(active.f)}>174                {t('metric.open', { name: active.f.name })} →175              </button>176            ) : null}177          </div>178        ) : null}179      </div>180      {showLegend ? (181        <figcaption className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1.5 text-2xs text-ink-2">182          {!compact ? <span className="mr-1 font-medium text-ink">{title}</span> : null}183          <ul className="flex flex-wrap items-center gap-x-2.5 gap-y-1">184            {legend.map((l) => (185              <li key={l.cls} className="inline-flex items-center gap-1 tnum">186                <span aria-hidden className="inline-block h-2.5 w-3.5 rounded-xs" style={{ background: seqVar(stepFor(l.cls, k)) }} />187                {l.label}188              </li>189            ))}190            <li className="inline-flex items-center gap-1">191              <span aria-hidden className="no-data-hatch inline-block h-2.5 w-3.5 rounded-xs" />192              {t('chart.legend.noData')}193            </li>194          </ul>195          {showHint ? (196            <>197              <span className="ml-auto hidden text-ink-3 md:inline">{t('chart.map.hoverHint')}</span>198              <span className="ml-auto text-ink-3 md:hidden">{t('chart.map.tapHint')}</span>199            </>200          ) : null}201        </figcaption>202      ) : null}203    </figure>204  );205}206