← Examples
ATP 2026 · Grand Slam / 1000 / 500 · dates + surface · data join

The 2026 Tennis Season, Mapped

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.

Hard Clay Grass size = level (Slam · 1000 · 500) · drag to pan, scroll to zoom

The schedule

How it uses mapjson

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.

API Calls

https://api.mapjson.com/v1/geo?layer=countries&filter=world&detail=low&format=geojson

Code

<!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>