Recently I had about 50 memorials in FindAGrave to visit in a single cemetery with GPS locations. I needed a short, but not shortest, path to visit them. Here’s a ChatGPT assisted, reasonable solution.

First, created a virtual cemetery with the memorials. Visiting its page, I entered the following in the development console window.

const memorialLinks = [...document.querySelectorAll('a[href*="/memorial/"]')]
  .map(a => a.href)
  .filter((v, i, a) => a.indexOf(v) === i); // dedupe

console.log(`Found ${memorialLinks.length} memorials`);

ChatGPT had problems scrapping the GPS data, after some debugging, I tested this function:

async function scrapeMemorial(url) {
  const res = await fetch(url, { credentials: 'include' });
  const html = await res.text();
  const doc = new DOMParser().parseFromString(html, 'text/html');

  const idMatch = url.match(/\/memorial\/(\d+)/);
  const memorialId = idMatch ? idMatch[1] : null;

  let latitude = null;
  let longitude = null;

  const mapLink = [...doc.querySelectorAll('a')]
    .find(a => a.textContent.trim().toLowerCase().includes('show map'));

  if (mapLink) {
    const href = mapLink.href;
    const qMatch = href.match(/[?&]q=([-0-9.]+),([-0-9.]+)/);
    if (qMatch) {
      latitude = qMatch[1];
      longitude = qMatch[2];
    }
  }

  return {
    id: memorialId,
    url,
    latitude,
    longitude
  };
}

This can be scrapped on all the memorials with this function:

const delay = ms => new Promise(r => setTimeout(r, ms));

async function scrapeAllMemorials(memorialLinks) {
  const results = [];

  for (let i = 0; i < memorialLinks.length; i++) {
    const url = memorialLinks[i];
    console.log(`Scraping ${i + 1} / ${memorialLinks.length}`);

    try {
      const data = await scrapeMemorial(url);
      results.push(data);
    } catch (err) {
      console.error('Failed:', url, err);
      results.push({
        id: null,
        url,
        latitude: null,
        longitude: null,
        error: true
      });
    }

    await delay(1200);
  }

  return results;
}

const memorialData = await scrapeAllMemorials(memorialLinks);

Make sure to use on memorials with GPS for path planning:

const points = memorialData
  .filter(m => m.latitude && m.longitude)
  .map(m => ({
    id: m.id,
    url: m.url,
    lat: Number(m.latitude),
    lon: Number(m.longitude)
  }));

Here is the distance function, note that R is not important for this application:

function distance(a, b) {
  const R = 6371000; // meters
  const toRad = d => d * Math.PI / 180;

  const dLat = toRad(b.lat - a.lat);
  const dLon = toRad(b.lon - a.lon);

  const lat1 = toRad(a.lat);
  const lat2 = toRad(b.lat);

  const h =
    Math.sin(dLat / 2) ** 2 +
    Math.cos(lat1) * Math.cos(lat2) *
    Math.sin(dLon / 2) ** 2;

  return 2 * R * Math.asin(Math.sqrt(h));
}

And a bit brutal nearest neighbor function (n squared order, ok for small sets):

function nearestNeighbor(points, startIndex = 0) {
  const unvisited = points.slice();
  const path = [];

  let current = unvisited.splice(startIndex, 1)[0];
  path.push(current);

  while (unvisited.length) {
    let nearestIdx = 0;
    let nearestDist = Infinity;

    for (let i = 0; i < unvisited.length; i++) {
      const d = distance(current, unvisited[i]);
      if (d < nearestDist) {
        nearestDist = d;
        nearestIdx = i;
      }
    }

    current = unvisited.splice(nearestIdx, 1)[0];
    path.push(current);
  }

  return path;
}

let path = nearestNeighbor(points);

Followed by a classic path optimization algorthm that removes crossing paths:

