my eye

author Angelo Gladding

name Topographical Map Contour Length Measurement Tool

published 2026-08-22T13:10:13.696313-07:00

type entry

updated 2026-08-22T16:04:06.240340-07:00

url /2026/08/22/sj

visibility public

widescreen yes

Content

<aside>
  <h1>Contour Measure</h1>
  <div class="sub">Calibrated polyline measurement for topo maps</div>

  <div class="group">
    <label>Load image or PDF</label>
    <input id="fileInput" type="file" accept="image/*,.pdf,application/pdf">
    <div class="tiny" style="margin-top:6px">PDF rendering uses PDF.js from a CDN. Images are fully local.</div>
  </div>

  <div class="group">
    <label>Mode</label>
    <div class="row">
      <button id="panBtn">Pan</button>
      <button id="calBtn">Calibrate</button>
    </div>
    <div class="row">
      <button id="measureBtn" class="primary">Trace contour</button>
      <button id="finishBtn" class="ok">Finish line</button>
    </div>
    <div class="row">
      <button id="undoBtn">Undo point</button>
      <button id="clearCurrentBtn">Clear current</button>
    </div>
  </div>

  <div class="group">
    <label>Calibration</label>
    <input id="knownDist" type="number" min="0" step="any" placeholder="Known distance">
    <div class="row">
      <select id="unit">
        <option value="ft">feet</option>
        <option value="m">meters</option>
        <option value="in">inches</option>
      </select>
      <button id="applyCalBtn">Apply</button>
    </div>
    <div id="calStatus" class="status">Not calibrated</div>
  </div>

  <div class="group">
    <label>Current contour name / elevation</label>
    <input id="lineName" placeholder="e.g. 542 ft">
    <div id="currentStatus" class="status">0 points · 0.00 ft</div>
  </div>

  <div class="group">
    <div class="row">
      <button id="exportBtn">Export CSV</button>
      <button id="clearAllBtn" class="danger">Clear all</button>
    </div>
    <div id="totalStatus" class="status" style="font-size:16px;font-weight:700">Total contour length: 0.00 ft</div>
    <div id="results"></div>
  </div>

  <div class="group tiny">
    <strong>Controls</strong><br>
    Click to add vertices. Double-click or <span class="kbd">Enter</span> to finish.<br>
    Mouse wheel = zoom. <span class="kbd">Space</span> + drag = pan.<br>
    <span class="kbd">Backspace</span> = undo point. <span class="kbd">Esc</span> = cancel current line.
  </div>
</aside>

<main>
  <div id="stage">
    <canvas id="canvas"></canvas>
    <div id="hud">Load a topo map to begin.</div>
  </div>
</main>

<script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/4.10.38/pdf.min.mjs" type="module"></script>
<script type="module">
const $$ = id => document.getElementById(id);
const canvas = $$("canvas"), ctx = canvas.getContext("2d");
const stage = $$("stage");
const fileInput=$$("fileInput"), panBtn=$$("panBtn"), calBtn=$$("calBtn"), measureBtn=$$("measureBtn"), finishBtn=$$("finishBtn");
const undoBtn=$$("undoBtn"), clearCurrentBtn=$$("clearCurrentBtn"), knownDist=$$("knownDist"), unit=$$("unit"), applyCalBtn=$$("applyCalBtn");
const calStatus=$$("calStatus"), currentStatus=$$("currentStatus"), lineName=$$("lineName"), totalStatus=$$("totalStatus"), results=$$("results"), exportBtn=$$("exportBtn"), clearAllBtn=$$("clearAllBtn"), hud=$$("hud");

let imgCanvas=null;
let mode="measure";
let scale=1, offsetX=0, offsetY=0;
let imageW=0,imageH=0;
let current=[];
let measurements=[];
let calibrationPts=[];
let unitsPerPixel=null;
let dragging=false,lastMouse=null,spaceDown=false;
let hoverPt=null;

