{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "visited-map",
  "title": "Visited Map",
  "description": "An SVG world map that highlights the countries you've visited, lived in or want to see, pins cities on top and counts how much of the world you've seen. No tiles or API keys; works in Server Components.",
  "dependencies": [
    "d3-geo",
    "topojson-client",
    "world-atlas"
  ],
  "devDependencies": [
    "@types/d3-geo",
    "@types/topojson-client"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/visited-map/visited-map.tsx",
      "content": "import { geoArea, geoMercator, geoPath } from \"d3-geo\"\nimport { useId } from \"react\"\nimport { feature } from \"topojson-client\"\nimport worldAtlas from \"world-atlas/countries-110m.json\"\n\nimport { cn } from \"@/lib/utils\"\n\nimport { VisitedMapZoom, type VisitedMapView } from \"./visited-map-zoom\"\n\n// This component is intentionally isomorphic: no \"use client\", no state or\n// effects (only useId, which also works on the server) and no server-only\n// APIs. Rendered from a Server Component, the map is plain SVG\n// markup and the TopoJSON never reaches the client bundle. Only `zoomable`\n// adds a client component (visited-map-zoom.tsx), which moves the\n// server-rendered map without ever seeing the geometry.\n\nexport type VisitedMapVariant = \"visited\" | \"lived\" | \"wishlist\" | \"current\"\n\n// ISO 3166-1 alpha-2 code → [world-atlas id (null when too small to be drawn),\n// name, center lng, center lat, continent]. Covers the 195 countries plus 50\n// territories. Names, centers and continents from Natural Earth (public\n// domain); the centers are label points, which sit on the main landmass\n// (unlike centroids). Continents follow its UN regions, with Cyprus in Europe\n// and the Christmas, Cocos, South Georgia and French Southern islands by\n// geography.\ntype ContinentCode = \"AF\" | \"AM\" | \"AS\" | \"EU\" | \"OC\" | \"AN\"\n\n// prettier-ignore\nconst countryData = {\n  AD: [null, \"Andorra\", 1.54, 42.55, \"EU\"],\n  AE: [\"784\", \"United Arab Emirates\", 54.55, 23.47, \"AS\"],\n  AF: [\"004\", \"Afghanistan\", 66.5, 34.16, \"AS\"],\n  AG: [null, \"Antigua and Barbuda\", -61.79, 17.35, \"AM\"],\n  AI: [null, \"Anguilla\", -63.03, 18.24, \"AM\"],\n  AL: [\"008\", \"Albania\", 20.11, 40.65, \"EU\"],\n  AM: [\"051\", \"Armenia\", 44.8, 40.46, \"AS\"],\n  AO: [\"024\", \"Angola\", 17.98, -12.18, \"AF\"],\n  AR: [\"032\", \"Argentina\", -64.17, -33.5, \"AM\"],\n  AS: [null, \"American Samoa\", -170.75, -14.33, \"OC\"],\n  AT: [\"040\", \"Austria\", 14.13, 47.52, \"EU\"],\n  AU: [\"036\", \"Australia\", 134.05, -24.13, \"OC\"],\n  AW: [null, \"Aruba\", -69.97, 12.52, \"AM\"],\n  AX: [null, \"Åland\", 19.87, 60.16, \"EU\"],\n  AZ: [\"031\", \"Azerbaijan\", 47.21, 40.4, \"AS\"],\n  BA: [\"070\", \"Bosnia and Herzegovina\", 18.07, 44.09, \"EU\"],\n  BB: [null, \"Barbados\", -59.57, 13.16, \"AM\"],\n  BD: [\"050\", \"Bangladesh\", 89.68, 24.21, \"AS\"],\n  BE: [\"056\", \"Belgium\", 4.8, 50.79, \"EU\"],\n  BF: [\"854\", \"Burkina Faso\", -1.36, 12.67, \"AF\"],\n  BG: [\"100\", \"Bulgaria\", 25.16, 42.51, \"EU\"],\n  BH: [null, \"Bahrain\", 50.55, 26.06, \"AS\"],\n  BI: [\"108\", \"Burundi\", 29.92, -3.33, \"AF\"],\n  BJ: [\"204\", \"Benin\", 2.35, 10.32, \"AF\"],\n  BL: [null, \"Saint Barthélemy\", -62.83, 17.9, \"AM\"],\n  BM: [null, \"Bermuda\", -64.76, 32.3, \"AM\"],\n  BN: [\"096\", \"Brunei\", 114.55, 4.45, \"AS\"],\n  BO: [\"068\", \"Bolivia\", -64.59, -16.67, \"AM\"],\n  BQ: [null, \"Caribbean Netherlands\", -63.13, 17.54, \"AM\"],\n  BR: [\"076\", \"Brazil\", -49.56, -12.1, \"AM\"],\n  BS: [\"044\", \"Bahamas\", -77.15, 26.4, \"AM\"],\n  BT: [\"064\", \"Bhutan\", 90.04, 27.54, \"AS\"],\n  BW: [\"072\", \"Botswana\", 24.18, -22.1, \"AF\"],\n  BY: [\"112\", \"Belarus\", 28.42, 53.82, \"EU\"],\n  BZ: [\"084\", \"Belize\", -88.71, 17.2, \"AM\"],\n  CA: [\"124\", \"Canada\", -101.91, 60.32, \"AM\"],\n  CC: [null, \"Cocos\", 96.83, -12.16, \"OC\"],\n  CD: [\"180\", \"DR Congo\", 23.46, -1.86, \"AF\"],\n  CF: [\"140\", \"Central African Republic\", 20.91, 6.99, \"AF\"],\n  CG: [\"178\", \"Congo\", 15.9, 0.14, \"AF\"],\n  CH: [\"756\", \"Switzerland\", 7.46, 46.72, \"EU\"],\n  CI: [\"384\", \"Ivory Coast\", -5.57, 7.49, \"AF\"],\n  CK: [null, \"Cook Islands\", -159.79, -21.22, \"OC\"],\n  CL: [\"152\", \"Chile\", -72.32, -38.15, \"AM\"],\n  CM: [\"120\", \"Cameroon\", 12.47, 4.59, \"AF\"],\n  CN: [\"156\", \"China\", 106.34, 32.5, \"AS\"],\n  CO: [\"170\", \"Colombia\", -73.17, 3.37, \"AM\"],\n  CR: [\"188\", \"Costa Rica\", -84.08, 10.07, \"AM\"],\n  CU: [\"192\", \"Cuba\", -77.98, 21.33, \"AM\"],\n  CV: [null, \"Cape Verde\", -23.64, 15.07, \"AF\"],\n  CW: [null, \"Curaçao\", -68.92, 12.15, \"AM\"],\n  CX: [null, \"Christmas Island\", 105.67, -10.49, \"OC\"],\n  CY: [\"196\", \"Cyprus\", 33.08, 34.91, \"EU\"],\n  CZ: [\"203\", \"Czechia\", 15.38, 49.88, \"EU\"],\n  DE: [\"276\", \"Germany\", 9.68, 50.96, \"EU\"],\n  DJ: [\"262\", \"Djibouti\", 42.5, 11.98, \"AF\"],\n  DK: [\"208\", \"Denmark\", 9.02, 55.97, \"EU\"],\n  DM: [null, \"Dominica\", -61.34, 15.46, \"AM\"],\n  DO: [\"214\", \"Dominican Republic\", -70.65, 19.1, \"AM\"],\n  DZ: [\"012\", \"Algeria\", 2.81, 27.4, \"AF\"],\n  EC: [\"218\", \"Ecuador\", -78.19, -1.26, \"AM\"],\n  EE: [\"233\", \"Estonia\", 25.87, 58.72, \"EU\"],\n  EG: [\"818\", \"Egypt\", 29.45, 26.19, \"AF\"],\n  EH: [\"732\", \"Western Sahara\", -12.63, 23.97, \"AF\"],\n  ER: [\"232\", \"Eritrea\", 38.29, 15.79, \"AF\"],\n  ES: [\"724\", \"Spain\", -3.46, 40.09, \"EU\"],\n  ET: [\"231\", \"Ethiopia\", 39.09, 8.03, \"AF\"],\n  FI: [\"246\", \"Finland\", 27.28, 63.25, \"EU\"],\n  FJ: [\"242\", \"Fiji\", 177.98, -17.83, \"OC\"],\n  FM: [null, \"Micronesia\", 158.23, 6.89, \"OC\"],\n  FO: [null, \"Faroe Islands\", -7.06, 62.19, \"EU\"],\n  FR: [\"250\", \"France\", 2.55, 46.7, \"EU\"],\n  GA: [\"266\", \"Gabon\", 11.84, -0.44, \"AF\"],\n  GB: [\"826\", \"United Kingdom\", -2.12, 54.4, \"EU\"],\n  GD: [null, \"Grenada\", -61.68, 12.11, \"AM\"],\n  GE: [\"268\", \"Georgia\", 43.74, 41.87, \"AS\"],\n  GF: [null, \"French Guiana\", -53.07, 4, \"AM\"],\n  GG: [null, \"Guernsey\", -2.56, 49.46, \"EU\"],\n  GH: [\"288\", \"Ghana\", -1.04, 7.72, \"AF\"],\n  GI: [null, \"Gibraltar\", -5.35, 36.13, \"EU\"],\n  GL: [\"304\", \"Greenland\", -39.34, 74.32, \"AM\"],\n  GM: [\"270\", \"Gambia\", -15, 13.64, \"AF\"],\n  GN: [\"324\", \"Guinea\", -10.02, 10.62, \"AF\"],\n  GP: [null, \"Guadeloupe\", -61.43, 16.3, \"AM\"],\n  GQ: [\"226\", \"Equatorial Guinea\", 10.34, 1.61, \"AF\"],\n  GR: [\"300\", \"Greece\", 21.73, 39.49, \"EU\"],\n  GS: [null, \"South Georgia\", -31.06, -55.68, \"AN\"],\n  GT: [\"320\", \"Guatemala\", -90.5, 14.98, \"AM\"],\n  GU: [null, \"Guam\", 144.7, 13.35, \"OC\"],\n  GW: [\"624\", \"Guinea-Bissau\", -14.52, 12.16, \"AF\"],\n  GY: [\"328\", \"Guyana\", -58.94, 5.12, \"AM\"],\n  HK: [null, \"Hong Kong\", 114.1, 22.45, \"AS\"],\n  HN: [\"340\", \"Honduras\", -86.89, 14.79, \"AM\"],\n  HR: [\"191\", \"Croatia\", 16.37, 45.81, \"EU\"],\n  HT: [\"332\", \"Haiti\", -72.22, 19.26, \"AM\"],\n  HU: [\"348\", \"Hungary\", 19.45, 47.09, \"EU\"],\n  ID: [\"360\", \"Indonesia\", 101.89, -0.95, \"AS\"],\n  IE: [\"372\", \"Ireland\", -7.8, 53.08, \"EU\"],\n  IL: [\"376\", \"Israel\", 34.85, 30.91, \"AS\"],\n  IM: [null, \"Isle of Man\", -4.53, 54.22, \"EU\"],\n  IN: [\"356\", \"India\", 79.36, 22.69, \"AS\"],\n  IO: [null, \"British Indian Ocean Territory\", 71.35, -6.19, \"AF\"],\n  IQ: [\"368\", \"Iraq\", 43.26, 33.09, \"AS\"],\n  IR: [\"364\", \"Iran\", 54.93, 32.17, \"AS\"],\n  IS: [\"352\", \"Iceland\", -18.67, 64.78, \"EU\"],\n  IT: [\"380\", \"Italy\", 11.08, 44.73, \"EU\"],\n  JE: [null, \"Jersey\", -2.09, 49.22, \"EU\"],\n  JM: [\"388\", \"Jamaica\", -77.32, 18.14, \"AM\"],\n  JO: [\"400\", \"Jordan\", 36.38, 30.81, \"AS\"],\n  JP: [\"392\", \"Japan\", 138.44, 36.14, \"AS\"],\n  KE: [\"404\", \"Kenya\", 37.91, 0.55, \"AF\"],\n  KG: [\"417\", \"Kyrgyzstan\", 74.53, 41.67, \"AS\"],\n  KH: [\"116\", \"Cambodia\", 104.5, 12.65, \"AS\"],\n  KI: [null, \"Kiribati\", -157.38, 1.82, \"OC\"],\n  KM: [null, \"Comoros\", 43.32, -11.73, \"AF\"],\n  KN: [null, \"Saint Kitts and Nevis\", -62.76, 17.34, \"AM\"],\n  KP: [\"408\", \"North Korea\", 126.44, 39.89, \"AS\"],\n  KR: [\"410\", \"South Korea\", 128.13, 36.38, \"AS\"],\n  KW: [\"414\", \"Kuwait\", 47.31, 29.41, \"AS\"],\n  KY: [null, \"Cayman Islands\", -81.24, 19.32, \"AM\"],\n  KZ: [\"398\", \"Kazakhstan\", 68.69, 49.05, \"AS\"],\n  LA: [\"418\", \"Laos\", 102.53, 19.43, \"AS\"],\n  LB: [\"422\", \"Lebanon\", 35.99, 34.13, \"AS\"],\n  LC: [null, \"Saint Lucia\", -60.98, 13.89, \"AM\"],\n  LI: [null, \"Liechtenstein\", 9.56, 47.11, \"EU\"],\n  LK: [\"144\", \"Sri Lanka\", 80.7, 7.58, \"AS\"],\n  LR: [\"430\", \"Liberia\", -9.46, 6.45, \"AF\"],\n  LS: [\"426\", \"Lesotho\", 28.25, -29.48, \"AF\"],\n  LT: [\"440\", \"Lithuania\", 24.09, 55.1, \"EU\"],\n  LU: [\"442\", \"Luxembourg\", 6.08, 49.73, \"EU\"],\n  LV: [\"428\", \"Latvia\", 25.46, 57.07, \"EU\"],\n  LY: [\"434\", \"Libya\", 18.01, 26.64, \"AF\"],\n  MA: [\"504\", \"Morocco\", -7.19, 31.65, \"AF\"],\n  MC: [null, \"Monaco\", 7.4, 43.74, \"EU\"],\n  MD: [\"498\", \"Moldova\", 28.49, 47.43, \"EU\"],\n  ME: [\"499\", \"Montenegro\", 19.14, 42.8, \"EU\"],\n  MF: [null, \"Saint Martin\", -63.05, 18.08, \"AM\"],\n  MG: [\"450\", \"Madagascar\", 46.7, -18.63, \"AF\"],\n  MH: [null, \"Marshall Islands\", 171.19, 7.08, \"OC\"],\n  MK: [\"807\", \"North Macedonia\", 21.56, 41.56, \"EU\"],\n  ML: [\"466\", \"Mali\", -2.04, 18.69, \"AF\"],\n  MM: [\"104\", \"Myanmar\", 95.8, 21.57, \"AS\"],\n  MN: [\"496\", \"Mongolia\", 104.15, 46, \"AS\"],\n  MO: [null, \"Macau\", 113.56, 22.13, \"AS\"],\n  MP: [null, \"Northern Mariana Islands\", 145.73, 15.19, \"OC\"],\n  MQ: [null, \"Martinique\", -61.06, 14.71, \"AM\"],\n  MR: [\"478\", \"Mauritania\", -9.74, 19.59, \"AF\"],\n  MS: [null, \"Montserrat\", -62.19, 16.74, \"AM\"],\n  MT: [null, \"Malta\", 14.43, 35.89, \"EU\"],\n  MU: [null, \"Mauritius\", 57.57, -20.3, \"AF\"],\n  MV: [null, \"Maldives\", 73.51, 4.17, \"AS\"],\n  MW: [\"454\", \"Malawi\", 33.61, -13.39, \"AF\"],\n  MX: [\"484\", \"Mexico\", -102.29, 23.92, \"AM\"],\n  MY: [\"458\", \"Malaysia\", 113.84, 2.53, \"AS\"],\n  MZ: [\"508\", \"Mozambique\", 37.84, -13.94, \"AF\"],\n  NA: [\"516\", \"Namibia\", 17.11, -20.58, \"AF\"],\n  NC: [\"540\", \"New Caledonia\", 165.08, -21.06, \"OC\"],\n  NE: [\"562\", \"Niger\", 9.5, 17.45, \"AF\"],\n  NF: [null, \"Norfolk Island\", 167.95, -29.03, \"OC\"],\n  NG: [\"566\", \"Nigeria\", 7.5, 9.44, \"AF\"],\n  NI: [\"558\", \"Nicaragua\", -85.07, 12.67, \"AM\"],\n  NL: [\"528\", \"Netherlands\", 5.61, 52.42, \"EU\"],\n  NO: [\"578\", \"Norway\", 9.68, 61.36, \"EU\"],\n  NP: [\"524\", \"Nepal\", 83.64, 28.3, \"AS\"],\n  NR: [null, \"Nauru\", 166.93, -0.52, \"OC\"],\n  NU: [null, \"Niue\", -169.86, -19.05, \"OC\"],\n  NZ: [\"554\", \"New Zealand\", 175.9, -39, \"OC\"],\n  OM: [\"512\", \"Oman\", 57.34, 22.12, \"AS\"],\n  PA: [\"591\", \"Panama\", -80.35, 8.72, \"AM\"],\n  PE: [\"604\", \"Peru\", -72.9, -12.98, \"AM\"],\n  PF: [null, \"French Polynesia\", -149.46, -17.63, \"OC\"],\n  PG: [\"598\", \"Papua New Guinea\", 143.91, -5.7, \"OC\"],\n  PH: [\"608\", \"Philippines\", 122.47, 11.2, \"AS\"],\n  PK: [\"586\", \"Pakistan\", 68.55, 29.33, \"AS\"],\n  PL: [\"616\", \"Poland\", 19.49, 51.99, \"EU\"],\n  PM: [null, \"Saint Pierre and Miquelon\", -56.33, 47.04, \"AM\"],\n  PN: [null, \"Pitcairn Islands\", -128.32, -24.36, \"OC\"],\n  PR: [\"630\", \"Puerto Rico\", -66.48, 18.23, \"AM\"],\n  PS: [\"275\", \"Palestine\", 35.29, 32.05, \"AS\"],\n  PT: [\"620\", \"Portugal\", -8.27, 39.61, \"EU\"],\n  PW: [null, \"Palau\", 134.58, 7.52, \"OC\"],\n  PY: [\"600\", \"Paraguay\", -60.15, -21.67, \"AM\"],\n  QA: [\"634\", \"Qatar\", 51.14, 25.24, \"AS\"],\n  RE: [null, \"Réunion\", 55.54, -21.11, \"AF\"],\n  RO: [\"642\", \"Romania\", 24.97, 45.73, \"EU\"],\n  RS: [\"688\", \"Serbia\", 20.79, 44.19, \"EU\"],\n  RU: [\"643\", \"Russia\", 44.69, 58.25, \"EU\"],\n  RW: [\"646\", \"Rwanda\", 30.1, -1.9, \"AF\"],\n  SA: [\"682\", \"Saudi Arabia\", 44.7, 23.81, \"AS\"],\n  SB: [\"090\", \"Solomon Islands\", 159.17, -8.03, \"OC\"],\n  SC: [null, \"Seychelles\", 55.48, -4.68, \"AF\"],\n  SD: [\"729\", \"Sudan\", 29.26, 16.33, \"AF\"],\n  SE: [\"752\", \"Sweden\", 19.02, 65.86, \"EU\"],\n  SG: [null, \"Singapore\", 103.82, 1.37, \"AS\"],\n  SH: [null, \"Saint Helena\", -5.71, -15.95, \"AF\"],\n  SI: [\"705\", \"Slovenia\", 14.92, 46.06, \"EU\"],\n  SJ: [null, \"Svalbard\", 18.08, 78.78, \"EU\"],\n  SK: [\"703\", \"Slovakia\", 19.05, 48.73, \"EU\"],\n  SL: [\"694\", \"Sierra Leone\", -11.76, 8.62, \"AF\"],\n  SM: [null, \"San Marino\", 12.44, 43.93, \"EU\"],\n  SN: [\"686\", \"Senegal\", -14.78, 15.14, \"AF\"],\n  SO: [\"706\", \"Somalia\", 45.19, 3.57, \"AF\"],\n  SR: [\"740\", \"Suriname\", -55.91, 4.14, \"AM\"],\n  SS: [\"728\", \"South Sudan\", 30.39, 7.23, \"AF\"],\n  ST: [null, \"São Tomé and Príncipe\", 7.02, 0.97, \"AF\"],\n  SV: [\"222\", \"El Salvador\", -88.89, 13.69, \"AM\"],\n  SX: [null, \"Sint Maarten\", -63.07, 18.04, \"AM\"],\n  SY: [\"760\", \"Syria\", 38.28, 35.01, \"AS\"],\n  SZ: [\"748\", \"Eswatini\", 31.47, -26.53, \"AF\"],\n  TC: [null, \"Turks and Caicos Islands\", -71.75, 21.82, \"AM\"],\n  TD: [\"148\", \"Chad\", 18.65, 15.14, \"AF\"],\n  TF: [\"260\", \"French Southern and Antarctic Lands\", 69.12, -49.3, \"AN\"],\n  TG: [\"768\", \"Togo\", 1.06, 8.81, \"AF\"],\n  TH: [\"764\", \"Thailand\", 101.07, 15.46, \"AS\"],\n  TJ: [\"762\", \"Tajikistan\", 72.59, 38.2, \"AS\"],\n  TK: [null, \"Tokelau\", -172.49, -8.56, \"OC\"],\n  TL: [\"626\", \"Timor-Leste\", 125.85, -8.8, \"AS\"],\n  TM: [\"795\", \"Turkmenistan\", 58.68, 39.86, \"AS\"],\n  TN: [\"788\", \"Tunisia\", 9.01, 33.69, \"AF\"],\n  TO: [null, \"Tonga\", -175.16, -21.21, \"OC\"],\n  TR: [\"792\", \"Türkiye\", 34.51, 39.35, \"AS\"],\n  TT: [\"780\", \"Trinidad and Tobago\", -61.25, 10.45, \"AM\"],\n  TV: [null, \"Tuvalu\", 179.21, -8.51, \"OC\"],\n  TW: [\"158\", \"Taiwan\", 120.87, 23.65, \"AS\"],\n  TZ: [\"834\", \"Tanzania\", 34.96, -6.05, \"AF\"],\n  UA: [\"804\", \"Ukraine\", 32.14, 49.72, \"EU\"],\n  UG: [\"800\", \"Uganda\", 32.95, 1.97, \"AF\"],\n  US: [\"840\", \"United States\", -97.48, 39.54, \"AM\"],\n  UY: [\"858\", \"Uruguay\", -55.97, -32.96, \"AM\"],\n  UZ: [\"860\", \"Uzbekistan\", 64.01, 41.69, \"AS\"],\n  VA: [null, \"Vatican City\", 12.45, 41.9, \"EU\"],\n  VC: [null, \"Saint Vincent and the Grenadines\", -61.34, 13.09, \"AM\"],\n  VE: [\"862\", \"Venezuela\", -64.6, 7.18, \"AM\"],\n  VG: [null, \"British Virgin Islands\", -64.64, 18.43, \"AM\"],\n  VI: [null, \"United States Virgin Islands\", -64.78, 17.75, \"AM\"],\n  VN: [\"704\", \"Vietnam\", 105.39, 21.72, \"AS\"],\n  VU: [\"548\", \"Vanuatu\", 166.91, -15.37, \"OC\"],\n  WF: [null, \"Wallis and Futuna\", -178.14, -14.29, \"OC\"],\n  WS: [null, \"Samoa\", -172.44, -13.64, \"OC\"],\n  XK: [null, \"Kosovo\", 20.86, 42.59, \"EU\"],\n  YE: [\"887\", \"Yemen\", 45.87, 15.33, \"AS\"],\n  YT: [null, \"Mayotte\", 45.15, -12.77, \"AF\"],\n  ZA: [\"710\", \"South Africa\", 23.67, -29.71, \"AF\"],\n  ZM: [\"894\", \"Zambia\", 26.4, -14.66, \"AF\"],\n  ZW: [\"716\", \"Zimbabwe\", 29.93, -18.91, \"AF\"],\n} as const satisfies Record<\n  string,\n  readonly [\n    id: string | null,\n    name: string,\n    lng: number,\n    lat: number,\n    continent: ContinentCode,\n  ]\n>\n\n/**\n * ISO 3166-1 alpha-2 code of one of the 195 countries or 50 territories (e.g.\n * `\"AR\"`, `\"CW\"`; `\"XK\"` for Kosovo). Every code gets a pin at its center, but\n * only the ones drawn at this map's 1:110m resolution can be highlighted:\n * small countries and islands (Singapore, Malta, Curaçao…) show just the pin.\n */\nexport type VisitedMapCountryCode = keyof typeof countryData\n\n// Territories, Kosovo, Taiwan and Western Sahara: not among the 195 countries\n// counted in stats (the 193 UN member states plus the two observer states,\n// Vatican and Palestine), though the ones drawn on the map can be highlighted.\n// prettier-ignore\nconst uncounted = new Set<string>([\n  \"AI\", \"AS\", \"AW\", \"AX\", \"BL\", \"BM\", \"BQ\", \"CC\", \"CK\", \"CW\", \"CX\", \"EH\",\n  \"FO\", \"GF\", \"GG\", \"GI\", \"GL\", \"GP\", \"GS\", \"GU\", \"HK\", \"IM\", \"IO\", \"JE\", \"KY\",\n  \"MF\", \"MO\", \"MP\", \"MQ\", \"MS\", \"NC\", \"NF\", \"NU\", \"PF\", \"PM\", \"PN\", \"PR\", \"RE\",\n  \"SH\", \"SJ\", \"SX\", \"TC\", \"TF\", \"TK\", \"TW\", \"VG\", \"VI\", \"WF\", \"XK\", \"YT\",\n])\n\nexport type VisitedMapPlace = {\n  name: string\n  /**\n   * Coordinates in **[longitude, latitude]** order (GeoJSON / d3 convention).\n   * Note that Google Maps copies them as \"lat, lng\", so swap them:\n   * Buenos Aires is `[-58.38, -34.6]`, not `[-34.6, -58.38]`.\n   */\n  coords: [lng: number, lat: number]\n  /** Country the place is in (e.g. `\"ES\"`); it's highlighted with the place's variant. */\n  country?: VisitedMapCountryCode\n  /** Defaults to \"visited\". */\n  variant?: VisitedMapVariant\n}\n\n/**\n * Countries by status, as ISO 3166-1 alpha-2 codes. Each one is highlighted\n * and gets a pin at its center. If a country shows up more than once (here or\n * through `places`), the strongest status wins: current > lived > visited >\n * wishlist.\n *\n * @example { current: \"AR\", lived: [\"IT\"], visited: [\"BR\", \"JP\"], wishlist: [\"FR\"] }\n */\nexport type VisitedMapCountries = {\n  current?: VisitedMapCountryCode\n  lived?: VisitedMapCountryCode[]\n  visited?: VisitedMapCountryCode[]\n  wishlist?: VisitedMapCountryCode[]\n}\n\nexport type VisitedMapProps = {\n  countries?: VisitedMapCountries\n  /** Cities or any other point, e.g. Barcelona inside Spain. */\n  places?: VisitedMapPlace[]\n  /** Show a pin at the center of each country in `countries`. Defaults to true. */\n  countryPins?: boolean\n  /**\n   * Hide how much of the world you've seen (see `getVisitedStats`), shown by\n   * default in the top-left corner: a chip that expands into the list of\n   * places by continent.\n   */\n  hideStats?: boolean\n  /** Hide the legend under the map, which lists the statuses in use. */\n  hideLegend?: boolean\n  /**\n   * Let people zoom and pan the map: buttons, ⌘/Ctrl + scroll, pinch and\n   * drag. Adds a small client component; without it the map ships no\n   * JavaScript.\n   */\n  zoomable?: boolean\n  /**\n   * Start zoomed in on a country, e.g. `\"AR\"`. Frames its main landmass\n   * (France without French Guiana, the US without Alaska). With `zoomable`,\n   * people can zoom out from there; without it, the map stays cropped.\n   */\n  focus?: VisitedMapCountryCode\n  /** Extra classes for the root element: the map card and its legend. */\n  className?: string\n}\n\nconst WIDTH = 960\nconst HEIGHT = 560\nconst PADDING = 8\nconst ANTARCTICA_ID = \"010\"\n\nconst rank: Record<VisitedMapVariant, number> = {\n  wishlist: 0,\n  visited: 1,\n  lived: 2,\n  current: 3,\n}\n\nconst codeById = new Map<string, string>([\n  ...Object.entries(countryData).flatMap(([code, [id]]) =>\n    id ? [[id, code] as const] : [],\n  ),\n  // The Malvinas Islands are shown as part of Argentina.\n  [\"238\", \"AR\"],\n])\n\nconst topology = worldAtlas as unknown as Parameters<typeof feature>[0]\nconst { features } = feature(\n  topology,\n  topology.objects.countries as Parameters<typeof feature>[1] & {\n    type: \"GeometryCollection\"\n  },\n)\nconst world = {\n  type: \"FeatureCollection\" as const,\n  features: features.filter((country) => country.id !== ANTARCTICA_ID),\n}\n\nconst projection = geoMercator().fitSize([WIDTH, HEIGHT], world)\n// One decimal is plenty (0.1 of a 960-wide viewBox, still sub-pixel at the\n// maximum zoom) and makes the markup about a quarter smaller than d3's default 3.\nconst path = geoPath(projection).digits(1)\n\ntype CountryFeature = (typeof world.features)[number]\n\n// Kosovo has no ISO numeric id in world-atlas; \"XK\" is its common code.\nfunction codeOf(country: CountryFeature) {\n  if (country.id !== undefined) return codeById.get(String(country.id))\n  return country.properties?.name === \"Kosovo\" ? \"XK\" : undefined\n}\n\nconst countryPaths = world.features.map((country, index) => ({\n  key: country.id ?? index,\n  code: codeOf(country),\n  d: path(country) ?? \"\",\n}))\n\n// Crop the viewBox to the land bounds so there is no empty band above/below.\nconst [[x0, y0], [x1, y1]] = path.bounds(world)\nconst viewBoxX = Math.floor(x0 - PADDING)\nconst viewBoxY = Math.floor(y0 - PADDING)\nconst viewBoxWidth = Math.ceil(x1 - x0 + PADDING * 2)\nconst viewBoxHeight = Math.ceil(y1 - y0 + PADDING * 2)\nconst viewBox = `${viewBoxX} ${viewBoxY} ${viewBoxWidth} ${viewBoxHeight}`\n\n// Matches visited-map-zoom.tsx (a client module, so its value can't be\n// imported here).\nconst MAX_ZOOM = 6\n// Share of the map around the focused country, so it isn't edge to edge.\nconst FOCUS_FILL = 0.7\n// Islands and territories smaller than this share of a country's largest\n// landmass are left out of its frame (Alaska is ~21% of the contiguous US,\n// French Guiana ~15% of France; Hokkaido is ~36% of Honshu).\nconst MAIN_LAND = 0.25\n\ntype Polygon = GeoJSON.Polygon[\"coordinates\"]\n\nfunction polygonsOf(code: string): Polygon[] {\n  return world.features.flatMap((country) => {\n    if (codeOf(country) !== code) return []\n    const { geometry } = country\n    if (geometry.type === \"Polygon\") return [geometry.coordinates]\n    if (geometry.type === \"MultiPolygon\") return geometry.coordinates\n    return []\n  })\n}\n\nfunction clamp(value: number, min: number, max: number) {\n  return Math.min(max, Math.max(min, value))\n}\n\n// Initial zoom that frames a country: `k` is the scale and `x`/`y` the\n// translation as fractions of the map's size (see visited-map-zoom.tsx).\nfunction focusView(code: string): VisitedMapView | undefined {\n  const key = code.toUpperCase()\n  const polygons = polygonsOf(key).map((coordinates) => ({\n    type: \"Polygon\" as const,\n    coordinates,\n  }))\n\n  let bounds: [[number, number], [number, number]]\n  if (polygons.length) {\n    const areas = polygons.map((polygon) => geoArea(polygon))\n    const largest = Math.max(...areas)\n    bounds = path.bounds({\n      type: \"MultiPolygon\",\n      coordinates: polygons\n        .filter((_, index) => areas[index] >= largest * MAIN_LAND)\n        .map((polygon) => polygon.coordinates),\n    })\n  } else {\n    // Too small to be drawn at this scale (Singapore, Malta…): zoom all the\n    // way in on its center.\n    const data = countryData[key as VisitedMapCountryCode]\n    const point = data && projection([data[2], data[3]])\n    if (!point) return undefined\n    bounds = [point, point]\n  }\n\n  const [[bx0, by0], [bx1, by1]] = bounds\n  const width = (bx1 - bx0) / viewBoxWidth\n  const height = (by1 - by0) / viewBoxHeight\n  const k = clamp(FOCUS_FILL / Math.max(width, height, 1e-6), 1, MAX_ZOOM)\n  const centerX = ((bx0 + bx1) / 2 - viewBoxX) / viewBoxWidth\n  const centerY = ((by0 + by1) / 2 - viewBoxY) / viewBoxHeight\n  return {\n    k,\n    x: clamp(0.5 - centerX * k, 1 - k, 0),\n    y: clamp(0.5 - centerY * k, 1 - k, 0),\n  }\n}\n\nconst pinStyles: Record<VisitedMapVariant, string> = {\n  visited: \"fill-sky-500 stroke-sky-700 dark:fill-sky-400 dark:stroke-sky-200\",\n  lived:\n    \"fill-emerald-500 stroke-emerald-700 dark:fill-emerald-400 dark:stroke-emerald-200\",\n  wishlist:\n    \"fill-amber-400/25 stroke-amber-500 dark:fill-amber-300/20 dark:stroke-amber-300\",\n  current:\n    \"fill-rose-500 stroke-rose-700 dark:fill-rose-400 dark:stroke-rose-200\",\n}\n\n/** Every status with its label, in legend order. */\nexport const visitedMapVariants: {\n  variant: VisitedMapVariant\n  label: string\n}[] = [\n  { variant: \"visited\", label: \"Visited\" },\n  { variant: \"lived\", label: \"Lived\" },\n  { variant: \"wishlist\", label: \"Wishlist\" },\n  { variant: \"current\", label: \"Current\" },\n]\n\n/** A status's pin, drawn like on the map. Handy for your own legend or filters. */\nexport function VisitedMapSwatch({\n  variant,\n  className,\n}: {\n  variant: VisitedMapVariant\n  className?: string\n}) {\n  return (\n    <svg viewBox=\"0 0 10 10\" aria-hidden className={cn(\"size-3\", className)}>\n      <circle\n        cx={5}\n        cy={5}\n        r={4}\n        strokeWidth={1.5}\n        className={pinStyles[variant]}\n      />\n    </svg>\n  )\n}\n\nconst countryStyles: Record<VisitedMapVariant, string> = {\n  visited: \"fill-sky-500/25 dark:fill-sky-400/20\",\n  lived: \"fill-emerald-500/25 dark:fill-emerald-400/20\",\n  wishlist: \"fill-amber-400/25 dark:fill-amber-300/15\",\n  current: \"fill-rose-500/25 dark:fill-rose-400/20\",\n}\n\ntype CountryVariants = Map<string, VisitedMapVariant>\n\n// Keeps the strongest variant per country code.\nfunction addVariant(\n  result: CountryVariants,\n  code: string,\n  variant: VisitedMapVariant,\n) {\n  const key = code.toUpperCase()\n  const existing = result.get(key)\n  if (!existing || rank[variant] > rank[existing]) result.set(key, variant)\n}\n\nfunction fromCountries(countries: VisitedMapCountries = {}): CountryVariants {\n  const result: CountryVariants = new Map()\n  for (const variant of [\"wishlist\", \"visited\", \"lived\"] as const) {\n    for (const code of countries[variant] ?? [])\n      addVariant(result, code, variant)\n  }\n  if (countries.current) addVariant(result, countries.current, \"current\")\n  return result\n}\n\nfunction resolveCountries(\n  countries?: VisitedMapCountries,\n  places: VisitedMapPlace[] = [],\n): CountryVariants {\n  const result = fromCountries(countries)\n  for (const place of places) {\n    if (place.country)\n      addVariant(result, place.country, place.variant ?? \"visited\")\n  }\n  return result\n}\n\ntype Pin = {\n  key: string\n  name: string\n  variant: VisitedMapVariant\n  coords: [number, number]\n}\n\nfunction projectPins(pins: Pin[]) {\n  // SVG has no z-index: put \"current\" last so it's never covered.\n  const sorted = [...pins].sort(\n    (a, b) => Number(a.variant === \"current\") - Number(b.variant === \"current\"),\n  )\n\n  return sorted.flatMap((pin) => {\n    const point = projection(pin.coords)\n    if (!point || !Number.isFinite(point[0]) || !Number.isFinite(point[1]))\n      return []\n\n    const [cx, cy] = point\n    return [\n      {\n        ...pin,\n        cx,\n        cy,\n        // Position of the dot as a percentage of the rendered map.\n        left: ((cx - viewBoxX) / viewBoxWidth) * 100,\n        top: ((cy - viewBoxY) / viewBoxHeight) * 100,\n      },\n    ]\n  })\n}\n\nexport type VisitedMapStats = {\n  /** Countries visited, counted once each (wishlist excluded). */\n  visited: number\n  /** Always 195: UN member states plus the two observer states. */\n  total: number\n  /** `visited / total` as a percentage, rounded to one decimal. */\n  percent: number\n}\n\nconst TOTAL_COUNTRIES = Object.keys(countryData).length - uncounted.size\n\n/**\n * Share of the world's 195 countries you've visited, from the same props the\n * map takes. Wishlist countries aren't counted, and neither are territories\n * (Greenland, Puerto Rico…), Kosovo or Taiwan, which can still be highlighted.\n */\nexport function getVisitedStats({\n  countries,\n  places,\n}: Pick<VisitedMapProps, \"countries\" | \"places\">): VisitedMapStats {\n  const visited = Array.from(resolveCountries(countries, places)).filter(\n    ([code, variant]) =>\n      variant !== \"wishlist\" && code in countryData && !uncounted.has(code),\n  ).length\n\n  return {\n    visited,\n    total: TOTAL_COUNTRIES,\n    percent: Math.round((visited / TOTAL_COUNTRIES) * 1000) / 10,\n  }\n}\n\n// Continents in display order.\nconst continents: [code: ContinentCode, name: string][] = [\n  [\"AF\", \"Africa\"],\n  [\"AM\", \"Americas\"],\n  [\"AS\", \"Asia\"],\n  [\"EU\", \"Europe\"],\n  [\"OC\", \"Oceania\"],\n  [\"AN\", \"Antarctica\"],\n]\n\n// Countries that count in the stats, per continent.\nconst continentTotals = Object.entries(countryData).reduce(\n  (totals, [code, data]) => {\n    if (!uncounted.has(code)) totals[data[4]] = (totals[data[4]] ?? 0) + 1\n    return totals\n  },\n  {} as Partial<Record<ContinentCode, number>>,\n)\n\ntype StatsItem = {\n  key: string\n  name: string\n  variant: VisitedMapVariant\n  /** Places inside this country. */\n  places?: string[]\n}\n\ntype StatsGroup = {\n  name: string\n  /** Strongest status in the group, for its dot. */\n  variant: VisitedMapVariant\n  /** \"5 of 45\": counted countries visited in this continent. */\n  count?: string\n  /** Counted countries visited in this continent, and their share of it. */\n  visited?: number\n  percent?: number\n  /** One line with everything in the group, shown while it's collapsed. */\n  summary: string\n  items: StatsItem[]\n}\n\nconst statusLabels: Partial<Record<VisitedMapVariant, string>> = {\n  current: \"Now\",\n  lived: \"Lived\",\n  wishlist: \"Wishlist\",\n}\n\nfunction strongest(items: StatsItem[]) {\n  return items.reduce<VisitedMapVariant>(\n    (best, item) => (rank[item.variant] > rank[best] ? item.variant : best),\n    \"wishlist\",\n  )\n}\n\n// \"Spain (Barcelona), Italy · Wishlist: Iceland\"\nfunction summarize(items: StatsItem[]) {\n  const label = (item: StatsItem) =>\n    item.places?.length ? `${item.name} (${item.places.join(\", \")})` : item.name\n  const seen = items.filter((item) => item.variant !== \"wishlist\").map(label)\n  const wishlist = items\n    .filter((item) => item.variant === \"wishlist\")\n    .map(label)\n  return [\n    seen.join(\", \"),\n    wishlist.length > 0 && `Wishlist: ${wishlist.join(\", \")}`,\n  ]\n    .filter(Boolean)\n    .join(\" · \")\n}\n\n// The highlighted countries by continent, each with its places, and the places\n// without a country at the end.\nfunction groupByContinent(\n  highlighted: CountryVariants,\n  places: VisitedMapPlace[],\n): StatsGroup[] {\n  const groups: StatsGroup[] = continents.flatMap(([continent, name]) => {\n    const items = Array.from(highlighted)\n      .flatMap(([code, variant]) => {\n        const data = countryData[code as VisitedMapCountryCode]\n        if (!data || data[4] !== continent) return []\n        return [\n          {\n            key: code,\n            name: data[1] as string,\n            variant,\n            places: places\n              .filter((place) => place.country?.toUpperCase() === code)\n              .map((place) => place.name),\n          },\n        ]\n      })\n      .sort((a, b) => a.name.localeCompare(b.name))\n    if (items.length === 0) return []\n    const visited = items.filter(\n      (item) => item.variant !== \"wishlist\" && !uncounted.has(item.key),\n    ).length\n    const total = continentTotals[continent] ?? 0\n    return [\n      {\n        name,\n        variant: strongest(items),\n        count: `${visited} of ${total}`,\n        visited,\n        percent: total > 0 ? (visited / total) * 100 : 0,\n        summary: summarize(items),\n        items,\n      },\n    ]\n  })\n\n  const others = places.flatMap((place, index) =>\n    place.country\n      ? []\n      : [\n          {\n            key: `place-${index}`,\n            name: place.name,\n            variant: place.variant ?? (\"visited\" as const),\n          },\n        ],\n  )\n  if (others.length > 0)\n    groups.push({\n      name: \"Other places\",\n      variant: strongest(others),\n      summary: summarize(others),\n      items: others,\n    })\n  return groups\n}\n\n// A ring that fills with the share visited: of the world in the chip, of a\n// continent in its card.\nfunction StatsRing({ visited, percent }: { visited: number; percent: number }) {\n  // Whole numbers fit inside the ring; getVisitedStats keeps the decimal.\n  const label = visited > 0 && percent < 1 ? \"<1\" : Math.round(percent)\n\n  return (\n    <span className=\"relative flex size-9 shrink-0 items-center justify-center\">\n      <svg\n        viewBox=\"0 0 36 36\"\n        aria-hidden\n        className=\"absolute inset-0 size-full -rotate-90\"\n      >\n        <circle\n          cx={18}\n          cy={18}\n          r={16}\n          fill=\"none\"\n          strokeWidth={3}\n          className=\"stroke-muted\"\n        />\n        <circle\n          cx={18}\n          cy={18}\n          r={16}\n          fill=\"none\"\n          strokeWidth={3}\n          strokeLinecap=\"round\"\n          pathLength={100}\n          // Keep a sliver visible for tiny percentages.\n          strokeDasharray={`${visited > 0 ? Math.max(percent, 2) : 0} 100`}\n          className=\"stroke-sky-500 dark:stroke-sky-400\"\n        />\n      </svg>\n      <span\n        className={cn(\n          \"font-semibold tabular-nums\",\n          // \"100%\" needs a smaller size to clear the ring.\n          label === 100 ? \"text-[0.5rem]\" : \"text-[0.625rem]\",\n        )}\n      >\n        {label}%\n      </span>\n    </span>\n  )\n}\n\nconst chipStyles: Record<VisitedMapVariant, string> = {\n  visited: \"\",\n  lived: \"border-emerald-500/40 bg-emerald-500/10\",\n  wishlist: \"border-amber-400/40 bg-amber-400/10\",\n  current: \"border-rose-500/40 bg-rose-500/10\",\n}\n\n// One continent: collapsed, a line with everything in it; open, a chip per\n// country with its status and places.\nfunction StatsCard({ group, name }: { group: StatsGroup; name: string }) {\n  return (\n    <details\n      // Cards of the same map share a name, so opening one closes the others.\n      name={name}\n      className=\"group/continent rounded-xl border bg-muted/30\"\n    >\n      <summary className=\"flex cursor-pointer list-none items-center gap-2.5 rounded-xl px-3 py-2 outline-none select-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset [&::-webkit-details-marker]:hidden\">\n        <VisitedMapSwatch variant={group.variant} className=\"shrink-0\" />\n        <span className=\"flex min-w-0 flex-1 flex-col\">\n          <span className=\"flex items-center gap-2\">\n            <span className=\"truncate font-medium\">{group.name}</span>\n            {group.count && (\n              <span className=\"shrink-0 rounded-md bg-muted px-1.5 py-0.5 text-xs text-muted-foreground tabular-nums\">\n                {group.count}\n              </span>\n            )}\n          </span>\n          <span className=\"truncate text-xs text-muted-foreground group-open/continent:hidden\">\n            {group.summary}\n          </span>\n        </span>\n        {group.percent !== undefined && (\n          <StatsRing visited={group.visited ?? 0} percent={group.percent} />\n        )}\n      </summary>\n      <ul className=\"flex flex-wrap gap-1.5 px-3 pb-3\">\n        {group.items.map((item) => (\n          <li\n            key={item.key}\n            className={cn(\n              \"flex items-center gap-1.5 rounded-md border px-2 py-1\",\n              chipStyles[item.variant],\n            )}\n          >\n            <VisitedMapSwatch variant={item.variant} className=\"shrink-0\" />\n            {item.name}\n            {statusLabels[item.variant] && (\n              <span className=\"text-xs text-muted-foreground\">\n                {statusLabels[item.variant]}\n              </span>\n            )}\n            {item.places && item.places.length > 0 && (\n              <span className=\"text-xs text-muted-foreground\">\n                {item.places.join(\", \")}\n              </span>\n            )}\n          </li>\n        ))}\n      </ul>\n    </details>\n  )\n}\n\n// A chip in the top-left corner (open sea above Alaska on a world map) that\n// expands into a card per continent. Native <details>, so it works without\n// JavaScript. Open, it fills the card on narrow maps (the padding keeps the\n// ring in place) and is a column on the left on wide ones. On narrow maps the\n// closed chip is just the ring (container queries, so it depends on the map's\n// width, not the window's).\nfunction StatsOverlay({\n  stats,\n  groups,\n}: {\n  stats: VisitedMapStats\n  groups: StatsGroup[]\n}) {\n  const name = useId()\n\n  return (\n    <details className=\"group/stats absolute top-2 left-2 z-20 max-h-[calc(100%-1rem)] max-w-[calc(100%-1rem)] overflow-y-auto rounded-3xl border bg-card/80 shadow-sm backdrop-blur-sm open:top-0 open:left-0 open:size-full open:max-h-full open:max-w-full open:rounded-lg open:border-transparent open:bg-card open:shadow-none @xl:open:top-2 @xl:open:left-2 @xl:open:h-auto @xl:open:max-h-[calc(100%-1rem)] @xl:open:w-80 @xl:open:max-w-[calc(100%-1rem)] @xl:open:rounded-2xl @xl:open:border-border @xl:open:shadow-sm\">\n      <summary className=\"sticky top-0 z-20 flex cursor-pointer list-none items-center gap-2 rounded-3xl p-1 outline-none select-none group-open/stats:rounded-none group-open/stats:bg-card group-open/stats:p-3 @xl:group-open/stats:rounded-t-2xl @xl:group-open/stats:p-1 @xl:group-open/stats:pr-3 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset @xl:pr-3 [&::-webkit-details-marker]:hidden\">\n        <StatsRing visited={stats.visited} percent={stats.percent} />\n        <span className=\"hidden flex-col leading-tight group-open/stats:flex @xl:flex\">\n          <span className=\"text-sm font-semibold tabular-nums\">\n            {stats.visited} of {stats.total}\n          </span>\n          <span className=\"text-xs text-muted-foreground\">\n            countries visited\n          </span>\n        </span>\n        <span className=\"sr-only\">Show places by continent</span>\n        <svg\n          viewBox=\"0 0 24 24\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeWidth={2}\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          aria-hidden\n          className=\"ml-auto hidden size-3.5 text-muted-foreground transition-transform group-open/stats:block group-open/stats:rotate-180 @xl:block\"\n        >\n          <path d=\"m6 9 6 6 6-6\" />\n        </svg>\n      </summary>\n      <div className=\"flex flex-col gap-1.5 px-2 pb-2 text-sm\">\n        {groups.length === 0 && (\n          <p className=\"px-1 text-muted-foreground\">No places yet.</p>\n        )}\n        {groups.map((group) => (\n          <StatsCard key={group.name} group={group} name={name} />\n        ))}\n      </div>\n    </details>\n  )\n}\n\nexport function VisitedMap({\n  countries,\n  places = [],\n  countryPins = true,\n  hideStats = false,\n  hideLegend = false,\n  zoomable = false,\n  focus,\n  className,\n}: VisitedMapProps) {\n  const highlighted = resolveCountries(countries, places)\n  // A country's pin reflects only what `countries` says about it, so a\n  // \"current\" city doesn't also make the country's center pulse.\n  const countryPinList: Pin[] = countryPins\n    ? Array.from(fromCountries(countries)).flatMap(([code, variant]) => {\n        const data = countryData[code as VisitedMapCountryCode]\n        if (!data) return []\n        const [, name, lng, lat] = data\n        return [{ key: `country-${code}`, name, variant, coords: [lng, lat] }]\n      })\n    : []\n  const points = projectPins([\n    ...countryPinList,\n    ...places.map((place, index) => ({\n      key: `place-${index}`,\n      name: place.name,\n      variant: place.variant ?? \"visited\",\n      coords: place.coords,\n    })),\n  ])\n\n  const usedVariants = new Set<VisitedMapVariant>([\n    ...highlighted.values(),\n    ...points.map((point) => point.variant),\n  ])\n  const legendItems = visitedMapVariants.filter((item) =>\n    usedVariants.has(item.variant),\n  )\n\n  const initialView = focus ? focusView(focus) : undefined\n\n  // The SVG and its tooltips; with `zoomable` they move and scale together.\n  // --visited-map-zoom (set by VisitedMapZoom) keeps pins, tooltips and\n  // borders the same size at any zoom.\n  const layer = (\n    <>\n      <svg viewBox={viewBox} className=\"block h-auto w-full\" aria-hidden>\n        <g\n          className=\"fill-muted stroke-border [stroke-width:calc(0.5px/var(--visited-map-zoom,1))]\"\n          strokeWidth={0.5}\n        >\n          {countryPaths.map(({ key, code, d }) => {\n            const variant = code ? highlighted.get(code) : undefined\n            return (\n              <path\n                key={key}\n                d={d}\n                data-country={code}\n                data-variant={variant}\n                className={variant ? countryStyles[variant] : undefined}\n              />\n            )\n          })}\n        </g>\n        {points.map((point) => (\n          <g\n            key={point.key}\n            data-variant={point.variant}\n            className={cn(\n              \"origin-center transform-fill [scale:calc(1/var(--visited-map-zoom,1))]\",\n              pinStyles[point.variant],\n            )}\n          >\n            {point.variant === \"current\" && (\n              <circle\n                cx={point.cx}\n                cy={point.cy}\n                r={5}\n                className=\"origin-center stroke-none opacity-75 transform-fill motion-safe:animate-ping\"\n              />\n            )}\n            <circle\n              cx={point.cx}\n              cy={point.cy}\n              r={point.variant === \"current\" ? 5 : 4}\n              strokeWidth={1.5}\n            />\n          </g>\n        ))}\n      </svg>\n      {/*\n        Tooltips are plain HTML laid over the SVG so their text doesn't shrink\n        with the map. Each hit area is focusable, so hover, click/tap and\n        keyboard focus all show the tooltip with CSS only (no JS needed).\n      */}\n      <ul aria-label=\"Places\" className=\"absolute inset-0\">\n        {points.map((point) => (\n          <li\n            key={point.key}\n            tabIndex={0}\n            className=\"group absolute size-5 -translate-x-1/2 -translate-y-1/2 [scale:calc(1/var(--visited-map-zoom,1))] cursor-pointer rounded-full outline-none hover:z-10 focus:z-10 focus-visible:ring-2 focus-visible:ring-ring\"\n            style={{ left: `${point.left}%`, top: `${point.top}%` }}\n          >\n            <span className=\"pointer-events-none absolute bottom-full left-1/2 mb-1 -translate-x-1/2 rounded-md border bg-popover px-2 py-1 text-xs whitespace-nowrap text-popover-foreground opacity-0 shadow-md transition-opacity group-hover:opacity-100 group-focus:opacity-100\">\n              {point.name}\n            </span>\n          </li>\n        ))}\n      </ul>\n    </>\n  )\n\n  return (\n    <div\n      data-slot=\"visited-map\"\n      className={cn(\"flex flex-col gap-3\", className)}\n    >\n      <div\n        data-slot=\"visited-map-card\"\n        className=\"@container rounded-xl border bg-card p-2\"\n      >\n        {/* On narrow maps the card is taller than the map, which is centered\n            in it: room for the list of places when the stats chip opens. */}\n        <div\n          className={cn(\n            \"relative flex flex-col justify-center\",\n            !hideStats && \"min-h-80 @xl:min-h-0\",\n          )}\n        >\n          {zoomable ? (\n            <VisitedMapZoom initialView={initialView}>{layer}</VisitedMapZoom>\n          ) : initialView ? (\n            // Focused but not zoomable: the same transform, applied once on\n            // the server, so it still ships no JavaScript.\n            <div className=\"relative overflow-hidden rounded-lg\">\n              <div\n                className=\"relative origin-top-left\"\n                style={\n                  {\n                    transform: `translate(${initialView.x * 100}%, ${initialView.y * 100}%) scale(${initialView.k})`,\n                    \"--visited-map-zoom\": initialView.k,\n                  } as React.CSSProperties\n                }\n              >\n                {layer}\n              </div>\n            </div>\n          ) : (\n            <div className=\"relative\">{layer}</div>\n          )}\n          {!hideStats && (\n            <StatsOverlay\n              stats={getVisitedStats({ countries, places })}\n              groups={groupByContinent(highlighted, places)}\n            />\n          )}\n        </div>\n      </div>\n      {!hideLegend && legendItems.length > 0 && (\n        <ul\n          aria-label=\"Legend\"\n          className=\"flex flex-wrap justify-center gap-x-5 gap-y-2 text-sm text-muted-foreground\"\n        >\n          {legendItems.map((item) => (\n            <li key={item.variant} className=\"flex items-center gap-2\">\n              <VisitedMapSwatch variant={item.variant} />\n              {item.label}\n            </li>\n          ))}\n        </ul>\n      )}\n    </div>\n  )\n}\n",
      "type": "registry:component"
    },
    {
      "path": "registry/visited-map/visited-map-zoom.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\n\n// Pan and zoom for VisitedMap's `zoomable` prop. The map itself is still\n// rendered by the server and passed in as children; this only moves and scales\n// it. Pins, tooltips and borders read --visited-map-zoom to keep their size.\n\nconst MIN_ZOOM = 1\n// world-atlas 110m gets visibly coarse past this.\nconst MAX_ZOOM = 6\nconst STEP = 1.5\n// Pixels a pointer must move before a press becomes a drag, so taps and clicks\n// still reach the pins.\nconst DRAG_THRESHOLD = 3\n\n/**\n * Scale `k` and translation `x`/`y`, stored as fractions of the map's size\n * (not pixels) so the view stays right when the map is resized.\n */\nexport type VisitedMapView = { k: number; x: number; y: number }\n\ntype View = VisitedMapView\n\nconst worldView: View = { k: 1, x: 0, y: 0 }\n\nfunction clamp(value: number, min: number, max: number) {\n  return Math.min(max, Math.max(min, value))\n}\n\n// Keeps the map covering the viewport: no empty space at the edges. `margin`\n// is the empty band above and below the map at 1×, as a fraction of the map's\n// height, when the viewport is taller than the map (narrow maps): zooming in\n// fills those bands first, growing from the center.\nfunction constrain({ k, x, y }: View, margin: number): View {\n  const zoom = clamp(k, MIN_ZOOM, MAX_ZOOM)\n  return {\n    k: zoom,\n    x: clamp(x, 1 - zoom, 0),\n    y:\n      zoom < 1 + 2 * margin\n        ? (1 - zoom) / 2\n        : clamp(y, 1 + margin - zoom, -margin),\n  }\n}\n\n// Zooms to `k` keeping the point (u, v), as fractions of the map's size at 1×,\n// under the cursor or fingers.\nfunction zoomAt(\n  view: View,\n  k: number,\n  u: number,\n  v: number,\n  margin: number,\n): View {\n  const zoom = clamp(k, MIN_ZOOM, MAX_ZOOM)\n  return constrain(\n    {\n      k: zoom,\n      x: u - ((u - view.x) / view.k) * zoom,\n      y: v - ((v - view.y) / view.k) * zoom,\n    },\n    margin,\n  )\n}\n\ntype Pointer = { x: number; y: number }\n\nfunction distance(a: Pointer, b: Pointer) {\n  return Math.hypot(a.x - b.x, a.y - b.y)\n}\n\nfunction midpoint(a: Pointer, b: Pointer): Pointer {\n  return { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 }\n}\n\nfunction Icon({ children }: { children: React.ReactNode }) {\n  return (\n    <svg\n      viewBox=\"0 0 24 24\"\n      fill=\"none\"\n      stroke=\"currentColor\"\n      strokeWidth={2}\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      aria-hidden\n      className=\"size-4\"\n    >\n      {children}\n    </svg>\n  )\n}\n\nfunction ZoomButton({\n  label,\n  disabled,\n  onClick,\n  children,\n}: {\n  label: string\n  disabled: boolean\n  onClick: () => void\n  children: React.ReactNode\n}) {\n  return (\n    <button\n      type=\"button\"\n      aria-label={label}\n      title={label}\n      disabled={disabled}\n      onClick={onClick}\n      className=\"flex size-7 items-center justify-center text-muted-foreground transition-colors outline-none hover:bg-accent hover:text-accent-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset disabled:pointer-events-none disabled:opacity-40\"\n    >\n      {children}\n    </button>\n  )\n}\n\nexport function VisitedMapZoom({\n  initialView = worldView,\n  children,\n}: {\n  /** Where the map starts, e.g. framing the `focus` country. */\n  initialView?: View\n  children: React.ReactNode\n}) {\n  const viewportRef = React.useRef<HTMLDivElement>(null)\n  const layerRef = React.useRef<HTMLDivElement>(null)\n  const [view, setView] = React.useState<View>(initialView)\n  // Buttons and double-click animate; wheel, drag and pinch follow the input.\n  const [animate, setAnimate] = React.useState(false)\n  const [dragging, setDragging] = React.useState(false)\n  const [hint, setHint] = React.useState<string | null>(null)\n\n  const viewRef = React.useRef(view)\n  const pointers = React.useRef(new Map<number, Pointer>())\n  const gesture = React.useRef<{\n    start: Pointer\n    last: Pointer\n    distance: number\n    moved: boolean\n  } | null>(null)\n\n  const update = React.useCallback((next: View, animated: boolean) => {\n    viewRef.current = next\n    setAnimate(animated)\n    setView(next)\n  }, [])\n\n  // The map's size at 1× (its layout box, which transforms don't change) and\n  // the band above it when the viewport is taller than the map.\n  const measure = React.useCallback(() => {\n    const layer = layerRef.current!\n    return {\n      width: layer.offsetWidth,\n      height: layer.offsetHeight,\n      top: layer.offsetTop,\n      margin: layer.offsetTop / layer.offsetHeight,\n    }\n  }, [])\n\n  // Position of a client point as fractions of the map's size at 1×.\n  const toFraction = React.useCallback(\n    (point: Pointer) => {\n      const rect = viewportRef.current!.getBoundingClientRect()\n      const map = measure()\n      return {\n        u: (point.x - rect.left) / map.width,\n        v: (point.y - rect.top - map.top) / map.height,\n        map,\n      }\n    },\n    [measure],\n  )\n\n  // A native listener: React's onWheel is passive and can't stop the page from\n  // scrolling. The wheel only zooms with ⌘/Ctrl (trackpad pinch sends Ctrl),\n  // so scrolling past the map still scrolls the page.\n  React.useEffect(() => {\n    const viewport = viewportRef.current\n    if (!viewport) return\n    let hintTimer: ReturnType<typeof setTimeout> | undefined\n\n    function onWheel(event: WheelEvent) {\n      if (!event.ctrlKey && !event.metaKey) {\n        const mac = /Mac|iPhone|iPad/.test(navigator.userAgent)\n        setHint(`Use ${mac ? \"⌘\" : \"Ctrl\"} + scroll to zoom`)\n        clearTimeout(hintTimer)\n        hintTimer = setTimeout(() => setHint(null), 1200)\n        return\n      }\n      event.preventDefault()\n      setHint(null)\n      const { u, v, map } = toFraction({ x: event.clientX, y: event.clientY })\n      const delta = clamp(event.deltaY, -50, 50)\n      const current = viewRef.current\n      update(\n        zoomAt(current, current.k * Math.exp(-delta * 0.01), u, v, map.margin),\n        false,\n      )\n    }\n\n    viewport.addEventListener(\"wheel\", onWheel, { passive: false })\n    return () => {\n      viewport.removeEventListener(\"wheel\", onWheel)\n      clearTimeout(hintTimer)\n    }\n  }, [toFraction, update])\n\n  function onPointerDown(event: React.PointerEvent<HTMLDivElement>) {\n    if (event.pointerType === \"mouse\" && event.button !== 0) return\n    pointers.current.set(event.pointerId, {\n      x: event.clientX,\n      y: event.clientY,\n    })\n    const points = [...pointers.current.values()]\n    const center =\n      points.length > 1 ? midpoint(points[0], points[1]) : points[0]\n    gesture.current = {\n      start: center,\n      last: center,\n      distance: points.length > 1 ? distance(points[0], points[1]) : 0,\n      moved: points.length > 1,\n    }\n  }\n\n  function onPointerMove(event: React.PointerEvent<HTMLDivElement>) {\n    if (!pointers.current.has(event.pointerId) || !gesture.current) return\n    pointers.current.set(event.pointerId, {\n      x: event.clientX,\n      y: event.clientY,\n    })\n    const points = [...pointers.current.values()]\n    const current = viewRef.current\n    const state = gesture.current\n\n    if (points.length > 1) {\n      // Pinch: scale by the change in finger distance around their midpoint,\n      // and pan by how far the midpoint moved.\n      const center = midpoint(points[0], points[1])\n      const spread = distance(points[0], points[1])\n      const { u, v, map } = toFraction(center)\n      const scaled = zoomAt(\n        current,\n        current.k * (state.distance ? spread / state.distance : 1),\n        u,\n        v,\n        map.margin,\n      )\n      update(\n        constrain(\n          {\n            ...scaled,\n            x: scaled.x + (center.x - state.last.x) / map.width,\n            y: scaled.y + (center.y - state.last.y) / map.height,\n          },\n          map.margin,\n        ),\n        false,\n      )\n      state.last = center\n      state.distance = spread\n      return\n    }\n\n    // One pointer only pans once zoomed in; at 1× the page scrolls instead.\n    if (current.k === 1) return\n    const point = points[0]\n    if (!state.moved && distance(point, state.start) < DRAG_THRESHOLD) return\n    if (!state.moved) {\n      state.moved = true\n      event.currentTarget.setPointerCapture(event.pointerId)\n      setDragging(true)\n    }\n    const map = measure()\n    update(\n      constrain(\n        {\n          ...current,\n          x: current.x + (point.x - state.last.x) / map.width,\n          y: current.y + (point.y - state.last.y) / map.height,\n        },\n        map.margin,\n      ),\n      false,\n    )\n    state.last = point\n  }\n\n  function onPointerUp(event: React.PointerEvent<HTMLDivElement>) {\n    pointers.current.delete(event.pointerId)\n    const rest = [...pointers.current.values()]\n    // Lifting one finger of a pinch continues as a pan from the other one.\n    gesture.current = rest.length\n      ? { start: rest[0], last: rest[0], distance: 0, moved: true }\n      : null\n    if (!rest.length) setDragging(false)\n  }\n\n  function onDoubleClick(event: React.MouseEvent<HTMLDivElement>) {\n    const { u, v, map } = toFraction({ x: event.clientX, y: event.clientY })\n    update(\n      zoomAt(viewRef.current, viewRef.current.k * 2, u, v, map.margin),\n      true,\n    )\n  }\n\n  // Buttons zoom around the center of the viewport.\n  function zoomBy(factor: number) {\n    update(\n      zoomAt(\n        viewRef.current,\n        viewRef.current.k * factor,\n        0.5,\n        0.5,\n        measure().margin,\n      ),\n      true,\n    )\n  }\n\n  const zoomed = view.k > 1\n  // Reset goes back to where the map started (the focused country, if any).\n  const atStart =\n    view.k === initialView.k &&\n    view.x === initialView.x &&\n    view.y === initialView.y\n\n  return (\n    <>\n      <div\n        ref={viewportRef}\n        data-slot=\"visited-map-viewport\"\n        onPointerDown={onPointerDown}\n        onPointerMove={onPointerMove}\n        onPointerUp={onPointerUp}\n        onPointerCancel={onPointerUp}\n        onDoubleClick={onDoubleClick}\n        className={cn(\n          // Fills the card, with the map centered: on narrow maps the card is\n          // taller than the map, and the zoomed map uses that room.\n          \"relative flex flex-1 flex-col justify-center overflow-hidden rounded-lg select-none\",\n          // At 1× one finger scrolls the page; zoomed in, it pans the map.\n          zoomed ? \"touch-none\" : \"touch-pan-x touch-pan-y\",\n          zoomed && (dragging ? \"cursor-grabbing\" : \"cursor-grab\"),\n        )}\n      >\n        <div\n          ref={layerRef}\n          className={cn(\n            \"relative origin-top-left\",\n            animate && \"transition-transform duration-300 ease-out\",\n          )}\n          style={\n            {\n              transform: `translate(${view.x * 100}%, ${view.y * 100}%) scale(${view.k})`,\n              \"--visited-map-zoom\": view.k,\n            } as React.CSSProperties\n          }\n        >\n          {children}\n        </div>\n      </div>\n      {/* Covers the whole card, not just the map: on narrow maps the card is\n          taller than the map. */}\n      <div\n        aria-hidden\n        className={cn(\n          \"pointer-events-none absolute inset-0 z-30 flex rounded-lg items-center justify-center bg-background/40 text-sm font-medium opacity-0 transition-opacity\",\n          hint && \"opacity-100\",\n        )}\n      >\n        <span className=\"rounded-md border bg-popover px-2.5 py-1 text-popover-foreground shadow-sm\">\n          {hint}\n        </span>\n      </div>\n      <div\n        role=\"group\"\n        aria-label=\"Zoom\"\n        className=\"absolute right-2 bottom-2 z-20 flex flex-col divide-y overflow-hidden rounded-md border bg-card/80 shadow-sm backdrop-blur-sm\"\n      >\n        <ZoomButton\n          label=\"Zoom in\"\n          disabled={view.k >= MAX_ZOOM}\n          onClick={() => zoomBy(STEP)}\n        >\n          <Icon>\n            <path d=\"M5 12h14\" />\n            <path d=\"M12 5v14\" />\n          </Icon>\n        </ZoomButton>\n        <ZoomButton\n          label=\"Zoom out\"\n          disabled={!zoomed}\n          onClick={() => zoomBy(1 / STEP)}\n        >\n          <Icon>\n            <path d=\"M5 12h14\" />\n          </Icon>\n        </ZoomButton>\n        <ZoomButton\n          label=\"Reset zoom\"\n          disabled={atStart}\n          onClick={() => update(initialView, true)}\n        >\n          <Icon>\n            <path d=\"M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8\" />\n            <path d=\"M3 3v5h5\" />\n          </Icon>\n        </ZoomButton>\n      </div>\n    </>\n  )\n}\n",
      "type": "registry:component"
    }
  ],
  "categories": [
    "map",
    "data-display"
  ],
  "type": "registry:component"
}