function twoOpt(path) {
  let improved = true;

  while (improved) {
    improved = false;

    for (let i = 1; i < path.length - 2; i++) {
      for (let j = i + 1; j < path.length - 1; j++) {
        const a = path[i - 1];
        const b = path[i];
        const c = path[j];
        const d = path[j + 1];

        const currentDist = distance(a, b) + distance(c, d);
        const newDist = distance(a, c) + distance(b, d);

        if (newDist < currentDist) {
          path.splice(i, j - i + 1, ...path.slice(i, j + 1).reverse());
          improved = true;
        }
      }
    }
  }

  return path;
}

path = twoOpt(path);

Here is a utility function to generate short path directions on google maps:

function googleMapsRoute(points) {
  const origin = `${points[0].lat},${points[0].lon}`;
  const destination = `${points[points.length - 1].lat},${points[points.length - 1].lon}`;
  const waypoints = points
    .slice(1, -1)
    .map(p => `${p.lat},${p.lon}`)
    .join('|');

  return `https://www.google.com/maps/dir/?api=1&travelmode=walking` +
         `&origin=${origin}` +
         `&destination=${destination}` +
         (waypoints ? `&waypoints=${waypoints}` : '');
}

This buids an array for these short path directions:

function buildRouteLinks(path, chunkSize = 8) {
  const links = [];

  for (let i = 0; i < path.length - 1; i += chunkSize) {
    const slice = path.slice(i, i + chunkSize + 1);
    if (slice.length >= 2) {
      links.push({
        start: i + 1,
        end: i + slice.length,
        url: googleMapsRoute(slice)
      });
    }
  }

  return links;
}

const mapRoutes = buildRouteLinks(path);

Next, build a web page with the memorials in the ordered direction:

const html = `
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Virtual Cemetery Visit Path</title>
  <style>
    body {
      font-family: Arial, sans-serif;
      margin: 2rem;
    }
    h1 {
      margin-bottom: 1rem;
    }
    table {
      border-collapse: collapse;
      width: 100%;
      font-size: 14px;
    }
    th, td {
      border: 1px solid #ccc;
      padding: 6px 8px;
      text-align: left;
    }
    th {
      background: #f0f0f0;
      position: sticky;
      top: 0;
    }
    tr:nth-child(even) {
      background: #fafafa;
    }
    a {
      color: #0645ad;
      text-decoration: none;
    }
    a:hover {
      text-decoration: underline;
    }
    .idx {
      text-align: right;
      color: #666;
    }
  </style>
</head>
<body>

<h1>Virtual Cemetery - Visit Order</h1>
<p>Total memorials: ${path.length}</p>

<table>
  <thead>
    <tr>
      <th>#</th>
      <th>Memorial ID</th>
      <th>Latitude</th>
      <th>Longitude</th>
      <th>Find A Grave</th>
      <th>Google Maps</th>
    </tr>
  </thead>
  <tbody>
    ${path.map((p, i) => `
      <tr>
        <td class="idx">${i + 1}</td>
        <td>${p.id}</td>
        <td>${p.lat}</td>
        <td>${p.lon}</td>
        <td>
          <a href="${p.url}" target="_blank">Memorial</a>
        </td>
        <td>
          <a href="https://www.google.com/maps?q=${p.lat},${p.lon}" target="_blank">
            Map
          </a>
        </td>
      </tr>
    `).join('')}
  </tbody>
</table>

<h2>Google Maps Walking Routes</h2>
<ol>
  ${mapRoutes.map(r => `
    <li>
      <a href="${r.url}" target="_blank">
        Memorials ${r.start}${r.end}
      </a>
    </li>
  `).join('')}
</ol>

</body>
</html>
`;

Finally, save the result:

const blob = new Blob([html], { type: 'text/html' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'virtual-cemetery-path.html';
a.click();

This will save me some time walking around!

Alternatively, here is a bookmarklet (all in one line):

javascript:(async()=>{const overlay=document.createElement('div');overlay.style.cssText='position:fixed;bottom:10px;right:10px;z-index:999999;background:#111;color:#fff;padding:10px 12px;border-radius:6px;font:12px Arial;box-shadow:0 2px 8px rgba(0,0,0,.4)';overlay.innerHTML='<b>FindAGrave Scraper</b><div id="cs-status">Starting…</div>';document.body.appendChild(overlay);const status=t=>document.getElementById('cs-status').innerHTML=t;const delay=m=>new Promise(r=>setTimeout(r,m));const dist=(a,b)=>{const R=6371000,toRad=d=>d*Math.PI/180;const dLat=toRad(b.lat-a.lat),dLon=toRad(b.lon-a.lon);const la1=toRad(a.lat),la2=toRad(b.lat);const h=Math.sin(dLat/2)**2+Math.cos(la1)*Math.cos(la2)*Math.sin(dLon/2)**2;return 2*R*Math.asin(Math.sqrt(h));};const nn=pts=>{const u=pts.slice(),p=[];let c=u.shift();p.push(c);while(u.length){let i=0,d=1e18;for(let j=0;j<u.length;j++){const x=dist(c,u[j]);if(x<d){d=x;i=j}}c=u.splice(i,1)[0];p.push(c)}return p};const gRoute=pts=>{const o=`${pts[0].lat},${pts[0].lon}`;const d=`${pts[pts.length-1].lat},${pts[pts.length-1].lon}`;const w=pts.slice(1,-1).map(p=>`${p.lat},${p.lon}`).join('|');return`https://www.google.com/maps/dir/?api=1&travelmode=walking&origin=${o}&destination=${d}`+(w?`&waypoints=${w}`:'');};const scrape=async url=>{const h=await(await fetch(url,{credentials:'include'})).text();const d=new DOMParser().parseFromString(h,'text/html');const id=(url.match(/\/memorial\/(\d+)/)||[])[1]||null;let lat=null,lon=null;const a=[...d.querySelectorAll('a')].find(x=>x.textContent.trim().toLowerCase().includes('show map'));if(a){const m=a.href.match(/[?&]q=([-0-9.]+),([-0-9.]+)/);if(m){lat=+m[1];lon=+m[2];}}return lat&&lon?{id,url,lat,lon}:null};const links=[...new Set([...document.querySelectorAll('a[href*="/memorial/"]')].map(a=>a.href))];if(!links.length){status('No memorials found');return;}status(`Found ${links.length} memorials`);const pts=[];for(let i=0;i<links.length;i++){status(`Scraping ${i+1} / ${links.length}<br>With coords: ${pts.length}`);try{const r=await scrape(links[i]);if(r)pts.push(r)}catch{}await delay(800)}if(pts.length<2){status('Not enough coordinates');return;}status(`Building path (${pts.length} points)…`);const path=nn(pts);const chunk=8;const routes=[];for(let i=0;i<path.length-1;i+=chunk){const slice=path.slice(i,i+chunk+1);if(slice.length>=2)routes.push({start:i+1,end:i+slice.length,url:gRoute(slice)})}status('Generating HTML…');const routeLinks=routes.map(r=>`<li><a href="${r.url}" target="_blank">Directions ${r.start}${r.end}</a></li>`).join('');const rows=path.map((p,i)=>`<tr><td>${i+1}</td><td>${p.id}</td><td>${p.lat}</td><td>${p.lon}</td><td><a href="${p.url}" target="_blank">Memorial</a></td><td><a href="https://www.google.com/maps?q=${p.lat},${p.lon}" target="_blank">Map</a></td></tr>`).join('');const html=`<!DOCTYPE html><html><head><meta charset="utf-8"><title>FindAGrave Path</title><style>body{font-family:Arial;margin:2rem}table{border-collapse:collapse;width:100%}th,td{border:1px solid #ccc;padding:6px}th{background:#eee;position:sticky;top:0}</style></head><body><h1>Optimized Visit Path</h1><h2>Google Maps Routes</h2><ol>${routeLinks}</ol><h2>Memorial Order</h2><table><thead><tr><th>#</th><th>ID</th><th>Lat</th><th>Lon</th><th>Find A Grave</th><th>Google Maps</th></tr></thead><tbody>${rows}</tbody></table></body></html>`;const w=window.open();w.document.write(html);w.document.close();overlay.remove();})();