{"id":"f35dbb8e82a89870","slug":"point-to-ukraine-on-a-map","trashed":false,"description":"","likes":71,"publish_level":"live","forks":5,"fork_of":null,"has_importers":false,"update_time":"2025-06-21T02:36:35.103Z","first_public_version":670,"paused_version":null,"publish_time":"2025-06-21T02:36:41.392Z","publish_version":670,"latest_version":670,"thumbnail":"9ee8229e9641475e101d5bece51acf75da6093793fc0ecfb050699317be5c5a0","default_thumbnail":"9ee8229e9641475e101d5bece51acf75da6093793fc0ecfb050699317be5c5a0","roles":[],"sharing":null,"owner":{"id":"af142d61d1f72c32","avatar_url":"https://avatars.observableusercontent.com/avatar/8f35cfadf5e34ea8c5da8c9decb514f55a0ea9f7babec9be636b56334bdd9c50","login":"chriszs","name":"Chris Zubak-Skees","bio":"Developer, journalist","home_url":"http://zubak-skees.dev","type":"team","tier":"starter_2024"},"creator":{"id":"7df35765d82ba3ca","avatar_url":"https://avatars.observableusercontent.com/avatar/8f35cfadf5e34ea8c5da8c9decb514f55a0ea9f7babec9be636b56334bdd9c50","login":"chriszs","name":"Chris Zubak-Skees","bio":"Developer, journalist","home_url":"http://zubak-skees.dev","tier":"pro"},"authors":[{"id":"7df35765d82ba3ca","avatar_url":"https://avatars.observableusercontent.com/avatar/8f35cfadf5e34ea8c5da8c9decb514f55a0ea9f7babec9be636b56334bdd9c50","name":"Chris Zubak-Skees","login":"chriszs","bio":"Developer, journalist","home_url":"http://zubak-skees.dev","tier":"pro","approved":true,"description":""}],"collections":[],"files":[{"id":"0ca9148938fe656b24d3cd9474fc7190de67f0c2fa0c6940f93f874da1572341e3691665fd40b708cdd5573317e4b18f96bfc1d3617464cb0dc2d8b607c3bddd","url":"https://static.observableusercontent.com/files/0ca9148938fe656b24d3cd9474fc7190de67f0c2fa0c6940f93f874da1572341e3691665fd40b708cdd5573317e4b18f96bfc1d3617464cb0dc2d8b607c3bddd","download_url":"https://static.observableusercontent.com/files/0ca9148938fe656b24d3cd9474fc7190de67f0c2fa0c6940f93f874da1572341e3691665fd40b708cdd5573317e4b18f96bfc1d3617464cb0dc2d8b607c3bddd?response-content-disposition=attachment%3Bfilename*%3DUTF-8%27%27world-atlas-110m.json","name":"world-atlas-110m.json","create_time":"2020-01-25T18:11:41.228Z","mime_type":"application/json","status":"public","size":102116,"content_encoding":"gzip","private_bucket_id":null},{"id":"200ac63b66db3fd13ac4cbd9cbce45e093d4b6f3b3f417180dab43baa66321735b55893ee593319cb3c008cd2d59df605eee2204f729f6b94623166eb75383f0","url":"https://static.observableusercontent.com/files/200ac63b66db3fd13ac4cbd9cbce45e093d4b6f3b3f417180dab43baa66321735b55893ee593319cb3c008cd2d59df605eee2204f729f6b94623166eb75383f0","download_url":"https://static.observableusercontent.com/files/200ac63b66db3fd13ac4cbd9cbce45e093d4b6f3b3f417180dab43baa66321735b55893ee593319cb3c008cd2d59df605eee2204f729f6b94623166eb75383f0?response-content-disposition=attachment%3Bfilename*%3DUTF-8%27%27country-ids.csv","name":"country-ids.csv","create_time":"2020-01-26T18:09:00.056Z","mime_type":"text/csv","status":"public","size":4347,"content_encoding":"gzip","private_bucket_id":null}],"comments":[],"commenting_lock":null,"suggestion_from":null,"suggestions_to":[],"version":670,"title":"Can you point to Ukraine on a map?","license":null,"copyright":"","nodes":[{"id":0,"value":"md`# Can you point to Ukraine on a map?`","pinned":false,"mode":"js","data":null,"name":null},{"id":70,"value":"md`That's what Secretary of State Mike Pompeo [reportedly asked](https://www.npr.org/2020/01/24/799244678/pompeo-wont-say-whether-he-owes-yovanovitch-an-apology-i-ve-done-what-s-right) NPR anchor Mary Louise Kelly.\n\nIn a [2014 survey](https://www.washingtonpost.com/news/monkey-cage/wp/2014/04/07/the-less-americans-know-about-ukraines-location-the-more-they-want-u-s-to-intervene/), about 16 percent of Americans could correctly locate Ukraine. About 60 percent of visitors to this page found it on the first try.`","pinned":false,"mode":"js","data":null,"name":null},{"id":340,"value":"success !== null\n  ? html`${\n      success\n        ? '<p class=\"success\">🏆 Congratulations! You pointed to Ukraine!</p>'\n        : html`<p class=\"failure\">Sorry. That wasn\\'t Ukraine. ${\n            countryName ? html` That's ${countryName}.` : ''\n          }</p>`\n    }`\n  : html`Can you find Ukraine on a map?`","pinned":false,"mode":"js","data":null,"name":null},{"id":629,"value":"viewof hardMode = checkbox({\n  description: \"No marked borders\",\n  options: [{ value: \"on\", label: \"Hard mode\" }],\n  value: \"off\"\n})","pinned":false,"mode":"js","data":null,"name":null},{"id":331,"value":"html`<div><small><b>${\n  success ? 'Everyone\\'s tries' : 'Tries'\n}:</b></small>${swatches({\n  color: colorScale\n})}`","pinned":false,"mode":"js","data":null,"name":null},{"id":7,"value":"map = {\n  let clickTimeout = null;\n\n  function startClick(e, feature) {\n    if (clickTimeout) {\n      clearTimeout(clickTimeout);\n    }\n    const coords = point(e.target, e);\n    /*\n    const transform = d3.zoomTransform(map.children[0]);\n    const coordsTranslated = transform.invert(coords);\n    console.log(coords, coordsTranslated);\n    */\n    clickTimeout = setTimeout(\n      handleClick.bind(this, e, feature, coords),\n      width <= 500 ? 300 : 0\n    );\n  }\n\n  function cancelClick() {\n    if (clickTimeout) {\n      clearTimeout(clickTimeout);\n    }\n  }\n\n  function zoomed() {\n    map.children[0].setAttribute(\"transform\", d3.event.transform);\n  }\n\n  const zoom = d3\n    .zoom()\n    .extent([\n      [0, 0],\n      [width, height]\n    ])\n    .translateExtent([\n      [-50, -50],\n      [width + 50, height + 50]\n    ])\n    .scaleExtent([1, 8])\n    .on(\"zoom\", zoomed);\n\n  function zoomIn() {\n    d3.select(map).transition().call(zoom.scaleBy, 2);\n  }\n\n  function zoomOut() {\n    d3.select(map).transition().call(zoom.scaleBy, 0.5);\n  }\n\n  const map = svg`<svg width=\"${width}\" height=\"${height}\" class=\"map${\n    hardMode === \"on\" ? \" hardMode\" : \"\"\n  }\">\n    <g>\n    <g transform=\"translate(-${width * 0.25},-${height * 0.1})scale(1.4)\">\n      ${features.map(\n        (feature) =>\n          svg`<path d=\"${path(\n            feature\n          )}\" class=\"country\" ondblclick=${cancelClick} onclick=${(e) =>\n            startClick(\n              e,\n              feature\n            )} onmouseover=${handleMouseover} onmouseout=${handleMouseout} />`\n      )}\n    ${\n      success\n        ? loggedTriesSample.map(\n            (point) =>\n              svg`<circle cx=\"${projection([point.long, point.lat])[0]}\" cy=\"${\n                projection([point.long, point.lat])[1]\n              }\" r=\"1\" fill=\"${colorScale(\n                parseInt(point.tries)\n              )}\" class=\"point\" />`\n          )\n        : \"\"\n    }\n    ${yourTries.map(\n      (point) =>\n        svg`<circle cx=\"${point.x * 100}%\" cy=\"${\n          point.y * 100\n        }%\" stroke=\"black\" stroke-width=\"1\" r=\"2\" fill=\"${colorScale(\n          point.tries\n        )}\" />`\n    )}\n    </g>\n    </g>\n    <g transform=\"scale(0.35)\">\n      <g>\n        <rect fill=\"white\" x=\"10\" y=\"10\" width=\"75\" height=\"75\" class=\"button\" onclick=${zoomIn} />\n        <path d=\"M79.122,13.679H16.878c-3.3,0-5.985,2.685-5.985,5.986v56.67c0,3.3,2.685,5.985,5.985,5.985h62.244  c3.3,0,5.985-2.685,5.985-5.985v-56.67C85.107,16.365,82.423,13.679,79.122,13.679z M60.048,49.445H49.445v10.603h-2.891V49.445  H35.952v-2.891h10.603V35.952h2.891v10.602h10.603V49.445z\" class=\"button\" onclick=${zoomIn}></path>\n      </g>\n      <g transform=\"translate(0,80)\">\n        <rect fill=\"white\" x=\"10\" y=\"10\" width=\"75\" height=\"75\" class=\"button\" onclick=${zoomOut} />\n        <path d=\"M80.289,12.392H15.711c-3.425,0-6.211,2.786-6.211,6.211v58.793c0,3.426,2.786,6.211,6.211,6.211h64.578  c3.425,0,6.211-2.785,6.211-6.211V18.603C86.5,15.178,83.714,12.392,80.289,12.392z M60.5,49.5h-25v-3h25V49.5z\" onclick=${zoomOut} class=\"button\"></path>\n      </g>\n    </g>\n  </svg>`;\n\n  if (width <= 500) {\n    d3.select(map).call(zoom.scaleBy, 1.5).call(zoom.translateBy, -50, 0);\n  }\n\n  const zoomCall = d3.select(map).call(zoom);\n\n  const dblclickHandler = zoomCall.on(\"dblclick.zoom\");\n\n  zoomCall.on(\"dblclick.zoom\", function (d, i, nodes) {\n    cancelClick();\n    dblclickHandler.call(this, d, i, nodes);\n  });\n\n  return map;\n}","pinned":false,"mode":"js","data":null,"name":null},{"id":659,"value":"md``","pinned":false,"mode":"js","data":null,"name":null},{"id":67,"value":"md`### Appendix`","pinned":false,"mode":"js","data":null,"name":null},{"id":624,"value":"import { checkbox } from \"@jashkenas/inputs\"","pinned":false,"mode":"js","data":null,"name":null},{"id":113,"value":"htl = require(\"htl@0.0.9\")","pinned":false,"mode":"js","data":null,"name":null},{"id":115,"value":"svg = htl.svg","pinned":false,"mode":"js","data":null,"name":null},{"id":5,"value":"d3 = require('d3')","pinned":false,"mode":"js","data":null,"name":null},{"id":13,"value":"topojson = require('topojson')","pinned":false,"mode":"js","data":null,"name":null},{"id":43,"value":"height = width <= 500 ? Math.round(width / 1.2) : Math.round(width / 1.8)","pinned":false,"mode":"js","data":null,"name":null},{"id":8,"value":"projection = d3\n  .geoNaturalEarth1()\n  .rotate([0, 0])\n  .precision(0.1)\n  .fitSize([width, height], { type: \"Sphere\" })","pinned":false,"mode":"js","data":null,"name":null},{"id":27,"value":"path = d3.geoPath().projection(projection)","pinned":false,"mode":"js","data":null,"name":null},{"id":11,"value":"world = FileAttachment(\"world-atlas-110m.json\").json()","pinned":false,"mode":"js","data":null,"name":null},{"id":494,"value":"countryIds = d3.csvParse(await FileAttachment(\"country-ids.csv\").text())","pinned":false,"mode":"js","data":null,"name":null},{"id":30,"value":"features = topojson.feature(world, world.objects.countries).features","pinned":false,"mode":"js","data":null,"name":null},{"id":217,"value":"// from https://github.com/d3/d3-selection/blob/master/src/point.js\npoint = function(node, event) {\n  const svg = node.ownerSVGElement || node;\n\n  if (svg.createSVGPoint) {\n    let point = svg.createSVGPoint();\n    (point.x = event.clientX), (point.y = event.clientY);\n    point = point.matrixTransform(node.getScreenCTM().inverse());\n    return [point.x, point.y];\n  }\n\n  const rect = node.getBoundingClientRect();\n  return [\n    event.clientX - rect.left - node.clientLeft,\n    event.clientY - rect.top - node.clientTop\n  ];\n}","pinned":false,"mode":"js","data":null,"name":null},{"id":124,"value":"handleClick = (e, feature, coords) => {\n  mutable countryName = (\n    countryIds.find(country => feature.id === country.code) || { name: null }\n  ).name;\n  mutable success = feature.id === '804';\n  mutable yourTries.push({\n    x: coords[0] / width,\n    y: coords[1] / height,\n    tries: tries + 1\n  });\n  try {\n    d3.json(\n      \"https://a8qabv339g.execute-api.us-east-1.amazonaws.com/dev/points\",\n      {\n        body: JSON.stringify({\n          country: feature.id,\n          x: coords[0],\n          y: coords[1],\n          width,\n          height,\n          tries: tries + 1,\n          bot: false,\n          hardMode\n        }),\n        headers: { \"content-type\": \"application/json\" },\n        method: \"POST\",\n        mode: \"cors\"\n      }\n    );\n    ++mutable tries;\n  } catch (e) {\n    // no-op\n  }\n}","pinned":false,"mode":"js","data":null,"name":null},{"id":166,"value":"handleMouseover = e => e.target.classList.add('highlighted')","pinned":false,"mode":"js","data":null,"name":null},{"id":168,"value":"handleMouseout = e => e.target.classList.remove('highlighted')","pinned":false,"mode":"js","data":null,"name":null},{"id":130,"value":"mutable success = null","pinned":false,"mode":"js","data":null,"name":null},{"id":498,"value":"mutable countryName = null","pinned":false,"mode":"js","data":null,"name":null},{"id":222,"value":"mutable tries = 0","pinned":false,"mode":"js","data":null,"name":null},{"id":292,"value":"mutable yourTries = []","pinned":false,"mode":"js","data":null,"name":null},{"id":243,"value":"loggedTries = {\n  try {\n    return await d3.tsv(\n      'https://d18fkkvkxmrwi8.cloudfront.net/sampled-tries.tsv'\n    );\n  } catch (e) {\n    return [];\n  }\n}","pinned":false,"mode":"js","data":null,"name":null},{"id":346,"value":"loggedTriesSample = loggedTries","pinned":false,"mode":"js","data":null,"name":null},{"id":319,"value":"colorScale = d3\n  .scaleOrdinal()\n  .domain([1, 2, 3, 4, 5]) // , 'yours'\n  .unknown(\"#f1eef6\")\n  .range(\n    [\"#f1eef6\", \"#bdc9e1\", \"#74a9cf\", \"#2b8cbe\", \"#045a8d\"] // \"green\",\n      .slice()\n      .reverse()\n  )","pinned":false,"mode":"js","data":null,"name":null},{"id":23,"value":"html`<style>\n.map {\n   max-width: 100%;\n   width: 100%;\n   height: auto;\n   background-color: white;\n   border: 1px solid rgb(240,240,240);\n}\n.country {\n   fill: rgb(240,240,240);\n   stroke: rgb(200,200,200);\n   stroke-width: 0.5;\n   cursor: pointer;\n}\n.highlighted {\n   fill: rgb(200,200,200);\n}\n.success {\n   color: green;\n}\n.failure {\n   color: red;\n}\n.point {\n   opacity: 0.4;\n   pointer-events: none;\n}\nsmall {\n   position: relative;\n   font-size: 11px;\n   font-family:-apple-system, system-ui, \"avenir next\", avenir, helvetica, \"helvetica neue\", ubuntu, roboto, noto, \"segoe ui\", arial, sans-serif;\n}\n.button {\n   cursor: pointer;\n}\n.hardMode .country {\n   stroke: rgb(240,240,240);\n}\n.hardMode .highlighted {\n   fill: rgb(240,240,240);\n}\n</style>`","pinned":false,"mode":"js","data":null,"name":null},{"id":270,"value":"import { swatches } from '@d3/color-legend'","pinned":false,"mode":"js","data":null,"name":null},{"id":606,"value":"import { pageAnalytics } from '@chriszs/page-analytics'","pinned":false,"mode":"js","data":null,"name":null},{"id":608,"value":"pageAnalytics","pinned":false,"mode":"js","data":null,"name":null}],"resolutions":[],"schedule":null,"last_view_time":null}