function setMode(m){
  mode=m;
  [panBtn,calBtn,measureBtn].forEach(b=>b.classList.remove("active"));
  ({pan:panBtn,calibrate:calBtn,measure:measureBtn}[m]||measureBtn).classList.add("active");
  stage.style.cursor = m==="pan" ? "grab" : "crosshair";
}
setMode("measure");

function resize(){
  canvas.width=stage.clientWidth*devicePixelRatio;
  canvas.height=stage.clientHeight*devicePixelRatio;
  canvas.style.width=stage.clientWidth+"px";
  canvas.style.height=stage.clientHeight+"px";
  draw();
}
addEventListener("resize",resize); resize();

function screenToImage(x,y){
  const rect=canvas.getBoundingClientRect();
  const sx=(x-rect.left)*devicePixelRatio, sy=(y-rect.top)*devicePixelRatio;
  return {x:(sx-offsetX)/scale, y:(sy-offsetY)/scale};
}
function imageToScreen(p){ return {x:p.x*scale+offsetX,y:p.y*scale+offsetY}; }

function dist(a,b){ return Math.hypot(b.x-a.x,b.y-a.y); }
function polyPixelLength(pts){
  let s=0; for(let i=1;i<pts.length;i++) s+=dist(pts[i-1],pts[i]); return s;
}
function convertToFeet(v,u){
  if(u==="ft") return v;
  if(u==="m") return v*3.280839895;
  if(u==="in") return v/12;
}
function displayFeet(px){
  if(!unitsPerPixel) return null;
  return px*unitsPerPixel;
}
function fitImage(){
  if(!imageW||!imageH)return;
  const w=canvas.width,h=canvas.height;
  scale=Math.min(w/imageW,h/imageH)*0.92;
  offsetX=(w-imageW*scale)/2;
  offsetY=(h-imageH*scale)/2;
}

function draw(){
  const w=canvas.width,h=canvas.height;
  ctx.setTransform(1,0,0,1,0,0);
  ctx.clearRect(0,0,w,h);
  ctx.fillStyle="#0b0d11";ctx.fillRect(0,0,w,h);

  if(imgCanvas){
    ctx.save();
    ctx.setTransform(scale,0,0,scale,offsetX,offsetY);
    ctx.drawImage(imgCanvas,0,0);
    ctx.restore();
  }

  // finished lines
  measurements.forEach((m,idx)=>{
    drawPolyline(m.points, "#62d394", 3, false);
    const p=imageToScreen(m.points[m.points.length-1]);
    ctx.fillStyle="#62d394";ctx.font=`$${12*devicePixelRatio}px sans-serif`;
    ctx.fillText(`$${m.name || "Line "+(idx+1)} · $${m.feet.toFixed(2)} ft`,p.x+8*devicePixelRatio,p.y-8*devicePixelRatio);
  });

  // current
  if(current.length){
    drawPolyline(current,"#4da3ff",3,true);
    current.forEach(p=>{
      const s=imageToScreen(p);ctx.beginPath();ctx.arc(s.x,s.y,4*devicePixelRatio,0,Math.PI*2);ctx.fillStyle="#4da3ff";ctx.fill();
    });
    if(hoverPt && mode==="measure"){
      const last=imageToScreen(current[current.length-1]), hp=imageToScreen(hoverPt);
      ctx.beginPath();ctx.moveTo(last.x,last.y);ctx.lineTo(hp.x,hp.y);ctx.strokeStyle="rgba(77,163,255,.6)";ctx.lineWidth=2*devicePixelRatio;ctx.stroke();
    }
  }

  // calibration
  if(calibrationPts.length){
    calibrationPts.forEach(p=>{
      const s=imageToScreen(p);ctx.beginPath();ctx.arc(s.x,s.y,5*devicePixelRatio,0,Math.PI*2);ctx.fillStyle="#ffcc66";ctx.fill();
    });
    if(calibrationPts.length===2) drawPolyline(calibrationPts,"#ffcc66",3,false);
  }
  updateHud();
}
function drawPolyline(pts,color,width,dashed){
  if(pts.length<2)return;
  ctx.save();
  ctx.beginPath();
  const p0=imageToScreen(pts[0]);ctx.moveTo(p0.x,p0.y);
  for(let i=1;i<pts.length;i++){const p=imageToScreen(pts[i]);ctx.lineTo(p.x,p.y);}
  ctx.strokeStyle=color;ctx.lineWidth=width*devicePixelRatio;
  if(dashed)ctx.setLineDash([8*devicePixelRatio,5*devicePixelRatio]);
  ctx.stroke();ctx.restore();
}
function updateHud(){
  if(!imgCanvas){hud.textContent="Load a topo map to begin.";return}
  const z=(scale/devicePixelRatio*100).toFixed(0);
  const c=unitsPerPixel ? `$${(1/unitsPerPixel).toFixed(2)} px/ft` : "uncalibrated";
  hud.innerHTML=`Zoom $${z}%<br>$${c}<br>$${measurements.length} completed line$${measurements.length===1?"":"s"}`;
}
function updateCurrent(){
  const px=polyPixelLength(current), ft=displayFeet(px);
  currentStatus.textContent=`$${current.length} point$${current.length===1?"":"s"} · $${ft==null ? px.toFixed(1)+" px" : ft.toFixed(2)+" ft"}`;
}
function updateResults(){
  const totalFeet=measurements.reduce((sum,m)=>sum+m.feet,0);
  totalStatus.textContent=`Total contour length: $${totalFeet.toFixed(2)} ft`;
  results.innerHTML="";
  measurements.forEach((m,i)=>{
    const d=document.createElement("div");d.className="measure";
    d.innerHTML=`<strong>$${escapeHtml(m.name||`Line $${i+1}`)} <span class="pill">$${m.feet.toFixed(2)} ft</span></strong><span>$${m.points.length} vertices</span>`;
    d.title="Click to delete";
    d.addEventListener("click",()=>{ if(confirm("Delete this measurement?")){measurements.splice(i,1);updateResults();draw();}});
    results.appendChild(d);
  });
}
function escapeHtml(s){return s.replace(/[&<>"']/g,c=>({"&":"&amp;","<":"&lt;",">":"&gt;","\"":"&quot;","'":"&#039;"}[c]));}

