{
"$type": "site.standard.document",
"bskyPostRef": {
"cid": "bafyreib5smh4p43s45342hveegljgsvdzkpqa6xb3gkx2l2fvl6km4wbhe",
"uri": "at://did:plc:25rdn5elo5izoxrmtis34zuk/app.bsky.feed.post/3mok4hzehqxt2"
},
"coverImage": {
"$type": "blob",
"ref": {
"$link": "bafkreiadcgyspwpzhx7e5pzrckuaycajy2temdoohymk5x6xksbg3p44ii"
},
"mimeType": "image/webp",
"size": 86384
},
"path": "/piyaklabs/routing-around-google-maps-in-korea-naver-kakao-deep-links-weird-coordinates-and-ios-clipboard-25mf",
"publishedAt": "2026-06-18T04:50:22.000Z",
"site": "https://dev.to",
"tags": [
"webdev",
"showdev",
"javascript",
"K-Map Router",
"github.com/piyaklabs/k-map-router",
"kmap.piyaklabs.com"
],
"textContent": "If you've traveled to Korea, you've hit this wall: **Google Maps can't give you walking or transit directions here.**\nMap-data export is restricted, so locals use **Naver Map** or **KakaoMap** instead. The usual workaround for visitors is\npainful — copy a place's Korean name from Google, paste it into Naver, repeat for every stop.\n\nSo I built K-Map Router: paste a Google Maps link → it opens that place (and the route) in\nNaver or Kakao. Free, no sign-up, nothing stored. Code is here:\ngithub.com/piyaklabs/k-map-router.\n\nThis post isn't a \"look how hard I worked\" story — honestly, I shipped it fast with heavy AI pair-programming (Claude Code).\nIt's a **field guide to the stuff that's genuinely hard to find documented** : how Korean map deep links and coordinates\nactually work. If you ever build something in this space, I hope this saves you a few days.\n\n## The architecture, in one breath\n\nA single **Cloudflare Worker** serves both the React SPA (static assets) and the `POST /api/resolve` endpoint — same origin,\nfree tier, **zero runtime dependencies**. Coordinate resolution is server-side (the browser → Google is blocked by CORS) and\nis nothing but `fetch` + regex + a little decoding. No DB, stateless.\n\n## Hard part #1: coordinates live in _different_ formats per URL\n\nYou can't just regex one pattern. A Google Maps URL (after following redirects) hides the coordinates in one of several\nshapes, and you have to try them in priority order:\n\n\n\n // 1) place pin — most authoritative\n // ...!3d{lat}!4d{lng}\n // 2) directions waypoint — ⚠️ REVERSED: !1d{lng}!2d{lat}\n // multiple pairs => last = destination, first = origin\n // 3) viewport center — /@{lat},{lng},17z\n // 4) ?query= / &destination= / &daddr=\n // 5) ?ll= / &sll=\n\n\nThe one that bit me hardest: **`/dir/` directions URLs store `!1d{longitude}!2d{latitude}` — longitude first.** Read it as\n`(lat, lng)` and you'll happily return a point that's in the ocean. And when there are multiple pairs, the _last_ pair is the\ndestination, the _first_ is the start point (which is how the tool can preserve A→B routes).\n\n## Hard part #2: mobile \"Copy link\" hides coordinates in a protobuf\n\nLinks shared from the **Google Maps mobile app** (the ones with `?g_st=...`) are special. They resolve to something like:\n\n\n\n .../maps?saddr=Seoul+Station&daddr=Gyeongbokgung&geocode=FWoPPQId...;FWFrPQIdEYSRBy...\n\n\nThere are **no plaintext coordinates anywhere** — they're base64url-encoded in the `geocode=` param, as a tiny protobuf. Each\n`;`-separated entry encodes one endpoint:\n\n\n\n // 0x15 = field 2, fixed32 (little-endian) = lat * 1e6\n // 0x1D = field 3, fixed32 (little-endian) = lng * 1e6\n function decodeGeocodeEntry(entry) {\n const bin = atob(entry.replace(/-/g, \"+\").replace(/_/g, \"/\"));\n let lat = null, lng = null;\n for (let i = 0; i + 4 < bin.length && (lat === null || lng === null); i++) {\n const tag = bin.charCodeAt(i);\n if (tag !== 0x15 && tag !== 0x1d) continue;\n let v = 0;\n for (let j = 3; j >= 0; j--) v = v * 256 + bin.charCodeAt(i + 1 + j); // LE\n if (v > 0x7fffffff) v -= 0x100000000;\n if (tag === 0x15) lat = v / 1e6; else lng = v / 1e6;\n i += 4;\n }\n return lat !== null && lng !== null ? { lat, lng } : null;\n }\n\n\nVerified against 경복궁 (Gyeongbokgung): `FWFrPQIdEYSRBy...` → `37.579617, 126.977041`. ✅\n\n## Hard part #3: the deep link specs (and their gotchas)\n\n**Naver** (primary — best transit + English):\n\n\n\n nmap://route/public?dlat={lat}&dlng={lng}&dname={enc}&appname={APPNAME}\n\n\n * `appname` is **required** (silently fails without it).\n * `dname` is optional — omit it and Naver shows the real address. Don't send a literal `Destination` placeholder.\n * Modes are different action paths: `route/walk`, `route/car`, `route/public`.\n\n\n\n**Kakao** (secondary):\n\n\n\n kakaomap://route?ep={lat},{lng}&by=publictransit\n\n\n * Modes: `by=foot | car | publictransit`.\n\n\n\n**Android:** custom schemes are flaky from Chrome. Use an `intent://` URL with a built-in store fallback:\n\n\n\n intent://route/public?...#Intent;scheme=nmap;package=com.nhn.android.nmap;S.browser_fallback_url=...;end\n\n\n## Hard part #4: Kakao's web URL uses a coordinate system from another dimension\n\nFor desktop fallback, Kakao's legacy `link/to` API can't take a start point. But its redirect target can — if you feed it\nKakao's internal **WCongnamul** coordinates:\n\n\n\n https://map.kakao.com/?map_type=TYPE_MAP&target=traffic&rt={sx},{sy},{ex},{ey}&rt1={from}&rt2={to}\n\n\nWCongnamul turned out to be **EPSG:5181 (a GRS80 Transverse Mercator) scaled ×2.5**. I implemented the projection by hand and\nit matched Kakao's own conversion to the integer for every test point. (Also: never put a comma in the `rt1/rt2` label — it\nbreaks the parser and silently drops the destination.)\n\n## Hard part #5: the iOS clipboard \"paste\" button that wouldn't paste\n\nThe \"Paste from clipboard\" button worked everywhere except iOS. Two reasons, both subtle:\n\n 1. Google Maps \"Copy link\" puts the URL on the clipboard as **`text/uri-list` only** — no `text/plain`. So `navigator.clipboard.readText()` returns an empty string.\n 2. iOS WebKit expires the user activation after your first `await`. So a `readText()` → fall back to `read()` chain **always fails on the second call** with `NotAllowedError`.\n\n\n\nThe fix is to make **exactly one** clipboard call inside the gesture, then read the type off the already-resolved\n`ClipboardItem` (those `getType` calls reuse the granted permission):\n\n\n\n const items = await navigator.clipboard.read(); // one call, in the gesture\n for (const item of items) {\n for (const type of [\"text/uri-list\", \"text/plain\", \"text/html\"]) {\n if (!item.types.includes(type)) continue;\n const text = (await (await item.getType(type)).text()).trim();\n // uri-list: first non-comment line is the URL\n if (text) return text;\n }\n }\n\n\nThe iOS \"Paste\" permission bubble itself is unavoidable — it's OS-enforced for any programmatic clipboard read.\n\n## Bonus: respect the mode the user already picked\n\nGoogle encodes the travel mode in the link (`travelmode=driving`, `dirflg=d`, or `!3e0`). Reading it means a shared _driving_\nroute opens directly in driving directions in Naver/Kakao — \"plan in Google Maps, navigate in Korea,\" unchanged.\n\n## Try it / take it\n\n// Detect dark theme var iframe = document.getElementById('tweet-2067124302403772609-944'); if (document.body.className.includes('dark-theme')) { iframe.src = \"https://platform.twitter.com/embed/Tweet.html?id=2067124302403772609&theme=dark\" }\n\n * Live: **kmap.piyaklabs.com**\n * Code (MIT-ish, stateless, zero deps): **github.com/piyaklabs/k-map-router**\n\n\n\nIf you're building anything that bridges Google Maps and Korean map apps, steal the deep-link and coordinate logic — that's\nexactly why it's public. Questions welcome. 🐣",
"title": "Routing around Google Maps in Korea: Naver & Kakao deep links, weird coordinates, and iOS clipboard"
}