The Strait of Hormuz — the chokepoint between Iran and Oman's Musandam Peninsula, and the sea lane for roughly a fifth of the world's traded oil — is only about 21 nautical miles wide at its narrowest. This map draws all eight littoral states around it, each from its own high-detail filter call: Iran, Iraq, Kuwait, Saudi Arabia, Bahrain, Qatar, the UAE, and Oman. Two dashed lines measure the narrows; the note below explains why both “33 km” and “39 km” get quoted. Hover a country for its ISO code; scroll to zoom into the strait — the measurements appear as you do.
The strait's famous “narrowest point” is measured between Larak Island (Iran) and Great Quoin Island (Oman) — a tiny islet off the tip of Musandam — at 21 nautical miles. That single figure gets quoted two ways: 21 nautical miles is ≈ 39 km, but read as 21 statute miles it becomes ≈ 33 km. Same distance, different mile (red line).
Great Quoin is too small for Natural Earth's 10m data, so it isn't in Oman's polygon at all — mapjson can't draw it, so it's a hand-placed marker. The nearest actual coastlines the data does carry — Larak to the Musandam mainland — sit about 49 km apart (grey line).
https://api.mapjson.com/v1/geo?filter={country}&detail=high&properties=name,iso2
// {country} = Iran · Iraq · Kuwait · Saudi Arabia · Bahrain · Qatar · United Arab Emirates · Oman
<!DOCTYPE html>
<html>
<head>
<script src="https://cdn.jsdelivr.net/npm/d3@7/dist/d3.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/topojson-client@3/dist/topojson-client.min.js"></script>
<style>
svg { width: 100%; display: block; background: #7ab4cc; }
#tooltip { position: fixed; background: #000; color: #fff; font-size: 12px; padding: 4px 8px; pointer-events: none; opacity: 0; }
#tiles-toggle { font: 11px monospace; padding: 6px 12px; margin: 8px 0; cursor: pointer; }
#attribution { font: 10px monospace; color: #888; }
</style>
</head>
<body>
<div id="map"></div>
<button id="tiles-toggle">Hide satellite</button> <span id="attribution">Imagery © Esri</span>
<div id="tooltip"></div>
<script>
const width = 960, height = 560;
const svg = d3.select("#map").append("svg").attr("viewBox", `0 0 ${width} ${height}`);
const tooltip = d3.select("#tooltip");
// Mercator framed by hand on the whole Persian Gulf.
const projection = d3.geoMercator().center([53.0, 26.9]).scale(3200).translate([width / 2, height / 2]);
const path = d3.geoPath(projection);
const base = "https://api.mapjson.com/v1/geo?detail=high&properties=name,iso2&filter=";
const fill = { IR:"#c1734e", IQ:"#8aa06a", KW:"#e2a13e", SA:"#cdbd82", BH:"#a97bb0", QA:"#d98b64", AE:"#d9c169", OM:"#7fa07c" };
const countries = ["Iran", "Iraq", "Kuwait", "Saudi Arabia", "Bahrain", "Qatar", "United Arab Emirates", "Oman"];
// Narrowest is Larak (Iran) → Great Quoin islet (Oman): 21 nmi ≈ 39 km (or 33 km read as
// statute miles). Great Quoin is absent from Natural Earth 10m. The nearest coastlines the
// data carries — Larak → Musandam mainland — are ~49 km apart.
const LARAK_Q = [56.379, 26.839], GREAT_QUOIN = [56.517, 26.483];
const LARAK_C = [56.341, 26.828], MUSANDAM_TIP = [56.364, 26.386];
const gLand = svg.append("g"); // zoomed (CSS transform)
const gTiles = gLand.append("g"); // satellite raster tiles (back)
const gCountries = gLand.append("g"); // country polygons
const gLines = gLand.append("g"); // measurement lines (front)
const gMarks = svg.append("g"); // NOT zoomed — fixed-size dots & labels
[ { a: LARAK_Q, b: GREAT_QUOIN, color: "#b0402f", dash: "6 4" }, // official narrowest → islet
{ a: LARAK_C, b: MUSANDAM_TIP, color: "#33475a", dash: "3 4" }, // coastline gap in the data
].forEach((L) => { const p1 = projection(L.a), p2 = projection(L.b);
gLines.append("line").attr("x1", p1[0]).attr("y1", p1[1]).attr("x2", p2[0]).attr("y2", p2[1])
.attr("stroke", L.color).attr("stroke-width", 1.4).attr("stroke-dasharray", L.dash).attr("vector-effect", "non-scaling-stroke"); });
// ---- satellite basemap (Esri World Imagery) — placed in map space so it scales with zoom ----
let tilesOn = true;
const TILE_Z = 7;
function drawTiles() {
const n = 1 << TILE_Z, size = projection.scale() * 2 * Math.PI / n;
const c0 = projection.invert([0, 0]), c1 = projection.invert([width, height]);
const xT = (lng) => (lng + 180) / 360 * n;
const yT = (lat) => (1 - Math.asinh(Math.tan(lat * Math.PI / 180)) / Math.PI) / 2 * n;
for (let x = Math.floor(xT(Math.min(c0[0], c1[0]))); x <= Math.floor(xT(Math.max(c0[0], c1[0]))); x++)
for (let y = Math.floor(yT(Math.max(c0[1], c1[1]))); y <= Math.floor(yT(Math.min(c0[1], c1[1]))); y++) {
const lng = x / n * 360 - 180, lat = Math.atan(Math.sinh(Math.PI * (1 - 2 * y / n))) * 180 / Math.PI;
const p = projection([lng, lat]);
gTiles.append("image")
.attr("href", `https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/${TILE_Z}/${y}/${x}`)
.attr("x", p[0]).attr("y", p[1]).attr("width", size + 0.6).attr("height", size + 0.6)
.attr("preserveAspectRatio", "none");
}
}
function applyTiles() {
gTiles.style("display", tilesOn ? null : "none");
gCountries.selectAll("path").attr("fill-opacity", tilesOn ? 0.5 : 1);
document.getElementById("attribution").style.display = tilesOn ? "" : "none";
document.getElementById("tiles-toggle").textContent = tilesOn ? "Hide satellite" : "Show satellite";
}
drawTiles();
applyTiles();
document.getElementById("tiles-toggle").addEventListener("click", () => { tilesOn = !tilesOn; applyTiles(); });
// Cities (dots), the Great Quoin islet (hollow dot — not in the data), seas & measurement
// labels (no dot). Islet + measurement labels only appear once zoomed in (declutter).
const places = [
{ name: "Basra", lng: 47.82, lat: 30.51, type: "city", small: true },
{ name: "Kuwait City", lng: 47.98, lat: 29.37, type: "city" },
{ name: "Dammam", lng: 50.10, lat: 26.43, type: "city", small: true },
{ name: "Manama", lng: 50.58, lat: 26.23, type: "city" },
{ name: "Doha", lng: 51.53, lat: 25.29, type: "city" },
{ name: "Abu Dhabi", lng: 54.37, lat: 24.45, type: "city" },
{ name: "Dubai", lng: 55.27, lat: 25.20, type: "city" },
{ name: "Bandar Abbas", lng: 56.27, lat: 27.18, type: "city" },
{ name: "Khasab", lng: 56.24, lat: 26.18, type: "city", small: true },
{ name: "Muscat", lng: 58.54, lat: 23.61, type: "city" },
{ name: "Larak I.", lng: 56.37, lat: 26.85, type: "island", zoom: 4 },
{ name: "Great Quoin I.", lng: 56.517, lat: 26.483, type: "islet", zoom: 4 },
{ name: "21 nmi · 39 km (33 mi)", lng: 56.448, lat: 26.661, type: "measure", dy: -6, accent: true, zoom: 4 },
{ name: "≈ 49 km coastline", lng: 56.353, lat: 26.607, type: "measure", dy: 15, zoom: 4 },
{ name: "Strait of Hormuz", lng: 57.05, lat: 26.20, type: "sea", strait: true },
{ name: "Persian Gulf", lng: 51.60, lat: 27.30, type: "sea" },
{ name: "Gulf of Oman", lng: 58.30, lat: 24.70, type: "sea" },
];
function renderMarks(t) {
const mk = gMarks.selectAll("g.mk").data(places);
const ent = mk.enter().append("g").attr("class", "mk");
ent.append("circle").attr("stroke", "#fff");
ent.append("text").attr("text-anchor", "middle").attr("font-family", "monospace")
.attr("stroke", "#fff").attr("stroke-width", 3).attr("paint-order", "stroke");
const m = ent.merge(mk)
.style("opacity", d => (d.zoom && t.k < d.zoom) ? 0 : 1)
.attr("transform", d => { const p = projection([d.lng, d.lat]); return `translate(${t.applyX(p[0])},${t.applyY(p[1])})`; });
m.select("circle")
.attr("r", d => (d.type === "sea" || d.type === "measure") ? 0 : d.type === "islet" ? 2.4 : d.type === "island" ? 2.4 : (d.small ? 2.5 : 3.4))
.attr("fill", d => d.type === "islet" ? "#7ab4cc" : "#333")
.attr("stroke", d => d.type === "islet" ? "#b0402f" : "#fff")
.attr("stroke-width", d => (d.type === "sea" || d.type === "measure") ? 0 : d.type === "islet" ? 1.3 : 0.9);
m.select("text")
.attr("y", d => d.type === "sea" ? 4 : d.type === "measure" ? (d.dy || 0) : (-(d.small ? 2.5 : 3.4) - 6))
.attr("font-size", d => d.type === "measure" ? 11 : d.type === "sea" ? 12 : (d.small || d.type === "island" || d.type === "islet") ? 10 : 12)
.attr("font-weight", d => d.type === "sea" ? 400 : 600)
.attr("font-style", d => d.type === "sea" ? "italic" : "normal")
.attr("letter-spacing", d => d.type === "sea" ? "0.14em" : "0")
.attr("fill", d => (d.accent || d.strait) ? "#b0402f" : d.type === "sea" ? "#2f6fb0" : d.type === "measure" ? "#33475a" : "#1a1a1a")
.text(d => d.name);
}
Promise.all(countries.map(f => d3.json(base + encodeURIComponent(f)))).then(topos => {
const feats = topos.flatMap(t => topojson.feature(t, t.objects.geo).features);
gCountries.selectAll("path").data(feats).join("path")
.attr("d", path)
.attr("fill", d => fill[d.properties.iso2] || "#d9d0be")
.attr("fill-opacity", tilesOn ? 0.5 : 1)
.attr("stroke", "#fff").attr("stroke-width", 0.7).attr("vector-effect", "non-scaling-stroke")
.style("cursor", "pointer")
.on("mouseover", function (e, d) { d3.select(this).attr("stroke", "#1a1a1a").raise();
tooltip.style("opacity", 1).text(d.properties.name + " (" + d.properties.iso2 + ")"); })
.on("mousemove", e => tooltip.style("left", (e.clientX + 12) + "px").style("top", (e.clientY - 8) + "px"))
.on("mouseout", function () { d3.select(this).attr("stroke", "#fff"); tooltip.style("opacity", 0); });
});
const zoom = d3.zoom().scaleExtent([1, 80]).on("zoom", (e) => { gLand.attr("transform", e.transform); renderMarks(e.transform); });
svg.call(zoom);
// load already zoomed into the Gulf so it fills the frame (drag/scroll to explore)
const c0 = projection([53.6, 27.0]), k0 = 1.3;
svg.call(zoom.transform, d3.zoomIdentity.translate(width / 2 - k0 * c0[0], height / 2 - k0 * c0[1]).scale(k0));
</script>
</body>
</html>