async function loadFile(file){
  const name=file.name.toLowerCase();
  if(name.endsWith(".pdf")||file.type==="application/pdf"){
    try{
      const pdfjsLib = await import("https://cdnjs.cloudflare.com/ajax/libs/pdf.js/4.10.38/pdf.min.mjs");
      pdfjsLib.GlobalWorkerOptions.workerSrc="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/4.10.38/pdf.worker.min.mjs";
      const bytes=new Uint8Array(await file.arrayBuffer());
      const pdf=await pdfjsLib.getDocument({data:bytes}).promise;
      const page=await pdf.getPage(1);
      const viewport=page.getViewport({scale:2});
      imgCanvas=document.createElement("canvas");
      imgCanvas.width=viewport.width;imgCanvas.height=viewport.height;
      await page.render({canvasContext:imgCanvas.getContext("2d"),viewport}).promise;
      imageW=imgCanvas.width;imageH=imgCanvas.height;
    }catch(e){alert("Could not render PDF. Try saving the page as PNG/JPG, or check internet access for PDF.js.");console.error(e);return}
  }else{
    const url=URL.createObjectURL(file);
    const img=new Image();
    await new Promise((res,rej)=>{img.onload=res;img.onerror=rej;img.src=url});
    imgCanvas=document.createElement("canvas");
    imgCanvas.width=img.naturalWidth;imgCanvas.height=img.naturalHeight;
    imgCanvas.getContext("2d").drawImage(img,0,0);
    URL.revokeObjectURL(url);
    imageW=imgCanvas.width;imageH=imgCanvas.height;
  }
  measurements=[];current=[];calibrationPts=[];unitsPerPixel=null;
  fitImage();updateResults();updateCurrent();calStatus.textContent="Not calibrated";draw();
}
fileInput.addEventListener("change",e=>{if(e.target.files[0])loadFile(e.target.files[0])});

