Every 2026 ATP Grand Slam, Masters 1000, and ATP 500, placed on a mapjson world map by host city — colored by surface, sized by level. The tennis calendar is a chase around the planet after warm weather: hard courts through the Australian and American summers, a European clay spring, a short grass season, then indoor hard as Europe cools. The schedule below groups it into those swings — hover one to light up its region on the map.
The basemap is a single mapjson call — layer=countries&filter=world&detail=low — drawn in Equal Earth with the seam nudged into the Bering Strait so Russia stays whole. The tournament calendar is a small embedded dataset (the ATP tour changes little year to year); each event is projected to its host city's coordinates and joined visually onto that geometry.
https://api.mapjson.com/v1/geo?layer=countries&filter=world&detail=low&format=geojson
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>The 2026 Tennis Season, Mapped</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
body { margin: 0; font-family: system-ui, sans-serif; color: #1a1a1a; background: #fafafa; }
.wrap { max-width: 920px; margin: 2rem auto; padding: 0 1rem; }
#map { border: 1px solid #e5e4df; background: #e8edf0; border-radius: 4px; overflow: hidden; }
#map svg { width: 100%; display: block; cursor: grab; }
.legend { display: flex; gap: 1.2rem; flex-wrap: wrap; font: 12px ui-monospace, monospace; color: #4a4a45; margin: 0.7rem 0 1.4rem; }
.legend .it { display: inline-flex; align-items: center; gap: 0.4rem; }
.legend .dot { width: 12px; height: 12px; border-radius: 50%; }
.tooltip { position: fixed; background: #1a1a1a; color: #fff; font: 11px/1.5 ui-monospace, monospace; padding: 0.4rem 0.65rem; pointer-events: none; opacity: 0; white-space: nowrap; border-radius: 3px; }
.tooltip b { color: #ffd7a0; }
.sched { columns: 2; column-gap: 2rem; }
.swing { break-inside: avoid; margin-bottom: 1.1rem; }
.swing-h { font-weight: 700; font-size: 14px; margin-bottom: 0.3rem; }
.swing-h span { font: 10px ui-monospace, monospace; text-transform: uppercase; color: #a7a6a0; margin-left: 0.4rem; }
.row { display: grid; grid-template-columns: 14px 1fr auto; gap: 0.5rem; align-items: baseline; padding: 0.3rem 0; border-bottom: 1px solid #efeee9; }
.row:hover { background: #f3f0e8; }
.row .dot { width: 9px; height: 9px; border-radius: 50%; margin-top: 5px; }
.row .nm { font-size: 13px; font-weight: 600; }
.row .ci, .row .dt { font: 11px ui-monospace, monospace; color: #6a6962; }
@media (max-width: 620px) { .sched { columns: 1; } }
</style>
</head>
<body>
<div class="wrap">
<div id="map"></div>
<div class="legend">
<span class="it"><span class="dot" style="background:#2b6ca3"></span> Hard</span>
<span class="it"><span class="dot" style="background:#c56a3a"></span> Clay</span>
<span class="it"><span class="dot" style="background:#4f9e5a"></span> Grass</span>
<span class="it" style="color:#8a897f">size = level · drag to pan, scroll to zoom</span>
</div>
<div class="sched" id="sched"></div>
<div class="tooltip" id="tooltip"></div>
</div>
<script src="https://cdn.jsdelivr.net/npm/d3@7/dist/d3.min.js"></script>
<script>
const W = 980, H = 520;
const svg = d3.select("#map").append("svg").attr("viewBox", `0 0 ${W} ${H}`);
const tip = d3.select("#tooltip");
const SIZE = { slam: 8, m1000: 6, a500: 4.5 }; // marker radius = level
const LEVEL = { slam: "Grand Slam", m1000: "Masters 1000", a500: "ATP 500" };
const SURF = { Hard: "#2b6ca3", Clay: "#c56a3a", Grass: "#4f9e5a" }; // marker color = surface
const surfKey = (s) => s.indexOf("Hard") === 0 ? "Hard" : s;
// the tour chases warm weather around the globe; each swing is a region + a slice of the year
const SWINGS = [
{ name: "Australian Summer", when: "January", region: "Oceania" },
{ name: "Gulf · Indoor · Golden Swing", when: "February", region: "Mid-East · Europe · S. America" },
{ name: "Sunshine Double", when: "March", region: "United States" },
{ name: "European Clay", when: "April – early June", region: "Europe" },
{ name: "Grass Season", when: "June – July", region: "Europe" },
{ name: "North American Hard", when: "July – September", region: "North America" },
{ name: "Asian Swing", when: "September – October",region: "Asia" },
{ name: "Indoor Europe", when: "October – November", region: "Europe" },
];
const T = [
{ n: "Australian Open", city: "Melbourne, Australia", lng: 144.98, lat: -37.82, lvl: "slam", surf: "Hard", dt: "Jan 19 – Feb 1", s: "2026-01-19", sw: 0 },
{ n: "Dallas Open", city: "Dallas, USA", lng: -96.80, lat: 32.78, lvl: "a500", surf: "Hard (i)", dt: "Feb 9 – 15", s: "2026-02-09", sw: 1 },
{ n: "ABN AMRO Open", city: "Rotterdam, Netherlands", lng: 4.48, lat: 51.92, lvl: "a500", surf: "Hard (i)", dt: "Feb 9 – 15", s: "2026-02-09", sw: 1 },
{ n: "Qatar Open", city: "Doha, Qatar", lng: 51.53, lat: 25.29, lvl: "a500", surf: "Hard", dt: "Feb 16 – 22", s: "2026-02-16", sw: 1 },
{ n: "Rio Open", city: "Rio de Janeiro, Brazil", lng: -43.17, lat: -22.91, lvl: "a500", surf: "Clay", dt: "Feb 16 – 22", s: "2026-02-16", sw: 1 },
{ n: "Dubai Championships", city: "Dubai, UAE", lng: 55.27, lat: 25.20, lvl: "a500", surf: "Hard", dt: "Feb 23 – Mar 1", s: "2026-02-23", sw: 1 },
{ n: "Mexican Open", city: "Acapulco, Mexico", lng: -99.88, lat: 16.86, lvl: "a500", surf: "Hard", dt: "Feb 23 – Mar 1", s: "2026-02-23", sw: 1 },
{ n: "Indian Wells Open", city: "Indian Wells, USA", lng: -116.30,lat: 33.72, lvl: "m1000", surf: "Hard", dt: "Mar 4 – 15", s: "2026-03-04", sw: 2 },
{ n: "Miami Open", city: "Miami, USA", lng: -80.24, lat: 25.96, lvl: "m1000", surf: "Hard", dt: "Mar 18 – 29", s: "2026-03-18", sw: 2 },
{ n: "Monte-Carlo Masters", city: "Monte-Carlo, Monaco", lng: 7.44, lat: 43.75, lvl: "m1000", surf: "Clay", dt: "Apr 5 – 12", s: "2026-04-05", sw: 3 },
{ n: "Barcelona Open", city: "Barcelona, Spain", lng: 2.16, lat: 41.39, lvl: "a500", surf: "Clay", dt: "Apr 13 – 19", s: "2026-04-13", sw: 3 },
{ n: "BMW Open", city: "Munich, Germany", lng: 11.57, lat: 48.14, lvl: "a500", surf: "Clay", dt: "Apr 20 – 26", s: "2026-04-20", sw: 3 },
{ n: "Madrid Open", city: "Madrid, Spain", lng: -3.68, lat: 40.44, lvl: "m1000", surf: "Clay", dt: "Apr 22 – May 3", s: "2026-04-22", sw: 3 },
{ n: "Italian Open", city: "Rome, Italy", lng: 12.45, lat: 41.93, lvl: "m1000", surf: "Clay", dt: "May 6 – 17", s: "2026-05-06", sw: 3 },
{ n: "Roland Garros", city: "Paris, France", lng: 2.25, lat: 48.85, lvl: "slam", surf: "Clay", dt: "May 24 – Jun 7", s: "2026-05-24", sw: 3 },
{ n: "Halle Open", city: "Halle, Germany", lng: 8.36, lat: 51.97, lvl: "a500", surf: "Grass", dt: "Jun 15 – 21", s: "2026-06-15", sw: 4 },
{ n: "Queen's Club", city: "London, UK", lng: -0.21, lat: 51.48, lvl: "a500", surf: "Grass", dt: "Jun 15 – 21", s: "2026-06-15", sw: 4 },
{ n: "Wimbledon", city: "London, UK", lng: -0.21, lat: 51.43, lvl: "slam", surf: "Grass", dt: "Jun 29 – Jul 12", s: "2026-06-29", sw: 4 },
{ n: "Hamburg Open", city: "Hamburg, Germany", lng: 9.99, lat: 53.55, lvl: "a500", surf: "Clay", dt: "Jul 13 – 19", s: "2026-07-13", sw: 5 },
{ n: "Washington Open", city: "Washington, USA", lng: -77.03, lat: 38.91, lvl: "a500", surf: "Hard", dt: "Jul 27 – Aug 2", s: "2026-07-27", sw: 5 },
{ n: "Canadian Open", city: "Montreal, Canada", lng: -73.61, lat: 45.50, lvl: "m1000", surf: "Hard", dt: "Aug 2 – 13", s: "2026-08-02", sw: 5 },
{ n: "Cincinnati Open", city: "Cincinnati, USA", lng: -84.28, lat: 39.35, lvl: "m1000", surf: "Hard", dt: "Aug 13 – 23", s: "2026-08-13", sw: 5 },
{ n: "US Open", city: "New York, USA", lng: -73.85, lat: 40.75, lvl: "slam", surf: "Hard", dt: "Aug 31 – Sep 13", s: "2026-08-31", sw: 5 },
{ n: "China Open", city: "Beijing, China", lng: 116.41, lat: 39.90, lvl: "a500", surf: "Hard", dt: "Sep 28 – Oct 4", s: "2026-09-28", sw: 6 },
{ n: "Japan Open", city: "Tokyo, Japan", lng: 139.69, lat: 35.68, lvl: "a500", surf: "Hard", dt: "Sep 28 – Oct 4", s: "2026-09-28", sw: 6 },
{ n: "Shanghai Masters", city: "Shanghai, China", lng: 121.47, lat: 31.23, lvl: "m1000", surf: "Hard", dt: "Oct 7 – 18", s: "2026-10-07", sw: 6 },
{ n: "Vienna Open", city: "Vienna, Austria", lng: 16.37, lat: 48.21, lvl: "a500", surf: "Hard (i)", dt: "Oct 26 – Nov 1", s: "2026-10-26", sw: 7 },
{ n: "Swiss Indoors", city: "Basel, Switzerland", lng: 7.59, lat: 47.56, lvl: "a500", surf: "Hard (i)", dt: "Oct 26 – Nov 1", s: "2026-10-26", sw: 7 },
{ n: "Paris Masters", city: "Paris, France", lng: 2.23, lat: 48.89, lvl: "m1000", surf: "Hard (i)", dt: "Nov 2 – 8", s: "2026-11-02", sw: 7 },
];
const projection = d3.geoEqualEarth().rotate([-11, 0]);
const path = d3.geoPath(projection);
const gZoom = svg.append("g");
const gLand = gZoom.append("g"), gDots = gZoom.append("g");
// pan + zoom; markers keep a constant screen size, a hovered swing/row is emphasized
let k = 1, hiPred = null;
function paint() {
gDots.selectAll("circle")
.attr("r", (d) => (SIZE[d.lvl] + (hiPred && hiPred(d) ? 4 : 0)) / k)
.attr("fill-opacity", (d) => hiPred ? (hiPred(d) ? 1 : 0.15) : 0.85)
.attr("stroke-width", 1.2 / k);
}
const setHi = (pred) => { hiPred = pred; paint(); };
const zoom = d3.zoom().scaleExtent([1, 8]).extent([[0, 0], [W, H]]).translateExtent([[0, 0], [W, H]])
.on("zoom", (e) => { k = e.transform.k; gZoom.attr("transform", e.transform); paint(); });
svg.call(zoom).style("cursor", "grab");
function showTip(e, d) {
tip.style("opacity", 1).style("left", (e.clientX + 14) + "px").style("top", (e.clientY - 10) + "px")
.html(`<b>${d.n}</b> · ${LEVEL[d.lvl]}<br>${d.city}<br>${d.dt} · ${d.surf}`);
}
const hideTip = () => tip.style("opacity", 0);
d3.json("https://api.mapjson.com/v1/geo?layer=countries&filter=world&detail=low&format=geojson").then(world => {
projection.fitExtent([[8, 8], [W - 8, H - 8]], { type: "Sphere" });
gLand.selectAll("path").data(world.features).join("path").attr("d", path)
.attr("fill", "#e4e0d5").attr("stroke", "#fff").attr("stroke-width", 0.5).attr("vector-effect", "non-scaling-stroke");
const order = { slam: 0, m1000: 1, a500: 2 };
gDots.selectAll("circle").data(T.slice().sort((a, b) => order[a.lvl] - order[b.lvl])).join("circle")
.attr("cx", d => projection([d.lng, d.lat])[0]).attr("cy", d => projection([d.lng, d.lat])[1])
.attr("fill", d => SURF[surfKey(d.surf)]).attr("stroke", "#fff")
.style("cursor", "pointer").on("mousemove", showTip).on("mouseout", hideTip);
paint();
});
// schedule grouped into the season's swings; hover a swing header to light up its region
const sched = d3.select("#sched");
SWINGS.forEach((sw, i) => {
const g = sched.append("div").attr("class", "swing");
g.append("div").attr("class", "swing-h").html(`${sw.name} <span>${sw.when} · ${sw.region}</span>`)
.on("mouseenter", () => setHi(c => c.sw === i)).on("mouseleave", () => setHi(null));
T.filter(t => t.sw === i).sort((a, b) => a.s.localeCompare(b.s)).forEach(d => {
const row = g.append("div").attr("class", "row")
.on("mouseenter", () => setHi(c => c.n === d.n)).on("mouseleave", () => setHi(null));
row.append("span").attr("class", "dot").style("background", SURF[surfKey(d.surf)]);
const mid = row.append("div");
mid.append("div").attr("class", "nm").text(d.n);
mid.append("div").attr("class", "ci").text(`${d.city} · ${d.surf}`);
row.append("div").attr("class", "dt").text(d.dt);
});
});
</script>
</body>
</html>