panBtn.onclick=()=>setMode("pan");
calBtn.onclick=()=>{calibrationPts=[];setMode("calibrate");draw()};
measureBtn.onclick=()=>setMode("measure");
finishBtn.onclick=finishLine;
undoBtn.onclick=()=>{current.pop();updateCurrent();draw()};
clearCurrentBtn.onclick=()=>{current=[];updateCurrent();draw()};

function finishLine(){
  if(current.length<2){return}
  if(!unitsPerPixel){alert("Calibrate first so the line can be stored in feet.");return}
  measurements.push({name:lineName.value.trim(),points:current.map(p=>({...p})),feet:polyPixelLength(current)*unitsPerPixel});
  current=[];lineName.value="";updateCurrent();updateResults();draw();
}
applyCalBtn.onclick=()=>{
  if(calibrationPts.length!==2){alert("Choose Calibrate, then click the two endpoints of a known distance.");return}
  const val=parseFloat(knownDist.value);
  if(!(val>0)){alert("Enter the known distance.");return}
  const feet=convertToFeet(val,unit.value), px=dist(calibrationPts[0],calibrationPts[1]);
  unitsPerPixel=feet/px;
  calStatus.textContent=`Calibrated: $${px.toFixed(1)} px = $${feet.toFixed(4)} ft · $${(unitsPerPixel).toFixed(6)} ft/px`;
  updateCurrent();draw();setMode("measure");
};

stage.addEventListener("mousedown",e=>{
  if(e.button===1 || mode==="pan" || spaceDown){
    dragging=true;lastMouse={x:e.clientX,y:e.clientY};stage.style.cursor="grabbing";e.preventDefault();
  }
});
stage.addEventListener("mousemove",e=>{
  if(dragging){
    offsetX+=(e.clientX-lastMouse.x)*devicePixelRatio;
    offsetY+=(e.clientY-lastMouse.y)*devicePixelRatio;
    lastMouse={x:e.clientX,y:e.clientY};draw();return;
  }
  hoverPt=screenToImage(e.clientX,e.clientY);draw();
});
addEventListener("mouseup",()=>{dragging=false;stage.style.cursor=(mode==="pan"||spaceDown)?"grab":"crosshair"});

stage.addEventListener("click",e=>{
  if(!imgCanvas || dragging || mode==="pan" || spaceDown)return;
  const p=screenToImage(e.clientX,e.clientY);
  if(p.x<0||p.y<0||p.x>imageW||p.y>imageH)return;
  if(mode==="calibrate"){
    if(calibrationPts.length>=2)calibrationPts=[];
    calibrationPts.push(p);
    draw();
  }else if(mode==="measure"){
    current.push(p);updateCurrent();draw();
  }
});
stage.addEventListener("dblclick",e=>{ if(mode==="measure"){e.preventDefault(); if(current.length>0) current.pop(); finishLine();} });

stage.addEventListener("wheel",e=>{
  if(!imgCanvas)return;
  e.preventDefault();
  const rect=canvas.getBoundingClientRect();
  const mx=(e.clientX-rect.left)*devicePixelRatio, my=(e.clientY-rect.top)*devicePixelRatio;
  const before={x:(mx-offsetX)/scale,y:(my-offsetY)/scale};
  const factor=e.deltaY<0?1.12:1/1.12;
  scale=Math.max(0.05,Math.min(20*devicePixelRatio,scale*factor));
  offsetX=mx-before.x*scale;offsetY=my-before.y*scale;draw();
},{passive:false});

addEventListener("keydown",e=>{
  if(e.code==="Space" && !["INPUT","SELECT"].includes(document.activeElement.tagName)){spaceDown=true;stage.style.cursor="grab";e.preventDefault()}
  if((e.key==="Backspace"||e.key==="Delete") && !["INPUT","SELECT"].includes(document.activeElement.tagName)){current.pop();updateCurrent();draw();e.preventDefault()}
  if(e.key==="Enter" && mode==="measure" && !["INPUT","SELECT"].includes(document.activeElement.tagName)){finishLine()}
  if(e.key==="Escape"){current=[];updateCurrent();draw()}
});
addEventListener("keyup",e=>{if(e.code==="Space"){spaceDown=false;stage.style.cursor=mode==="pan"?"grab":"crosshair"}});

clearAllBtn.onclick=()=>{
  if(confirm("Clear all finished measurements?")){measurements=[];current=[];updateResults();updateCurrent();draw();}
};
exportBtn.onclick=()=>{
  if(!measurements.length){alert("No completed measurements.");return}
  const rows=[["name","length_ft","vertices"]];
  measurements.forEach((m,i)=>rows.push([m.name||`Line $${i+1}`,m.feet.toFixed(4),m.points.length]));
  rows.push(["TOTAL",measurements.reduce((s,m)=>s+m.feet,0).toFixed(4),""]);
  const csv=rows.map(r=>r.map(v=>`"$${String(v).replaceAll('"','""')}"`).join(",")).join("\n");
  const blob=new Blob([csv],{type:"text/csv"}),a=document.createElement("a");
  a.href=URL.createObjectURL(blob);a.download="contour_measurements.csv";a.click();setTimeout(()=>URL.revokeObjectURL(a.href),1000);
};
</script>

<style>
  :root{
    --bg:#111318; --panel:#1a1e25; --panel2:#222833; --text:#eef2f7; --muted:#9aa6b2;
    --accent:#4da3ff; --danger:#ff6b6b; --ok:#62d394; --line:#2e3744;
  }
  *{box-sizing:border-box}
  html,body{height:100%;margin:0;font-family:Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif;background:var(--bg);color:var(--text)}
  body{display:grid;grid-template-columns:320px 1fr;overflow:hidden}
  aside{background:var(--panel);border-right:1px solid var(--line);padding:16px;overflow:auto}
  main{position:relative;overflow:hidden}
  h1{font-size:20px;margin:0 0 4px}
  .sub{color:var(--muted);font-size:13px;margin-bottom:16px}
  .group{padding:12px 0;border-top:1px solid var(--line)}
  .group:first-of-type{border-top:0}
  label{display:block;font-size:12px;color:var(--muted);margin-bottom:6px}
  input,select,button{
    width:100%;background:var(--panel2);color:var(--text);border:1px solid #394454;
    border-radius:8px;padding:9px 10px;font:inherit
  }
  input[type=file]{padding:8px}
  button{cursor:pointer}
  button:hover{border-color:#5a6a7d}
  button.primary{background:#173f68;border-color:#2d7fcf}
  button.ok{background:#174a32;border-color:#2c8d61}
  button.danger{background:#5a2424;border-color:#a94a4a}
  button.active{outline:2px solid var(--accent)}
  .row{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-top:8px}
  .tiny{font-size:12px;color:var(--muted);line-height:1.45}
  .status{font-size:13px;background:#12161c;border:1px solid var(--line);border-radius:8px;padding:10px;margin-top:8px}
  #stage{position:absolute;inset:0;background:#0b0d11;cursor:crosshair}
  canvas{position:absolute;left:0;top:0}
  #hud{
    position:absolute;right:14px;top:14px;background:rgba(15,18,23,.88);
    border:1px solid #38414d;border-radius:10px;padding:10px 12px;font-size:12px;line-height:1.6;
    pointer-events:none
  }
  #results{margin-top:8px}
  .measure{
    border:1px solid var(--line);border-radius:8px;padding:9px;margin-bottom:7px;background:#151a20
  }
  .measure strong{display:block;font-size:13px}
  .measure span{font-size:12px;color:var(--muted)}
  .pill{display:inline-block;padding:2px 6px;border-radius:999px;background:#273140;color:#c8d5e3;font-size:11px;margin-left:4px}
  .kbd{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;background:#252b34;border:1px solid #3b4553;border-radius:5px;padding:1px 5px}
  @media(max-width:800px){
    body{grid-template-columns:1fr;grid-template-rows:auto 1fr}
    aside{border-right:0;border-bottom:1px solid var(--line);max-height:42vh}
  }
</style>