{"id":13171,"date":"2026-05-19T15:00:20","date_gmt":"2026-05-19T13:00:20","guid":{"rendered":"https:\/\/geopard.tech\/?page_id=13171"},"modified":"2026-08-17T16:26:42","modified_gmt":"2026-08-17T14:26:42","slug":"%d8%ae%d8%b7%d9%88%d8%b7-%d8%a5%d8%b1%d8%b4%d8%a7%d8%af%d9%8a%d8%a9","status":"publish","type":"page","link":"https:\/\/geopard.tech\/ar\/guidance-lines\/","title":{"rendered":"\u0645\u062d\u0627\u0643\u064a \u062e\u0637\u0648\u0637 \u0627\u0644\u062a\u0648\u062c\u064a\u0647"},"content":{"rendered":"\n<!--\n  Compact hero block for \/guidance-lines\/ \u2014 pasted INLINE into page 13171\n  (not a shared reusable block, since block 13098 is field-explorer-specific\n  despite the generic name).\n\n  Style mirrors block 13098: green-cream radial-gradient backdrop, eyebrow\n  pill, headline with orange\u2192green accent, 5 feature chips ending with a\n  \"data stays in your browser\" trust chip.\n\n  If you reset the page content via WP admin and need to rebuild it: paste\n  this block in a Custom HTML block as the FIRST block of the page, then\n  the build\/embed_only.html as the SECOND block, then Reusable Block 13099\n  as the THIRD block.\n-->\n<div class=\"gp-lead\">\n<style>\n\/* Rule \u00a78 \u2014 hide the geopard child theme's bare <h1> page title so the\n   tool's custom hero is the first thing the user sees. The theme renders\n   the page title as #simple_page > .container > h1 (no class), so the\n   standard .entry-title \/ .page-title \/ .wp-block-post-title selectors\n   don't reach it. *\/\n#simple_page > .container > h1{display:none !important}\n.gp-lead{font-family:var(--wp--preset--font-family--nunito,\"Nunito\",system-ui,-apple-system,Segoe UI,sans-serif);color:#212121;max-width:1700px;margin:0 auto;padding:0 8px}\n.gp-lead h1{font-family:var(--wp--preset--font-family--poppins,\"Poppins\",system-ui,sans-serif);font-weight:800;color:#0e3a1c;line-height:1.15;letter-spacing:-0.018em;margin:0 auto 12px;font-size:1.95rem;max-width:880px}\n.gp-lead h1 .accent{background:linear-gradient(120deg,#f76a0c 0%,#15701e 100%);-webkit-background-clip:text;background-clip:text;color:transparent}\n\/* Hero: keep the wide-canvas backdrop (1700 px max via .gp-lead) but\n   center the text + chip column inside it so a 1920 \u00d7 1080 viewport\n   doesn't leave the entire right half empty. The radial gradients in\n   the background still produce asymmetric green\/orange glows that\n   add visual interest at the edges. *\/\n.gp-lead .gpl-hero{position:relative;background:radial-gradient(900px 360px at 0% -10%,rgba(74,222,128,0.18),transparent 60%),radial-gradient(700px 320px at 105% 110%,rgba(247,106,12,0.10),transparent 60%),linear-gradient(180deg,#fbfcf6 0%,#f1f6e8 100%);border:1px solid #d6e2c5;border-radius:20px;padding:28px 36px 24px;margin:6px 0 24px;overflow:hidden;box-shadow:0 12px 32px rgba(20,83,40,0.06),0 0 0 1px rgba(255,255,255,0.55) inset;text-align:center}\n.gp-lead .gpl-hero-eyebrow{display:inline-flex;align-items:center;gap:8px;font-family:var(--wp--preset--font-family--poppins,\"Poppins\",sans-serif);font-size:.74rem;font-weight:700;letter-spacing:.14em;text-transform:uppercase;color:#15701e;background:#fff;border:1px solid #cfe4d4;border-radius:999px;padding:6px 12px;margin:0 0 14px;box-shadow:0 1px 2px rgba(20,83,40,0.05)}\n.gp-lead .gpl-hero-eyebrow::before{content:\"\";width:7px;height:7px;border-radius:50%;background:#22c55e;box-shadow:0 0 0 3px rgba(34,197,94,0.22)}\n.gp-lead .gpl-hero .lede{font-size:1.02rem;line-height:1.55;color:#243024;margin:0 auto;max-width:760px;font-weight:400}\n.gp-lead .gpl-hero .lede strong{color:#0e3a1c;font-weight:600}\n.gp-lead .gpl-hero-features{display:flex;flex-wrap:wrap;gap:8px;margin:18px 0 0;justify-content:center}\n.gp-lead .gpl-hero-feat{display:inline-flex;align-items:center;gap:8px;background:#fff;border:1px solid #d8e6d8;color:#1c4a2a;font-size:.86rem;font-weight:600;padding:6px 12px;border-radius:999px;font-family:var(--wp--preset--font-family--poppins,\"Poppins\",sans-serif);box-shadow:0 1px 2px rgba(20,83,40,0.04)}\n.gp-lead .gpl-hero-feat::before{content:\"\\2713\";width:16px;height:16px;flex:0 0 16px;background:#22c55e;color:#fff;border-radius:50%;font-size:10px;font-weight:800;line-height:16px;text-align:center}\n.gp-lead .gpl-hero-feat.is-trust{background:transparent;border-color:transparent;color:#3a4a3a;padding:6px 4px;box-shadow:none}\n.gp-lead .gpl-hero-feat.is-trust::before{content:\"\\1F512\";width:auto;height:auto;background:transparent;color:#15701e;font-size:13px;line-height:1}\n@media (max-width:1199px){\n  .gp-lead .gpl-hero{padding:24px 28px}\n  .gp-lead h1{font-size:1.75rem}\n}\n@media (max-width:760px){\n  .gp-lead{padding:0 12px}\n  .gp-lead .gpl-hero{padding:20px 18px;margin:4px 0 16px;border-radius:16px}\n  .gp-lead .gpl-hero-eyebrow{font-size:.66rem;letter-spacing:.12em;padding:5px 10px;margin-bottom:10px}\n  .gp-lead h1{font-size:1.35rem;line-height:1.2;margin-bottom:8px}\n  .gp-lead .gpl-hero .lede{font-size:.95rem;line-height:1.5}\n  .gp-lead .gpl-hero-features{gap:6px;margin-top:12px}\n  .gp-lead .gpl-hero-feat{font-size:.78rem;padding:5px 10px}\n}\n@media (max-width:480px){\n  .gp-lead{padding:0 8px}\n  .gp-lead .gpl-hero{padding:16px 14px;border-radius:12px;margin:2px 0 12px}\n  .gp-lead h1{font-size:1.15rem;letter-spacing:-0.01em}\n  .gp-lead .gpl-hero .lede{font-size:.86rem;line-height:1.45}\n  .gp-lead .gpl-hero-feat{font-size:.72rem;padding:4px 8px}\n  .gp-lead .gpl-hero-feat.is-trust{padding:4px 2px}\n}\n<\/style>\n<section class=\"gpl-hero\">\n  <div class=\"gpl-hero-eyebrow\">Guidance Lines Simulator \u00b7 Free \u00b7 No signup<\/div>\n  <h1>5 drive plans. Pick the one that <span class=\"accent\">saves the most fuel.<\/span><\/h1>\n  <p class=\"lede\">Drop your boundary + AB lines or pick a sample \u2014 we score <strong>AB Straight \u00b7 AB Curve \u00b7 Boundary \u00b7 Topography \u00b7 Auto-blocks<\/strong> on coverage, fuel, and annual ROI, then play back the winner.<\/p>\n  <div class=\"gpl-hero-features\">\n    <span class=\"gpl-hero-feat\">5 approaches scored<\/span>\n    <span class=\"gpl-hero-feat\">Annual ROI<\/span>\n    <span class=\"gpl-hero-feat\">Your lines + boundary<\/span>\n    <span class=\"gpl-hero-feat\">Slope-aware<\/span>\n    <span class=\"gpl-hero-feat is-trust\">Files stay in your browser<\/span>\n  <\/div>\n<\/section>\n<\/div>\n\n\n\n<div class=\"gpl-wrap\" id=\"gpl-root\">\n<style>\n.gpl-wrap{all:initial;display:block;font-family:\"Nunito\",system-ui,-apple-system,sans-serif;font-size:13px;line-height:1.45;color:#212121;background:#fafbf4;border:1px solid #eeeeee;border-radius:12px;overflow:hidden;max-width:1700px;margin:0 auto;box-shadow:0 4px 20px rgba(20,83,40,.05)}\n.gpl-wrap *,.gpl-wrap *::before,.gpl-wrap *::after{box-sizing:border-box}\n\/* The [hidden] attribute must always win: several element rules set display\n   (flex \/ table-row), which otherwise override the browser default and leave\n   hidden elements visible \u2014 e.g. the \"Your current lines\" approach radio and\n   its Compare-All row, which must only appear after guidance lines are imported. *\/\n.gpl-wrap [hidden]{display:none !important}\n.gpl-wrap .gpl-top{display:flex;align-items:center;padding:14px 18px;border-bottom:1px solid #eeeeee;background:#ffffff;gap:24px;flex-wrap:wrap}\n.gpl-wrap .gpl-privacy-strip{margin:0;padding:6px 18px;font-size:11px;line-height:1.35;color:#4c6066;background:#fafbf4;border-bottom:1px solid #eeeeee;text-align:center;font-family:\"Nunito\",system-ui,sans-serif}\n.gpl-wrap .gpl-privacy-strip strong{color:#145328;font-weight:700}\n.gpl-wrap .gpl-brand{display:flex;align-items:center;gap:12px;min-width:0;flex:0 0 auto}\n.gpl-wrap .gpl-top .gpl-cta{margin-left:auto}\n.gpl-wrap .gpl-logo{width:28px;height:28px;border-radius:7px;background:linear-gradient(135deg,#145328 0%,#1a7951 100%);display:flex;align-items:center;justify-content:center;color:#fff;font-weight:700;font-size:14px;flex:none}\n.gpl-wrap .gpl-h1{font-size:15px;font-weight:600;letter-spacing:-.01em;font-family:\"Poppins\",system-ui,sans-serif}\n.gpl-wrap .gpl-chip{display:inline-flex;align-items:center;gap:6px;font-family:\"DM Mono\",ui-monospace,monospace;font-size:11px;padding:2px 8px;border-radius:10px;background:#fafbf4;color:#4c6066;border:1px solid #eeeeee;margin-top:3px}\n.gpl-wrap .gpl-chip .gpl-dot{width:6px;height:6px;border-radius:50%;background:#15701e;flex:none}\n.gpl-wrap .gpl-cta{background:#15701e;color:#fff;border:0;padding:9px 14px;border-radius:7px;font-weight:600;font-size:13px;cursor:pointer;font-family:inherit;display:inline-flex;align-items:center;gap:7px;text-decoration:none;transition:background .15s}\n.gpl-wrap .gpl-cta:hover{background:#1b7a2a}\n.gpl-wrap .gpl-grid{display:grid;grid-template-columns:var(--gpl-left-w,320px) 5px 1fr 5px var(--gpl-right-w,320px);grid-template-areas:\"left splitter main splitter-right right\";grid-template-rows:820px;min-height:820px;max-height:880px}\n.gpl-wrap .gpl-side{grid-area:left;background:#ffffff;border-right:1px solid #eeeeee;padding:5px 9px;display:flex;flex-direction:column;gap:1px;min-width:0;overflow-y:auto;max-height:820px}\n.gpl-wrap .gpl-splitter{grid-area:splitter;background:transparent;cursor:col-resize;position:relative;user-select:none;-webkit-user-select:none;touch-action:none}\n.gpl-wrap .gpl-splitter::before{content:\"\";position:absolute;left:50%;top:50%;width:1px;height:34px;transform:translate(-50%,-50%);background:#dddddd;transition:background .15s,width .15s}\n.gpl-wrap .gpl-splitter:hover::before,.gpl-wrap .gpl-splitter.is-drag::before{background:#15701e;width:3px;border-radius:2px;height:48px}\n.gpl-wrap .gpl-splitter-right{grid-area:splitter-right;background:transparent;cursor:col-resize;position:relative;user-select:none;-webkit-user-select:none;touch-action:none}\n.gpl-wrap .gpl-splitter-right::before{content:\"\";position:absolute;left:50%;top:50%;width:1px;height:34px;transform:translate(-50%,-50%);background:#dddddd;transition:background .15s,width .15s}\n.gpl-wrap .gpl-splitter-right:hover::before,.gpl-wrap .gpl-splitter-right.is-drag::before{background:#15701e;width:3px;border-radius:2px;height:48px}\n.gpl-wrap .gpl-main{grid-area:main;position:relative;background:#fafbf4;display:flex;flex-direction:column;min-height:0}\n.gpl-wrap .gpl-main > .gpl-fbar{position:relative;flex:0 0 auto;top:auto;left:auto;right:auto;margin:0;border-radius:0;border:0;border-bottom:1px solid #e8ebe2;background:#ffffff;backdrop-filter:none;box-shadow:none;z-index:auto}\n.gpl-wrap .gpl-main > .gpl-canvas-shell{position:relative;flex:1 1 auto;min-height:0;overflow:hidden}\n.gpl-wrap .gpl-side-right{grid-area:right;background:#ffffff;border-left:1px solid #eeeeee;padding:8px 8px;display:flex;flex-direction:column;gap:5px;min-width:0;overflow-y:auto;max-height:820px}\n\/* Plan view toggle (left sidebar, top) \u2014 Proposed vs Your current *\/\n.gpl-wrap .gpl-planview-toggle{display:flex;gap:4px;background:#f5f5f5;border-radius:6px;padding:3px;margin-top:4px}\n.gpl-wrap .gpl-planview-tab{flex:1;background:transparent;border:0;color:#4c6066;font-family:inherit;font-size:11.5px;font-weight:600;padding:7px 8px;border-radius:4px;cursor:pointer;transition:all .12s}\n.gpl-wrap .gpl-planview-tab:hover{color:#15701e}\n.gpl-wrap .gpl-planview-tab.is-on{background:#15701e;color:#ffffff}\n.gpl-wrap .gpl-planview-tab.is-on[data-planview=\"uploaded\"]{background:#2166c8}\n\/* Compare All moved below the main grid, full width single column. *\/\n.gpl-wrap .gpl-below{display:block;padding:16px 18px;background:#ffffff;border-top:1px solid #eeeeee}\n.gpl-wrap .gpl-below .gpl-card{margin:0}\n.gpl-wrap .gpl-cmp-hint{font-size:11px;color:#4c6066;background:#f5faf7;border:1px dashed #c5dccd;border-radius:5px;padding:8px 10px;margin-top:8px;line-height:1.4}\n\/* Responsive side-panel widths \u2014 graduated descent so the canvas keeps a\n   usable working area at every common viewport. Above 1400 px we use the\n   full 280 px side panels; from 1400 down we slim them progressively until\n   the 820 px stack-vertically breakpoint takes over. *\/\n@media (max-width:1399px){\n  .gpl-wrap .gpl-grid{grid-template-columns:var(--gpl-left-w,280px) 5px 1fr 5px var(--gpl-right-w,280px)}\n}\n@media (max-width:1199px){\n  .gpl-wrap .gpl-grid{grid-template-columns:var(--gpl-left-w,240px) 5px 1fr 5px var(--gpl-right-w,240px)}\n  \/* Tooltip narrower on narrow panels so it doesn't overflow the scroll\n     container. The 230 px default would overshoot a 220 px panel by 10 px. *\/\n  .gpl-wrap .gpl-info-ico::after{width:200px}\n}\n@media (max-width:1023px){\n  .gpl-wrap .gpl-grid{grid-template-columns:var(--gpl-left-w,200px) 5px 1fr 5px var(--gpl-right-w,210px)}\n  .gpl-wrap .gpl-info-ico::after{width:180px;font-size:10.5px}\n  \/* Compare All grid shrinks too \u2014 its fixed-width number columns were\n     consuming most of a 190 px side panel. *\/\n  .gpl-wrap .gpl-cmp{overflow-x:auto}\n  .gpl-wrap .gpl-cmp-row{grid-template-columns:minmax(130px,1.4fr) repeat(10, minmax(64px, 1fr));font-size:11px;gap:6px;padding:8px 10px;min-width:920px}\n  .gpl-wrap .gpl-r-row{padding:0}\n  .gpl-wrap .gpl-r-k{font-size:9.5px}\n  .gpl-wrap .gpl-r-v{font-size:11.5px}\n  \/* Tighter approach-label padding so the Best badge + info icon stay\n     on one line. *\/\n  .gpl-wrap .gpl-approach label{padding:6px 8px;font-size:12px;min-height:30px}\n}\n.gpl-wrap .gpl-canvas{display:block;width:100%;height:100%;cursor:default;background-color:#fafbf4;background-image:linear-gradient(rgba(20,83,40,0.07) 1px,transparent 1px),linear-gradient(90deg,rgba(20,83,40,0.07) 1px,transparent 1px),linear-gradient(rgba(20,83,40,0.04) 1px,transparent 1px),linear-gradient(90deg,rgba(20,83,40,0.04) 1px,transparent 1px);background-size:80px 80px,80px 80px,16px 16px,16px 16px;background-position:0 0,0 0,0 0,0 0}\n.gpl-wrap .gpl-copyright{position:absolute;right:8px;bottom:6px;font-family:\"Poppins\",system-ui,sans-serif;font-size:10px;color:rgba(20,83,40,0.55);letter-spacing:.02em;pointer-events:none;background:rgba(250,251,244,0.72);padding:2px 6px;border-radius:4px;font-weight:600;z-index:2}\n.gpl-wrap .gpl-top-fields{display:flex;align-items:center;gap:6px;flex-wrap:wrap;font-family:\"Poppins\",system-ui,sans-serif}\n.gpl-wrap .gpl-top-fields-lbl{font-size:9.5px;font-weight:700;text-transform:uppercase;letter-spacing:.08em;color:#4c6066;flex:none}\n.gpl-wrap .gpl-top-fields .gpl-field-picker{display:flex;gap:3px;flex:0 0 auto}\n.gpl-wrap .gpl-top-fields .gpl-fld{flex-direction:row;padding:4px 9px;font-size:11px;min-height:28px;align-items:center;line-height:1}\n.gpl-wrap .gpl-top-fields .gpl-fld-sub{display:none}\n\/* Merged \"Run on your field\" CTA \u2014 the import button IS the run-on-your-\n   field action (free, in-browser, no signup), so it's styled as the\n   prominent green CTA rather than a quiet dashed upload link. is-success\n   \/ is-error states (set by showUploadStatus) still override the bg. *\/\n.gpl-wrap .gpl-top-fields .gpl-upload{margin-top:0;padding:7px 14px;min-height:32px;font-size:12px;font-weight:700;align-items:center;line-height:1;background:#15701e;color:#fff;border:0;border-radius:7px;box-shadow:0 1px 3px rgba(20,83,40,0.18)}\n.gpl-wrap .gpl-top-fields .gpl-upload:hover{background:#1b7a2a;border:0}\n.gpl-wrap .gpl-top-fields .gpl-upload.is-success{background:#15701e;color:#fff;border:0}\n.gpl-wrap .gpl-top-fields .gpl-upload.is-error{background:#fef2f2;color:#dc2626;border:1px solid #fca5a5}\n.gpl-wrap .gpl-top-fields .gpl-upload-txt{font-size:12px}\n.gpl-wrap .gpl-top-fields .gpl-upload-ico{font-size:13px}\n.gpl-wrap .gpl-top-fields .gpl-import-nav{margin-top:0;gap:4px}\n.gpl-wrap .gpl-top-fields .gpl-imp-btn{width:26px;height:26px;font-size:13px}\n.gpl-wrap .gpl-top-fields .gpl-imp-lbl{font-size:10px;min-width:46px}\n.gpl-wrap .gpl-card h3{font-size:9px;font-weight:700;text-transform:uppercase;letter-spacing:.08em;color:#4c6066;margin:0 0 1px 0;font-family:\"Poppins\",system-ui,sans-serif;padding-top:2px;border-top:1px solid #f2f2f2}\n.gpl-wrap .gpl-card:first-child h3{padding-top:0;border-top:0}\n\/* Pulsing attention ring \u2014 fires when a layout warning surfaces. Catches the\n   user's eye on the specific card they need to act on (Headland \/ Turnarounds\n   \/ AB line \/ Approach). Ring stays bright for ~1.4 s \u00d7 3 cycles, then fades to\n   a soft glow so the user can still spot it later if they look away mid-fix. *\/\n.gpl-wrap .gpl-card.gpl-card-attention{position:relative;animation:gplPulse 1.4s ease-in-out 0s 3;border-radius:7px}\n.gpl-wrap .gpl-card.gpl-card-attention h3{color:#c2410c}\n@keyframes gplPulse{\n  0%{box-shadow:0 0 0 0 rgba(247,106,12,0.55),0 0 0 0 rgba(247,106,12,0.35)}\n  35%{box-shadow:0 0 0 4px rgba(247,106,12,0.45),0 0 14px 6px rgba(247,106,12,0.30)}\n  100%{box-shadow:0 0 0 2px rgba(247,106,12,0.20),0 0 0 0 rgba(247,106,12,0)}\n}\n.gpl-wrap .gpl-card{display:flex;flex-direction:column;gap:2px;padding:0}\n.gpl-wrap .gpl-approach{display:flex;flex-direction:column;gap:2px}\n.gpl-wrap .gpl-approach label{display:flex;align-items:center;gap:6px;padding:4px 8px;border:1px solid #e1e8dd;border-radius:6px;cursor:pointer;background:#ffffff;font-size:12px;transition:transform .12s ease,border-color .12s ease,background .12s ease,box-shadow .12s ease;min-height:28px;line-height:1.15;font-weight:500;color:#1c4a2a;box-shadow:0 1px 2px rgba(20,83,40,0.04)}\n.gpl-wrap .gpl-approach label:hover{border-color:#15701e;background:linear-gradient(180deg,#fafbf4 0%,#f0f6e9 100%);transform:translateY(-1px);box-shadow:0 2px 6px rgba(20,83,40,0.10)}\n.gpl-wrap .gpl-approach label:active{transform:translateY(0);box-shadow:0 1px 2px rgba(20,83,40,0.06)}\n.gpl-wrap .gpl-approach input[type=radio]{accent-color:#15701e;width:16px;height:16px;margin:0;flex:none}\n.gpl-wrap .gpl-approach label.is-on{border-color:#15701e;background:linear-gradient(180deg,#e7f5ec 0%,#dff5e6 100%);font-weight:700;color:#0e3a1c;box-shadow:0 0 0 2px rgba(34,197,94,0.12),0 2px 6px rgba(20,83,40,0.08)}\n.gpl-wrap .gpl-approach label > span:first-of-type{flex:1;min-width:0}\n\/* Inline select row \u2014 label + select on the same line so the Machine row\n   doesn't waste two rows of vertical space. *\/\n.gpl-wrap .gpl-select-row-inline{flex-direction:row;align-items:center;gap:6px;display:flex}\n.gpl-wrap .gpl-select-row-inline label{flex:none;font-size:10.5px;color:#4c6066;min-width:54px}\n.gpl-wrap .gpl-select-row-inline select{flex:1;min-width:0}\n\/* Two-column grid for paired numeric inputs (Width \/ L-per-km, Turn r \/ Buffer).\n   Each child .gpl-input-row collapses to label + input only, no fixed width on the\n   label so they share the row 50\/50. *\/\n.gpl-wrap .gpl-grid-2{display:grid;grid-template-columns:1fr 1fr;gap:6px}\n.gpl-wrap .gpl-grid-2 .gpl-input-row{margin:0}\n.gpl-wrap .gpl-grid-2 .gpl-input-row label{flex:none;min-width:0;font-size:10.5px;color:#4c6066}\n.gpl-wrap .gpl-grid-2 .gpl-input-row input{flex:1;min-width:0;width:auto}\n\/* AB-line direction: slider + tiny \u21ba refit button on one row *\/\n.gpl-wrap .gpl-ab-row{align-items:center;gap:8px}\n.gpl-wrap .gpl-ab-row .gpl-ab-auto{flex:none;width:28px;height:28px;padding:0;margin:0;font-size:14px;line-height:1;border-radius:6px;display:inline-flex;align-items:center;justify-content:center}\n.gpl-wrap .gpl-ab-coords{margin-top:8px;padding:8px 10px;background:#fafbf4;border:1px solid #e3e8e1;border-radius:6px}\n.gpl-wrap .gpl-ab-coords-title{font-size:10px;font-weight:700;color:#145328;text-transform:uppercase;letter-spacing:.05em;margin-bottom:6px}\n.gpl-wrap .gpl-ab-coords-row{display:flex;align-items:center;gap:5px;margin-bottom:5px}\n.gpl-wrap .gpl-ab-coords-lbl{flex:0 0 14px;font-weight:700;font-size:11px;color:#145328}\n.gpl-wrap .gpl-ab-coords input[type=number]{flex:1;min-width:0;padding:4px 6px;font-size:11px;font-family:\"DM Mono\",ui-monospace,monospace;border:1px solid #d6dcd0;border-radius:4px;background:#ffffff}\n.gpl-wrap .gpl-ab-coords input[type=number]:focus{border-color:#15701e;outline:none}\n.gpl-wrap .gpl-ab-coords-btn{margin-top:3px;padding:5px 10px;font-size:11px;font-weight:600;background:#15701e;color:#ffffff;border:0;border-radius:4px;cursor:pointer;width:100%}\n.gpl-wrap .gpl-ab-coords-btn:hover{background:#145328}\n.gpl-wrap .gpl-ab-coords-hint{margin-top:4px;font-size:10px;color:#4c6066;font-family:\"DM Mono\",ui-monospace,monospace;line-height:1.3;min-height:0}\n.gpl-wrap .gpl-ab-coords-hint.is-error{color:#dc2626}\n.gpl-wrap .gpl-ab-coords-hint.is-success{color:#15701e}\n.gpl-wrap .gpl-reco{display:none;font-size:9px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:#15701e;background:#dff5e6;padding:2px 6px;border-radius:4px;margin-left:6px;font-family:\"DM Mono\",ui-monospace,monospace}\n.gpl-wrap .gpl-reco.is-on{display:inline-block}\n\/* Info icon on each Approach row. Visible \u24d8-style badge that triggers a\n   styled tooltip on hover\/focus. Tooltip is positioned absolutely relative\n   to the icon and extends LEFTWARD (into the label area) so the side panel's\n   overflow:auto doesn't clip it. tabindex=0 (added in JS) lets keyboard\n   users + touch users tap-focus to reveal the tip. *\/\n.gpl-wrap .gpl-info-ico{display:inline-flex;align-items:center;justify-content:center;width:14px;height:14px;border-radius:50%;background:#e8eee8;color:#4c6066;font-family:\"DM Mono\",ui-monospace,monospace;font-size:9px;font-weight:700;font-style:italic;cursor:help;flex:none;margin-left:6px;position:relative;line-height:1;transition:background .12s,color .12s}\n.gpl-wrap .gpl-cmp-info{margin-left:4px;vertical-align:middle;text-transform:none;letter-spacing:0}\n.gpl-wrap .gpl-info-ico:hover,.gpl-wrap .gpl-info-ico:focus{background:#15701e;color:#fff;outline:none}\n\/* Tooltip drops BELOW the icon (was UP-LEFT, which got clipped by the side\n   panel's overflow-y:auto whenever the icon sat near the top of the panel).\n   Anchored to the icon's right edge so the 230 px-wide tip extends leftward\n   into the row instead of off the right side of the panel. Vertical\n   offset puts it just under the icon + arrow. *\/\n.gpl-wrap .gpl-info-ico::after{content:attr(data-tip);position:absolute;top:calc(100% + 8px);right:-6px;width:230px;background:#143a1c;color:#fff;padding:8px 10px;border-radius:6px;font-family:var(--wp--preset--font-family--nunito,\"Nunito\",sans-serif);font-size:11px;font-weight:400;font-style:normal;line-height:1.45;text-align:left;white-space:normal;letter-spacing:0;text-transform:none;pointer-events:none;opacity:0;transition:opacity .15s;z-index:50;box-shadow:0 6px 16px rgba(0,0,0,.18)}\n.gpl-wrap .gpl-info-ico::before{content:\"\";position:absolute;top:calc(100% + 3px);right:0;border:5px solid transparent;border-bottom-color:#143a1c;pointer-events:none;opacity:0;transition:opacity .15s;z-index:51}\n.gpl-wrap .gpl-info-ico:hover::after,.gpl-wrap .gpl-info-ico:focus::after,.gpl-wrap .gpl-info-ico:hover::before,.gpl-wrap .gpl-info-ico:focus::before{opacity:1}\n\/* Best badge now sits BEFORE the info icon. Both ride flex auto-margins:\n   the Name span (flex:1) absorbs all spare space, so Best + i naturally\n   anchor at the right edge of the row. *\/\n.gpl-wrap .gpl-input-row{display:flex;align-items:center;gap:5px}\n.gpl-wrap .gpl-input-row label{font-size:11px;color:#4c6066;flex:1}\n.gpl-wrap .gpl-input-row input{width:54px;padding:4px 6px;border:1px solid #eeeeee;border-radius:4px;font-family:inherit;font-size:13px;text-align:right;min-height:28px}\n.gpl-wrap .gpl-input-row .gpl-unit{font-size:10px;color:#4c6066;width:26px}\n.gpl-wrap .gpl-results{display:flex;flex-direction:column;gap:2px;font-family:\"DM Mono\",ui-monospace,monospace;font-size:11px;background:#fafbf4;padding:6px 8px;border-radius:5px;border:1px solid #eeeeee}\n.gpl-wrap .gpl-r-row{display:flex;justify-content:space-between;align-items:baseline;padding:1px 0}\n.gpl-wrap .gpl-r-k{color:#4c6066;font-size:10px}\n.gpl-wrap .gpl-r-v{font-weight:600;color:#212121;font-size:12px}\n\/* Right-panel inline input row (e.g. Diesel price field sitting in the\n   Analytics card). Keeps the same key-on-left, value-on-right alignment\n   as readonly rows but the \"value\" slot holds a compact number input +\n   the $\/L currency dropdown. *\/\n.gpl-wrap .gpl-r-input{align-items:center;padding:4px 0}\n.gpl-wrap .gpl-r-ctl{display:inline-flex;align-items:center;gap:5px}\n.gpl-wrap .gpl-r-ctl input{width:80px;padding:6px 8px;border:1px solid #d6d8d2;border-radius:5px;font-family:inherit;font-size:13px;text-align:right;min-height:32px;font-weight:700;color:#0e3a1c;max-width:none;background:#ffffff}\n.gpl-wrap .gpl-r-ctl input:focus{border-color:#15701e;outline:0;box-shadow:0 0 0 2px rgba(21,112,30,0.18)}\n.gpl-wrap .gpl-r-ctl select{padding:6px 6px;border:1px solid #d6d8d2;border-radius:5px;font-family:inherit;font-size:12px;background:#fafbf4;min-height:32px;width:62px;font-weight:600;color:#0e3a1c;max-width:none}\n.gpl-wrap .gpl-r-savings{font-size:11px;font-weight:600}\n.gpl-wrap .gpl-r-row.gpl-r-pri{border-top:1px solid #eeeeee;padding-top:6px;margin-top:2px}\n.gpl-wrap .gpl-r-section{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.08em;color:#4c6066;padding:8px 0 2px 0;margin-top:4px;border-top:1px solid #eeeeee;font-family:\"Poppins\",system-ui,sans-serif}\n.gpl-wrap .gpl-r-section:first-child{border-top:0;margin-top:0;padding-top:0}\n.gpl-wrap .gpl-r-sub{padding-left:8px;color:#4c6066}\n.gpl-wrap .gpl-r-row .gpl-r-ctl input[type=\"number\"]{font-family:\"DM Mono\",ui-monospace,monospace}\n.gpl-wrap .gpl-r-row .gpl-r-ctl .gpl-unit{font-size:10px;color:#4c6066;margin-left:4px}\n.gpl-wrap .gpl-below-cmp h3{font-size:16px;font-weight:700;color:#145328;text-transform:none;letter-spacing:0;margin:0 0 4px 0;border-top:0;padding-top:0;font-family:\"Poppins\",system-ui,sans-serif}\n.gpl-wrap .gpl-cmp-help{font-size:12.5px;color:#4c6066;margin:0 0 14px 0;line-height:1.45}\n.gpl-wrap .gpl-cmp{margin-top:0;background:#ffffff;border:1px solid #e3e8e1;border-radius:10px;overflow:auto;box-shadow:0 1px 3px rgba(20,83,40,.04)}\n.gpl-wrap .gpl-cmp-row{display:grid;grid-template-columns:minmax(160px,1.7fr) repeat(10, minmax(80px, 1fr));align-items:center;padding:12px 16px;font-size:13px;border-bottom:1px solid #f0f2ec;gap:10px;transition:background .12s}\n.gpl-wrap .gpl-cmp-row:last-child{border-bottom:0}\n.gpl-wrap .gpl-cmp-row:hover:not(.gpl-cmp-head){background:#fafbf4}\n.gpl-wrap .gpl-cmp-row.gpl-cmp-head{font-size:11.5px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:#5a6f60;background:linear-gradient(180deg,#fafbf4 0%,#f3f5ec 100%);font-family:\"Poppins\",system-ui,sans-serif;border-bottom:2px solid #d9e7dc;padding:14px 16px}\n.gpl-wrap .gpl-cmp-row.gpl-cmp-head:hover{background:linear-gradient(180deg,#fafbf4 0%,#f3f5ec 100%)}\n.gpl-wrap .gpl-cmp-name{font-family:\"Poppins\",system-ui,sans-serif;font-weight:600;color:#145328;font-size:13.5px}\n.gpl-wrap .gpl-cmp-val{text-align:right;font-family:\"DM Mono\",ui-monospace,monospace;font-size:13px;font-feature-settings:\"tnum\" 1;font-variant-numeric:tabular-nums}\n.gpl-wrap .gpl-cmp-row.is-best{background:linear-gradient(90deg,rgba(21,112,30,0.07) 0%,rgba(21,112,30,0.02) 100%);box-shadow:inset 3px 0 0 #15701e}\n.gpl-wrap .gpl-cmp-row.is-best:hover{background:linear-gradient(90deg,rgba(21,112,30,0.10) 0%,rgba(21,112,30,0.03) 100%)}\n.gpl-wrap .gpl-cmp-row.is-best .gpl-cmp-name::before{content:\"\u2605 \";color:#15701e;font-size:12px;margin-right:1px}\n.gpl-wrap .gpl-cmp-row.gpl-cmp-uploaded{background:rgba(33,102,200,0.06);border-top:1px solid #d6e4f2;box-shadow:inset 3px 0 0 #2166c8}\n.gpl-wrap .gpl-cmp-row.gpl-cmp-uploaded:hover{background:rgba(33,102,200,0.10)}\n.gpl-wrap .gpl-cmp-row.gpl-cmp-uploaded .gpl-cmp-name{color:#2166c8;font-weight:700}\n.gpl-wrap .gpl-cmp-sort{cursor:pointer;user-select:none;-webkit-user-select:none;display:inline-flex;align-items:center;justify-content:flex-end;gap:5px;padding:3px 6px;border-radius:5px;transition:background .12s,color .12s;text-decoration:none}\n.gpl-wrap .gpl-cmp-sort:hover{color:#15701e;background:#eaf3ed}\n.gpl-wrap .gpl-cmp-row.gpl-cmp-head .gpl-cmp-sort:first-child{justify-content:flex-start}\n.gpl-wrap .gpl-cmp-sort .gpl-cmp-arr{display:inline-block;width:11px;height:11px;font-size:9px;color:#a8b3a3;line-height:1;text-align:center;opacity:.6}\n.gpl-wrap .gpl-cmp-sort:hover .gpl-cmp-arr{opacity:1;color:#15701e}\n.gpl-wrap .gpl-cmp-sort .gpl-cmp-arr::before{content:\"\u21c5\"}\n.gpl-wrap .gpl-cmp-sort.is-sort-asc{color:#15701e;background:#dff5e6}\n.gpl-wrap .gpl-cmp-sort.is-sort-asc:hover{background:#cfecda}\n.gpl-wrap .gpl-cmp-sort.is-sort-asc .gpl-cmp-arr{opacity:1}\n.gpl-wrap .gpl-cmp-sort.is-sort-asc .gpl-cmp-arr::before{content:\"\u25b2\";color:#15701e}\n.gpl-wrap .gpl-cmp-sort.is-sort-desc{color:#15701e;background:#dff5e6}\n.gpl-wrap .gpl-cmp-sort.is-sort-desc:hover{background:#cfecda}\n.gpl-wrap .gpl-cmp-sort.is-sort-desc .gpl-cmp-arr{opacity:1}\n.gpl-wrap .gpl-cmp-sort.is-sort-desc .gpl-cmp-arr::before{content:\"\u25bc\";color:#15701e}\n.gpl-wrap .gpl-cmp-best{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-top:12px;padding:12px 16px;background:linear-gradient(90deg,#dff5e6 0%,#e9f7ee 60%,#f0faf3 100%);border:1px solid #b8e5c6;border-radius:8px;font-size:12.5px}\n.gpl-wrap .gpl-cmp-best-badge{font-size:10.5px;font-weight:700;text-transform:uppercase;letter-spacing:.08em;color:#fff;background:#15701e;padding:4px 10px;border-radius:4px;font-family:\"DM Mono\",ui-monospace,monospace}\n.gpl-wrap .gpl-cmp-best-name{font-weight:700;color:#145328;font-size:13.5px}\n.gpl-wrap .gpl-cmp-best-why{color:#1e5d3a;font-size:12px;line-height:1.45;flex:1;min-width:200px}\n.gpl-wrap .gpl-cmp-best{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-top:8px;padding:8px 10px;background:linear-gradient(90deg,#dff5e6 0%,#e9f7ee 100%);border:1px solid #b8e5c6;border-radius:6px;font-size:11.5px}\n.gpl-wrap .gpl-cmp-best-badge{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:#fff;background:#15701e;padding:3px 8px;border-radius:3px;font-family:\"DM Mono\",ui-monospace,monospace}\n.gpl-wrap .gpl-cmp-best-name{font-weight:700;color:#145328;font-size:12.5px}\n.gpl-wrap .gpl-cmp-best-why{color:#1e5d3a;font-size:11px;line-height:1.4;flex:1;min-width:160px}\n.gpl-wrap .gpl-cmp-row.is-best{background:#f5faf7}\n.gpl-wrap .gpl-cmp-row:last-child{border-bottom:0}\n.gpl-wrap .gpl-cmp-row.gpl-cmp-head{font-weight:600;color:#4c6066;text-transform:uppercase;letter-spacing:.04em;font-size:10px;background:#fafbf4}\n.gpl-wrap .gpl-cmp-name{font-family:\"DM Mono\",ui-monospace,monospace}\n.gpl-wrap .gpl-cmp-val{text-align:right;font-family:\"DM Mono\",ui-monospace,monospace}\n.gpl-wrap .gpl-cmp-row.gpl-cmp-current{background:transparent}\n\/* \"Your existing\" comparison row (uploaded guidance lines). Distinct blue\n   accent so the user can spot their existing plan vs the four generated\n   proposals. Hidden by default; recompute sets hidden=false when an upload\n   is active. *\/\n.gpl-wrap .gpl-cmp-row.gpl-cmp-uploaded{background:rgba(33,102,200,0.06);border-top:1px solid #d6e4f2}\n.gpl-wrap .gpl-cmp-row.gpl-cmp-uploaded .gpl-cmp-name{color:#2166c8;font-weight:700}\n.gpl-wrap .gpl-unit-toggle{display:inline-flex;gap:2px;background:#fafbf4;border:1px solid #eeeeee;border-radius:6px;padding:2px;margin-bottom:5px;align-self:flex-start}\n.gpl-wrap .gpl-unit-tab{background:transparent;border:0;padding:3px 8px;font-size:10px;font-family:\"DM Mono\",ui-monospace,monospace;font-weight:700;color:#4c6066;cursor:pointer;border-radius:4px;min-width:28px}\n.gpl-wrap .gpl-unit-tab.is-on{background:#15701e;color:#fff}\n.gpl-wrap .gpl-slider-row{display:flex;align-items:center;gap:6px}\n.gpl-wrap .gpl-range{flex:1;accent-color:#15701e;min-height:24px}\n.gpl-wrap .gpl-slider-val{font-family:\"DM Mono\",ui-monospace,monospace;font-size:10px;color:#4c6066;min-width:90px;text-align:right}\n.gpl-wrap .gpl-select-row{display:flex;flex-direction:column;gap:2px}\n.gpl-wrap .gpl-select-row label{font-size:10px;color:#4c6066}\n.gpl-wrap .gpl-select-row select{padding:4px 6px;border:1px solid #eeeeee;border-radius:4px;font-family:inherit;font-size:12px;background:#ffffff;min-height:32px}\n.gpl-wrap .gpl-cur-sel{margin-left:4px;padding:4px 6px;border:1px solid #eeeeee;border-radius:5px;font-family:inherit;font-size:11px;background:#ffffff;min-height:32px;width:46px}\n.gpl-wrap .gpl-hint{font-size:9px;color:#4c6066;font-family:\"DM Mono\",ui-monospace,monospace;margin:-1px 0 2px 0;line-height:1.3;font-style:italic}\n.gpl-wrap .gpl-field-picker{display:grid;grid-template-columns:1fr 1fr;gap:3px}\n.gpl-wrap .gpl-fld{background:#ffffff;border:1px solid #eeeeee;border-radius:5px;padding:3px 5px;font-family:inherit;font-size:10px;font-weight:600;cursor:pointer;color:#212121;display:flex;flex-direction:column;align-items:flex-start;gap:0;text-align:left;transition:all .12s;min-height:28px;line-height:1.1}\n.gpl-wrap .gpl-fld:hover{border-color:#1b7a2a;background:#fafbf4}\n.gpl-wrap .gpl-fld.is-on{border-color:#15701e;background:#fafbf4;font-weight:700}\n.gpl-wrap .gpl-fld-imported{border-color:#2166c8;background:#eef4fd;color:#2166c8}\n.gpl-wrap .gpl-fld-imported:hover{border-color:#1a4e9c;background:#dee9fa}\n.gpl-wrap .gpl-fld-imported.is-on{border-color:#1a4e9c;background:#dee9fa;color:#1a4e9c}\n.gpl-wrap .gpl-fld-imported::before{content:\"\u2934\";margin-right:5px;font-size:11px}\n.gpl-wrap .gpl-fld-sub{font-size:8px;color:#4c6066;font-weight:500;font-style:italic;text-transform:none;letter-spacing:0}\n.gpl-wrap .gpl-upload{display:flex;align-items:center;justify-content:center;gap:5px;background:#fafbf4;color:#15701e;border:1px dashed #b8e5c6;padding:5px 8px;border-radius:5px;font-family:inherit;font-size:10px;font-weight:600;cursor:pointer;min-height:30px;margin-top:2px;transition:all .12s}\n.gpl-wrap .gpl-upload:hover{background:#e9f3ec;border-color:#15701e;border-style:solid}\n.gpl-wrap .gpl-upload-ico{font-size:13px;line-height:1}\n.gpl-wrap .gpl-upload.is-error{color:#dc2626;border-color:#fca5a5;background:#fef2f2}\n.gpl-wrap .gpl-upload.is-success{color:#15701e;background:#dff5e6;border-color:#15701e;border-style:solid}\n.gpl-wrap .gpl-import-nav{display:flex;align-items:center;gap:6px;margin-top:3px;justify-content:center}\n.gpl-wrap .gpl-imp-btn{width:28px;height:28px;background:#fafbf4;border:1px solid #d9e7dc;border-radius:5px;color:#15701e;cursor:pointer;font-size:14px;font-weight:700;font-family:inherit;line-height:1;padding:0;transition:all .12s}\n.gpl-wrap .gpl-imp-btn:hover{background:#dff5e6;border-color:#15701e}\n.gpl-wrap .gpl-imp-btn:disabled{opacity:0.35;cursor:not-allowed}\n.gpl-wrap .gpl-imp-lbl{font-family:\"DM Mono\",ui-monospace,monospace;font-size:11px;font-weight:600;color:#4c6066;min-width:60px;text-align:center}\n.gpl-wrap .gpl-ab-auto{background:#fafbf4;color:#15701e;border:1px solid #d9e7dc;padding:4px 8px;border-radius:4px;font-family:inherit;font-size:10px;font-weight:600;cursor:pointer;min-height:26px;transition:all .12s;width:100%;margin-top:1px}\n.gpl-wrap .gpl-ab-auto:hover{background:#e9f3ec;border-color:#15701e}\n.gpl-wrap .gpl-ab-auto.is-auto{background:#dff5e6;border-color:#15701e}\n\/* Heading picker \u2014 radio-list style: pick which target to optimise heading for *\/\n.gpl-wrap .gpl-heading-na{background:#f5f6f1;border:1px dashed #d9e7dc;border-radius:5px;padding:8px 10px;font-size:11px;color:#4c6066;line-height:1.5}\n.gpl-wrap .gpl-heading-na strong{color:#145328}\n.gpl-wrap .gpl-heading-auto{background:#fafbf4;border:1px solid #d9e7dc;border-radius:5px;padding:8px 10px}\n.gpl-wrap .gpl-heading-auto-val{font-family:\"DM Mono\",ui-monospace,monospace;font-size:18px;font-weight:700;color:#145328;line-height:1;margin-bottom:3px}\n.gpl-wrap .gpl-heading-auto-lbl{font-size:10.5px;color:#4c6066;line-height:1.4}\n\/* Auto-blocks per-block heading list *\/\n.gpl-wrap .gpl-blocks-list{display:flex;flex-direction:column;gap:4px}\n.gpl-wrap .gpl-blocks-row{display:flex;align-items:center;gap:6px;padding:6px 8px;background:#fafbf4;border:1px solid #d9e7dc;border-radius:5px;font-size:11.5px}\n.gpl-wrap .gpl-blocks-row.is-override{border-color:#15701e;background:#dff5e6}\n.gpl-wrap .gpl-blocks-row .gpl-blocks-lbl{flex:1;min-width:0;font-weight:600;color:#145328}\n.gpl-wrap .gpl-blocks-row .gpl-blocks-area{font-size:10px;color:#4c6066;font-family:\"DM Mono\",ui-monospace,monospace;font-weight:500}\n.gpl-wrap .gpl-blocks-row input[type=\"number\"]{width:48px;font-family:\"DM Mono\",ui-monospace,monospace;font-size:11px;padding:3px 5px;border:1px solid #d9e7dc;border-radius:4px;text-align:right;background:#fff}\n.gpl-wrap .gpl-blocks-row .gpl-blocks-deg-unit{font-size:10px;color:#4c6066;font-family:\"DM Mono\",ui-monospace,monospace}\n.gpl-wrap .gpl-blocks-row button.gpl-blocks-reset-one{background:transparent;border:0;color:#4c6066;cursor:pointer;font-size:13px;padding:2px 4px;line-height:1;border-radius:3px}\n.gpl-wrap .gpl-blocks-row button.gpl-blocks-reset-one:hover{background:#fff;color:#15701e}\n.gpl-wrap .gpl-blocks-row button.gpl-blocks-reset-one[disabled]{opacity:0.3;cursor:default}\n.gpl-wrap .gpl-heading-picker{display:flex;flex-direction:column;gap:4px}\n.gpl-wrap .gpl-heading-intro{font-size:10px;color:#4c6066;font-weight:600;text-transform:uppercase;letter-spacing:.04em;margin:0 0 2px 0}\n.gpl-wrap .gpl-heading-options{display:flex;flex-direction:column;gap:2px}\n.gpl-wrap .gpl-heading-opt{display:flex;align-items:center;gap:8px;background:#fff;color:#212121;border:1px solid #eee;border-radius:5px;padding:6px 9px;font-family:inherit;font-size:11.5px;font-weight:500;cursor:pointer;text-align:left;transition:all .12s;min-height:30px}\n.gpl-wrap .gpl-heading-opt:hover{background:#fafbf4;border-color:#1a7951}\n.gpl-wrap .gpl-heading-opt.is-on{background:#dff5e6;border-color:#15701e;color:#145328;font-weight:600}\n.gpl-wrap .gpl-heading-opt input[type=\"radio\"]{margin:0;accent-color:#15701e;flex:none}\n.gpl-wrap .gpl-heading-opt .gpl-h-lbl{flex:1;min-width:0}\n.gpl-wrap .gpl-heading-opt .gpl-h-val{font-family:\"DM Mono\",ui-monospace,monospace;font-size:11px;font-weight:600;color:#4c6066;white-space:nowrap;padding:2px 6px;background:#fafbf4;border-radius:3px}\n.gpl-wrap .gpl-heading-opt.is-on .gpl-h-val{color:#15701e;background:#fff}\n.gpl-wrap .gpl-heading-opt.is-converge .gpl-h-val::after{content:\" \u2261\";color:#4c6066;font-weight:400}\n\/* Slider visible only in custom mode *\/\n.gpl-wrap .gpl-heading-picker[data-mode=\"custom\"] .gpl-ab-row,\n.gpl-wrap .gpl-heading-picker[data-mode=\"custom\"] .gpl-hint{display:flex}\n.gpl-wrap .gpl-heading-picker .gpl-ab-row,\n.gpl-wrap .gpl-heading-picker .gpl-hint{display:none}\n\/* Warning banner \u2014 own row above the canvas (sibling to playback bar +\n   canvas-shell). Previously it was position:absolute over the canvas, which\n   overlapped the passes-info tray on the left AND got hidden under the\n   Ruler\/Set-AB toolbar on the right. Static-flow placement keeps it clearly\n   visible and doesn't fight for the limited corner real estate. *\/\n.gpl-wrap .gpl-warn{flex:0 0 auto;background:rgba(247,106,12,0.95);color:#fff;padding:7px 14px;font-size:12px;font-weight:600;text-align:center;display:none;border-bottom:1px solid rgba(255,255,255,0.25);line-height:1.35}\n.gpl-wrap .gpl-warn.is-on{display:block}\n\/* Floating CTA \u2014 sits over the canvas bottom-right like field-data-explorer's\n   gpy-floating-cta. Dismiss persists in sessionStorage. Hidden on mobile to\n   avoid covering the map. *\/\n.gpl-wrap .gpl-pro-lock{display:inline-flex;align-items:center;justify-content:center;background:#f76a0c;color:#fff;font-size:9px;font-weight:700;letter-spacing:.08em;padding:2px 5px;border-radius:3px;margin-left:6px;cursor:pointer;font-family:\"DM Mono\",ui-monospace,monospace}\n.gpl-wrap .gpl-pro-lock:hover{background:#ff7d24}\n.gpl-wrap #gpl-approach label.is-pro-locked{opacity:0.6}\n.gpl-wrap #gpl-approach label.is-pro-locked input[type=\"radio\"]{pointer-events:none}\n.gpl-wrap #gpl-approach label.is-pro-locked > span:first-of-type{text-decoration:none}\n.gpl-wrap .gpl-floating-cta{position:absolute;bottom:14px;right:14px;width:240px;max-width:34vw;background:#143a1c;color:#e0eee5;border:1px solid #15701e;border-radius:10px;padding:8px 11px 9px;z-index:6;box-shadow:0 6px 18px rgba(0,0,0,.18);font-family:inherit}\n.gpl-wrap .gpl-floating-cta-h{font-size:10.5px;font-weight:700;color:#9be5b0;text-transform:uppercase;letter-spacing:.08em;margin:0 0 5px 0;font-family:\"DM Mono\",ui-monospace,monospace}\n.gpl-wrap .gpl-floating-cta ul{list-style:none;padding:0;margin:0 0 7px 0;font-size:10.5px;line-height:1.42;color:#e0eee5;font-weight:500}\n.gpl-wrap .gpl-floating-cta li{display:flex;align-items:flex-start;gap:5px;padding:1px 0}\n.gpl-wrap .gpl-floating-cta li::before{content:\"\\2713\";color:#f76a0c;font-weight:700;flex:none;font-size:10px;line-height:1.42}\n.gpl-wrap .gpl-floating-cta-btn{display:flex;align-items:center;justify-content:center;gap:5px;background:#f76a0c;color:#0b0f14;border:0;border-radius:6px;padding:7px 8px;font-weight:700;font-size:12px;text-decoration:none;font-family:inherit;width:100%;transition:background .15s}\n.gpl-wrap .gpl-floating-cta-btn:hover{background:#ff7d24;transform:translateY(-1px)}\n.gpl-wrap .gpl-floating-cta-btn .arr{transition:transform .15s}\n.gpl-wrap .gpl-floating-cta-btn:hover .arr{transform:translateX(3px)}\n\/* Export menu \u2014 drops below the Export toolbar button *\/\n.gpl-wrap .gpl-export-menu{position:absolute;top:54px;right:14px;background:#fff;border:1px solid #d9e7dc;border-radius:7px;box-shadow:0 6px 14px rgba(20,83,40,.10);padding:4px;z-index:7;display:flex;flex-direction:column;gap:2px;min-width:180px}\n.gpl-wrap .gpl-export-menu button{background:transparent;border:0;color:#212121;font-family:inherit;font-size:11.5px;font-weight:500;padding:7px 10px;text-align:left;cursor:pointer;border-radius:5px;transition:background .12s}\n.gpl-wrap .gpl-export-menu button:hover{background:#fafbf4;color:#15701e}\n.gpl-wrap .gpl-export-menu[hidden]{display:none}\n.gpl-wrap .gpl-cta-mini{display:inline-flex;align-items:center;gap:5px;background:#15701e;color:#fff;text-decoration:none;padding:7px 11px;border-radius:6px;font-size:12px;font-weight:600;font-family:inherit;transition:background .15s;min-height:44px;line-height:1}\n.gpl-wrap .gpl-cta-mini:hover{background:#1b7a2a}\n.gpl-wrap .gpl-cta-card{background:linear-gradient(145deg,#eefbf3 0%,#dff5e6 100%);border:1px solid #b8e5c6;border-radius:10px;padding:14px;margin-top:auto;position:relative;overflow:hidden}\n.gpl-wrap .gpl-cta-card::before{content:\"\";position:absolute;top:-30px;right:-30px;width:80px;height:80px;background:radial-gradient(circle,rgba(21,112,30,.14) 0%,transparent 70%);pointer-events:none}\n.gpl-wrap .gpl-cta-card h4{font-size:10px;text-transform:uppercase;letter-spacing:.14em;color:#0f5033;margin:0 0 10px 0;font-weight:700;font-family:\"Poppins\",system-ui,sans-serif;position:relative}\n.gpl-wrap .gpl-cta-list{list-style:none;padding:0;margin:0 0 12px 0;font-size:12px;line-height:1.6;position:relative}\n.gpl-wrap .gpl-cta-list li{color:#1e5d3a;display:flex;align-items:flex-start;gap:8px;font-weight:500;margin-bottom:4px}\n.gpl-wrap .gpl-cta-list li::before{content:\"\u2713\";color:#15701e;font-weight:700;flex:none}\n.gpl-wrap .gpl-cta-btn-lg{display:flex;align-items:center;justify-content:center;gap:6px;background:#15701e;color:#fff;text-decoration:none;padding:11px 12px;border-radius:7px;font-weight:700;font-size:13px;font-family:inherit;position:relative;width:100%;transition:background .15s;min-height:44px;box-sizing:border-box}\n.gpl-wrap .gpl-cta-btn-lg:hover{background:#1b7a2a}\n.gpl-wrap .gpl-cta-arr{transition:transform .15s}\n.gpl-wrap .gpl-cta-btn-lg:hover .gpl-cta-arr{transform:translateX(3px)}\n.gpl-wrap .gpl-cta-note{font-size:10px;color:#4c6066;margin:8px 0 0 0;line-height:1.45;text-align:center;font-style:italic}\n.gpl-wrap .gpl-tray{position:absolute;top:10px;left:10px;background:rgba(255,255,255,.95);border:1px solid #eeeeee;border-radius:8px;padding:6px 10px;font-family:\"DM Mono\",ui-monospace,monospace;font-size:11px;color:#4c6066;backdrop-filter:blur(4px);box-shadow:0 2px 6px rgba(0,0,0,.05);z-index:2}\n\/* Playback bar sits as a dedicated row ABOVE the canvas (sibling to .gpl-canvas-shell\n   inside the .gpl-main flex column). This frees the top-of-canvas for the map toolbar\n   (.gpl-tools) so they never overlap \u2014 previously both were absolute-positioned at\n   top:10\/14 px and collided on the right side. *\/\n.gpl-wrap .gpl-pb{flex:0 0 auto;background:#ffffff;border:0;border-bottom:1px solid #e8ebe2;border-radius:0;padding:8px 12px;display:flex;align-items:center;gap:10px;box-shadow:0 1px 3px rgba(20,83,40,0.04);flex-wrap:wrap;z-index:auto}\n.gpl-wrap .gpl-pb-btn{background:#15701e;color:#fff;border:0;width:32px;height:32px;border-radius:6px;cursor:pointer;font-size:13px;display:flex;align-items:center;justify-content:center;padding:0;font-family:inherit;transition:background .15s;flex:none;line-height:1}\n.gpl-wrap .gpl-pb-btn:hover{background:#1b7a2a}\n.gpl-wrap .gpl-pb-btn.is-playing::before{content:\"\u275a\u275a\";font-size:11px;letter-spacing:-1px}\n.gpl-wrap .gpl-pb-btn.is-playing{font-size:0}\n.gpl-wrap .gpl-pb-track{flex:1;height:8px;background:#fafbf4;border-radius:4px;position:relative;cursor:pointer;min-width:80px;border:1px solid #eeeeee}\n.gpl-wrap .gpl-pb-fill{position:absolute;left:0;top:0;height:100%;background:#15701e;border-radius:3px;pointer-events:none;width:0%}\n.gpl-wrap .gpl-pb-thumb{position:absolute;top:50%;left:0%;transform:translate(-50%,-50%);width:14px;height:14px;background:#15701e;border:2px solid #ffffff;border-radius:50%;pointer-events:none;box-shadow:0 1px 4px rgba(0,0,0,.25)}\n.gpl-wrap .gpl-pb-seg{flex:none;display:flex;gap:2px;padding:2px;background:#fafbf4;border:1px solid #eeeeee;border-radius:6px}\n.gpl-wrap .gpl-pb-spd{padding:4px 7px;font-size:10px;background:transparent;border:0;color:#4c6066;cursor:pointer;border-radius:4px;font-family:\"DM Mono\",ui-monospace,monospace;font-weight:700;min-width:28px;font-family:inherit}\n.gpl-wrap .gpl-pb-spd.on{background:#15701e;color:#fff}\n.gpl-wrap .gpl-pb-stats{flex:none;font-family:\"DM Mono\",ui-monospace,monospace;font-size:10px;color:#4c6066;text-align:right;min-width:130px;line-height:1.3;font-weight:600}\n.gpl-wrap .gpl-scale{position:absolute;right:10px;bottom:10px;background:rgba(255,255,255,.95);border:1px solid #eeeeee;border-radius:6px;padding:4px 8px;font-family:\"DM Mono\",ui-monospace,monospace;font-size:10px;font-weight:600;color:#4c6066;display:flex;align-items:center;gap:6px;backdrop-filter:blur(4px);z-index:3;line-height:1;box-shadow:0 2px 4px rgba(0,0,0,.05)}\n.gpl-wrap .gpl-scale-bar{display:inline-block;width:60px;height:6px;border-left:2px solid #145328;border-right:2px solid #145328;border-bottom:2px solid #145328;box-sizing:border-box}\n.gpl-wrap .gpl-scale-lbl{white-space:nowrap}\n\/* Rule \u00a79 \u2014 map toolbar mirrors field-explorer's .gpy-ctrl: horizontal flex\n   row at top-right of the canvas, 34\u00d734 px buttons with subtle white background,\n   ruler \/ set-AB \/ clear are \"wide\" variants with icon + short text label, active\n   state uses the brand accent orange (#f76a0c). Keep this exact look on every\n   tool that ships a map so user muscle-memory is consistent. *\/\n.gpl-wrap .gpl-tools{position:absolute;top:14px;right:14px;display:flex;gap:6px;z-index:5}\n.gpl-wrap .gpl-tool-btn{background:#ffffff;border:1px solid #eeeeee;color:#4c6066;width:34px;height:34px;border-radius:7px;cursor:pointer;font-size:16px;font-weight:600;padding:0;font-family:inherit;line-height:1;box-shadow:0 1px 3px rgba(20,83,40,0.06);transition:all .15s;display:inline-flex;align-items:center;justify-content:center;gap:5px}\n.gpl-wrap .gpl-tool-btn svg{width:16px;height:16px;flex:none;display:block}\n.gpl-wrap .gpl-tool-btn:hover{background:#fafbf4;border-color:#d9e7dc;color:#15701e}\n.gpl-wrap .gpl-tool-btn.is-on{background:#f76a0c;color:#fff;border-color:#f76a0c}\n.gpl-wrap .gpl-tool-btn.is-on:hover{background:#ff7d24;border-color:#ff7d24;color:#fff}\n.gpl-wrap .gpl-tool-btn.gpl-btn-wide{width:auto;padding:0 10px;font-size:11px;letter-spacing:.02em}\n.gpl-wrap .gpl-ruler-hint{position:absolute;left:50%;bottom:14px;transform:translateX(-50%);background:rgba(20,83,40,0.92);color:#fff;padding:7px 12px;border-radius:7px;font-size:11px;font-weight:600;font-family:\"DM Mono\",ui-monospace,monospace;display:none;z-index:3;box-shadow:0 2px 8px rgba(0,0,0,.15)}\n.gpl-wrap .gpl-ruler-hint.is-on{display:block}\n.gpl-wrap .gpl-legend{position:absolute;bottom:10px;left:10px;background:rgba(255,255,255,.95);border:1px solid #eeeeee;border-radius:8px;padding:8px 12px;font-size:11px;display:flex;flex-direction:column;gap:4px;backdrop-filter:blur(4px)}\n.gpl-wrap .gpl-lg-row{display:flex;align-items:center;gap:6px}\n.gpl-wrap .gpl-lg-swatch{width:14px;height:3px;border-radius:2px;flex:none}\n\/* Recompute loader \u2014 overlay inside the canvas-shell. Shown when a\n   recompute is in flight; hides as soon as the synchronous geometry pass\n   completes. requestAnimationFrame + setTimeout(0) in scheduleRecompute\n   yield one frame before the heavy work so the browser actually paints\n   this overlay before the main thread freezes. *\/\n.gpl-wrap .gpl-loader{position:absolute;top:0;left:0;right:0;bottom:0;display:none;align-items:center;justify-content:center;background:rgba(250,251,244,0.66);backdrop-filter:blur(2px);z-index:30;pointer-events:none}\n.gpl-wrap .gpl-loader.is-on{display:flex}\n\/* Dynamic field switch: blur + fade the old plan out while the new one loads,\n   then it eases back to sharp when the loader clears (hideLoader removes the\n   class). Pairs with the loader's own backdrop blur for a soft depth effect. *\/\n.gpl-wrap .gpl-canvas{transition:filter .34s cubic-bezier(.4,0,.2,1),opacity .34s cubic-bezier(.4,0,.2,1)}\n.gpl-wrap .gpl-canvas-shell.is-switching .gpl-canvas{filter:blur(7px) saturate(.9);opacity:.32}\n@media(prefers-reduced-motion:reduce){.gpl-wrap .gpl-canvas{transition:opacity .2s ease}.gpl-wrap .gpl-canvas-shell.is-switching .gpl-canvas{filter:none;opacity:.55}}\n.gpl-wrap .gpl-loader-card{display:flex;align-items:center;gap:13px;background:#fff;padding:14px 20px;border-radius:12px;box-shadow:0 10px 28px rgba(20,83,40,0.22);font-family:var(--wp--preset--font-family--nunito,\"Nunito\",sans-serif)}\n\/* GeoPard-branded \"G\" badge with an orbiting accent ring (the brand\n   orange) so the user clearly reads \"GeoPard is calculating your field\". *\/\n.gpl-wrap .gpl-loader-badge{position:relative;width:40px;height:40px;flex:none;display:flex;align-items:center;justify-content:center}\n.gpl-wrap .gpl-loader-badge::before{content:\"\";position:absolute;inset:0;border-radius:50%;border:2.5px solid #e3ece1;border-top-color:#f76a0c;border-right-color:#f76a0c;animation:gpl-spin .7s linear infinite}\n.gpl-wrap .gpl-loader-g{width:30px;height:30px;border-radius:8px;background:linear-gradient(135deg,#145328 0%,#1a7951 100%);display:flex;align-items:center;justify-content:center;color:#fff;font-weight:700;font-size:15px;animation:gpl-loader-pulse 1.4s ease-in-out infinite}\n.gpl-wrap .gpl-loader-txt{display:flex;flex-direction:column;gap:2px;line-height:1.25}\n.gpl-wrap .gpl-loader-title{font-size:13px;font-weight:700;color:#145328;letter-spacing:.01em}\n.gpl-wrap .gpl-loader-sub{font-size:11px;font-weight:600;color:#4c6066}\n@keyframes gpl-spin{to{transform:rotate(360deg)}}\n@keyframes gpl-loader-pulse{0%,100%{transform:scale(1)}50%{transform:scale(0.9)}}\n\n@media (max-width: 820px){\n  \/* Phone\/tablet: canvas full-width on top, then SETTINGS (left, starts\n     with the Approach picker) immediately under the canvas so the user\n     can switch approach without scrolling past Analytics, then ANALYTICS\n     (right) below that. min-height:auto lets each grid row size itself;\n     canvas row uses 60vh on portrait phones so it dominates the viewport. *\/\n  .gpl-wrap .gpl-grid{grid-template-columns:1fr;grid-template-areas:\"main\" \"left\" \"right\";grid-template-rows:60vh auto auto;min-height:auto;max-height:none}\n  .gpl-wrap .gpl-below{grid-template-columns:1fr;padding:12px}\n  \/* On narrow screens the info-icon tooltip would otherwise extend\n     230 px LEFT and overflow the viewport. Drop it back down to a\n     fixed-position style: tooltip sits below the icon, full row width\n     minus padding, no shifting needed. *\/\n  .gpl-wrap .gpl-info-ico::after{width:auto;left:8px;right:8px;top:calc(100% + 8px);position:absolute;max-width:none}\n  .gpl-wrap .gpl-info-ico::before{display:none}\n  .gpl-wrap .gpl-side{border-right:0;border-top:1px solid #eeeeee;max-height:none;padding:12px 14px;gap:10px}\n  .gpl-wrap .gpl-splitter{display:none}\n  .gpl-wrap .gpl-splitter-right{display:none}\n  .gpl-wrap .gpl-side-right{border-left:0;border-top:1px solid #eeeeee;max-height:none;padding:12px 14px;gap:10px}\n  .gpl-wrap .gpl-main{min-height:50vh}\n  \/* Bigger cards + tap targets on touch *\/\n  .gpl-wrap .gpl-card h3{font-size:10px}\n  .gpl-wrap .gpl-approach label{min-height:44px;font-size:13px;padding:7px 10px}\n  .gpl-wrap input[type=number],.gpl-wrap select{font-size:14px;min-height:38px}\n  .gpl-wrap .gpl-fld{min-height:44px;font-size:11px;padding:6px 8px}\n  .gpl-wrap .gpl-fld-sub{font-size:9px}\n  .gpl-wrap .gpl-upload{min-height:44px;font-size:12px}\n  .gpl-wrap .gpl-top-fields .gpl-upload{min-height:44px;font-size:12px;padding:9px 16px}\n  .gpl-wrap .gpl-ab-auto{min-height:36px;font-size:11px}\n  \/* Tool buttons: smaller (icon-only) on phone\/tablet so all 7 fit in one\n     row right under the playback bar. Wide buttons (Ruler \/ Set AB \/\n     Clear \/ Export) collapse their text label to icon-only on mobile;\n     the icon + title attribute remain for affordance. *\/\n  .gpl-wrap .gpl-tool-btn{width:32px;height:32px}\n  .gpl-wrap .gpl-tool-btn svg{width:16px;height:16px}\n  .gpl-wrap .gpl-tool-btn.gpl-btn-wide{width:32px;padding:0;font-size:0;letter-spacing:0}\n  .gpl-wrap .gpl-tool-btn.gpl-btn-wide > span{display:none}\n  .gpl-wrap .gpl-cta-mini,.gpl-wrap .gpl-cta-btn-lg{min-height:44px}\n  .gpl-wrap .gpl-slider-val{font-size:11px;min-width:80px}\n  .gpl-wrap .gpl-range{min-height:32px}\n  \/* Results row font + spacing \u2014 easier to read on phones *\/\n  .gpl-wrap .gpl-results{font-size:12px;padding:8px 10px;gap:3px}\n  .gpl-wrap .gpl-r-k{font-size:11px}\n  .gpl-wrap .gpl-r-v{font-size:13px}\n  \/* Compare All grid \u2014 keep 4 columns but tighter *\/\n  .gpl-wrap .gpl-cmp-row{padding:8px 10px;font-size:11.5px;grid-template-columns:minmax(140px,1.5fr) repeat(10, minmax(68px, 1fr));min-width:880px}\n  .gpl-wrap .gpl-cmp{overflow-x:auto}\n  \/* Playback strip \u2014 wrap better when narrow *\/\n  .gpl-wrap .gpl-pb{flex-wrap:wrap;padding:6px 8px;gap:6px}\n  .gpl-wrap .gpl-pb-stats{min-width:0;flex-basis:100%;text-align:left;font-size:10px;line-height:1.35;padding-top:2px;border-top:1px dashed #eeeeee}\n  .gpl-wrap .gpl-pb-track{min-width:50px}\n  \/* Tools \u2014 sit right under the playback bar at the top of the canvas.\n     The playback bar is a sibling ABOVE the canvas-shell, so top:8px is\n     directly under the playback line. *\/\n  .gpl-wrap .gpl-tools{top:8px;right:8px;gap:4px}\n  .gpl-wrap .gpl-export-menu{top:46px;right:8px}\n  .gpl-wrap .gpl-tray{top:48px;font-size:10px;padding:5px 8px}\n  \/* Legend and scale-bar \u2014 smaller, less in the way on phones *\/\n  .gpl-wrap .gpl-legend{font-size:10px;padding:7px 10px;gap:3px}\n  .gpl-wrap .gpl-scale{font-size:9px;padding:3px 7px}\n  \/* Unit-toggle (km\/mi) tabs \u2014 bigger tap target *\/\n  .gpl-wrap .gpl-unit-tab{padding:5px 12px;font-size:11px;min-height:32px}\n  \/* Field-picker grid \u2014 2\u00d72 still, slightly more breathing room *\/\n  .gpl-wrap .gpl-field-picker{gap:5px}\n}\n@media (max-width: 480px){\n  .gpl-wrap .gpl-main{min-height:48vh}\n  .gpl-wrap .gpl-grid{grid-template-rows:48vh auto auto}\n  .gpl-wrap .gpl-approach label{min-height:44px;font-size:14px}\n  .gpl-wrap input[type=number],.gpl-wrap select{font-size:14px;min-height:40px}\n  .gpl-wrap .gpl-cta-mini,.gpl-wrap .gpl-cta-btn-lg{min-height:44px}\n  .gpl-wrap .gpl-fld{min-height:46px;font-size:12px}\n  .gpl-wrap .gpl-tool-btn{width:34px;height:34px}\n  .gpl-wrap .gpl-tool-btn svg{width:17px;height:17px}\n  .gpl-wrap .gpl-tool-btn.gpl-btn-wide{width:34px}\n  .gpl-wrap .gpl-cmp-row{grid-template-columns:1fr 34px 42px 42px;font-size:10.5px}\n  .gpl-wrap .gpl-input-row .gpl-unit{font-size:11px;width:30px}\n  \/* Top bar (header + CTA) \u2014 let the CTA wrap onto its own row on small screens *\/\n  .gpl-wrap .gpl-top{padding:10px 12px;gap:8px}\n  .gpl-wrap .gpl-cta{padding:10px 14px;font-size:13px;min-height:42px}\n  .gpl-wrap .gpl-h1{font-size:14px}\n  \/* Floating CTA: hide on small screens \u2014 covers the map *\/\n  .gpl-wrap .gpl-floating-cta{display:none}\n}\n<\/style>\n\n<div class=\"gpl-top\">\n  <div class=\"gpl-brand\">\n    <div class=\"gpl-logo\">G<\/div>\n    <div>\n      <div class=\"gpl-h1\">Guidance Lines Simulator<\/div>\n    <\/div>\n  <\/div>\n  <div class=\"gpl-top-fields\" role=\"region\" aria-label=\"Field picker\">\n    <span class=\"gpl-top-fields-lbl\">Field:<\/span>\n    <div class=\"gpl-field-picker\" id=\"gpl-field-picker\">\n      <button type=\"button\" class=\"gpl-fld is-on\" data-field=\"lshape\" title=\"L-shape with obstacle \/ hill \u00b7 25 ha \u00b7 Auto-blocks wins here\">L-shape<\/button>\n      <button type=\"button\" class=\"gpl-fld\" data-field=\"hex\" title=\"Irregular hex \u00b7 27 ha\">Hex<\/button>\n      <button type=\"button\" class=\"gpl-fld\" data-field=\"rect\" title=\"Classic Midwest rectangle \u00b7 31 ha\">Rect<\/button>\n      <button type=\"button\" class=\"gpl-fld\" data-field=\"pivot\" title=\"Irrigation pivot circle \u00b7 28 ha\">Pivot<\/button>\n      <button type=\"button\" class=\"gpl-fld\" data-field=\"twoblock\" title=\"Two elongated disconnected blocks with near-perpendicular dominant axes \u00b7 25 ha \u00b7 Auto-blocks wins here clearly\">2 blocks<\/button>\n      <button type=\"button\" class=\"gpl-fld gpl-fld-imported\" data-field=\"imported\" id=\"gpl-fld-imported\" title=\"Field derived from your imported lines \/ boundary\" hidden>Imported<\/button>\n    <\/div>\n    <label class=\"gpl-upload\" id=\"gpl-upload-lbl\" title=\"Run the simulator on your field, no signup. Drop a field boundary or your existing guidance lines (GeoJSON \/ KML \/ Shapefile zip). Your uploaded files are read in your browser and never uploaded. Only the field boundary shape is sent to GeoPard to compute the plan, and it is not stored.\">\n      <input type=\"file\" id=\"gpl-upload\" accept=\".geojson,.json,.kml,.zip,.shp,application\/json,application\/zip,application\/vnd.google-earth.kml+xml\" multiple hidden>\n      <span class=\"gpl-upload-ico\">\u2934<\/span>\n      <span class=\"gpl-upload-txt\">Run on your field, no signup<\/span>\n    <\/label>\n    <span class=\"gpl-upload-hint\" id=\"gpl-upload-hint\" hidden>Drop guidance lines or field boundary \u00b7 auto-derive boundary<\/span>\n  <\/div>\n<\/div>\n<p class=\"gpl-privacy-strip\"><strong>Your uploaded files stay in your browser.<\/strong> Only the field boundary shape is sent to GeoPard to compute the plan, and it is not stored.<\/p>\n\n<div class=\"gpl-grid\">\n  <aside class=\"gpl-side\">\n    <div class=\"gpl-card\">\n      <h3>Approach<\/h3>\n      <div class=\"gpl-approach\" id=\"gpl-approach\">\n        <label id=\"gpl-ap-uploaded-label\" class=\"gpl-ap-uploaded\" hidden><input type=\"radio\" name=\"gpl-ap\" value=\"uploaded\"><span>Your current lines<\/span><span class=\"gpl-reco\" data-reco=\"uploaded\">Best<\/span><span class=\"gpl-info-ico\" data-tip=\"Play back + analyse the guidance lines you uploaded. Shown only after you import existing lines (GeoJSON \/ KML \/ Shapefile). Compares head-to-head with the four proposed approaches in the same units.\">i<\/span><\/label>\n        <label class=\"is-on\"><input type=\"radio\" name=\"gpl-ap\" value=\"ab-straight\" checked><span>AB Straight<\/span><span class=\"gpl-reco\" data-reco=\"ab-straight\">Best<\/span><span class=\"gpl-info-ico\" data-tip=\"Parallel straight passes from an AB reference line. Standard for flat, rectangular fields with uniform soil and terrain. Lowest path complexity, easiest for autosteer and the operator to execute.\">i<\/span><\/label>\n        <label><input type=\"radio\" name=\"gpl-ap\" value=\"ab-curve\"><span>AB Curve<\/span><span class=\"gpl-reco\" data-reco=\"ab-curve\">Best<\/span><span class=\"gpl-info-ico\" data-tip=\"Curved reference baseline. Every pass is a parallel offset of one recorded curve, so passes tile at the full implement width \u2014 use it when terraces, ditches, or dry creek beds suggest a natural curve. Slightly more overlap than AB Straight near irregular boundaries (the curve meets the edge at varying angles). Numbers are planimetric (2D); on hilly fields the true 3D surface area is ~1\u20132 % larger.\">i<\/span><\/label>\n        <label><input type=\"radio\" name=\"gpl-ap\" value=\"boundary\"><span>Boundary Follow<\/span><span class=\"gpl-reco\" data-reco=\"boundary\">Best<\/span><span class=\"gpl-info-ico\" data-tip=\"Passes follow the field boundary inward in concentric loops. Best for irregular field shapes where straight passes leave many gaps. Headland and body merge into one continuous spiral, but turns can be tighter than the equipment supports on sharp corners.\">i<\/span><\/label>\n        <label id=\"gpl-ap-adaptive-label\"><input type=\"radio\" name=\"gpl-ap\" value=\"adaptive\"><span>Topography follow<\/span><span class=\"gpl-reco\" data-reco=\"adaptive\">Best<\/span><span class=\"gpl-pro-lock\" id=\"gpl-ap-adaptive-lock\" hidden title=\"Requires real elevation data. Sign up at GeoPard to use Topography follow on your own fields.\">PRO<\/span><span class=\"gpl-info-ico\" data-tip=\"Passes follow elevation contours (lines of constant height). Reduces cross-slope driving on hilly fields: less erosion, less runoff, ~10 to 25% less fuel on slopes above 5%. Pairs naturally with strip-cropping and cover-crop systems.\">i<\/span><\/label>\n        <label><input type=\"radio\" name=\"gpl-ap\" value=\"auto-blocks\"><span>Auto-blocks<\/span><span class=\"gpl-reco\" data-reco=\"auto-blocks\">Best<\/span><span class=\"gpl-info-ico\" data-tip=\"Splits the field into natural sub-blocks (multi-part shapefiles, L-shapes, complex concave boundaries) and picks the optimal AB heading per block. Each region is worked with its own pass orientation aligned to that block's longest edge. Best for irregular fields where one heading can't cover the whole area efficiently.\">i<\/span><\/label>\n        <label><input type=\"radio\" name=\"gpl-ap\" value=\"concentric\"><span>Concentric (pivot)<\/span><span class=\"gpl-reco\" data-reco=\"concentric\">Best<\/span><span class=\"gpl-info-ico\" data-tip=\"Full circular loops around the field centre, spaced one implement width apart, driven from the edge inward. For round center-pivot fields: the machine drives complete circles and steps in one width at a time, with no headland U-turns. Best when the field is a circle; leaves a small disc uncovered at the very centre.\">i<\/span><\/label>\n      <\/div>\n    <\/div>\n    <div class=\"gpl-card\" id=\"gpl-heading-card\">\n      <h3>AB line direction<\/h3>\n      <!-- Boundary Follow message \u2014 heading is irrelevant for a spiral pattern. -->\n      <div class=\"gpl-heading-na\" id=\"gpl-heading-na\" hidden>\n        <strong>Not used.<\/strong> Boundary Follow spirals from the field edge inward \u2014 there&#8217;s no AB heading to set.\n      <\/div>\n      <!-- Topography Follow read-only \u2014 algorithm picks the angle perpendicular to gradient. -->\n      <div class=\"gpl-heading-auto\" id=\"gpl-heading-auto\" hidden>\n        <div class=\"gpl-heading-auto-val\"><span id=\"gpl-heading-auto-deg\">\u2014\u00b0<\/span><\/div>\n        <div class=\"gpl-heading-auto-lbl\">Auto \u00b7 oriented along the field contours<\/div>\n      <\/div>\n      <!-- Auto-blocks \u2014 list of detected sub-blocks with per-block angle input. -->\n      <div class=\"gpl-blocks-list\" id=\"gpl-blocks-list\" hidden>\n        <div class=\"gpl-heading-intro\">Per-block heading <span style=\"font-weight:400;color:#4c6066;text-transform:none;letter-spacing:0\">(\u00b0)<\/span><\/div>\n        <div id=\"gpl-blocks-rows\"><\/div>\n        <button type=\"button\" id=\"gpl-blocks-reset\" class=\"gpl-ab-auto\" title=\"Reset every block back to its automatic orientation\">\u21ba Reset all blocks<\/button>\n      <\/div>\n      <div class=\"gpl-heading-picker\" id=\"gpl-heading-picker\">\n        <div class=\"gpl-heading-intro\">Optimise for:<\/div>\n        <div class=\"gpl-heading-options\" id=\"gpl-heading-options\">\n          <label class=\"gpl-heading-opt is-on\" data-strategy=\"longest\" title=\"Align passes with the field's longest edge. Geometric default \u2014 smoothest, easiest to drive.\">\n            <input type=\"radio\" name=\"gpl-heading\" value=\"longest\" checked>\n            <span class=\"gpl-h-lbl\">Longest Edge<\/span>\n            <span class=\"gpl-h-val\" id=\"gpl-h-longest\">\u2014<\/span>\n          <\/label>\n          <label class=\"gpl-heading-opt\" data-strategy=\"crossings\" title=\"Angle that minimises path self-crossings (rule \u00a76). Fewer crossings = less wheel-track repeats, cleaner plan.\">\n            <input type=\"radio\" name=\"gpl-heading\" value=\"crossings\">\n            <span class=\"gpl-h-lbl\">Fewest Crossings<\/span>\n            <span class=\"gpl-h-val\" id=\"gpl-h-crossings\">\u2014<\/span>\n          <\/label>\n          <label class=\"gpl-heading-opt\" data-strategy=\"distance\" title=\"Angle that minimises total drive distance (pass km + turn km). Best diesel outcome on most fields.\">\n            <input type=\"radio\" name=\"gpl-heading\" value=\"distance\">\n            <span class=\"gpl-h-lbl\">Shortest Distance<\/span>\n            <span class=\"gpl-h-val\" id=\"gpl-h-distance\">\u2014<\/span>\n          <\/label>\n          <label class=\"gpl-heading-opt\" data-strategy=\"time\" title=\"Angle that minimises total work TIME. Turn-arounds are driven slower than working passes, so the time-optimal heading can differ from shortest-distance \u2014 it trades a little distance for fewer\/shorter turns.\">\n            <input type=\"radio\" name=\"gpl-heading\" value=\"time\">\n            <span class=\"gpl-h-lbl\">Shortest Time<\/span>\n            <span class=\"gpl-h-val\" id=\"gpl-h-time\">\u2014<\/span>\n          <\/label>\n          <label class=\"gpl-heading-opt\" data-strategy=\"custom\" title=\"Dial in a custom heading angle \u2014 useful when terrain, tramlines, or operator preference overrides the algorithm.\">\n            <input type=\"radio\" name=\"gpl-heading\" value=\"custom\">\n            <span class=\"gpl-h-lbl\">Custom<\/span>\n            <span class=\"gpl-h-val\" id=\"gpl-h-custom\">slider<\/span>\n          <\/label>\n        <\/div>\n        <div class=\"gpl-slider-row gpl-ab-row\">\n          <input type=\"range\" id=\"gpl-ab-deg\" min=\"0\" max=\"180\" step=\"1\" value=\"0\" class=\"gpl-range\">\n          <button type=\"button\" id=\"gpl-ab-auto\" class=\"gpl-ab-auto\" title=\"Reset to Longest Edge\">\u21ba<\/button>\n        <\/div>\n        <div class=\"gpl-hint\" id=\"gpl-ab-val\">0\u00b0<\/div>\n        <!-- AB direction by GPS coordinates. Useful when the operator\n             knows the exact A and B GPS points the tractor logged\n             (from a previous season's pass) and wants to lock the\n             simulator's axis to that recorded line. -->\n        <div class=\"gpl-ab-coords\" id=\"gpl-ab-coords\">\n          <div class=\"gpl-ab-coords-title\">Or by GPS coordinates<\/div>\n          <div class=\"gpl-ab-coords-row\">\n            <span class=\"gpl-ab-coords-lbl\">A<\/span>\n            <input type=\"number\" id=\"gpl-ab-a-lat\" placeholder=\"latitude\" step=\"0.000001\" inputmode=\"decimal\">\n            <input type=\"number\" id=\"gpl-ab-a-lng\" placeholder=\"longitude\" step=\"0.000001\" inputmode=\"decimal\">\n          <\/div>\n          <div class=\"gpl-ab-coords-row\">\n            <span class=\"gpl-ab-coords-lbl\">B<\/span>\n            <input type=\"number\" id=\"gpl-ab-b-lat\" placeholder=\"latitude\" step=\"0.000001\" inputmode=\"decimal\">\n            <input type=\"number\" id=\"gpl-ab-b-lng\" placeholder=\"longitude\" step=\"0.000001\" inputmode=\"decimal\">\n          <\/div>\n          <button type=\"button\" id=\"gpl-ab-coords-apply\" class=\"gpl-ab-coords-btn\" title=\"Use the bearing between A and B as the AB direction\">Apply<\/button>\n          <div class=\"gpl-ab-coords-hint\" id=\"gpl-ab-coords-hint\"><\/div>\n        <\/div>\n      <\/div>\n    <\/div>\n    <div class=\"gpl-card\">\n      <h3>Equipment<\/h3>\n      <div class=\"gpl-select-row gpl-select-row-inline\">\n        <label for=\"gpl-machine\">Machine<\/label>\n        <select id=\"gpl-machine\">\n          <option value=\"tractor-std\" selected>Tractor + implement (std)<\/option>\n          <option value=\"tractor-large\">Large tractor + planter<\/option>\n          <option value=\"sprayer\">Self-propelled sprayer<\/option>\n          <option value=\"combine\">Combine harvester<\/option>\n          <option value=\"articulated\">Articulated 4WD<\/option>\n          <option value=\"custom\">Custom (manual radius)<\/option>\n        <\/select>\n      <\/div>\n      <div class=\"gpl-input-row\">\n        <label for=\"gpl-wm\">Width<\/label>\n        <input type=\"number\" id=\"gpl-wm\" min=\"3\" max=\"60\" step=\"0.5\" value=\"18\">\n        <span class=\"gpl-unit\" data-u=\"width\">m<\/span>\n      <\/div>\n    <\/div>\n    <div class=\"gpl-card\">\n      <h3>Headland<\/h3>\n      <div class=\"gpl-slider-row\">\n        <input type=\"range\" id=\"gpl-hl-mult\" min=\"0\" max=\"4\" step=\"1\" value=\"1\" class=\"gpl-range\">\n        <span class=\"gpl-slider-val\" id=\"gpl-hl-val\">18 m \u00b7 1\u00d7<\/span>\n      <\/div>\n    <\/div>\n    <div class=\"gpl-card\">\n      <h3>Turnarounds<\/h3>\n      <div class=\"gpl-select-row gpl-select-row-inline\">\n        <label for=\"gpl-turn-style\">Style<\/label>\n        <select id=\"gpl-turn-style\">\n          <option value=\"uturn\" selected>U-turn (half-circle)<\/option>\n          <option value=\"none\">None \u2014 driver decides<\/option>\n        <\/select>\n      <\/div>\n      <div class=\"gpl-input-row\">\n        <label for=\"gpl-turn-r\">Turn r<\/label>\n        <input type=\"number\" id=\"gpl-turn-r\" min=\"4\" max=\"30\" step=\"0.5\" value=\"6\">\n        <span class=\"gpl-unit\" data-u=\"turn-r\">m<\/span>\n      <\/div>\n      <div class=\"gpl-hint\" id=\"gpl-turn-r-hint\">auto \u00b7 std tractor<\/div>\n    <\/div>\n  <\/aside>\n  <div class=\"gpl-splitter\" id=\"gpl-splitter\" aria-label=\"Resize settings panel\" title=\"Drag to resize\"><\/div>\n\n  <main class=\"gpl-main\">\n    <div class=\"gpl-pb\" id=\"gpl-pb\">\n      <button type=\"button\" class=\"gpl-pb-btn\" id=\"gpl-pb-play\" aria-label=\"Play \/ pause\">\u25b6<\/button>\n      <div class=\"gpl-pb-track\" id=\"gpl-pb-track\">\n        <div class=\"gpl-pb-fill\" id=\"gpl-pb-fill\"><\/div>\n        <div class=\"gpl-pb-thumb\" id=\"gpl-pb-thumb\"><\/div>\n      <\/div>\n      <div class=\"gpl-pb-seg\">\n        <button type=\"button\" class=\"gpl-pb-spd on\" data-spd=\"1\">1\u00d7<\/button>\n        <button type=\"button\" class=\"gpl-pb-spd\" data-spd=\"2\">2\u00d7<\/button>\n        <button type=\"button\" class=\"gpl-pb-spd\" data-spd=\"4\">4\u00d7<\/button>\n      <\/div>\n      <div class=\"gpl-pb-stats\" id=\"gpl-pb-stats\">0% \u00b7 0 \/ 0 km<\/div>\n    <\/div>\n    <div class=\"gpl-warn\" id=\"gpl-warn\"><\/div>\n    <div class=\"gpl-canvas-shell\">\n    <canvas class=\"gpl-canvas\" id=\"gpl-canvas\"><\/canvas>\n    <div class=\"gpl-loader\" id=\"gpl-loader\" aria-live=\"polite\" aria-hidden=\"true\">\n      <div class=\"gpl-loader-card\">\n        <div class=\"gpl-loader-badge\"><span class=\"gpl-loader-g\">G<\/span><\/div>\n        <div class=\"gpl-loader-txt\">\n          <span class=\"gpl-loader-title\">Calculating your field<\/span>\n          <span class=\"gpl-loader-sub\" id=\"gpl-loader-sub\">Planning guidance lines\u2026<\/span>\n        <\/div>\n      <\/div>\n    <\/div>\n    <div class=\"gpl-copyright\">\u00a9 GeoPard Agriculture<\/div>\n    <div class=\"gpl-tray\" id=\"gpl-tray\">\u2014 ha sample<\/div>\n    <div class=\"gpl-legend\">\n      <div class=\"gpl-lg-row\"><span class=\"gpl-lg-swatch\" style=\"background:linear-gradient(to right,#6d4fa2 0%,#4ec0a7 25%,#ffe882 50%,#f5a85c 75%,#db5050 100%);width:32px;height:8px;border-radius:2px\"><\/span>Elevation low \u2192 high<\/div>\n      <div class=\"gpl-lg-row\"><span class=\"gpl-lg-swatch\" style=\"background:#145328\"><\/span>Field boundary<\/div>\n      <div class=\"gpl-lg-row\"><span class=\"gpl-lg-swatch\" style=\"background:#f76a0c\"><\/span>Guidance pass<\/div>\n      <div class=\"gpl-lg-row\"><span class=\"gpl-lg-swatch\" style=\"background:rgba(26,121,81,.35)\"><\/span>Headland strip<\/div>\n      <div class=\"gpl-lg-row\"><span class=\"gpl-lg-swatch\" style=\"background:#a21caf\"><\/span>U-turn \/ direction \u2192<\/div>\n      <div class=\"gpl-lg-row\"><span class=\"gpl-lg-swatch\" style=\"background:rgba(162,28,175,.14);width:14px;height:8px\"><\/span>Wheel-track compaction<\/div>\n      <div class=\"gpl-lg-row\"><span class=\"gpl-lg-swatch\" style=\"background:rgba(247,106,12,.35);width:14px;height:8px\"><\/span>Swath covered<\/div>\n      <div class=\"gpl-lg-row\" id=\"gpl-lg-uploaded\" hidden><span class=\"gpl-lg-swatch\" style=\"background:rgba(33,102,200,.55);width:14px;height:0;border-top:2px dashed rgba(33,102,200,.55)\"><\/span>Your existing lines<\/div>\n      <div class=\"gpl-lg-row\" id=\"gpl-lg-uploaded-ring\" hidden><span class=\"gpl-lg-swatch\" style=\"background:rgba(33,102,200,.85);width:14px;height:0;border-top:2px solid rgba(33,102,200,.85)\"><\/span>Your headland trace<\/div>\n    <\/div>\n    <div class=\"gpl-scale\" id=\"gpl-scale\"><span class=\"gpl-scale-bar\"><\/span><span class=\"gpl-scale-lbl\" id=\"gpl-scale-lbl\">\u2014 m<\/span><\/div>\n    <div class=\"gpl-tools\">\n      <button type=\"button\" class=\"gpl-tool-btn gpl-btn-wide\" id=\"gpl-tool-ruler\" title=\"Measure distance \u2014 click two points on the map, click again to clear\" aria-label=\"Ruler\">\n        <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><rect x=\"2\" y=\"9\" width=\"20\" height=\"6\" rx=\"1\"\/><line x1=\"6\" y1=\"9\" x2=\"6\" y2=\"13\"\/><line x1=\"10\" y1=\"9\" x2=\"10\" y2=\"13\"\/><line x1=\"14\" y1=\"9\" x2=\"14\" y2=\"13\"\/><line x1=\"18\" y1=\"9\" x2=\"18\" y2=\"13\"\/><\/svg>\n        <span>Ruler<\/span>\n      <\/button>\n      <button type=\"button\" class=\"gpl-tool-btn gpl-btn-wide\" id=\"gpl-tool-ab\" title=\"Set AB line \u2014 click two points to lock the guidance direction; ESC to cancel\" aria-label=\"Set AB line\">\n        <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><circle cx=\"5\" cy=\"19\" r=\"2\"\/><circle cx=\"19\" cy=\"5\" r=\"2\"\/><line x1=\"6.4\" y1=\"17.6\" x2=\"17.6\" y2=\"6.4\"\/><\/svg>\n        <span>Set AB<\/span>\n      <\/button>\n      <button type=\"button\" class=\"gpl-tool-btn gpl-btn-wide\" id=\"gpl-tool-clear\" title=\"Clear ruler + custom AB line; reset zoom to fit\" aria-label=\"Clear overlays and reset view\">\n        <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M3 6h18M8 6V4a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v2M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6\"\/><\/svg>\n        <span>Clear<\/span>\n      <\/button>\n      <button type=\"button\" class=\"gpl-tool-btn gpl-btn-wide\" id=\"gpl-tool-export\" title=\"Export guidance lines \u2014 Shapefile \/ GeoJSON \/ KML, ready for John Deere Operations Center, CNH FieldOps, AGCO PTx, Trimble\" aria-label=\"Export guidance lines\">\n        <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M12 3v12\"\/><polyline points=\"7,10 12,15 17,10\"\/><path d=\"M3 19h18\"\/><\/svg>\n        <span>Export<\/span>\n      <\/button>\n      <div class=\"gpl-export-menu\" id=\"gpl-export-menu\" hidden>\n        <button type=\"button\" data-fmt=\"shp\" title=\"Zipped shapefile (.shp \/ .shx \/ .dbf \/ .prj) \u2014 universal GIS format accepted by John Deere Operations Center, CNH FieldOps, AGCO PTx, Trimble Ag Software\">Shapefile (.zip)<\/button>\n        <button type=\"button\" data-fmt=\"geojson\" title=\"GeoJSON LineString collection \u2014 modern web standard, supported by JD Ops Center, QGIS, ArcGIS Online\">GeoJSON (.geojson)<\/button>\n        <button type=\"button\" data-fmt=\"kml\" title=\"Keyhole Markup Language \u2014 Google Earth + most field-management viewers\">KML (.kml)<\/button>\n      <\/div>\n      <button type=\"button\" class=\"gpl-tool-btn\" id=\"gpl-tool-zoom-in\" title=\"Zoom in\" aria-label=\"Zoom in\">+<\/button>\n      <button type=\"button\" class=\"gpl-tool-btn\" id=\"gpl-tool-zoom-out\" title=\"Zoom out\" aria-label=\"Zoom out\">\u2212<\/button>\n      <button type=\"button\" class=\"gpl-tool-btn\" id=\"gpl-tool-fit\" title=\"Fit field\" aria-label=\"Reset view\">\u27f2<\/button>\n    <\/div>\n    <div class=\"gpl-ruler-hint\" id=\"gpl-ruler-hint\">Click two points on the canvas to measure<\/div>\n    <div class=\"gpl-ruler-hint\" id=\"gpl-ab-hint\">Click the first point of your AB line \u00b7 ESC to cancel<\/div>\n    <aside class=\"gpl-floating-cta\" id=\"gpl-floating-cta\">\n      <div class=\"gpl-floating-cta-h\">Smarter guidance in GeoPard<\/div>\n      <ul>\n        <li>AI assistant \u2014 knows your fields + crop plan<\/li>\n        <li>Topography Follow with hi-res elevation (real DEM, not synthetic)<\/li>\n        <li>Auto-detect AB lines from hi-res satellite imagery<\/li>\n        <li>2-way sync: JD Ops, CNH FieldOps, AGCO PTx + API<\/li>\n        <li>Prescription maps aligned with AB lines<\/li>\n        <li>Export ISO-XML, shapefile, GeoJSON for any tractor<\/li>\n      <\/ul>\n      <a class=\"gpl-floating-cta-btn\" href=\"https:\/\/app.geopard.tech\/signup?utm_source=guidance-lines&amp;utm_medium=wp-embed&amp;utm_campaign=floating-cta\" target=\"_blank\" rel=\"noopener\">\n        Start free trial<span class=\"arr\">\u2192<\/span>\n      <\/a>\n    <\/aside>\n    <\/div>\n  <\/main>\n  <div class=\"gpl-splitter-right\" id=\"gpl-splitter-right\" aria-label=\"Resize analytics panel\" title=\"Drag to resize\"><\/div>\n  <aside class=\"gpl-side-right\" id=\"gpl-side-right\">\n    <div class=\"gpl-card\">\n      <h3>Analytics<\/h3>\n      <div class=\"gpl-unit-toggle\" role=\"tablist\" title=\"Switch unit system \u2014 converts all inputs + outputs\">\n        <button type=\"button\" class=\"gpl-unit-tab is-on\" data-unit-system=\"metric\">Metric<\/button>\n        <button type=\"button\" class=\"gpl-unit-tab\" data-unit-system=\"us\">US<\/button>\n      <\/div>\n      <div class=\"gpl-results\">\n        <div class=\"gpl-r-row\"><span class=\"gpl-r-k\">Field area<\/span><span class=\"gpl-r-v\" id=\"gpl-r-area\">\u2014 ha<\/span><\/div>\n        <div class=\"gpl-r-row gpl-r-pri\"><span class=\"gpl-r-k\">Passes<\/span><span class=\"gpl-r-v\" id=\"gpl-r-passes\">\u2014<\/span><\/div>\n        <div class=\"gpl-r-row\"><span class=\"gpl-r-k\">Productivity<\/span><span class=\"gpl-r-v\" id=\"gpl-r-prod\" title=\"Field area worked per hour at the configured working speed.\">\u2014 ha\/h<\/span><\/div>\n        <div class=\"gpl-r-row gpl-r-input\"><span class=\"gpl-r-k\" data-u=\"speed-label\">Working speed<\/span><span class=\"gpl-r-ctl\"><input type=\"number\" id=\"gpl-speed\" min=\"2\" max=\"30\" step=\"0.5\" value=\"10\"><span class=\"gpl-unit\" data-u=\"speed\">km\/h<\/span><\/span><\/div>\n        <div class=\"gpl-r-section\">Time<\/div>\n        <div class=\"gpl-r-row\"><span class=\"gpl-r-k\">Total<\/span><span class=\"gpl-r-v\" id=\"gpl-r-time-total\">\u2014<\/span><\/div>\n        <div class=\"gpl-r-row\"><span class=\"gpl-r-k gpl-r-sub\">Working<\/span><span class=\"gpl-r-v\" id=\"gpl-r-time-work\">\u2014<\/span><\/div>\n        <div class=\"gpl-r-row\"><span class=\"gpl-r-k gpl-r-sub\">Non-working<\/span><span class=\"gpl-r-v\" id=\"gpl-r-time-trans\" title=\"Time spent on turnarounds and transport between disconnected field parts. Lower means more time on the implement, less idling.\">\u2014<\/span><\/div>\n        <div class=\"gpl-r-section\">Distance<\/div>\n        <div class=\"gpl-r-row\"><span class=\"gpl-r-k\">Total<\/span><span class=\"gpl-r-v\" id=\"gpl-r-drive\">\u2014 km<\/span><\/div>\n        <div class=\"gpl-r-row\"><span class=\"gpl-r-k gpl-r-sub\">Working<\/span><span class=\"gpl-r-v\" id=\"gpl-r-len\">\u2014 km<\/span><\/div>\n        <div class=\"gpl-r-row\"><span class=\"gpl-r-k gpl-r-sub\">Non-working<\/span><span class=\"gpl-r-v\" id=\"gpl-r-turnlen\">\u2014 km<\/span><\/div>\n        <div class=\"gpl-r-row\"><span class=\"gpl-r-k gpl-r-sub\">Turnarounds<\/span><span class=\"gpl-r-v\" id=\"gpl-r-turns\">\u2014<\/span><\/div>\n        <div class=\"gpl-r-section\">Coverage<\/div>\n        <div class=\"gpl-r-row gpl-r-pri\"><span class=\"gpl-r-k\">Covered<\/span><span class=\"gpl-r-v\" id=\"gpl-r-cov\">\u2014 %<\/span><\/div>\n        <div class=\"gpl-r-row\"><span class=\"gpl-r-k gpl-r-sub\">Overlap area<\/span><span class=\"gpl-r-v\" id=\"gpl-r-overlap-ha\" title=\"Ground driven over twice or more. Lower = less compaction, less wasted diesel. Rule \u00a76 \u2014 minimise wheel-pass repeats.\">\u2014 ha<\/span><\/div>\n        <div class=\"gpl-r-row\"><span class=\"gpl-r-k gpl-r-sub\">Missed zone<\/span><span class=\"gpl-r-v\" id=\"gpl-r-missed\" title=\"Field area not touched by any swath. Lower = more complete coverage.\">\u2014 ha<\/span><\/div>\n        <div class=\"gpl-r-row\"><span class=\"gpl-r-k\">Path crossings<\/span><span class=\"gpl-r-v\" id=\"gpl-r-crossings\" title=\"How often the drive path crosses itself (excluding unavoidable pass-to-ring junctions). Each crossing is a wheel-track repeat. Rule \u00a76 \u2014 minimise path crossings.\">\u2014<\/span><\/div>\n        <div class=\"gpl-r-section\">Cost<\/div>\n        <div class=\"gpl-r-row gpl-r-pri\"><span class=\"gpl-r-k\">Fuel<\/span><span class=\"gpl-r-v\" id=\"gpl-r-fuel\">\u2014 L<\/span><\/div>\n        <div class=\"gpl-r-row\"><span class=\"gpl-r-k gpl-r-sub\">Turn fuel<\/span><span class=\"gpl-r-v\" id=\"gpl-r-turnfuel\">\u2014 L<\/span><\/div>\n        <div class=\"gpl-r-row\"><span class=\"gpl-r-k gpl-r-sub\">Slope cost<\/span><span class=\"gpl-r-v\" id=\"gpl-r-slope\">+ 0%<\/span><\/div>\n        <div class=\"gpl-r-row\"><span class=\"gpl-r-k\">Avg grade<\/span><span class=\"gpl-r-v\" id=\"gpl-r-grade\">\u2014 %<\/span><\/div>\n        <div class=\"gpl-r-row gpl-r-input\"><span class=\"gpl-r-k\" data-u=\"consumption-label\">L\/km<\/span><span class=\"gpl-r-ctl\"><input type=\"number\" id=\"gpl-cons\" min=\"0.1\" max=\"5\" step=\"0.1\" value=\"0.6\"><\/span><\/div>\n        <div class=\"gpl-r-row gpl-r-input\"><span class=\"gpl-r-k\">Diesel price<\/span><span class=\"gpl-r-ctl\"><input type=\"number\" id=\"gpl-fuel\" min=\"0.1\" max=\"3\" step=\"0.05\" value=\"1.20\"><select id=\"gpl-currency\" class=\"gpl-cur-sel\" data-u=\"currency\"><option value=\"usd\" selected>$\/L<\/option><option value=\"eur\">\u20ac\/L<\/option><\/select><\/span><\/div>\n        <div class=\"gpl-r-row\"><span class=\"gpl-r-k\">Cost<\/span><span class=\"gpl-r-v\" id=\"gpl-r-cost\">$ \u2014<\/span><\/div>\n        <div class=\"gpl-r-row\"><span class=\"gpl-r-k\">CO\u2082<\/span><span class=\"gpl-r-v\" id=\"gpl-r-co2\" title=\"Tractor exhaust CO\u2082 from the diesel burned on this plan, at ~2.68 kg CO\u2082 per litre (standard agricultural diesel emission factor).\">\u2014 kg<\/span><\/div>\n        <div class=\"gpl-r-row\"><span class=\"gpl-r-k\">vs AB straight<\/span><span class=\"gpl-r-savings\" id=\"gpl-r-sav\">\u2014<\/span><\/div>\n      <\/div>\n    <\/div>\n  <\/aside>\n<\/div>\n\n<!-- Compare All + Annual ROI moved out of the side panel so the canvas\n     and analytics get full vertical space. Two-column layout on\n     widescreen, stacks vertically on tablet \/ phone. The \"Your existing\"\n     row inside Compare All toggles via JS when the user has uploaded\n     their own guidance lines (UPLOADED_LINES populated). -->\n<div class=\"gpl-below\" id=\"gpl-below\">\n  <div class=\"gpl-card gpl-below-cmp\">\n    <h3>Compare all approaches<\/h3>\n    <div class=\"gpl-cmp\" id=\"gpl-cmp-table\" role=\"grid\">\n      <div class=\"gpl-cmp-row gpl-cmp-head\" role=\"row\">\n        <span class=\"gpl-cmp-sort\" data-sort=\"name\" title=\"Sort by approach name (alphabetical)\">Approach<span class=\"gpl-cmp-arr\"><\/span><\/span>\n        <span class=\"gpl-cmp-val gpl-cmp-sort\" data-sort=\"cov\" title=\"Field area covered by at least one pass swath. Higher is better.\">Coverage<span class=\"gpl-cmp-arr\"><\/span><\/span>\n        <span class=\"gpl-cmp-val gpl-cmp-sort\" data-sort=\"missed\" title=\"Field area not touched by any swath. Lower is better.\">Missed area<span class=\"gpl-cmp-arr\"><\/span><\/span>\n        <span class=\"gpl-cmp-val gpl-cmp-sort\" data-sort=\"compaction\">Area at risk<span class=\"gpl-cmp-arr\"><\/span><span class=\"gpl-info-ico gpl-cmp-info\" data-tip=\"Soil compaction risk zone \u2014 the area where the operator's wheel tracks drove over the SAME ground 2\u00d7 or more. Repeat passes compact soil, reduce root depth + water infiltration, cut next-season yield 5\u201315 % on the compacted band, and accelerate runoff + erosion. Long-term issue: heavy compaction takes 2\u20133 seasons of recovery (deep tillage, cover-cropping) to undo. Reported in hectares (or acres). Lower = healthier soil + better next-season yield.\">i<\/span><\/span>\n        <span class=\"gpl-cmp-val gpl-cmp-sort\" data-sort=\"repeats\" title=\"Percentage of the worked area driven over twice or more (overlap %). Lower is better.\">Repeats<span class=\"gpl-cmp-arr\"><\/span><\/span>\n        <span class=\"gpl-cmp-val gpl-cmp-sort\" data-sort=\"cross\" title=\"Number of points where the drive path crosses itself. Each crossing is a wheel-track repeat. Lower is better.\">Path crossings<span class=\"gpl-cmp-arr\"><\/span><\/span>\n        <span class=\"gpl-cmp-val gpl-cmp-sort\" data-sort=\"km\" title=\"Total drive distance per field (matches the Analytics unit setting). Lower is better.\">Distance<span class=\"gpl-cmp-arr\"><\/span><\/span>\n        <span class=\"gpl-cmp-val gpl-cmp-sort\" data-sort=\"turns\" title=\"Number of headland turnarounds. Lower is better \u2014 every turn lifts the implement.\">Turnarounds<span class=\"gpl-cmp-arr\"><\/span><\/span>\n        <span class=\"gpl-cmp-val gpl-cmp-sort\" data-sort=\"time\" title=\"Total time to work the field at the configured working speed. Lower is better.\">Time<span class=\"gpl-cmp-arr\"><\/span><\/span>\n        <span class=\"gpl-cmp-val gpl-cmp-sort\" data-sort=\"fuel\" title=\"Fuel consumption per field at the configured L\/km. Lower is better.\">Fuel<span class=\"gpl-cmp-arr\"><\/span><\/span>\n        <span class=\"gpl-cmp-val gpl-cmp-sort\" data-sort=\"cost\" title=\"Total fuel cost per field at the configured price. Lower is better.\">Cost<span class=\"gpl-cmp-arr\"><\/span><\/span>\n      <\/div>\n      <div class=\"gpl-cmp-row\" data-cmp=\"ab-straight\" role=\"row\">\n        <span class=\"gpl-cmp-name\">AB Straight<\/span>\n        <span class=\"gpl-cmp-val\">\u2014<\/span><span class=\"gpl-cmp-val\">\u2014<\/span><span class=\"gpl-cmp-val\">\u2014<\/span><span class=\"gpl-cmp-val\">\u2014<\/span><span class=\"gpl-cmp-val\">\u2014<\/span><span class=\"gpl-cmp-val\">\u2014<\/span><span class=\"gpl-cmp-val\">\u2014<\/span><span class=\"gpl-cmp-val\">\u2014<\/span><span class=\"gpl-cmp-val\">\u2014<\/span><span class=\"gpl-cmp-val\">\u2014<\/span>\n      <\/div>\n      <div class=\"gpl-cmp-row\" data-cmp=\"ab-curve\" role=\"row\">\n        <span class=\"gpl-cmp-name\">AB Curve<\/span>\n        <span class=\"gpl-cmp-val\">\u2014<\/span><span class=\"gpl-cmp-val\">\u2014<\/span><span class=\"gpl-cmp-val\">\u2014<\/span><span class=\"gpl-cmp-val\">\u2014<\/span><span class=\"gpl-cmp-val\">\u2014<\/span><span class=\"gpl-cmp-val\">\u2014<\/span><span class=\"gpl-cmp-val\">\u2014<\/span><span class=\"gpl-cmp-val\">\u2014<\/span><span class=\"gpl-cmp-val\">\u2014<\/span><span class=\"gpl-cmp-val\">\u2014<\/span>\n      <\/div>\n      <div class=\"gpl-cmp-row\" data-cmp=\"boundary\" role=\"row\">\n        <span class=\"gpl-cmp-name\">Boundary Follow<\/span>\n        <span class=\"gpl-cmp-val\">\u2014<\/span><span class=\"gpl-cmp-val\">\u2014<\/span><span class=\"gpl-cmp-val\">\u2014<\/span><span class=\"gpl-cmp-val\">\u2014<\/span><span class=\"gpl-cmp-val\">\u2014<\/span><span class=\"gpl-cmp-val\">\u2014<\/span><span class=\"gpl-cmp-val\">\u2014<\/span><span class=\"gpl-cmp-val\">\u2014<\/span><span class=\"gpl-cmp-val\">\u2014<\/span><span class=\"gpl-cmp-val\">\u2014<\/span>\n      <\/div>\n      <div class=\"gpl-cmp-row\" data-cmp=\"adaptive\" role=\"row\">\n        <span class=\"gpl-cmp-name\">Topography follow<\/span>\n        <span class=\"gpl-cmp-val\">\u2014<\/span><span class=\"gpl-cmp-val\">\u2014<\/span><span class=\"gpl-cmp-val\">\u2014<\/span><span class=\"gpl-cmp-val\">\u2014<\/span><span class=\"gpl-cmp-val\">\u2014<\/span><span class=\"gpl-cmp-val\">\u2014<\/span><span class=\"gpl-cmp-val\">\u2014<\/span><span class=\"gpl-cmp-val\">\u2014<\/span><span class=\"gpl-cmp-val\">\u2014<\/span><span class=\"gpl-cmp-val\">\u2014<\/span>\n      <\/div>\n      <div class=\"gpl-cmp-row\" data-cmp=\"auto-blocks\" role=\"row\">\n        <span class=\"gpl-cmp-name\">Auto-blocks<\/span>\n        <span class=\"gpl-cmp-val\">\u2014<\/span><span class=\"gpl-cmp-val\">\u2014<\/span><span class=\"gpl-cmp-val\">\u2014<\/span><span class=\"gpl-cmp-val\">\u2014<\/span><span class=\"gpl-cmp-val\">\u2014<\/span><span class=\"gpl-cmp-val\">\u2014<\/span><span class=\"gpl-cmp-val\">\u2014<\/span><span class=\"gpl-cmp-val\">\u2014<\/span><span class=\"gpl-cmp-val\">\u2014<\/span><span class=\"gpl-cmp-val\">\u2014<\/span>\n      <\/div>\n      <div class=\"gpl-cmp-row\" data-cmp=\"concentric\" role=\"row\">\n        <span class=\"gpl-cmp-name\">Concentric (pivot)<\/span>\n        <span class=\"gpl-cmp-val\">\u2014<\/span><span class=\"gpl-cmp-val\">\u2014<\/span><span class=\"gpl-cmp-val\">\u2014<\/span><span class=\"gpl-cmp-val\">\u2014<\/span><span class=\"gpl-cmp-val\">\u2014<\/span><span class=\"gpl-cmp-val\">\u2014<\/span><span class=\"gpl-cmp-val\">\u2014<\/span><span class=\"gpl-cmp-val\">\u2014<\/span><span class=\"gpl-cmp-val\">\u2014<\/span><span class=\"gpl-cmp-val\">\u2014<\/span>\n      <\/div>\n      <div class=\"gpl-cmp-row gpl-cmp-uploaded\" data-cmp=\"uploaded\" hidden role=\"row\">\n        <span class=\"gpl-cmp-name\">Your current lines<\/span>\n        <span class=\"gpl-cmp-val\">\u2014<\/span><span class=\"gpl-cmp-val\">\u2014<\/span><span class=\"gpl-cmp-val\">\u2014<\/span><span class=\"gpl-cmp-val\">\u2014<\/span><span class=\"gpl-cmp-val\">\u2014<\/span><span class=\"gpl-cmp-val\">\u2014<\/span><span class=\"gpl-cmp-val\">\u2014<\/span><span class=\"gpl-cmp-val\">\u2014<\/span><span class=\"gpl-cmp-val\">\u2014<\/span><span class=\"gpl-cmp-val\">\u2014<\/span>\n      <\/div>\n    <\/div>\n    <div class=\"gpl-cmp-best\" id=\"gpl-cmp-best\">\n      <span class=\"gpl-cmp-best-badge\">Best for this field<\/span>\n      <span class=\"gpl-cmp-best-name\" id=\"gpl-cmp-best-name\">\u2014<\/span>\n      <span class=\"gpl-cmp-best-why\" id=\"gpl-cmp-best-why\">analysing\u2026<\/span>\n    <\/div>\n    <div class=\"gpl-cmp-hint\" id=\"gpl-cmp-uploaded-hint\" hidden>Upload your existing guidance lines to compare the four proposed approaches against your current plan, head-to-head on the same field.<\/div>\n  <\/div>\n<\/div>\n\n<script nowprocket data-no-optimize=\"1\" data-no-defer=\"1\" data-no-minify=\"1\">\n(function(){\n  var root = document.getElementById('gpl-root');\n  if(!root) return;\n  var canvas = document.getElementById('gpl-canvas');\n  var ctx = canvas.getContext('2d');\n  var trayEl = document.getElementById('gpl-tray');\n  \/\/ Display names for the four approaches \u2014 used when surfacing the\n  \/\/ recommendation's stats so the user sees WHICH option the numbers\n  \/\/ (\"86% cov \u00b7 125 turns \u00b7 68.4 L\") refer to instead of guessing.\n  var AP_LABELS = {\n    'ab-straight': 'AB Straight',\n    'ab-curve':    'AB Curve',\n    'boundary':    'Boundary Follow',\n    'adaptive':    'Topography follow',\n    'auto-blocks': 'Auto-blocks',\n    'concentric':  'Concentric'\n  };\n  \/* \u2550\u2550\u2550 GPL-PLANNER-CORE BEGIN (generated from planner-core.js \u2014 DO NOT EDIT HERE; edit planner-core.js, scripts\/rebuild.sh re-injects) \u2550\u2550\u2550 *\/\n  \/* \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n     GeoPard Guidance Planner \u2014 algorithm core\n     tools\/guidance-lines\/planner-core.js\n     SOURCE OF TRUTH for every path-planning algorithm the Guidance Lines\n     tool uses (rule \u00a76 in CLAUDE.md). scripts\/rebuild.sh inlines this file\n     verbatim into source.html between the GPL-PLANNER-CORE markers, so the\n     single-file embed keeps working unchanged. The same file require()s in\n     Node \u2014 that is what the planner service (backend\/guidance-planner) and\n     the tests-harness loadCore() path consume.\n     Rules that apply here (factory CLAUDE.md):\n     - No ampersand characters anywhere, not even in comments (rule 1 \u2014\n       WordPress encodes them inside script tags). Write the ternary form\n       \"a ? b : a\" instead of double-ampersand short-circuit.\n     - No browser\/DOM globals \u2014 this file must stay runnable in plain\n       Node with zero stubs. Host-specific defaults are injected (see\n       gapTolDefaultProvider). The test gate greps for violations.\n     - Deterministic: no Date.now \/ Math.random.\n     Phase 0 of docs\/superpowers\/plans\/2026-06-19-guidance-planner-backend-migration.md\n     \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550 *\/\n  \/\/ Sample fields \u2014 units are real meters, rendered with a uniform scale fitting\n  \/\/ the canvas. Four shapes cover the common cases: irregular polygon, rectangular\n  \/\/ Midwest section, L-shaped (obstacle \/ hill corner), oval (centre-pivot remnant).\n  var FIELDS = {\n    hex: [\n      { x:  20, y: 220 }, { x:  80, y:  60 }, { x: 290, y:  35 },\n      { x: 580, y:  90 }, { x: 690, y: 240 }, { x: 640, y: 460 },\n      { x: 380, y: 540 }, { x: 130, y: 510 }, { x:  40, y: 380 }\n    ],\n    rect: [\n      { x:  40, y:  60 }, { x: 720, y:  70 }, { x: 730, y: 520 }, { x:  50, y: 510 }\n    ],\n    lshape: [\n      \/\/ Exaggerated slim L \u2014 top arm ~140 m thick \u00d7 600 m long\n      \/\/ (horizontal-trending), side arm ~140 m thick \u00d7 480 m long\n      \/\/ (vertical-trending). 90\u00b0 clean inner reflex. Slim arms make\n      \/\/ any global-axis approach (AB Straight, Topography) waste big\n      \/\/ chunks of fuel on miss-oriented passes, so the per-block axis\n      \/\/ of Auto-blocks wins by a clear, visible margin.\n      \/\/ Going clockwise from upper-left:\n      { x:  25, y:  50 }, { x: 190, y:  25 }, { x: 410, y:  30 },\n      { x: 575, y:  55 }, { x: 615, y: 100 },   \/\/ top-right of arm 1\n      { x: 640, y: 220 }, { x: 660, y: 380 }, { x: 645, y: 490 },\n      { x: 605, y: 555 },                       \/\/ bottom-right of arm 2\n      { x: 500, y: 565 }, { x: 410, y: 545 },\n      { x: 425, y: 420 }, { x: 440, y: 290 },\n      { x: 455, y: 195 },                       \/\/ INNER concave reflex (90\u00b0)\n      { x: 320, y: 175 }, { x: 165, y: 170 },\n      { x:  25, y: 165 }\n    ],\n    \/\/ Irrigation pivot field \u2014 a true circle drawn by the centre-pivot\n    \/\/ sprinkler. Radius \u2248 300 m gives ~28 ha which matches a typical\n    \/\/ quarter-section pivot (130 m wide, 360\u00b0 coverage, ~32 ha gross \/\n    \/\/ ~28 ha actually wetted).\n    pivot: (function(){\n      var pts = [];\n      var cx = 380, cy = 290, r = 300;\n      for(var i=0; i<28; i++){\n        var t = (i \/ 28) * Math.PI * 2;\n        pts.push({ x: cx + Math.cos(t) * r, y: cy + Math.sin(t) * r });\n      }\n      return pts;\n    })(),\n    \/\/ Two-block field \u2014 two disconnected IRREGULAR polygons with\n    \/\/ dominant axes ~55\u00b0 apart (not perpendicular \u2014 real adjacent\n    \/\/ operational fields rarely line up at a clean 90\u00b0). Polygon A\n    \/\/ (~6 ha, axis tilted ~\u221213\u00b0 from +x) and B (~6 ha, axis ~+110\u00b0)\n    \/\/ have shapes that aren't rectangular either, so the demo reads\n    \/\/ as a real field, not a textbook diagram. A global field axis\n    \/\/ splits the difference and works poorly on both; Auto-blocks\n    \/\/ gives each polygon its own PCA axis \u2192 only approach that hits\n    \/\/ high coverage on BOTH.\n    twoblock: [\n      \/\/ Bbox encompasses both elongated blocks (used for default canvas fit).\n      { x:  30, y: 270 }, { x:  20, y:  80 }, { x: 240, y:  50 },\n      { x: 690, y:  80 }, { x: 720, y: 250 }, { x: 480, y: 295 },\n      { x: 200, y: 290 }\n    ]\n  };\n  var currentField = 'lshape';\n  var BOUNDARY = FIELDS[currentField];\n  \/\/ Multi-part field model. BOUNDARY_PARTS is always an array of polygon vertex\n  \/\/ arrays. For built-in fields and single-polygon imports, length === 1.\n  \/\/ For multi-polygon imports (shapefile with multiple outer rings, GeoJSON\n  \/\/ MultiPolygon, KML with multiple Placemarks), each ring becomes a part and\n  \/\/ the machine works each one in turn with a transport leg between them.\n  \/\/ Real-world rule: a tractor cannot easily drive between disconnected field\n  \/\/ parts (forest \/ river \/ road in between) \u2014 so we finish one part fully\n  \/\/ BEFORE starting the next, and visualise the inter-part hop as a gray\n  \/\/ dashed \"transport\" segment with no swath behind it.\n  var BOUNDARY_PARTS = [BOUNDARY];\n  \/\/ Per-field synthetic elevation. terrain(x, y) \u2192 metres above the field's\n  \/\/ own lowest point. Calibrated to demonstrate the real ROI of contour\n  \/\/ planning: AB-straight grinds across these ~6-10% grades and pays the\n  \/\/ full slope-penalty fuel surcharge, while Contour-follow drives along\n  \/\/ the iso-z lines and pays ~0% slope penalty.\n  \/\/   hex     \u2014 east-facing slope, 35 m drop across ~670 m (\u2248 5% mean)\n  \/\/   rect    \u2014 diagonal ridge, 40 m peak (\u2248 7% on the flanks)\n  \/\/   lshape  \u2014 hill at the concave corner (NE), 50 m peak (\u2248 10% locally)\n  \/\/   pivot   \u2014 mostly flat with a gentle uniform grade (\u2248 4-6 m of relief\n  \/\/             across the circle, well under 1%), the realistic setting for\n  \/\/             a center-pivot field (they sit on gently sloping to near-level\n  \/\/             ground, never over a central hill). Kept below Topography's\n  \/\/             gradient-rotation threshold so the circular concentric pattern\n  \/\/             stays the natural + recommended plan \u2014 how pivot guidance\n  \/\/             actually works in the field.\n  \/\/   custom  \u2014 flat (no elevation data for uploaded boundaries)\n  \/\/ The functions are deliberately smooth so contour lines look natural.\n  var TERRAIN = {\n    hex: function(x, y){\n      \/\/ Tilted plane + gentle waves\n      return 35 * ((x - 20) \/ 670) + 4 * Math.sin(y \/ 90) + 2 * Math.cos(x \/ 110);\n    },\n    rect: function(x, y){\n      \/\/ Diagonal ridge\n      var u = (x + y) \/ 1200;\n      return 40 * Math.exp(-Math.pow(u - 0.5, 2) * 8) - 5;\n    },\n    lshape: function(x, y){\n      \/\/ Hill near the concave corner (~455, 195) of the slim L-shape,\n      \/\/ plus a gentle southwest tilt.\n      var dx = (x - 455) \/ 200, dy = (y - 195) \/ 180;\n      return 45 * Math.exp(-(dx*dx + dy*dy)) + (x - 25) * 0.025;\n    },\n    pivot: function(x, y){\n      \/\/ Mostly flat with a gentle uniform grade falling to the east (~0.3 %),\n      \/\/ plus faint undulation for visual texture. Total relief ~4-6 m across\n      \/\/ the ~600 m circle \u2014 realistic for a center-pivot field on gently\n      \/\/ sloping ground. Mean gradient stays under the 0.005 rotation\n      \/\/ threshold, so Topography falls back and Concentric stays recommended.\n      var grade = 0.003 * x;\n      var undulation = 1.2 * Math.sin(x \/ 150) + 0.9 * Math.cos(y \/ 170);\n      return grade + undulation;\n    },\n    twoblock: function(x, y){\n      \/\/ Two LOCAL elevation features \u2014 one centred on each block \u2014 so\n      \/\/ the heatmap is visible across both polygons but the gradients\n      \/\/ POINT in DIFFERENT directions per block. A global axis picked\n      \/\/ by Topography Follow can only satisfy one block; auto-blocks\n      \/\/ (per-block axis) is the only approach that fits both. Avoids\n      \/\/ a uniform slope that would let Topography mis-win the demo.\n      var dxA = (x - 240) \/ 200, dyA = (y - 170) \/ 150;\n      var dxB = (x - 580) \/ 180, dyB = (y - 290) \/ 250;\n      \/\/ Hill on Block A (gentle, centred upper-left), dip on Block B\n      \/\/ (centred lower-right). Combined range ~14 m \u2014 enough for the\n      \/\/ 0.5 m drawTerrain visibility threshold to fire.\n      return 14 * Math.exp(-(dxA*dxA + dyA*dyA)) - 10 * Math.exp(-(dxB*dxB + dyB*dyB));\n    },\n    custom: function(){ return 0; }\n  };\n  function terrainAt(x, y){\n    var fn = TERRAIN[currentField] || TERRAIN.custom;\n    return fn(x, y);\n  }\n  \/\/ AB-by-coordinates anchor \u2014 when the user pins an AB direction via\n  \/\/ GPS coordinates, store the projected canvas coords of point A here.\n  \/\/ The parallel-pass grid snaps so one pass goes EXACTLY through the\n  \/\/ anchor (= the operator's recorded A point) instead of just borrowing\n  \/\/ the bearing. Cleared on slider input, Auto reset, field switch, and\n  \/\/ new imports. {x, y} canvas coords or null.\n  var userAxisAnchor = null;\n  \/\/ Auto-blocks per-block angle overrides. Key = block centroid\n  \/\/ rounded to 5 m (stable across recomputes as long as the polygon\n  \/\/ doesn't change). Value = angle in degrees [0, 180). When set,\n  \/\/ generateLinesAll uses this instead of the block's PCA axis.\n  var BLOCK_AXIS_OVERRIDES = {};\n  function fieldStats(b){\n    var minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;\n    for(var i=0; i<b.length; i++){\n      if(b[i].x < minX) minX = b[i].x;\n      if(b[i].x > maxX) maxX = b[i].x;\n      if(b[i].y < minY) minY = b[i].y;\n      if(b[i].y > maxY) maxY = b[i].y;\n    }\n    var s = 0;\n    for(var j=0; j<b.length; j++){\n      var k = (j + 1) % b.length;\n      s += b[j].x * b[k].y - b[k].x * b[j].y;\n    }\n    return { minX: minX, maxX: maxX, minY: minY, maxY: maxY, area: Math.abs(s) * 0.5 };\n  }\n  \/\/ Recommend an approach by computing real metrics on the current field for\n  \/\/ each candidate, then scoring against five operator priorities:\n  \/\/   1. coverage % \u2014 bad coverage means uncovered acres\n  \/\/   2. overlap % \u2014 double-covered ground = wasted diesel + compaction\n  \/\/   3. crossings \u2014 paths that visibly cross are wheel-track repeats\n  \/\/   4. turnaround count \u2014 fewer turns = less time + fuel\n  \/\/   5. fuel L \u2014 total drive \u00d7 consumption\n  \/\/\n  \/\/ Score = coverage \u2212 0.4 \u00d7 turns \u2212 1.5 \u00d7 fuel \u2212 0.8 \u00d7 overlap \u2212 0.6 \u00d7 crossings.\n  \/\/ Coverage \/ overlap are 0\u2013100; turns 10\u201330; fuel 5\u201320 L; crossings 0\u201330\n  \/\/ typically; weights bring them into similar ranges so coverage still\n  \/\/ dominates while the new compaction signals can break ties between\n  \/\/ otherwise-equal approaches. (Used as the \"why\" string \u2014 the actual\n  \/\/ recommendByMetrics tier rules below apply hard filters on crossings.)\n  \/\/\n  \/\/ Falls back to the shape-based heuristic only when metric eval fails\n  \/\/ (e.g., zero-pass result). recommendByMetrics is called from recompute\n  \/\/ AFTER the four Compare All layouts have already been computed, so we\n  \/\/ don't pay the cost of generating layouts twice.\n  \/\/ Default field axis = direction of the LONGEST boundary edge (or the\n  \/\/ dominant edge-direction cluster for curved boundaries where no single\n  \/\/ edge is uniquely longest). Operators expect AB Straight passes to run\n  \/\/ PARALLEL to the longest field edge \u2014 that's the standard ag practice\n  \/\/ for rectangular and trapezoidal fields, and the natural axis even on\n  \/\/ L-shapes and irregular hex fields.\n  \/\/\n  \/\/ Algorithm:\n  \/\/   1. Bin every edge's direction (mod \u03c0) into 1\u00b0 buckets, weighted by\n  \/\/      edge length. The bucket with the most total edge-length defines\n  \/\/      the dominant axis direction.\n  \/\/   2. Use a weighted centroid inside the peak bin (over a \u00b13\u00b0 window)\n  \/\/      for sub-degree accuracy.\n  \/\/   3. Require the peak bucket to carry \u2265 25 % of all edge length \u2014\n  \/\/      otherwise no single direction dominates (Pivot's 28-sided circle,\n  \/\/      a curved-headland import, etc.) and we fall back to PCA so the\n  \/\/      axis still tracks the polygon's overall elongation.\n  function fieldAxis(b){\n    var n = b.length;\n    if(n < 3) return { ux: 1, uy: 0 };\n    var BINS = 180;  \/\/ 1\u00b0 resolution across [0, \u03c0)\n    var hist = new Float64Array(BINS);\n    var sumCos = new Float64Array(BINS);\n    var sumSin = new Float64Array(BINS);\n    var totalLen = 0;\n    for(var i=0; i<n; i++){\n      var j = (i + 1) % n;\n      var ex = b[j].x - b[i].x, ey = b[j].y - b[i].y;\n      var elen = Math.sqrt(ex * ex + ey * ey);\n      if(elen < 1e-6) continue;\n      \/\/ Direction mod \u03c0 so opposite edges (parallel sides) accumulate\n      \/\/ in the same bucket.\n      var dang = Math.atan2(ey, ex);\n      if(dang < 0) dang += Math.PI;\n      if(dang >= Math.PI) dang -= Math.PI;\n      var bIdx = Math.floor(dang \/ Math.PI * BINS);\n      if(bIdx >= BINS) bIdx = BINS - 1;\n      if(bIdx < 0) bIdx = 0;\n      hist[bIdx] += elen;\n      \/\/ Double-angle trick so circular mean inside the bin doesn't wrap\n      \/\/ (mod-\u03c0 directions live on a circle of period \u03c0, equivalent to\n      \/\/ mod-2\u03c0 after doubling).\n      var d2 = dang * 2;\n      sumCos[bIdx] += Math.cos(d2) * elen;\n      sumSin[bIdx] += Math.sin(d2) * elen;\n      totalLen += elen;\n    }\n    if(totalLen < 1e-6){\n      return { ux: 1, uy: 0 };\n    }\n    \/\/ Find peak bin (with \u00b13 neighbours folded in \u2014 narrow histogram\n    \/\/ bins can split a single direction across two buckets when the field\n    \/\/ axis lands near a degree boundary).\n    var peakIdx = 0;\n    var peakVal = 0;\n    for(var k=0; k<BINS; k++){\n      var s = hist[k];\n      for(var d=1; d<=3; d++){\n        s += hist[(k + d) % BINS];\n        s += hist[(k - d + BINS) % BINS];\n      }\n      if(s > peakVal){ peakVal = s; peakIdx = k; }\n    }\n    if(peakVal > totalLen * 0.25){\n      \/\/ Weighted centroid across the \u00b13 window for sub-bin accuracy.\n      var aggCos = 0, aggSin = 0;\n      for(var dd=-3; dd<=3; dd++){\n        var bb = (peakIdx + dd + BINS) % BINS;\n        aggCos += sumCos[bb];\n        aggSin += sumSin[bb];\n      }\n      var meanAng2 = Math.atan2(aggSin, aggCos);  \/\/ in [-\u03c0, \u03c0]\n      if(meanAng2 < 0) meanAng2 += 2 * Math.PI;\n      var ang = meanAng2 * 0.5;  \/\/ undo the 2\u00d7 doubling\n      return { ux: Math.cos(ang), uy: Math.sin(ang) };\n    }\n    \/\/ Fall back to PCA on vertex positions when no edge direction\n    \/\/ dominates (Pivot, curved upload boundary, etc.).\n    var mxs = 0, mys = 0;\n    for(var pi=0; pi<n; pi++){ mxs += b[pi].x; mys += b[pi].y; }\n    mxs \/= n; mys \/= n;\n    var sxx = 0, syy = 0, sxy = 0;\n    for(var pj=0; pj<n; pj++){\n      var dvx = b[pj].x - mxs, dvy = b[pj].y - mys;\n      sxx += dvx * dvx; syy += dvy * dvy; sxy += dvx * dvy;\n    }\n    var ang0 = 0.5 * Math.atan2(2 * sxy, sxx - syy);\n    return { ux: Math.cos(ang0), uy: Math.sin(ang0) };\n  }\n  \/\/ Concave-safe line clipper. Finds all edge intersections, sorts by t,\n  \/\/ and pairs them up: each consecutive (inside-entry, inside-exit) pair\n  \/\/ becomes one returned segment. Works for arbitrary simple polygons\n  \/\/ (convex or concave). Returns array of segments [{x0,y0,x1,y1}, ...]\n  \/\/ or null when the line misses the polygon entirely.\n  \/\/ Liang-Barsky was the old implementation; it ASSUMED convex and produced\n  \/\/ wrong clips on the L-shape interior (concave at the inner-corner).\n  function clipLineToBoundarySegments(x0, y0, x1, y1, b){\n    var dx = x1 - x0, dy = y1 - y0;\n    var nb = b.length;\n    var ts = [];\n    for(var i=0; i<nb; i++){\n      var j = (i + 1) % nb;\n      var ax = b[i].x, ay = b[i].y;\n      var ex = b[j].x - ax, ey = b[j].y - ay;\n      \/\/ Parametric line \u00d7 parametric edge: x0+t\u00b7dx = ax+u\u00b7ex etc.\n      \/\/ Solve for t, u in [0,1]. denom = dx\u00b7(-ey) \u2212 dy\u00b7(-ex) = dy\u00b7ex \u2212 dx\u00b7ey\n      var denom = dy * ex - dx * ey;\n      if(Math.abs(denom) < 1e-9) continue;\n      var u = (dx * (ay - y0) - dy * (ax - x0)) \/ denom;\n      var t = (ex * (ay - y0) - ey * (ax - x0)) \/ denom;\n      if(u < -1e-9 ? true : u > 1 + 1e-9) continue;\n      if(t < -1e-9 ? true : t > 1 + 1e-9) continue;\n      ts.push(t < 0 ? 0 : (t > 1 ? 1 : t));\n    }\n    if(ts.length < 2) return null;\n    ts.sort(function(a, b){ return a - b; });\n    \/\/ De-dup near-equal t's (line passing exactly through a vertex hits 2 edges)\n    var clean = [ts[0]];\n    for(var k=1; k<ts.length; k++){\n      if(ts[k] - clean[clean.length-1] > 1e-6) clean.push(ts[k]);\n    }\n    \/\/ Pair consecutive t's, but only keep pairs whose midpoint is INSIDE the\n    \/\/ polygon (filters away \"outside gaps\" between concave segments).\n    var segs = [];\n    for(var p=0; p+1<clean.length; p+=2){\n      var t0 = clean[p], t1 = clean[p+1];\n      var midT = (t0 + t1) * 0.5;\n      var mx = x0 + dx * midT, my = y0 + dy * midT;\n      if(!pointInPoly(mx, my, b)) continue;\n      if(t1 - t0 < 1e-6) continue;\n      segs.push({ x0: x0 + dx * t0, y0: y0 + dy * t0, x1: x0 + dx * t1, y1: y0 + dy * t1 });\n    }\n    return segs.length > 0 ? segs : null;\n  }\n  \/\/ Offset a closed polygon inward by distM. Returns array of {x,y} or null when collapse.\n  \/\/ Decompose a simple polygon into convex sub-polygons by splitting at\n  \/\/ each concave (reflex) vertex. For an L-shape (one concave vertex),\n  \/\/ returns two rectangles. For a polygon already convex (Rect, Hex,\n  \/\/ Pivot, most curved uploads), returns the polygon unchanged.\n  \/\/\n  \/\/ Why we need this: Boundary Follow on a concave polygon produces one\n  \/\/ chain of inward-offset rings that pinches at the concave corner \u2014\n  \/\/ the long arm gets fewer rings before the polygon collapses,\n  \/\/ dropping coverage in the middle. Per-convex-part rings tile each\n  \/\/ arm independently and recover full coverage. Per rule \u00a76 the\n  \/\/ operator transports across the split (no double-coverage).\n  \/\/\n  \/\/ Strategy: find the first concave vertex, cast a ray along one of\n  \/\/ its adjoining edges (extended past the vertex into the interior),\n  \/\/ find the first polygon edge the ray hits, split the polygon along\n  \/\/ that line, recurse on each piece. Defaults to returning the input\n  \/\/ polygon unchanged if no valid split is found.\n  \/\/ Decompose a (possibly multi-armed) concave polygon into \"blocks\"\n  \/\/ separated by the strongest reflex bends. Unlike decomposeIntoConvex\n  \/\/ (which insists each cut produce two fully convex halves and gives\n  \/\/ up otherwise), this version cuts at the strongest reflex it can\n  \/\/ find, then RECURSES on each half. Result: a 3-arm polygon yields 3\n  \/\/ blocks; a star yields N blocks; a slightly-concave shape stays as\n  \/\/ 1 block. Used by the 'auto-blocks' approach for per-block axis\n  \/\/ alignment. minSubArea (m\u00b2) prevents pathological micro-cuts on\n  \/\/ dense real-world boundaries.\n  function simplifyPolygon(poly, mergeDistM, perpTolM){\n    if(!poly ? true : poly.length < 4) return poly;\n    var md = mergeDistM > 0 ? mergeDistM : 0;\n    var pt = perpTolM > 0 ? perpTolM : 0;\n    \/\/ Pass 1: merge close adjacent vertices.\n    var merged = [];\n    for(var i=0; i<poly.length; i++){\n      var p = poly[i];\n      if(merged.length > 0){\n        var q = merged[merged.length - 1];\n        var dxM = p.x - q.x, dyM = p.y - q.y;\n        if(dxM * dxM + dyM * dyM < md * md) continue;\n      }\n      merged.push({ x: p.x, y: p.y });\n    }\n    \/\/ Wrap-around: drop last if it's close to first.\n    if(merged.length >= 4){\n      var fM = merged[0];\n      var lM = merged[merged.length - 1];\n      var dxW = fM.x - lM.x, dyW = fM.y - lM.y;\n      if(dxW * dxW + dyW * dyW < md * md) merged.pop();\n    }\n    if(merged.length < 4) return poly;\n    \/\/ Pass 2: strip near-collinear vertices (perpendicular distance\n    \/\/ from the chord prev\u2192next below tolerance). Single forward pass\n    \/\/ is sufficient \u2014 collinear chains collapse one vertex at a time.\n    var clean = [];\n    var nM = merged.length;\n    for(var k=0; k<nM; k++){\n      var prev = clean.length > 0 ? clean[clean.length - 1] : merged[(k - 1 + nM) % nM];\n      var curr = merged[k];\n      var next = merged[(k + 1) % nM];\n      var ax = next.x - prev.x, ay = next.y - prev.y;\n      var aLen = Math.sqrt(ax * ax + ay * ay);\n      if(aLen < 1e-9){ clean.push(curr); continue; }\n      var px = curr.x - prev.x, py = curr.y - prev.y;\n      var perp = Math.abs(px * ay - py * ax) \/ aLen;\n      var dotAB = px * ax + py * ay;\n      \/\/ Only drop the vertex when it sits BETWEEN prev and next on the\n      \/\/ same line (dotAB > 0 and projection < aLen\u00b2) \u2014 guards against\n      \/\/ dropping a sharp spike whose perpendicular happens to be small.\n      if(perp < pt ? dotAB > 0 ? dotAB < aLen * aLen : false : false) continue;\n      clean.push(curr);\n    }\n    \/\/ Runaway-collapse guard: the running-chord collinear pass keeps\n    \/\/ growing the prev\u2192next chord as it drops vertices, so on a\n    \/\/ slightly-curved boundary (lv closed loop 287 verts, 27.8 ha)\n    \/\/ each next vertex looks \"collinear\" with the now-long chord \u2192\n    \/\/ 287 \u2192 7 vertices, area 27.8 ha \u2192 0.9 ha, boundary shrinks\n    \/\/ catastrophically and misaligns from the lines that share the\n    \/\/ same geographic source (reported 2026-06-03). If the simplify\n    \/\/ dropped more than \u00bd the input, reject and return the merge-only\n    \/\/ result (still safe \u2014 duplicate-vertex merge alone can't collapse\n    \/\/ shape).\n    if(clean.length < merged.length * 0.5) return merged;\n    return clean.length >= 4 ? clean : merged;\n  }\n  function pointInPoly(x, y, poly){\n    var inside = false;\n    var n = poly.length;\n    for(var i=0, j=n-1; i<n; j=i++){\n      var xi = poly[i].x, yi = poly[i].y;\n      var xj = poly[j].x, yj = poly[j].y;\n      var intersect = ((yi > y) !== (yj > y)) ? (x < (xj - xi) * (y - yi) \/ (yj - yi + 1e-12) + xi) : false;\n      if(intersect) inside = !inside;\n    }\n    return inside;\n  }\n  \/\/ Closest point on the polygon's boundary to (x, y). Returns {x, y}\n  \/\/ \u2014 the projection onto whichever edge is nearest. Used by the\n  \/\/ single-polygon decomposition transport routing to snap any sample\n  \/\/ that falls outside the original polygon back to its nearest edge,\n  \/\/ so the visible transport line hugs the field boundary instead of\n  \/\/ cutting through a concave notch.\n  function pointInHoles(x, y, holes){\n    if(!holes ? true : holes.length === 0) return false;\n    for(var h=0; h<holes.length; h++){\n      if(pointInPoly(x, y, holes[h])) return true;\n    }\n    return false;\n  }\n  \/\/ Inside the field PART = inside its outer ring AND outside every hole.\n  function pointInFieldPart(x, y, part){\n    if(!pointInPoly(x, y, part)) return false;\n    if(part.holes ? part.holes.length > 0 : false){\n      if(pointInHoles(x, y, part.holes)) return false;\n    }\n    return true;\n  }\n  \/\/ Subtract hole interiors from a list of clipped segments. A pass that\n  \/\/ crosses a hole splits into the before-hole + after-hole sub-segments;\n  \/\/ the operator lifts + drives around the obstacle. `holes` are the\n  \/\/ already-buffered rings (expanded by the obstacle clearance).\n  function assignHoles(rings){\n    if(!rings ? true : rings.length <= 1) return rings || [];\n    function ringArea(r){\n      var s = 0;\n      for(var i=0; i<r.length; i++){ var j=(i+1)%r.length; s += r[i].x*r[j].y - r[j].x*r[i].y; }\n      return Math.abs(s) * 0.5;\n    }\n    function ringCentroid(r){\n      var x=0, y=0; for(var i=0;i<r.length;i++){ x+=r[i].x; y+=r[i].y; } return { x: x\/r.length, y: y\/r.length };\n    }\n    var meta = rings.map(function(r){ return { ring: r, area: ringArea(r), cen: ringCentroid(r), container: -1 }; });\n    \/\/ For each ring, find the smallest-area OTHER ring that contains its centroid.\n    for(var i=0; i<meta.length; i++){\n      var best = -1, bestArea = Infinity;\n      for(var j=0; j<meta.length; j++){\n        if(i === j) continue;\n        if(meta[j].area <= meta[i].area) continue;  \/\/ container must be bigger\n        if(!pointInPoly(meta[i].cen.x, meta[i].cen.y, meta[j].ring)) continue;\n        if(meta[j].area < bestArea){ bestArea = meta[j].area; best = j; }\n      }\n      meta[i].container = best;\n    }\n    var outParts = [];\n    for(var k=0; k<meta.length; k++){\n      if(meta[k].container >= 0) continue;  \/\/ it's a hole \u2014 skipped here\n      var part = meta[k].ring;\n      var holes = [];\n      for(var m=0; m<meta.length; m++){\n        if(meta[m].container === k) holes.push(meta[m].ring);\n      }\n      if(holes.length > 0) part.holes = holes;\n      outParts.push(part);\n    }\n    return outParts.length > 0 ? outParts : rings;\n  }\n  \/\/ Build a candidate turn arc connecting (x1,y1) to (x2,y2) given style + radius\n  \/\/ + perpendicular direction (sign = +1 or -1 = which side to bulge).\n  \/\/ Returns array of {x, y} sample points (length ~24+) or null when geometry fails.\n  function rasterCoverage(layout, wM){\n    \/\/ Multi-part: union the cells in ANY part. Single-part: just the boundary.\n    var parts = layout.boundaryParts ? (layout.boundaryParts.length ? layout.boundaryParts : [layout.boundary ? layout.boundary : BOUNDARY]) : [layout.boundary ? layout.boundary : BOUNDARY];\n    \/\/ Combined bbox across all parts (so cellgrid covers them all).\n    var minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;\n    for(var pp=0; pp<parts.length; pp++){\n      for(var pv=0; pv<parts[pp].length; pv++){\n        var px0 = parts[pp][pv].x, py0 = parts[pp][pv].y;\n        if(px0 < minX) minX = px0; if(px0 > maxX) maxX = px0;\n        if(py0 < minY) minY = py0; if(py0 > maxY) maxY = py0;\n      }\n    }\n    var stats = { minX: minX, maxX: maxX, minY: minY, maxY: maxY };\n    var cellSize = Math.max(2, wM \/ 3);\n    var nx = Math.max(8, Math.ceil((stats.maxX - stats.minX) \/ cellSize));\n    var ny = Math.max(8, Math.ceil((stats.maxY - stats.minY) \/ cellSize));\n    \/\/ First pass: mark cells inside ANY field part\n    var inField = new Uint8Array(nx * ny);\n    var fieldCellCount = 0;\n    for(var iy=0; iy<ny; iy++){\n      for(var ix=0; ix<nx; ix++){\n        var cx = stats.minX + (ix + 0.5) * cellSize;\n        var cy = stats.minY + (iy + 0.5) * cellSize;\n        var inAny = false;\n        for(var ip=0; ip<parts.length; ip++){\n          if(pointInPoly(cx, cy, parts[ip])){\n            \/\/ Obstacle\/hole cells are NOT part of the workable field \u2014\n            \/\/ they must not count toward field area (else \"missed area\"\n            \/\/ is inflated) nor toward coverage. Exclude them here.\n            if(parts[ip].holes ? pointInHoles(cx, cy, parts[ip].holes) : false) break;\n            inAny = true; break;\n          }\n        }\n        if(inAny){\n          inField[iy * nx + ix] = 1;\n          fieldCellCount++;\n        }\n      }\n    }\n    \/\/ Collect all swath segments \u2014 body-pass segments and ring segments.\n    var segs = [];\n    if(layout.passes){\n      for(var pi=0; pi<layout.passes.length; pi++){\n        var pa = layout.passes[pi];\n        if(pa.samples){\n          for(var si=1; si<pa.samples.length; si++){\n            segs.push({ x0: pa.samples[si-1].x, y0: pa.samples[si-1].y, x1: pa.samples[si].x, y1: pa.samples[si].y });\n          }\n        } else if(pa.x0 !== undefined){\n          segs.push({ x0: pa.x0, y0: pa.y0, x1: pa.x1, y1: pa.y1 });\n        }\n      }\n    }\n    if(segs.length === 0){\n      return { coveredM2: 0, fieldM2: fieldCellCount * cellSize * cellSize, pct: 0, overlapM2: 0, overlapPct: 0 };\n    }\n    \/\/ Inverted-loop coverage count \u2014 for each segment, iterate ONLY the\n    \/\/ grid cells inside its bounding box (expanded by halfW). The old\n    \/\/ implementation was for-each-cell \u00d7 for-each-segment, which is\n    \/\/ O(cells \u00d7 segs) and breaks down on big AB-Curve fields with\n    \/\/ dense sample arrays (a 460 ha field with 18 m swath \u21d2 ~100k cells\n    \/\/ \u00d7 ~35k sample segs = 3.5 B distance checks \u21d2 1.1 s per recompute).\n    \/\/ The inverted loop only touches cells near each segment (a 10 m\n    \/\/ segment with half-width 9 m covers ~4-9 cells) \u21d2 < 50 ms even\n    \/\/ for the worst-case AB-Curve case. Same hit-counting semantics.\n    var half = wM * 0.5;\n    var half2 = half * half;\n    var hitsArr = new Uint8Array(nx * ny);\n    for(var sg=0; sg<segs.length; sg++){\n      var s = segs[sg];\n      var sdx = s.x1 - s.x0, sdy = s.y1 - s.y0;\n      var L2 = sdx*sdx + sdy*sdy;\n      if(L2 < 1e-9) continue;\n      var sBx0 = Math.min(s.x0, s.x1) - half;\n      var sBy0 = Math.min(s.y0, s.y1) - half;\n      var sBx1 = Math.max(s.x0, s.x1) + half;\n      var sBy1 = Math.max(s.y0, s.y1) + half;\n      var ixA = Math.max(0,    Math.floor((sBx0 - stats.minX) \/ cellSize));\n      var ixB = Math.min(nx-1, Math.floor((sBx1 - stats.minX) \/ cellSize));\n      var iyA = Math.max(0,    Math.floor((sBy0 - stats.minY) \/ cellSize));\n      var iyB = Math.min(ny-1, Math.floor((sBy1 - stats.minY) \/ cellSize));\n      for(var iyS=iyA; iyS<=iyB; iyS++){\n        for(var ixS=ixA; ixS<=ixB; ixS++){\n          var idxS = iyS * nx + ixS;\n          if(!inField[idxS]) continue;\n          if(hitsArr[idxS] >= 2) continue;  \/\/ already overlap, no need to recount\n          var cxS = stats.minX + (ixS + 0.5) * cellSize;\n          var cyS = stats.minY + (iyS + 0.5) * cellSize;\n          var t = ((cxS - s.x0) * sdx + (cyS - s.y0) * sdy) \/ L2;\n          if(t < 0 ? true : t > 1) continue;\n          var pxD = s.x0 + sdx * t - cxS;\n          var pyD = s.y0 + sdy * t - cyS;\n          if(pxD * pxD + pyD * pyD <= half2){\n            if(hitsArr[idxS] < 2) hitsArr[idxS]++;\n          }\n        }\n      }\n    }\n    var hits = hitsArr;\n    var coveredCount = 0, overlapCount = 0;\n    var uncoveredIdx = [];\n    for(var hi=0; hi<hits.length; hi++){\n      if(!inField[hi]) continue;\n      if(hits[hi] >= 1) coveredCount++;\n      else uncoveredIdx.push(hi);\n      if(hits[hi] >= 2) overlapCount++;\n    }\n    \/\/ FRACTIONAL EDGE ACCOUNTING (2026-06-11 \u2014 \"missed area too big\").\n    \/\/ The whole-cell test marks a cell covered only when its CENTRE is\n    \/\/ within wM\/2 of a centreline, so every boundary cell whose centre\n    \/\/ sits just past a swath edge counts as 100 % missed even when 70 %\n    \/\/ of the cell is painted. The bias \u2248 perimeter \u00d7 cellSize\/2 \u2014 on a\n    \/\/ 6 km-perimeter client field that overstated \"missed area\" by\n    \/\/ ~3.5 ha while the actual plan was near-complete (the published\n    \/\/ competitor benchmarks use polygon-boolean accounting and report\n    \/\/ the same plans at < 1 % missed). Fix: only for the UNCOVERED\n    \/\/ cells (a few hundred at most), subsample 3 \u00d7 3 and credit the\n    \/\/ fraction of subpoints that fall inside a swath. Bounded cost:\n    \/\/ uncovered cells \u00d7 9 subpoints \u00d7 segments.\n    var fracCovered = 0;\n    if(uncoveredIdx.length > 0 ? segs.length > 0 : false){\n      var subOff = [ -cellSize \/ 3, 0, cellSize \/ 3 ];\n      \/\/ Pre-extract seg bboxes once; per cell build a SHORTLIST of segs\n      \/\/ whose padded bbox touches the cell, then run the 9 subpoints\n      \/\/ against just that shortlist (usually 0-3 segs). The naive\n      \/\/ per-subpoint full scan cost 9 \u00d7 segs per cell and pushed a\n      \/\/ 200 ha Boundary-Follow recompute to ~2.6 s.\n      var segBB = new Float64Array(segs.length * 4);\n      for(var sb=0; sb<segs.length; sb++){\n        var sbs = segs[sb];\n        segBB[sb*4]   = Math.min(sbs.x0, sbs.x1) - half;\n        segBB[sb*4+1] = Math.max(sbs.x0, sbs.x1) + half;\n        segBB[sb*4+2] = Math.min(sbs.y0, sbs.y1) - half;\n        segBB[sb*4+3] = Math.max(sbs.y0, sbs.y1) + half;\n      }\n      var padC = cellSize * 0.5;\n      var shortlist = [];\n      for(var ui=0; ui<uncoveredIdx.length; ui++){\n        var uIdx = uncoveredIdx[ui];\n        var uIy = Math.floor(uIdx \/ nx);\n        var uIx = uIdx - uIy * nx;\n        var ucx = stats.minX + (uIx + 0.5) * cellSize;\n        var ucy = stats.minY + (uIy + 0.5) * cellSize;\n        shortlist.length = 0;\n        for(var sb2=0; sb2<segs.length; sb2++){\n          if(ucx < segBB[sb2*4] - padC ? true : ucx > segBB[sb2*4+1] + padC) continue;\n          if(ucy < segBB[sb2*4+2] - padC ? true : ucy > segBB[sb2*4+3] + padC) continue;\n          shortlist.push(segs[sb2]);\n        }\n        if(shortlist.length === 0) continue;\n        var subCov = 0;\n        for(var syi=0; syi<3; syi++){\n          for(var sxi=0; sxi<3; sxi++){\n            var spx = ucx + subOff[sxi];\n            var spy = ucy + subOff[syi];\n            var hit = false;\n            for(var usg=0; usg<shortlist.length; usg++){\n              var us = shortlist[usg];\n              var udx = us.x1 - us.x0, udy = us.y1 - us.y0;\n              var uL2 = udx*udx + udy*udy;\n              if(uL2 < 1e-9) continue;\n              var ut = ((spx - us.x0) * udx + (spy - us.y0) * udy) \/ uL2;\n              if(ut < 0) ut = 0; else if(ut > 1) ut = 1;\n              var upx = us.x0 + udx * ut - spx;\n              var upy = us.y0 + udy * ut - spy;\n              if(upx*upx + upy*upy <= half2){ hit = true; break; }\n            }\n            if(hit) subCov++;\n          }\n        }\n        fracCovered += subCov \/ 9;\n      }\n    }\n    coveredCount += fracCovered;\n    var cellArea = cellSize * cellSize;\n    var fieldM2 = fieldCellCount * cellArea;\n    var coveredM2 = coveredCount * cellArea;\n    var overlapM2 = overlapCount * cellArea;\n    \/\/ overlapPct = overlap area \/ WORKED area (cells with \u2265 1 hit). This\n    \/\/ matches operator intuition: \"how much of what I worked, did I work\n    \/\/ twice\". Reported as a separate field so the consumer can pick.\n    var overlapOfWorked = coveredCount > 0 ? (overlapCount \/ coveredCount * 100) : 0;\n    return {\n      coveredM2: coveredM2,\n      fieldM2: fieldM2,\n      pct: fieldM2 > 0 ? (coveredM2 \/ fieldM2 * 100) : 0,\n      overlapM2: overlapM2,\n      overlapPct: overlapOfWorked\n    };\n  }\n  \/\/ Count how many times the drive path crosses itself between segments\n  \/\/ belonging to DIFFERENT path elements (pass i vs pass j, ring vs turn\n  \/\/ arc, turn arc vs another turn arc). Within-element self-touches (a\n  \/\/ curved AB pass's adjacent vertices, a turn-arc polyline's own\n  \/\/ sampling) are excluded by tagging each segment with its parent\n  \/\/ element id and skipping same-parent pairs.\n  \/\/\n  \/\/ Rule \u00a76 \u2014 surfaced in analytics + scoring. Body-pass to ring junctions\n  \/\/ at headland endpoints are unavoidable (one per pass end) and treated\n  \/\/ as the baseline; the metric becomes interesting when it exceeds the\n  \/\/ unavoidable junction count (2 \u00d7 nPasses).\n  \/\/\n  \/\/ O(n\u00b2) over segments with bbox-prune. n \u2248 200-800 on typical fields \u2192\n  \/\/ ~40k-640k comparisons. Cheap relative to rasterCoverage.\n  function countDriveCrossings(layout){\n    function segCrossParam(ax, ay, bx, by, cx, cy, dx, dy){\n      var s1x = bx - ax, s1y = by - ay;\n      var s2x = dx - cx, s2y = dy - cy;\n      var denom = (-s2x * s1y + s1x * s2y);\n      if(Math.abs(denom) < 1e-9) return null;\n      var s = (-s1y * (ax - cx) + s1x * (ay - cy)) \/ denom;\n      var t = ( s2x * (ay - cy) - s2y * (ax - cx)) \/ denom;\n      if(s > 0.02 ? s < 0.98 ? t > 0.02 ? t < 0.98 : false : false : false){\n        return { s: s, t: t };\n      }\n      return null;\n    }\n    var segs = [];\n    function pushPolyline(coords, parentId, kind){\n      for(var i=1; i<coords.length; i++){\n        var a = coords[i-1], b = coords[i];\n        var dx = b.x - a.x, dy = b.y - a.y;\n        if(dx * dx + dy * dy < 0.25) continue;\n        segs.push({ x0: a.x, y0: a.y, x1: b.x, y1: b.y, pid: parentId, kind: kind });\n      }\n    }\n    if(layout.passes){\n      for(var pi=0; pi<layout.passes.length; pi++){\n        var p = layout.passes[pi];\n        var pkind = p.kind === 'headland-ring' ? 'ring' : 'body';\n        if(p.samples) pushPolyline(p.samples, 'p' + pi, pkind);\n        else if(p.x0 !== undefined) segs.push({ x0: p.x0, y0: p.y0, x1: p.x1, y1: p.y1, pid: 'p' + pi, kind: pkind });\n      }\n    }\n    if(layout.turnArcs){\n      for(var ti=0; ti<layout.turnArcs.length; ti++){\n        var ta = layout.turnArcs[ti];\n        if(ta.kind === 'transport') continue;\n        if(ta.coords) pushPolyline(ta.coords, 't' + ti, 'arc');\n      }\n    }\n    var n = segs.length;\n    if(n < 4) return 0;\n    var cnt = 0;\n    for(var i=0; i<n; i++){\n      var si = segs[i];\n      var ix0 = si.x0 < si.x1 ? si.x0 : si.x1;\n      var ix1 = si.x0 < si.x1 ? si.x1 : si.x0;\n      var iy0 = si.y0 < si.y1 ? si.y0 : si.y1;\n      var iy1 = si.y0 < si.y1 ? si.y1 : si.y0;\n      for(var j=i+1; j<n; j++){\n        var sj = segs[j];\n        if(sj.pid === si.pid) continue;\n        if(sj.x0 > ix1 ? sj.x1 > ix1 : false) continue;\n        if(sj.x0 < ix0 ? sj.x1 < ix0 : false) continue;\n        if(sj.y0 > iy1 ? sj.y1 > iy1 : false) continue;\n        if(sj.y0 < iy0 ? sj.y1 < iy0 : false) continue;\n        var hit = segCrossParam(si.x0, si.y0, si.x1, si.y1, sj.x0, sj.y0, sj.x1, sj.y1);\n        if(!hit) continue;\n        \/\/ Rule \u00a76 \u2014 arc\/ring overlap is INHERENT to the headland strip:\n        \/\/ the U-turn arc by design swings through the headland zone\n        \/\/ where the ring's wheel-track centerline lives. Counting every\n        \/\/ arc-ring intersection inflates the metric (a single U-turn\n        \/\/ can cross the ring 2\u00d7 \u2014 entering and exiting the strip).\n        \/\/ Exempt ALL arc-vs-ring intersections (operator drives both\n        \/\/ the ring AND the arc during turnarounds \u2014 same painted strip).\n        var arcIsI = si.kind === 'arc';\n        var arcIsJ = sj.kind === 'arc';\n        var ringIsI = si.kind === 'ring';\n        var ringIsJ = sj.kind === 'ring';\n        if((arcIsI ? ringIsJ : false) ? true : (arcIsJ ? ringIsI : false)) continue;\n        \/\/ Body-pass endpoints land ON the ring by construction (pass\n        \/\/ start\/end clipped to inner offset). Exempt body-vs-ring near\n        \/\/ the body pass's endpoints \u2014 the pass tangentially touches the\n        \/\/ ring at start + end, that's not a \"real\" crossing.\n        var bodyIsI = si.kind === 'body';\n        var bodyIsJ = sj.kind === 'body';\n        if((bodyIsI ? ringIsJ : false) ? true : (bodyIsJ ? ringIsI : false)){\n          var bodyParam = bodyIsI ? hit.s : hit.t;\n          if(bodyParam < 0.05 ? true : bodyParam > 0.95) continue;\n        }\n        cnt++;\n      }\n    }\n    return cnt;\n  }\n  function computeMetrics(layout, wM, dieselPrice, consumption){\n    \/\/ Pass (tramline) length only \u2014 what the operator actually works on.\n    var passLen = 0;\n    var nPasses = layout.passes.length;\n    for(var i=0; i<nPasses; i++){\n      var p = layout.passes[i];\n      if(p.samples){\n        for(var s=1; s<p.samples.length; s++){\n          var dx = p.samples[s].x - p.samples[s-1].x;\n          var dy = p.samples[s].y - p.samples[s-1].y;\n          passLen += Math.sqrt(dx*dx + dy*dy);\n        }\n      } else {\n        var dxL = p.x1 - p.x0, dyL = p.y1 - p.y0;\n        passLen += Math.sqrt(dxL*dxL + dyL*dyL);\n      }\n    }\n    \/\/ Turn-arc length \u2014 what the operator drives BETWEEN passes (header up).\n    \/\/ Transport hops between disconnected parts are NOT turnarounds and don't\n    \/\/ burn implement-engaged fuel \u2014 count them in the separate transportLen\n    \/\/ bucket so the turn count + turn-fuel stay honest.\n    var turnLen = 0;\n    var transportLen = 0;\n    var nTurns = 0;\n    if(layout.turnArcs){\n      for(var ti=0; ti<layout.turnArcs.length; ti++){\n        var tArc = layout.turnArcs[ti];\n        var arc = tArc.coords;\n        var arcLen = 0;\n        for(var as=1; as<arc.length; as++){\n          var dxA = arc[as].x - arc[as-1].x, dyA = arc[as].y - arc[as-1].y;\n          arcLen += Math.sqrt(dxA*dxA + dyA*dyA);\n        }\n        \/\/ Implement-up movement (traverse \/ ring-route \/ ring-jump \/\n        \/\/ explicit transport) is NOT a U-turn \u2014 the operator drives the\n        \/\/ headland or worked ring with the implement lifted. Counting\n        \/\/ these in turnLen inflated the metric by 20\u00d7 on L-shape AB\n        \/\/ Straight (reported 2026-06-04 \u2014 \"too big distance, too many\n        \/\/ boundary repeats\"): the 800 m long traverses across the\n        \/\/ concave notch were summed into turn distance, ballooning\n        \/\/ total drive to 36 km vs 13 km for the decomposed approaches.\n        var isLifted = tArc.kind === 'transport'\n          ? true\n          : (tArc.kind === 'traverse'\n              ? true\n              : (tArc.kind === 'ring-route'\n                  ? true\n                  : tArc.kind === 'ring-jump'));\n        if(isLifted){\n          transportLen += arcLen;\n        } else {\n          turnLen += arcLen;\n          nTurns++;\n        }\n      }\n    }\n    \/\/ Total drive path = tramline + turn arcs.\n    \/\/ Pass fuel scales linearly with consumption \u00d7 length, PLUS a slope\n    \/\/ penalty: driving along a contour line is normal-fuel; driving across\n    \/\/ contours (uphill\/downhill) penalises ~10\u00d7 the absolute grade\u00b2. Real\n    \/\/ tractors burn 30\u201340 % more diesel at a sustained 5 % up-grade.\n    \/\/ Turnaround fuel is 1.25\u00d7 per metre because the engine works harder\n    \/\/ under a tight pivot (hydraulics raising\/lowering the implement,\n    \/\/ gear changes) \u2014 typical headland-turn penalty.\n    var totalLen = passLen + turnLen + transportLen;\n    var slopePenalty = computeSlopePenalty(layout);\n    var passFuelBase = (passLen \/ 1000) * consumption;\n    var passFuel = passFuelBase * (1 + slopePenalty);\n    var turnFuel = (turnLen \/ 1000) * consumption * 1.25;\n    \/\/ Transport hops drive the road (no implement load) \u2014 flat fuel burn.\n    var transportFuel = (transportLen \/ 1000) * consumption;\n    var totalFuel = passFuel + turnFuel + transportFuel;\n    var cost = totalFuel * dieselPrice;\n    \/\/ Coverage \u2014 RASTER-BASED for accuracy. Sum-of-strip math overstated\n    \/\/ (double-counted overlaps, ignored corner gaps and headland-ring under-fill\n    \/\/ at sharp corners) \u2192 clamped to 100% even when the real coverage was <95%.\n    \/\/ The grid approach computes the true union area: for each cell whose\n    \/\/ centre lies inside the field boundary, check if it's also inside any\n    \/\/ swath strip. coveragePct = covered \/ total. cellSize defaults to wM\/3\n    \/\/ so cells are smaller than the swath, capturing under-coverage near\n    \/\/ corners and uncovered pockets in concave fields.\n    \/\/ Multi-part: sum part areas; single-part: just the polygon.\n    var statsParts = layout.boundaryParts ? (layout.boundaryParts.length ? layout.boundaryParts : [layout.boundary ? layout.boundary : BOUNDARY]) : [layout.boundary ? layout.boundary : BOUNDARY];\n    var fieldM2 = 0;\n    for(var sp=0; sp<statsParts.length; sp++){\n      fieldM2 += fieldStats(statsParts[sp]).area;\n      \/\/ Subtract obstacle\/hole area so field area + missed-area math\n      \/\/ reflect the actually-workable ground, not the gross polygon.\n      if(statsParts[sp].holes ? statsParts[sp].holes.length > 0 : false){\n        for(var fh=0; fh<statsParts[sp].holes.length; fh++){\n          fieldM2 -= polyArea(statsParts[sp].holes[fh]);\n        }\n      }\n    }\n    if(fieldM2 < 0) fieldM2 = 0;\n    var coverageRes = rasterCoverage(layout, wM);\n    var coveredM2 = coverageRes.coveredM2;\n    var coveragePct = coverageRes.pct;\n    var overlapPct = coverageRes.overlapPct || 0;\n    var overlapM2 = coverageRes.overlapM2 || 0;\n    \/\/ Path-crossings count \u2014 how many times the drive path crosses itself,\n    \/\/ measured between segments that belong to different passes \/ turn arcs.\n    \/\/ A high count signals the planner is sending the machine over ground\n    \/\/ it already drove (wheel-track repeats) AND visually marks a sub-\n    \/\/ optimal plan. Rule \u00a76 \u2014 used in scoring + surfaced in analytics.\n    var crossings = countDriveCrossings(layout);\n    return {\n      passes: nPasses,\n      passLengthM: passLen,\n      turnLengthM: turnLen,\n      totalDriveM: totalLen,\n      turns: nTurns,\n      fuelL: totalFuel,\n      passFuelL: passFuel,\n      turnFuelL: turnFuel,\n      costUSD: cost,\n      coveragePct: coveragePct,\n      coveredM2: coveredM2,\n      fieldM2: fieldM2,\n      overlapM2: overlapM2,\n      overlapPct: overlapPct,\n      crossings: crossings,\n      slopePenalty: slopePenalty,\n      avgGradePct: layout.avgGradePct || 0,\n      \/\/ Auto-blocks sub-block count (>1 when the planner decomposed a\n      \/\/ single concave polygon into multiple convex sub-blocks). Used\n      \/\/ by recommendByMetrics to fire the multi-part bonus for fields\n      \/\/ like the redesigned L-shape where the decomposition is the\n      \/\/ whole reason to pick auto-blocks.\n      blockCount: layout.blocks ? layout.blocks.length : 1\n    };\n  }\n  \/\/ Compute average grade-along-pass-direction for body passes, returns\n  \/\/ a fuel multiplier penalty (0 = flat, 0.3 = 30% extra fuel). Also stores\n  \/\/ the average absolute grade % on the layout for display. Penalty model:\n  \/\/   penalty = k_lin \u00d7 mean(|grade|) + k_sq \u00d7 mean(|grade|\u00b2)\n  \/\/ The combined model captures BOTH the constant headwind of climbing\n  \/\/ (linear in grade \u2014 gravity force \u00d7 cos for the implement + roll\n  \/\/ resistance bump on soft soil) AND the quadratic punishment when grade\n  \/\/ gets steep (engine inefficiency at low gear, hydraulic load spikes).\n  \/\/ Calibration against published tractor-power-curve trial data:\n  \/\/   5% sustained grade \u2192 ~25 % extra fuel\n  \/\/   10% sustained grade \u2192 ~55 % extra fuel\n  \/\/   15% sustained grade \u2192 ~90 % extra fuel\n  \/\/ Cross-contour passes accumulate the full grade; on-contour passes\n  \/\/ (Contour-follow \/ Adaptive) accumulate ~0. This is what makes contour\n  \/\/ planning visibly cheaper on hilly fields \u2014 the AB-straight plan's\n  \/\/ pass-fuel can swing 40\u201380 % higher than Contour-follow's on a 10 %\n  \/\/ ridge, which dwarfs the extra km that AB saves.\n  function computeSlopePenalty(layout){\n    if(!layout.passes ? true : layout.passes.length === 0) return 0;\n    var totalLen = 0;\n    var weightedGrade2 = 0;\n    var weightedAbsGrade = 0;\n    var sampleStep = 4;  \/\/ metres\n    function accumulateSeg(x0, y0, x1, y1){\n      var sdx = x1 - x0, sdy = y1 - y0;\n      var L = Math.sqrt(sdx*sdx + sdy*sdy);\n      if(L < 0.5) return;\n      var nS = Math.max(2, Math.ceil(L \/ sampleStep));\n      var z0 = terrainAt(x0, y0);\n      for(var k=1; k<=nS; k++){\n        var t = k \/ nS;\n        var mx = x0 + sdx * t, my = y0 + sdy * t;\n        var z1 = terrainAt(mx, my);\n        var dh = L \/ nS;\n        var grade = dh > 1e-6 ? (z1 - z0) \/ dh : 0;\n        weightedGrade2 += grade * grade * dh;\n        weightedAbsGrade += Math.abs(grade) * dh;\n        totalLen += dh;\n        z0 = z1;\n      }\n    }\n    for(var pi=0; pi<layout.passes.length; pi++){\n      var pa = layout.passes[pi];\n      if(pa.kind === 'headland-ring') continue;  \/\/ ring follows boundary, ignore slope cost\n      if(pa.samples){\n        for(var si=1; si<pa.samples.length; si++){\n          accumulateSeg(pa.samples[si-1].x, pa.samples[si-1].y, pa.samples[si].x, pa.samples[si].y);\n        }\n      } else if(pa.x0 !== undefined){\n        accumulateSeg(pa.x0, pa.y0, pa.x1, pa.y1);\n      }\n    }\n    if(totalLen < 1) return 0;\n    var meanGrade2 = weightedGrade2 \/ totalLen;\n    var meanAbsGrade = weightedAbsGrade \/ totalLen;\n    layout.avgGradePct = meanAbsGrade * 100;  \/\/ for display\n    \/\/ Combined linear + quadratic model \u2014 calibrated AGGRESSIVELY so the\n    \/\/ Contour-follow value-prop is unambiguous on the demo terrains. Real\n    \/\/ tractor field data shows pass-fuel rises sharply with sustained\n    \/\/ grade in low gear (gravity + hydraulic load + drivetrain\n    \/\/ inefficiency at higher torque). Targets:\n    \/\/  @  3% grade \u2192  ~25% penalty\n    \/\/  @  5% grade \u2192  ~45% penalty\n    \/\/  @  8% grade \u2192  ~75% penalty\n    \/\/  @ 10% grade \u2192  ~100% penalty\n    \/\/  @ 15% grade \u2192 cap (160% penalty)\n    var penalty = 7 * meanAbsGrade + 30 * meanGrade2;\n    if(penalty > 1.6) penalty = 1.6;\n    return penalty;\n  }\n  \/\/ Auto-detect the implement width from uploaded body AB lines. Each\n  \/\/ short polyline (2-10 vertices) is one body pass; the gap between\n  \/\/ adjacent parallel passes IS the implement width. Method:\n  \/\/   1. Filter to short polylines (skip headland traces, which would\n  \/\/      bias the dominant-axis fit).\n  \/\/   2. Sum oriented line directions \u2192 dominant axis ux\/uy.\n  \/\/   3. Project each line's midpoint onto the perpendicular axis (px\/py).\n  \/\/   4. Sort projections, take consecutive gaps.\n  \/\/   5. Drop near-zero gaps (back-and-forth duplicates of the same pass).\n  \/\/   6. Take the median of the SMALLEST third of remaining gaps \u2014 these\n  \/\/      are the true adjacent-pass spacings (any larger gaps are skipped\n  \/\/      passes or obstacles).\n  \/\/ Returns metres, or null when too few lines to estimate.\n  \/\/ Filter lines down to actually-driven tracks. AB-reference markers\n  \/\/ (line.driven === false) get excluded from width \/ ring detection\n  \/\/ so they don't contaminate the inferred implement parameters.\n  function drivenLinesOnly(lines){\n    if(!lines) return [];\n    var out = [];\n    for(var di=0; di<lines.length; di++){\n      if(lines[di].driven === false) continue;\n      out.push(lines[di]);\n    }\n    return out;\n  }\n  \/\/ Is a polyline a closed loop (first vertex coincides with last)?\n  \/\/ Real-world export files (lv Eichwiese sample) often include a\n  \/\/ closed-loop headland-ring REFERENCE as a separate feature on top\n  \/\/ of the actual body passes. Treating that loop as a driven pass\n  \/\/ paints swath around the entire boundary even though the\n  \/\/ operator's plan only covered body \u2014 reported 2026-06-03.\n  \/\/\n  \/\/ RULE: closed loops in imported line files are NEVER actually\n  \/\/ driven body passes. They're either (a) the boundary itself, or\n  \/\/ (b) a headland-ring reference. The body-pass clusterer +\n  \/\/ playback + Compare All \"Your current lines\" must all skip them.\n  \/\/ `countInnerHeadlandRings` is the one allowed exception \u2014 its\n  \/\/ entire job is to count closed loops to seed the headland slider.\n  \/\/ Default closed-loop tolerance comes from the host via this injectable\n  \/\/ provider (the UI wires it to the implement-width input below); headless\n  \/\/ callers without a provider get one swath of slop at the 18 m default.\n  \/\/ Keeps the core algorithm DOM-free.\n  var gapTolDefaultProvider = null;\n  function isImportedLineLoop(line, gapTolM){\n    if(!line ? true : line.length < 3) return false;\n    var tol = (typeof gapTolM === 'number' ? gapTolM : null);\n    if(tol === null){\n      \/\/ No explicit tolerance \u2192 derive from the host-provided implement\n      \/\/ width (closed enough = within one swath of slop).\n      tol = gapTolDefaultProvider ? gapTolDefaultProvider() : 18;\n    }\n    var dx = line[0].x - line[line.length - 1].x;\n    var dy = line[0].y - line[line.length - 1].y;\n    return (dx * dx + dy * dy) < tol * tol;\n  }\n  \/\/ Filter to lines that should be treated as driven body passes:\n  \/\/ driven===true (or unset) AND not a closed loop. Used by playback,\n  \/\/ Compare All \"Your current lines\", and any other consumer that\n  \/\/ wants the operator's ACTUAL plan, not reference geometry.\n  function bodyPassesOnly(lines, gapTolM){\n    var driven = drivenLinesOnly(lines);\n    var out = [];\n    for(var bi=0; bi<driven.length; bi++){\n      if(isImportedLineLoop(driven[bi], gapTolM)) continue;\n      out.push(driven[bi]);\n    }\n    return out;\n  }\n  \/\/ Does a closed polygon (last vertex != first) have any pair of non-\n  \/\/ adjacent edges that cross? Used to validate the concave-hull output\n  \/\/ before installing it as BOUNDARY.\n  function polygonSelfIntersects(poly){\n    var n = poly.length;\n    if(n < 4) return false;\n    function segCross(ax, ay, bx, by, cx, cy, dx, dy){\n      var s1x = bx - ax, s1y = by - ay;\n      var s2x = dx - cx, s2y = dy - cy;\n      var denom = (-s2x * s1y + s1x * s2y);\n      if(Math.abs(denom) < 1e-9) return false;\n      var s = (-s1y * (ax - cx) + s1x * (ay - cy)) \/ denom;\n      var t = ( s2x * (ay - cy) - s2y * (ax - cx)) \/ denom;\n      return s > 1e-6 ? s < 1 - 1e-6 ? t > 1e-6 ? t < 1 - 1e-6 : false : false : false;\n    }\n    for(var i=0; i<n; i++){\n      var i2 = (i + 1) % n;\n      for(var j=i+2; j<n; j++){\n        var j2 = (j + 1) % n;\n        if(i === j2) continue;  \/\/ wraparound adjacent edge\n        var a = poly[i], b = poly[i2], c = poly[j], d = poly[j2];\n        if(segCross(a.x, a.y, b.x, b.y, c.x, c.y, d.x, d.y)) return true;\n      }\n    }\n    return false;\n  }\n  \/\/ Repair a self-intersecting polygon by clipping out the self-crossing\n  \/\/ loops. On boundaries with sharp acute tips (lv Landgut upper-left V)\n  \/\/ the inward-offset polygon folds back on itself near the apex \u2014\n  \/\/ offsetPolygonInward emits a \"ring\" with a small loop where the two\n  \/\/ offset edges from the V's arms cross. The bulk of the polygon is\n  \/\/ valid; we want to keep that and excise the loop.\n  \/\/\n  \/\/ Algorithm: find the first pair of non-adjacent edges that cross.\n  \/\/ Splitting at the crossing point produces two sub-polygons; keep the\n  \/\/ one with larger absolute area (the OUTER bulk, since the offending\n  \/\/ loop is the smaller side near the tip). Repeat up to MAX iterations\n  \/\/ (so a pathological input can't cause unbounded work). Reported\n  \/\/ 2026-06-04 \u2014 lv Landgut headland ring + strip were both skipped\n  \/\/ because the FIRST ring already self-intersected at the tip.\n  function expandLoopOutward(loop, distM){\n    var n = loop ? loop.length : 0;\n    if(n < 3 ? true : distM <= 0) return loop;\n    var s = 0;\n    for(var i=0; i<n; i++){ var j=(i+1)%n; s += loop[i].x*loop[j].y - loop[j].x*loop[i].y; }\n    var sign = s > 0 ? 1 : -1;\n    var out = [];\n    for(var v=0; v<n; v++){\n      var p = loop[(v - 1 + n) % n];\n      var c = loop[v];\n      var nx = loop[(v + 1) % n];\n      var e1x = c.x - p.x, e1y = c.y - p.y;\n      var e2x = nx.x - c.x, e2y = nx.y - c.y;\n      var l1 = Math.sqrt(e1x*e1x + e1y*e1y); if(l1 < 1e-6) l1 = 1;\n      var l2 = Math.sqrt(e2x*e2x + e2y*e2y); if(l2 < 1e-6) l2 = 1;\n      var n1x = (e1y \/ l1) * sign, n1y = -(e1x \/ l1) * sign;\n      var n2x = (e2y \/ l2) * sign, n2y = -(e2x \/ l2) * sign;\n      var avgX = (n1x + n2x) * 0.5;\n      var avgY = (n1y + n2y) * 0.5;\n      var al = Math.sqrt(avgX*avgX + avgY*avgY);\n      if(al < 1e-6){ out.push({ x: c.x, y: c.y }); continue; }\n      \/\/ Miter cap: scale by 1\/cos(half-angle) so the expansion at sharp\n      \/\/ corners actually sits distM out along the bisector.\n      var cosFull = n1x*n2x + n1y*n2y;\n      var cosHalf = Math.sqrt(Math.max(0, (1 + cosFull) * 0.5));\n      if(cosHalf < 0.5) cosHalf = 0.5;\n      out.push({\n        x: c.x + (avgX \/ al) * (distM \/ cosHalf),\n        y: c.y + (avgY \/ al) * (distM \/ cosHalf)\n      });\n    }\n    return out;\n  }\n  function concaveHullFromLines(lines, allPts, expandOutwardM){\n    if(!lines ? true : lines.length < 2) return convexHull(allPts);\n    \/\/ ----- Strategy 1: outermost closed headland ring -----\n    \/\/ A polyline is a candidate boundary ring when it has \u2265 20 vertices,\n    \/\/ its first vertex coincides with its last (closed loop), and its\n    \/\/ bbox covers \u2265 half of allPts' bbox (so we don't pick up a small\n    \/\/ inner obstacle ring as the field outline).\n    var allMnx = Infinity, allMxx = -Infinity, allMny = Infinity, allMxy = -Infinity;\n    for(var ap=0; ap<allPts.length; ap++){\n      var pp = allPts[ap];\n      if(pp.x < allMnx) allMnx = pp.x;\n      if(pp.x > allMxx) allMxx = pp.x;\n      if(pp.y < allMny) allMny = pp.y;\n      if(pp.y > allMxy) allMxy = pp.y;\n    }\n    var allArea = (allMxx - allMnx) * (allMxy - allMny);\n    var bestRing = null, bestRingArea = 0;\n    for(var li0=0; li0<lines.length; li0++){\n      var ln0 = lines[li0];\n      if(ln0.length < 20) continue;\n      var f = ln0[0], l = ln0[ln0.length - 1];\n      var closeGap2 = (f.x - l.x) * (f.x - l.x) + (f.y - l.y) * (f.y - l.y);\n      if(closeGap2 > 25) continue;  \/\/ > 5 m gap = not closed\n      var mnx = Infinity, mxx = -Infinity, mny = Infinity, mxy = -Infinity;\n      for(var rv=0; rv<ln0.length; rv++){\n        var rp = ln0[rv];\n        if(rp.x < mnx) mnx = rp.x;\n        if(rp.x > mxx) mxx = rp.x;\n        if(rp.y < mny) mny = rp.y;\n        if(rp.y > mxy) mxy = rp.y;\n      }\n      var ringArea = (mxx - mnx) * (mxy - mny);\n      \/\/ Must cover at least half the global bbox to be the field outline,\n      \/\/ not an internal obstacle ring.\n      if(allArea > 0 ? ringArea < allArea * 0.5 : false) continue;\n      if(ringArea > bestRingArea){ bestRing = ln0; bestRingArea = ringArea; }\n    }\n    if(bestRing){\n      \/\/ Simplify to ~3 m vertex spacing. A 700\u00d71300 m headland ring sampled\n      \/\/ at 1 Hz can carry 500-800 vertices; offsetPolygonInward + pointInPoly\n      \/\/ + rasterCoverage run repeatedly per recompute and slow noticeably\n      \/\/ past ~150 vertices. 3 m is well below typical implement width so\n      \/\/ no useful field-shape detail is lost.\n      var minStep2 = 9;  \/\/ 3 m\n      \/\/ CLONE each vertex \u2014 the picked ring is one of `projLines`'s\n      \/\/ polylines, and the caller (applyCombinedGeoImport) later shifts\n      \/\/ BOTH projBoundary AND projLines independently. Pushing\n      \/\/ bestRing[bv] by reference caused the same vertex object to land\n      \/\/ in both arrays \u2192 got shifted TWICE \u2192 the loop in UPLOADED_LINES\n      \/\/ drifted (cx, cy) away from the boundary it was derived from,\n      \/\/ making the dashed-blue overlay look projection-broken\n      \/\/ (reported 2026-06-03 on lv Eichwiese).\n      var simp = [{ x: bestRing[0].x, y: bestRing[0].y }];\n      for(var bv=1; bv<bestRing.length; bv++){\n        var pr = simp[simp.length - 1];\n        var ddx = bestRing[bv].x - pr.x, ddy = bestRing[bv].y - pr.y;\n        if(ddx * ddx + ddy * ddy > minStep2) simp.push({ x: bestRing[bv].x, y: bestRing[bv].y });\n      }\n      \/\/ Strip the closing duplicate (so the polygon is open at the seam\n      \/\/ like every other BOUNDARY in the tool).\n      if(simp.length > 3){\n        var sf = simp[0], sl = simp[simp.length - 1];\n        if((sf.x - sl.x) * (sf.x - sl.x) + (sf.y - sl.y) * (sf.y - sl.y) < minStep2) simp.pop();\n      }\n      if(simp.length >= 4 ? !polygonSelfIntersects(simp) : false){\n        \/\/ Expand the loop outward by wM\/2 (or whatever caller passed)\n        \/\/ so the boundary sits at the true field edge, not at the\n        \/\/ wheel-track centerline. Skip if the result self-intersects\n        \/\/ (degenerate input \u2014 fall back to the un-expanded loop).\n        if(expandOutwardM > 0){\n          var expanded = expandLoopOutward(simp, expandOutwardM);\n          if(expanded ? expanded.length >= 4 : false){\n            if(!polygonSelfIntersects(expanded)) return expanded;\n          }\n        }\n        return simp;\n      }\n      \/\/ Fall through to Strategy 2 if the headland trace was self-\n      \/\/ intersecting (very unusual \u2014 would mean the operator's drive\n      \/\/ crossed itself).\n    }\n    \/\/ ----- Strategy 2: parallel-AB-line endpoint polygon -----\n    \/\/ Determine the dominant pass direction by summing oriented line\n    \/\/ vectors. Each line gets its direction flipped if needed so all\n    \/\/ contribute the same sign. Skip high-vertex polylines (those are\n    \/\/ headland traces, not body passes) so the axis fit isn't biased.\n    var sumDx = 0, sumDy = 0;\n    for(var li=0; li<lines.length; li++){\n      var ln = lines[li];\n      if(ln.length < 2 ? true : ln.length > 10) continue;\n      var dx0 = ln[ln.length - 1].x - ln[0].x;\n      var dy0 = ln[ln.length - 1].y - ln[0].y;\n      var len0 = Math.sqrt(dx0 * dx0 + dy0 * dy0);\n      if(len0 < 1) continue;\n      if(sumDx * dx0 + sumDy * dy0 < 0){ dx0 = -dx0; dy0 = -dy0; }\n      sumDx += dx0; sumDy += dy0;\n    }\n    var axLen = Math.sqrt(sumDx * sumDx + sumDy * sumDy);\n    if(axLen < 1) return convexHull(allPts);\n    var ux = sumDx \/ axLen, uy = sumDy \/ axLen;\n    var px = -uy, py = ux;  \/\/ perpendicular (right-hand)\n    \/\/ Build start + end point arrays with consistent orientation.\n    \/\/ Only short polylines (\u2264 10 vertices) feed the endpoint polygon \u2014\n    \/\/ multi-vertex headland traces would contribute irrelevant points.\n    var startPts = [], endPts = [];\n    for(var li2=0; li2<lines.length; li2++){\n      var ln2 = lines[li2];\n      if(ln2.length < 2 ? true : ln2.length > 10) continue;\n      var s = ln2[0], e = ln2[ln2.length - 1];\n      var alongS = s.x * ux + s.y * uy;\n      var alongE = e.x * ux + e.y * uy;\n      \/\/ Whichever endpoint projects FARTHER along the dominant axis is the\n      \/\/ \"end\"; the other is the \"start\". Same convention for every line so\n      \/\/ starts cluster on one headland, ends on the opposite headland.\n      if(alongS < alongE){ startPts.push(s); endPts.push(e); }\n      else { startPts.push(e); endPts.push(s); }\n    }\n    if(startPts.length < 2) return convexHull(allPts);\n    \/\/ Order both arrays by perpendicular projection so adjacent points in\n    \/\/ each array are adjacent passes on the field.\n    function perpKey(p){ return p.x * px + p.y * py; }\n    startPts.sort(function(a, b){ return perpKey(a) - perpKey(b); });\n    endPts.sort(function(a, b){ return perpKey(a) - perpKey(b); });\n    var boundary = startPts.slice();\n    for(var ei=endPts.length-1; ei>=0; ei--) boundary.push(endPts[ei]);\n    if(boundary.length < 4) return convexHull(allPts);\n    \/\/ Validate: no self-intersection AND non-trivial area.\n    var area = 0;\n    for(var bi=0; bi<boundary.length; bi++){\n      var bj = (bi + 1) % boundary.length;\n      area += boundary[bi].x * boundary[bj].y - boundary[bj].x * boundary[bi].y;\n    }\n    if(Math.abs(area) < 100) return convexHull(allPts);\n    if(polygonSelfIntersects(boundary)) return convexHull(allPts);\n    return boundary;\n  }\n  \/\/ 2D convex hull (Andrew's monotone chain) \u2014 used as a fallback when\n  \/\/ the concave-hull algorithm can't produce a valid polygon, and to\n  \/\/ bootstrap any general-purpose endpoint-hulling.\n  function convexHull(pts){\n    if(pts.length < 3) return pts.slice();\n    var sorted = pts.slice().sort(function(a, b){\n      return a.x !== b.x ? a.x - b.x : a.y - b.y;\n    });\n    function chCross(o, a, b){\n      return (a.x - o.x) * (b.y - o.y) - (a.y - o.y) * (b.x - o.x);\n    }\n    var n = sorted.length;\n    var hull = [];\n    \/\/ Lower hull\n    for(var i=0; i<n; i++){\n      while(hull.length >= 2 ? chCross(hull[hull.length-2], hull[hull.length-1], sorted[i]) <= 0 : false){\n        hull.pop();\n      }\n      hull.push(sorted[i]);\n    }\n    var lowerCount = hull.length + 1;\n    \/\/ Upper hull\n    for(var j=n-2; j>=0; j--){\n      while(hull.length >= lowerCount ? chCross(hull[hull.length-2], hull[hull.length-1], sorted[j]) <= 0 : false){\n        hull.pop();\n      }\n      hull.push(sorted[j]);\n    }\n    hull.pop();  \/\/ last point = first point of lower hull\n    return hull;\n  }\n  function extractAllPolygonsGeoJSON(obj){\n    var polys = [];\n    function pushFeature(f, defaultName){\n      if(!f) return;\n      var props = f.properties || {};\n      var name = props.name || props.Name || props.NAME || props.field || props.Field || props.FIELD || props.id || defaultName;\n      var geom = f.geometry ? f.geometry : (f.type === 'Polygon' ? f : (f.type === 'MultiPolygon' ? f : null));\n      if(!geom) return;\n      \/\/ Emit EVERY ring \u2014 outer AND inner (holes). Previously only\n      \/\/ coordinates[0] (the outer ring) was kept, silently discarding\n      \/\/ holes, which killed obstacle support on real imports (reported\n      \/\/ 2026-06-10 on field 41.22\u2026 \u2014 9 parts + 6 holes lost their holes).\n      \/\/ The inner rings come back through projection \u2192 assignHoles, which\n      \/\/ re-parents each contained ring as a hole of its smallest\n      \/\/ container (rule \u00a76 obstacle_boundary).\n      if(geom.type === 'Polygon'){\n        for(var ri=0; ri<geom.coordinates.length; ri++){\n          polys.push({ name: ri === 0 ? name : (name || 'poly') + ' ring ' + ri, coords: geom.coordinates[ri] });\n        }\n      } else if(geom.type === 'MultiPolygon'){\n        for(var i=0; i<geom.coordinates.length; i++){\n          for(var rj=0; rj<geom.coordinates[i].length; rj++){\n            polys.push({ name: (name || 'poly') + ' #' + (i + 1) + (rj === 0 ? '' : ' ring ' + rj), coords: geom.coordinates[i][rj] });\n          }\n        }\n      }\n    }\n    if(obj.type === 'FeatureCollection' ? obj.features : false){\n      for(var k=0; k<obj.features.length; k++) pushFeature(obj.features[k], 'feature ' + (k + 1));\n    } else if(obj.type === 'Feature'){\n      pushFeature(obj, 'feature 1');\n    } else if(obj.type === 'Polygon' ? true : obj.type === 'MultiPolygon'){\n      pushFeature({ geometry: obj, properties: {} }, 'polygon');\n    }\n    \/\/ Sort by approximate area (cross-product sum of lng\/lat coords \u2014 not\n    \/\/ m\u00b2 but monotonic, so largest polygon still wins).\n    polys.forEach(function(p){\n      var a = 0;\n      for(var i=0; i<p.coords.length-1; i++){\n        a += p.coords[i][0] * p.coords[i+1][1] - p.coords[i+1][0] * p.coords[i][1];\n      }\n      p._areaScore = Math.abs(a);\n    });\n    polys.sort(function(a, b){ return b._areaScore - a._areaScore; });\n    return polys;\n  }\n  \/\/ Signed-area helper \u2014 used to pick the largest part as the \"main\" boundary\n  \/\/ for backward-compat call sites that still expect a single polygon.\n  function polyArea(p){\n    var s = 0;\n    for(var i=0; i<p.length; i++){\n      var j = (i + 1) % p.length;\n      s += p[i].x * p[j].y - p[j].x * p[i].y;\n    }\n    return Math.abs(s) * 0.5;\n  }\n  \/* \u2500\u2500 Node\/CommonJS export \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n     Inert when this file is inlined into the browser IIFE (module is\n     undefined there). This object is the contract the planner service\n     (backend\/guidance-planner) and the test gate build on. *\/\n  if (typeof module !== 'undefined' ? !!module.exports : false) {\n    module.exports = {\n      \/\/ sample data (ships for the test gate \u2014 rect\/hex\/lshape\/pivot\/weg)\n      FIELDS: FIELDS,\n      TERRAIN: TERRAIN,\n      \/\/ algorithm surface\n      terrainAt: terrainAt,\n      blockCentroidKey: blockCentroidKey,\n      fieldStats: fieldStats,\n      scoreApproach: scoreApproach,\n      recommendByMetrics: recommendByMetrics,\n      fieldAxis: fieldAxis,\n      clipLineToBoundarySegments: clipLineToBoundarySegments,\n      decomposeIntoBlocks: decomposeIntoBlocks,\n      decomposeIntoConvex: decomposeIntoConvex,\n      simplifyPolygon: simplifyPolygon,\n      offsetPolygonInward: offsetPolygonInward,\n      roundPolygonCorners: roundPolygonCorners,\n      pointInPoly: pointInPoly,\n      nearestPointOnPolygonBoundary: nearestPointOnPolygonBoundary,\n      arcInsidePoly: arcInsidePoly,\n      arcAvoidsInterior: arcAvoidsInterior,\n      pointInHoles: pointInHoles,\n      pointInFieldPart: pointInFieldPart,\n      subtractHolesFromSegs: subtractHolesFromSegs,\n      dirHoleBuffer: dirHoleBuffer,\n      splitRingAroundHoles: splitRingAroundHoles,\n      arcAvoidsHoles: arcAvoidsHoles,\n      assignHoles: assignHoles,\n      buildArc: buildArc,\n      outwardSign: outwardSign,\n      buildTurnArc: buildTurnArc,\n      buildDrivable: buildDrivable,\n      orientSeg: orientSeg,\n      traverseInsideBoundary: traverseInsideBoundary,\n      walkRingBetween: walkRingBetween,\n      routeAroundHole: routeAroundHole,\n      routeAlongRing: routeAlongRing,\n      buildSerpentine: buildSerpentine,\n      buildFillPasses: buildFillPasses,\n      generateLines: generateLines,\n      generateLinesAll: generateLinesAll,\n      rasterCoverage: rasterCoverage,\n      countDriveCrossings: countDriveCrossings,\n      computeMetrics: computeMetrics,\n      computeSlopePenalty: computeSlopePenalty,\n      drivenLinesOnly: drivenLinesOnly,\n      isImportedLineLoop: isImportedLineLoop,\n      bodyPassesOnly: bodyPassesOnly,\n      polygonSelfIntersects: polygonSelfIntersects,\n      repairSelfIntersections: repairSelfIntersections,\n      expandLoopOutward: expandLoopOutward,\n      concaveHullFromLines: concaveHullFromLines,\n      convexHull: convexHull,\n      extractAllPolygonsGeoJSON: extractAllPolygonsGeoJSON,\n      polyArea: polyArea,\n      \/\/ state accessors for headless hosts (mirror the UI's direct writes)\n      setCurrentField: function(fk){ currentField = fk; },\n      getCurrentField: function(){ return currentField; },\n      setBoundary: function(b){ BOUNDARY = b; },\n      getBoundary: function(){ return BOUNDARY; },\n      setBoundaryParts: function(p){ BOUNDARY_PARTS = p; },\n      getBoundaryParts: function(){ return BOUNDARY_PARTS; },\n      setUserAxisAnchor: function(a){ userAxisAnchor = a; },\n      setBlockAxisOverrides: function(m){ BLOCK_AXIS_OVERRIDES = m ? m : {}; },\n      getBlockAxisOverrides: function(){ return BLOCK_AXIS_OVERRIDES; },\n      setGapTolDefaultProvider: function(fn){ gapTolDefaultProvider = fn; }\n    };\n  }\n  \/* \u2550\u2550\u2550 GPL-PLANNER-CORE END \u2550\u2550\u2550 *\/\n  \/* \u2500\u2500 Remote plan provider (Phase 3 of the backend migration) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n     Local compute stays the default (window.__GP_PLANNER_LOCAL !== false).\n     Remote mode: fetch a short-lived token, POST the projected boundary\n     (relative metres only \u2014 no lat\/lng, no names, no uploaded files),\n     cache the response, and recompute() consumes the cache through the\n     planLayout seam below. On ANY remote failure we fall back to local\n     for the session, so the tool never breaks for the user. *\/\n  var PLANNER_API_BASE = (typeof window !== 'undefined' ? window.__GP_PLANNER_API : null) ?\n    window.__GP_PLANNER_API : 'https:\/\/zhkinjmqpmtofhqp7m5vh5ypzy0ezuxn.lambda-url.us-west-2.on.aws';\n  \/\/ Test hook \u2014 the vm harness has no URL flag to flip post-eval, so the\n  \/\/ gate honours this var first. Set ONLY via __plannerTestForceRemote\n  \/\/ (exported through tests-harness.js); never touched by product code.\n  var plannerForceRemote = false;\n  function __plannerTestForceRemote(v){ plannerForceRemote = v; }\n  function plannerRemoteEnabled(){\n    if(plannerForceRemote === true) return true;\n    if(typeof window === 'undefined') return false;\n    \/\/ Remote is the DEFAULT (Phase 4). Escape hatches for rollback \/ dev:\n    \/\/ window.__GP_PLANNER_LOCAL === true, or ?planner=local in the URL, force\n    \/\/ local compute for the session. The core stays in the embed, so local is\n    \/\/ always available \u2014 any remote failure also degrades to it silently.\n    if(window.__GP_PLANNER_LOCAL === true) return false;\n    var q = (window.location ? window.location.search : '') || '';\n    if(q.indexOf('planner=local') >= 0) return false;\n    return true;\n  }\n  var plannerToken = null;\n  var plannerTokenExp = 0;\n  var plannerBroken = false;   \/\/ one failure \u2192 local for the rest of the session\n  \/\/ True only on a remote-only stripped embed (Phase 4) where the local core\n  \/\/ was removed from the build: planLayout then has nothing to fall back to,\n  \/\/ so recompute surfaces the retry banner instead of throwing. Always false\n  \/\/ in the full source build and in today's embed (strip flag off).\n  var plannerCoreMissing = false;\n  \/\/ Guards the self-healing fetch in recompute() so a stripped embed does not\n  \/\/ fire overlapping plan fetches while one is already in flight.\n  var plannerFetchInFlight = false;\n  \/\/ Monotonic fetch id. Rapid slider drags can put two plan fetches in\n  \/\/ flight at once (debounce fires, fetch A starts, user drags again,\n  \/\/ fetch B starts). Responses can complete out of order \u2014 an OLDER\n  \/\/ response must never overwrite the cache a NEWER one filled, and a\n  \/\/ superseded request's failure must not flip plannerBroken while the\n  \/\/ newer request is still live.\n  var plannerReqSeq = 0;\n  \/\/ Watchdog ceiling per plan fetch (token mint + POST + retry included).\n  \/\/ A hung fetch on a flaky mobile network must not leave the loader\n  \/\/ overlay up forever \u2014 after this, degrade to local for the session.\n  var PLANNER_FETCH_TIMEOUT_MS = 12000;\n  function plannerFetchToken(){\n    return fetch(PLANNER_API_BASE + '\/v1\/plan-token')\n      .then(function(r){ if(!r.ok) throw new Error('token http ' + r.status); return r.json(); })\n      .then(function(j){ plannerToken = j.token; plannerTokenExp = j.exp; return j.token; });\n  }\n  var REMOTE_PLAN_CACHE = { key: null, byApproach: {}, compare: null, sweep: null, recommendation: null };\n  \/\/ Geometry digest: part count + total vertex count (holes INCLUDED) +\n  \/\/ a cheap deterministic coordinate checksum over partsPayload() output.\n  \/\/ Folding the geometry into the cache key means a hole edit or a\n  \/\/ same-part-count reimport can never be served a stale plan (rule \u00a76 \u2014\n  \/\/ the machine must never drive a plan computed for a different boundary).\n  function boundaryDigest(){\n    var pp = partsPayload();\n    var n = 0, s = 0;\n    for(var i=0; i<pp.length; i++){\n      var o = pp[i].outer;\n      for(var j=0; j<o.length; j++){ n++; s += o[j][0] * 31 + o[j][1] * 7; }\n      var hs = pp[i].holes;\n      if(hs){\n        for(var k=0; k<hs.length; k++){\n          for(var m=0; m<hs[k].length; m++){ n++; s += hs[k][m][0] * 31 + hs[k][m][1] * 7; }\n        }\n      }\n    }\n    return pp.length + ':' + n + ':' + s.toFixed(3);\n  }\n  \/\/ Key is built from the CALLER'S parameter values \u2014 never getInputs() or\n  \/\/ any other DOM read \u2014 so planLayout's hit test is self-consistent with\n  \/\/ the values plannerFetchPlans actually sent to the API. The approach is\n  \/\/ deliberately NOT part of the key: one fetch fills byApproach for every\n  \/\/ approach at these inputs, and the baseline + selected lookups differ\n  \/\/ only by approach.\n  function remoteCacheKey(wM, headlandM, turnStyle, turnRadiusM, outsideBufferM, axis){\n    return [wM, headlandM, turnStyle, turnRadiusM, outsideBufferM,\n      axis ? axis.ux.toFixed(9) : 'a', axis ? axis.uy.toFixed(9) : 'a',\n      boundaryDigest()].join('|');\n  }\n  \/\/ Freshness check shared by every remote-cache consumer (compare rows,\n  \/\/ sweep rows). True only when remote mode is live AND the cache was\n  \/\/ filled for exactly these inputs + axis + geometry.\n  function remoteCacheFresh(inp, axis){\n    if(plannerRemoteEnabled() ? plannerBroken : true) return false;\n    return REMOTE_PLAN_CACHE.key === remoteCacheKey(inp.wM, inp.headlandM, inp.turnStyle, inp.turnR, inp.turnBuf, axis);\n  }\n  function partsPayload(){\n    var out = [];\n    for(var i=0; i<BOUNDARY_PARTS.length; i++){\n      var ring = BOUNDARY_PARTS[i];\n      var part = { outer: ring.map(function(p){ return [p.x, p.y]; }) };\n      if(ring.holes ? ring.holes.length > 0 : false){\n        part.holes = ring.holes.map(function(h){ return h.map(function(p){ return [p.x, p.y]; }); });\n      }\n      out.push(part);\n    }\n    return out;\n  }\n  \/\/ Hydrate a server \"full layout\" into the exact object shape the\n  \/\/ renderer + metrics panel consume (parity-tested against local).\n  function applyRemotePlan(resp){\n    var s = resp ? resp.selected : null;\n    if(!s) return null;\n    return { passes: s.passes, turnArcs: s.turnArcs, drivePath: s.drivePath,\n      boundaryParts: s.boundaryParts, blocks: s.blocks, warning: s.warning,\n      interiors: null, interior: null, drivable: null, boundary: null,\n      orderedPasses: null, blockPolys: null, __remoteMetrics: s.metrics };\n  }\n  \/\/ The standard trial headings the client sweeps \u2014 a superset of\n  \/\/ runAxisSweep's coverage angles (0..75 step 15) and the heading-strategy\n  \/\/ picker's 12-angle set (0..165 step 15, heavy fields use a 4-angle\n  \/\/ subset). One metrics-only server sweep on these angles serves both\n  \/\/ consumers without per-angle round-trips. Hard cap 16 (server 413s).\n  var PLANNER_SWEEP_DEGS = [0, 15, 30, 45, 60, 75, 90, 105, 120, 135, 150, 165];\n  \/\/ Rebuild the computeMetrics shape from a server compare row (numbers\n  \/\/ only, no geometry). Every field paintCmpRow \/ recommendByMetrics \/\n  \/\/ the ROI block read must be present; derived fields use the same\n  \/\/ identities computeMetrics itself guarantees (overlap % is measured\n  \/\/ against WORKED area; cost = fuel litres \u00d7 diesel price).\n  function metricsFromCompareRow(row, inp){\n    var fieldM2 = (row.fieldHa || 0) * 10000;\n    var coveredM2 = fieldM2 * (row.coveragePct || 0) \/ 100;\n    return {\n      passes: 0,\n      passLengthM: (row.passKm || 0) * 1000,\n      turnLengthM: (row.turnKm || 0) * 1000,\n      totalDriveM: (row.distanceKm || 0) * 1000,\n      turns: row.turns || 0,\n      fuelL: row.fuelL || 0,\n      costUSD: (row.fuelL || 0) * inp.diesel,\n      coveragePct: row.coveragePct || 0,\n      coveredM2: coveredM2,\n      fieldM2: fieldM2,\n      overlapM2: coveredM2 * (row.overlapPct || 0) \/ 100,\n      overlapPct: row.overlapPct || 0,\n      crossings: row.crossings || 0,\n      slopePenalty: 0,\n      avgGradePct: 0,\n      blockCount: row.blockCount || 1,\n      __fromCompareRow: true\n    };\n  }\n  \/\/ The \"Best\" recommendation MODEL (recommendByMetrics + scoreApproach \u2014 the\n  \/\/ weighted score + tier\/gate rules) is server-only in the stripped embed.\n  \/\/ The server already returns its pick as resp.recommendation, so the client\n  \/\/ consumes that instead of scoring locally. Order of preference:\n  \/\/   1. the server's pick (production \/ stripped embed),\n  \/\/   2. the real model when the core is inlined (full \/ dev build \u2014 the\n  \/\/      typeof guard keeps this a no-op on a stripped embed; rebuild.sh\n  \/\/      exempts recommendByMetrics from the dangling-strip guard),\n  \/\/   3. a simple, non-secret coverage-first fallback (clears the crossings +\n  \/\/      overlap gates) for the rare stripped-embed case with no fresh pick.\n  function pickBestApproach(apMetrics){\n    var srv = REMOTE_PLAN_CACHE.recommendation;\n    if(srv ? apMetrics[srv] : false) return { pick: srv };\n    if(typeof recommendByMetrics === 'function') return recommendByMetrics(apMetrics);\n    var order = ['ab-straight', 'ab-curve', 'boundary', 'adaptive', 'auto-blocks', 'concentric'];\n    var best = null, bestRank = -1;\n    for(var i=0; i<order.length; i++){\n      var m = apMetrics[order[i]];\n      if(!m) continue;\n      var clean = (m.crossings || 0) <= 8 ? (m.overlapPct || 0) <= 20 : false;\n      var rank = (clean ? 100 : 0) + (m.coveragePct || 0);\n      if(rank > bestRank){ bestRank = rank; best = order[i]; }\n    }\n    return { pick: best ? best : 'ab-straight' };\n  }\n  function plannerFetchPlans(inp, axis, done){\n    \/\/ done() is guarded so it fires exactly once \u2014 a throw INSIDE the\n    \/\/ success callback must not re-enter it through the catch below.\n    var finished = false;\n    var seq = ++plannerReqSeq;\n    function finish(err){\n      if(finished) return;\n      finished = true;\n      clearTimeout(watchdog);\n      done(err);\n    }\n    \/\/ Hung-fetch watchdog: fires done() so the pending recompute runs\n    \/\/ locally and the loader resolves. Only the LATEST request may declare\n    \/\/ the session broken \u2014 a superseded request timing out says nothing\n    \/\/ about the request that replaced it.\n    var watchdog = setTimeout(function(){\n      console.warn('[GPL] remote planner timed out \u2014 falling back to local');\n      if(seq === plannerReqSeq) plannerBroken = true;\n      finish(new Error('timeout'));\n    }, PLANNER_FETCH_TIMEOUT_MS);\n    var reqApproach = current === 'uploaded' ? 'ab-straight' : current;\n    function postPlan(token){\n      return fetch(PLANNER_API_BASE + '\/v1\/plan', {\n        method: 'POST', headers: { 'Content-Type': 'application\/json' },\n        body: JSON.stringify({\n          v: 1, approach: reqApproach,\n          wM: inp.wM, headlandM: inp.headlandM, turnStyle: inp.turnStyle,\n          turnRadiusM: inp.turnR, outsideBufferM: inp.turnBuf,\n          dieselPrice: inp.diesel, consumption: inp.cons,\n          \/\/ Sample-field identity so the server can apply the SAME synthetic\n          \/\/ elevation the demo renders (Topography follow only wins on a real\n          \/\/ gradient). Sample keys map to the server's built-in TERRAIN; any\n          \/\/ uploaded \/ custom field sends 'custom' \u2192 flat, so nothing about\n          \/\/ the user's own field leaks and Topography falls back to AB axis.\n          field: currentField,\n          parts: partsPayload(), axis: axis, compareAll: true,\n          fullFor: (reqApproach === 'ab-straight' ? [] : ['ab-straight']),\n          sweep: { approach: reqApproach, angles: PLANNER_SWEEP_DEGS.slice(0, 16) },\n          token: token })\n      });\n    }\n    function attempt(allowRetry){\n      var mint = (plannerToken ? (Date.now()\/1000 < plannerTokenExp - 10) : false)\n        ? Promise.resolve(plannerToken) : plannerFetchToken();\n      mint.then(postPlan).then(function(r){\n        if(r.status === 401){\n          \/\/ Token expired mid-flight (or clock skew): mint a fresh one and\n          \/\/ retry ONCE before declaring the remote path broken.\n          plannerToken = null;\n          if(allowRetry){ attempt(false); return null; }\n          throw new Error('auth');\n        }\n        if(!r.ok) throw new Error('plan http ' + r.status);\n        return r.json();\n      }).then(function(resp){\n        if(!resp) return;   \/\/ the 401 retry chain owns this request now\n        if(finished) return;   \/\/ watchdog already handed this cycle to local\n        if(seq !== plannerReqSeq){\n          \/\/ Superseded: a newer fetch started after this one and its\n          \/\/ response owns the cache. Drop this write (an out-of-order\n          \/\/ older response must not clobber the newer key) but still\n          \/\/ finish() so this cycle's recompute + loader resolve \u2014 the\n          \/\/ recompute reads CURRENT inputs and simply misses the cache.\n          finish(null);\n          return;\n        }\n        REMOTE_PLAN_CACHE.key = remoteCacheKey(inp.wM, inp.headlandM, inp.turnStyle, inp.turnR, inp.turnBuf, axis);\n        REMOTE_PLAN_CACHE.byApproach = {};\n        REMOTE_PLAN_CACHE.byApproach[resp.selected.approach] = applyRemotePlan(resp);\n        if(resp.full){\n          for(var k in resp.full){\n            REMOTE_PLAN_CACHE.byApproach[k] = applyRemotePlan({ selected: resp.full[k] });\n          }\n        }\n        REMOTE_PLAN_CACHE.compare = resp.compare ? resp.compare : null;\n        REMOTE_PLAN_CACHE.sweep = resp.sweep ? { approach: reqApproach, rows: resp.sweep } : null;\n        REMOTE_PLAN_CACHE.recommendation = resp.recommendation;\n        finish(null);\n      }).catch(function(err){\n        if(finished) return;   \/\/ a throw inside done() must not flip plannerBroken\n        console.warn('[GPL] remote planner failed \u2014 falling back to local:', err.message);\n        \/\/ Only the latest request may break the session \u2014 see watchdog note.\n        if(seq === plannerReqSeq) plannerBroken = true;\n        finish(err);\n      });\n    }\n    try { attempt(true); }\n    catch(e){\n      \/\/ Synchronous throw (fetch unavailable, malformed parts payload):\n      \/\/ same contract as an async failure \u2014 done() still fires so the\n      \/\/ pending recompute runs locally and the loader resolves.\n      if(seq === plannerReqSeq) plannerBroken = true;\n      finish(e);\n    }\n  }\n  \/\/ THE seam. Local mode: direct call (today's behaviour, byte-identical).\n  \/\/ Remote mode: serve from the cache plannerFetchPlans filled; a miss\n  \/\/ falls back to local so recompute never returns nothing.\n  function planLayout(approach, wM, parts, axis, headlandM, turnStyle, turnRadiusM, outsideBufferM){\n    if(plannerRemoteEnabled() ? !plannerBroken : false){\n      \/\/ Hit test uses THIS call's arguments (plus the geometry digest) so\n      \/\/ the comparison can never disagree with what the fetch stored.\n      \/\/ NOTE: the digest reads the global BOUNDARY_PARTS \u2014 every current\n      \/\/ call site passes exactly those parts. A future call site planning\n      \/\/ OTHER geometry (sub-blocks, previews) must call the local core\n      \/\/ directly, not this seam, or it would be served the full-field plan.\n      var hit = REMOTE_PLAN_CACHE.key === remoteCacheKey(wM, headlandM, turnStyle, turnRadiusM, outsideBufferM, axis)\n        ? REMOTE_PLAN_CACHE.byApproach[approach] : null;\n      if(hit) return hit;\n    }\n    \/\/ Local fallback. On a remote-only stripped embed (Phase 4) the core is\n    \/\/ absent \u2014 there is nothing to compute with locally, so flag it and\n    \/\/ return null; scheduleRecompute reads the flag and shows the retry\n    \/\/ banner rather than letting a ReferenceError escape. Core present\n    \/\/ (full build + today's embed) \u2192 normal local pass-through.\n    if(typeof generateLinesAll !== 'function'){ plannerCoreMissing = true; return null; }\n    return generateLinesAll(approach, wM, parts, axis, headlandM, turnStyle, turnRadiusM, outsideBufferM);\n  }\n  \/\/ Multi-part parts for fields with disconnected polygons. Looked up\n  \/\/ by the field-switch handler; absent entries default to [BOUNDARY].\n  var FIELD_PARTS = {\n    twoblock: [\n      \/\/ Block A \u2014 long horizontal arm ~9 vertices, aspect ~3.0 (700 m \u00d7 235 m),\n      \/\/ long axis ~\u22125\u00b0 from +x. The exaggerated elongation makes the\n      \/\/ global-axis approaches waste a clearly visible chunk on Block B\n      \/\/ and gives auto-blocks an obvious win \u2014 each block's own PCA\n      \/\/ axis tiles tight against the long dimension.\n      [\n        { x:  20, y: 230 }, { x:  20, y:  90 }, { x: 220, y:  50 },\n        { x: 460, y:  55 }, { x: 690, y:  80 }, { x: 720, y: 220 },\n        { x: 530, y: 280 }, { x: 280, y: 295 }, { x: 100, y: 285 }\n      ],\n      \/\/ Block B \u2014 long vertical arm, aspect ~2.5 (220 m \u00d7 540 m), long\n      \/\/ axis ~80\u00b0 from +x (i.e. axes differ from Block A by ~85\u00b0). Tall\n      \/\/ shape so AB Straight + Topography aligned to A's horizontal axis\n      \/\/ leaves wide diagonal gaps inside B \u2014 auto-blocks aligns B to its\n      \/\/ own vertical PCA and covers \u2265 95 %.\n      [\n        { x: 780, y:  60 }, { x: 920, y:  90 }, { x: 970, y: 240 },\n        { x: 985, y: 420 }, { x: 940, y: 580 }, { x: 830, y: 605 },\n        { x: 740, y: 540 }, { x: 720, y: 380 }, { x: 730, y: 220 },\n        { x: 745, y: 100 }\n      ]\n    ]\n  };\n  \/\/ AB-line direction override. null = use PCA result; otherwise degrees 0\u2013180\n  \/\/ measured from the +x axis (counter-clockwise in screen-coords where +y is\n  \/\/ down, so this matches \"compass-like\" intuitive direction on the canvas).\n  var userAxisDeg = null;\n  \/\/ Heading strategy \u2014 which of the 4 options the user picked. 'longest'\n  \/\/ is the geometric default (PCA\/longest-edge angle); 'crossings' and\n  \/\/ 'distance' compute their angles via a small angle sweep against the\n  \/\/ current approach + field; 'custom' lets the user dial in any angle\n  \/\/ via the slider below.\n  var headingStrategy = 'longest';\n  \/\/ Deferred Compare All work \u2014 cleared when a fresh recompute fires.\n  var compareTimer = null;\n  var headingAngles = { longest: 0, crossings: null, distance: null, time: null };\n  \/\/ Combined stats across multiple boundary parts \u2014 union bbox + total area.\n  \/\/ Used by recompute() so a multi-part import reports the SUM of part areas\n  \/\/ (not just the largest part).\n  function fieldStatsAll(parts){\n    if(!parts ? true : parts.length === 0) return { minX: 0, maxX: 0, minY: 0, maxY: 0, area: 0 };\n    var minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity, area = 0;\n    for(var i=0; i<parts.length; i++){\n      var st = fieldStats(parts[i]);\n      if(st.minX < minX) minX = st.minX;\n      if(st.maxX > maxX) maxX = st.maxX;\n      if(st.minY < minY) minY = st.minY;\n      if(st.maxY > maxY) maxY = st.maxY;\n      area += st.area;\n    }\n    return { minX: minX, maxX: maxX, minY: minY, maxY: maxY, area: area };\n  }\n  \/\/ Shape-based fallback (used at INITIAL load only, before any layout has\n  \/\/ run). Returns just the pick \u2014 recommendByMetrics replaces this on the\n  \/\/ first recompute().\n  function recommendApproach(b){\n    var stats = fieldStats(b);\n    var bboxArea = (stats.maxX - stats.minX) * (stats.maxY - stats.minY);\n    var rect = bboxArea > 0 ? (stats.area \/ bboxArea) : 0;\n    var per = 0;\n    for(var i=0; i<b.length; i++){\n      var j = (i + 1) % b.length;\n      var dx = b[j].x - b[i].x, dy = b[j].y - b[i].y;\n      per += Math.sqrt(dx*dx + dy*dy);\n    }\n    var comp = per > 0 ? (4 * Math.PI * stats.area \/ (per * per)) : 0;\n    var pick;\n    if(comp > 0.78) pick = 'boundary';\n    else if(rect > 0.85) pick = 'ab-straight';\n    else if(rect > 0.55) pick = 'ab-curve';\n    else pick = 'adaptive';\n    return { pick: pick, why: 'analysing\u2026' };\n  }\n  \/\/ Back-compat wrapper: return the LONGEST segment (or null). Callers that\n  \/\/ want all sub-segments (for proper L-shape coverage) use ...Segments above.\n  function clipLineToBoundary(x0, y0, x1, y1, b){\n    var segs = clipLineToBoundarySegments(x0, y0, x1, y1, b);\n    if(!segs) return null;\n    var best = null, bestLen = -1;\n    for(var i=0; i<segs.length; i++){\n      var s = segs[i];\n      var sdx = s.x1 - s.x0, sdy = s.y1 - s.y0;\n      var len = sdx*sdx + sdy*sdy;\n      if(len > bestLen){ bestLen = len; best = s; }\n    }\n    return best;\n  }\n  \/\/ \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n  \/\/ OBSTACLES \/ HOLES (rule \u00a76 edge-type taxonomy \u2192 `obstacle_boundary`).\n  \/\/ A field part can carry `.holes` \u2014 an array of inner rings (ponds,\n  \/\/ woodlots, poles, no-work zones). Real client fields routinely have\n  \/\/ them (one imported field had 9). Body passes, U-turn arcs, transports,\n  \/\/ swath, and coverage must all treat hole interiors as NOT drivable +\n  \/\/ NOT countable. Holes are buffered outward by ~wM\/2 (an obstacle\n  \/\/ headland) so the implement keeps clearance.\n  \/\/ \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n  \/\/ Generate guidance layout for one approach. Returns:\n  \/\/   { passes: [...], headlandPoly, interiorPoly, drivable, turnArcs, drivePath, warning }\n  \/\/ The smart pipeline:\n  \/\/   1. drivable = boundary + outsideBufferM outward\n  \/\/   2. interior = boundary inward-offset by headlandM (the worked zone)\n  \/\/   3. headland = boundary minus interior (the turnaround zone)\n  \/\/   4. Passes clipped to INTERIOR (not full boundary) \u2014 endpoints sit on the\n  \/\/      inner edge of the headland by construction\n  \/\/   5. Serpentine through passes, generating turn arcs that bulge OUTWARD\n  \/\/      into the headland strip via the validation cascade\n  \/\/ For 'boundary' approach: skip interior passes, generate only the headland-\n  \/\/ following ring(s) \u2014 useful for orchards \/ grasslands where the field is\n  \/\/ worked along its perimeter.\n  \/\/ Trace a single contour line at constant elevation using a predictor-\n  \/\/ corrector scheme. Each step:\n  \/\/   PREDICT \u2014 move stepM perpendicular to the local gradient.\n  \/\/   CORRECT \u2014 sample elevation at the new point, move along the gradient\n  \/\/             by ((targetZ - currentZ) \/ |gradient|) to restore the target\n  \/\/             elevation. Keeps the path on the contour even when the\n  \/\/             terrain function is non-linear (e.g. a circular hill on the\n  \/\/             pivot field would otherwise drift off-level by step 50).\n  \/\/ Walks in `dir` (\u00b11) until it exits `clipPoly` or hits maxSteps. Returns\n  \/\/ sample points.\n  function walkContour(start, dir, clipPoly, stepM, maxSteps){\n    var pts = [{ x: start.x, y: start.y }];\n    var eps = 0.5;\n    var targetZ = terrainAt(start.x, start.y);\n    var prevPx = null, prevPy = null;\n    var closeRadius = stepM * 1.5;\n    var closeRadiusSq = closeRadius * closeRadius;\n    for(var s=0; s<maxSteps; s++){\n      \/\/ Loop detection \u2014 for closed contours (e.g. a circle around a hill\n      \/\/ peak), the walker would otherwise spin forever. Stop once we've\n      \/\/ come back within stepM \u00d7 1.5 of the seed after at least 10 steps.\n      \/\/ pts._closed flag tells the caller this contour wrapped around.\n      if(s > 10){\n        var dxS = pts[pts.length-1].x - start.x;\n        var dyS = pts[pts.length-1].y - start.y;\n        if(dxS*dxS + dyS*dyS < closeRadiusSq){ pts.push({ x: start.x, y: start.y }); pts._closed = true; break; }\n      }\n      var last = pts[pts.length - 1];\n      \/\/ Numerical gradient via central differences\n      var gx = (terrainAt(last.x + eps, last.y) - terrainAt(last.x - eps, last.y)) \/ (2 * eps);\n      var gy = (terrainAt(last.x, last.y + eps) - terrainAt(last.x, last.y - eps)) \/ (2 * eps);\n      var gLen2 = gx*gx + gy*gy;\n      if(gLen2 < 1e-12) break;\n      var gLen = Math.sqrt(gLen2);\n      \/\/ Predictor: perpendicular to gradient (rotated 90\u00b0), normalized\n      var px = -gy \/ gLen * dir;\n      var py = gx \/ gLen * dir;\n      \/\/ Smooth direction so the curve doesn't ping-pong on noisy terrain\n      if(prevPx !== null){\n        px = px * 0.7 + prevPx * 0.3;\n        py = py * 0.7 + prevPy * 0.3;\n        var pl = Math.sqrt(px*px + py*py);\n        if(pl > 1e-6){ px \/= pl; py \/= pl; }\n      }\n      prevPx = px; prevPy = py;\n      var nx = last.x + px * stepM;\n      var ny = last.y + py * stepM;\n      \/\/ Corrector: nudge back to target elevation along the local gradient\n      var nz = terrainAt(nx, ny);\n      var dz = targetZ - nz;\n      var gMag2x = (terrainAt(nx + eps, ny) - terrainAt(nx - eps, ny)) \/ (2 * eps);\n      var gMag2y = (terrainAt(nx, ny + eps) - terrainAt(nx, ny - eps)) \/ (2 * eps);\n      var gMag2sq = gMag2x*gMag2x + gMag2y*gMag2y;\n      if(gMag2sq > 1e-12){\n        var corr = dz \/ gMag2sq;\n        nx += gMag2x * corr;\n        ny += gMag2y * corr;\n      }\n      if(!pointInPoly(nx, ny, clipPoly)) break;\n      pts.push({ x: nx, y: ny });\n    }\n    return pts;\n  }\n  \/\/ Build contour-following passes from the active terrain. Spacing = wM\n  \/\/ (perpendicular implement-width), so each pass's swath tiles the next.\n  \/\/ Returns array of { samples, kind: 'adaptive' } passes clipped to\n  \/\/ `clipPoly` (the interior polygon).\n  function buildContourPasses(wM, clipPoly){\n    \/\/ 1. Find elevation range across clipPoly\n    var stats = fieldStats(clipPoly);\n    var lo = Infinity, hi = -Infinity;\n    var sampleN = 24;  \/\/ sparse sweep to find lo\/hi\n    var dxS = (stats.maxX - stats.minX) \/ sampleN;\n    var dyS = (stats.maxY - stats.minY) \/ sampleN;\n    for(var iy=0; iy<sampleN; iy++){\n      for(var ix=0; ix<sampleN; ix++){\n        var cxS = stats.minX + (ix + 0.5) * dxS;\n        var cyS = stats.minY + (iy + 0.5) * dyS;\n        if(!pointInPoly(cxS, cyS, clipPoly)) continue;\n        var z = terrainAt(cxS, cyS);\n        if(z < lo) lo = z;\n        if(z > hi) hi = z;\n      }\n    }\n    if(hi - lo < 0.3) return [];  \/\/ essentially flat \u2014 no useful contours\n    \/\/ 2. Estimate typical gradient magnitude across the field. Sampling the\n    \/\/    centroid alone fails for fields where the centroid sits on a peak\n    \/\/    or saddle (e.g. pivot \u2014 round hill at the centre \u2192 \u2207=0 there).\n    \/\/    Take 9 samples on a 3\u00d73 grid, use the median magnitude.\n    var gradSamples = [];\n    for(var gxi=0; gxi<3; gxi++){\n      for(var gyi=0; gyi<3; gyi++){\n        var sx = stats.minX + (gxi + 1) * (stats.maxX - stats.minX) \/ 4;\n        var sy = stats.minY + (gyi + 1) * (stats.maxY - stats.minY) \/ 4;\n        if(!pointInPoly(sx, sy, clipPoly)) continue;\n        var gxS = (terrainAt(sx + 1, sy) - terrainAt(sx - 1, sy)) * 0.5;\n        var gyS = (terrainAt(sx, sy + 1) - terrainAt(sx, sy - 1)) * 0.5;\n        gradSamples.push(Math.sqrt(gxS*gxS + gyS*gyS));\n      }\n    }\n    if(gradSamples.length === 0) return [];\n    gradSamples.sort(function(a, b){ return a - b; });\n    \/\/ 33rd-percentile gradient (was median = 50th). Spatial spacing between\n    \/\/ adjacent contour lines = elevStep \/ local_gradient. With the median\n    \/\/ gradient, ANY point with a below-median gradient ends up further\n    \/\/ than wM from its neighbours \u2014 visible as the wide diagonal gaps on\n    \/\/ Rect's diagonal-ridge terrain. The 33rd percentile shrinks elevStep\n    \/\/ ~30 %, so the lower-gradient half of the field is much closer to\n    \/\/ wM apart. Steep zones over-densify slightly; the existing dedupe\n    \/\/ pass drops any contour that doesn't add new coverage.\n    var pctIdx = Math.max(0, Math.floor(gradSamples.length * 0.33));\n    var gMag = gradSamples[pctIdx];\n    if(gMag < 1e-4) gMag = gradSamples[Math.floor(gradSamples.length \/ 2)];\n    if(gMag < 1e-4) return [];  \/\/ truly flat \u2192 no useful contours\n    var elevStep = wM * gMag;\n    \/\/ 3. Pick N levels evenly between lo+elevStep\/2 and hi-elevStep\/2\n    var nLevels = Math.max(2, Math.floor((hi - lo) \/ elevStep));\n    if(nLevels > 80) nLevels = 80;  \/\/ cap to avoid pathological terrain\n    var levels = [];\n    for(var L=0; L<nLevels; L++){\n      levels.push(lo + (L + 0.5) * (hi - lo) \/ nLevels);\n    }\n    \/\/ 4. For each level, find a seed point inside clipPoly with terrain \u2248 level\n    \/\/    then walk the contour in both directions, joining into one pass.\n    var passes = [];\n    var seedGrid = 32;\n    var sdx = (stats.maxX - stats.minX) \/ seedGrid;\n    var sdy = (stats.maxY - stats.minY) \/ seedGrid;\n    \/\/ Pre-sample on a finer grid for seeds\n    var seedZ = new Float32Array(seedGrid * seedGrid);\n    var seedIn = new Uint8Array(seedGrid * seedGrid);\n    for(var iy2=0; iy2<seedGrid; iy2++){\n      for(var ix2=0; ix2<seedGrid; ix2++){\n        var cx2 = stats.minX + (ix2 + 0.5) * sdx;\n        var cy2 = stats.minY + (iy2 + 0.5) * sdy;\n        seedZ[iy2 * seedGrid + ix2] = terrainAt(cx2, cy2);\n        seedIn[iy2 * seedGrid + ix2] = pointInPoly(cx2, cy2, clipPoly) ? 1 : 0;\n      }\n    }\n    var stepM = Math.max(2, wM * 0.4);\n    var maxSteps = 800;\n    for(var li=0; li<levels.length; li++){\n      var lv = levels[li];\n      \/\/ Find the seed cell where seedZ is closest to lv (among in-field cells)\n      var bestIdx = -1, bestDiff = Infinity;\n      for(var k=0; k<seedZ.length; k++){\n        if(!seedIn[k]) continue;\n        var diff = Math.abs(seedZ[k] - lv);\n        if(diff < bestDiff){ bestDiff = diff; bestIdx = k; }\n      }\n      if(bestIdx < 0) continue;\n      var ixS = bestIdx % seedGrid;\n      var iyS = Math.floor(bestIdx \/ seedGrid);\n      var seed = { x: stats.minX + (ixS + 0.5) * sdx, y: stats.minY + (iyS + 0.5) * sdy };\n      \/\/ Walk forward first. If it returns a closed loop, the contour is\n      \/\/ closed (e.g. circular hill) \u2014 skip the backward walk (would just\n      \/\/ trace the same loop in reverse, doubling the path length).\n      var fwd = walkContour(seed, +1, clipPoly, stepM, maxSteps);\n      var samples;\n      if(fwd._closed){\n        samples = fwd;\n      } else {\n        var bwd = walkContour(seed, -1, clipPoly, stepM, maxSteps);\n        bwd.reverse();\n        samples = bwd.concat(fwd.slice(1));\n      }\n      if(samples.length >= 4) passes.push({ samples: samples, kind: 'adaptive' });\n    }\n    \/\/ Dedupe \u2014 in steep zones the marching-elevation step packs multiple\n    \/\/ contour passes within wM of each other, which renders as a tangled\n    \/\/ bundle of intersecting curves. Walk passes in order and KEEP a pass\n    \/\/ only when it adds genuinely new coverage. Same rasterise-test as\n    \/\/ buildFillPasses but applied during pass emission.\n    if(passes.length > 1){\n      var dStats = fieldStats(clipPoly);\n      var dCell = Math.max(2, wM \/ 3);\n      var dnx = Math.max(8, Math.ceil((dStats.maxX - dStats.minX) \/ dCell));\n      var dny = Math.max(8, Math.ceil((dStats.maxY - dStats.minY) \/ dCell));\n      var dCovered = new Uint8Array(dnx * dny);\n      var dHalf2 = (wM * 0.5) * (wM * 0.5);\n      var kept = [];\n      function markPassCoverage(p){\n        for(var si=1; si<p.samples.length; si++){\n          var sa = p.samples[si-1], sb = p.samples[si];\n          var sdx = sb.x - sa.x, sdy = sb.y - sa.y;\n          var segL = Math.sqrt(sdx*sdx + sdy*sdy);\n          var nSt = Math.max(1, Math.ceil(segL \/ dCell));\n          for(var st=0; st<=nSt; st++){\n            var tt = st \/ nSt;\n            var sxk = sa.x + sdx * tt;\n            var syk = sa.y + sdy * tt;\n            \/\/ Mark cells within wM\/2 of this sample\n            var ixc = Math.floor((sxk - dStats.minX) \/ dCell);\n            var iyc = Math.floor((syk - dStats.minY) \/ dCell);\n            var r = Math.ceil((wM * 0.5) \/ dCell);\n            for(var dy0=-r; dy0<=r; dy0++){\n              for(var dx0=-r; dx0<=r; dx0++){\n                var ix2 = ixc + dx0, iy2 = iyc + dy0;\n                if(ix2 < 0 ? true : iy2 < 0 ? true : ix2 >= dnx ? true : iy2 >= dny) continue;\n                var ccx = dStats.minX + (ix2 + 0.5) * dCell;\n                var ccy = dStats.minY + (iy2 + 0.5) * dCell;\n                var pdx = ccx - sxk, pdy = ccy - syk;\n                if(pdx*pdx + pdy*pdy <= dHalf2) dCovered[iy2 * dnx + ix2] = 1;\n              }\n            }\n          }\n        }\n      }\n      function passAddsNewCoverage(p){\n        \/\/ Sample the pass, count how many of its sample positions land in\n        \/\/ cells NOT yet covered. Keep the pass when \u226545 % of samples\n        \/\/ contribute new ground. Tuned to drop the tight contour bundles\n        \/\/ that form near ridges + peaks (visible as a tangle of crossing\n        \/\/ curves) while still keeping every distinct iso-elevation band\n        \/\/ across the wider, gentler part of the field.\n        var totalSamp = 0, newSamp = 0;\n        for(var si=0; si<p.samples.length; si++){\n          var sxk = p.samples[si].x, syk = p.samples[si].y;\n          var ixc = Math.floor((sxk - dStats.minX) \/ dCell);\n          var iyc = Math.floor((syk - dStats.minY) \/ dCell);\n          if(ixc < 0 ? true : iyc < 0 ? true : ixc >= dnx ? true : iyc >= dny) continue;\n          totalSamp++;\n          if(!dCovered[iyc * dnx + ixc]) newSamp++;\n        }\n        return totalSamp > 0 ? (newSamp \/ totalSamp) >= 0.30 : false;\n      }\n      for(var dp=0; dp<passes.length; dp++){\n        if(kept.length === 0 ? true : passAddsNewCoverage(passes[dp])){\n          kept.push(passes[dp]);\n          markPassCoverage(passes[dp]);\n        }\n      }\n      passes = kept;\n    }\n    return passes;\n  }\n  \/\/ RENDER\n  var DPR = window.devicePixelRatio > 1 ? window.devicePixelRatio : 1;\n  function resize(){\n    var rect = canvas.getBoundingClientRect();\n    canvas.width = Math.floor(rect.width * DPR);\n    canvas.height = Math.floor(rect.height * DPR);\n    ctx.setTransform(DPR, 0, 0, DPR, 0, 0);\n  }\n  \/\/ View transform \u2014 fit-to-field is the base, then user can zoom\/pan around.\n  var view = { zoom: 1, panX: 0, panY: 0 };\n  function getScale(){\n    var rect = canvas.getBoundingClientRect();\n    var pad = 36;\n    \/\/ Bounding box covers ALL parts of a multi-polygon field so the\n    \/\/ initial fit shows every block + the inter-block gap. Previously\n    \/\/ only the primary (largest) polygon was framed \u2192 on the 2-block\n    \/\/ sample (and any multi-part import) one block sat outside the\n    \/\/ visible canvas until the user manually panned (reported\n    \/\/ 2026-06-03).\n    var partsForFit = BOUNDARY_PARTS ? (BOUNDARY_PARTS.length > 0 ? BOUNDARY_PARTS : [BOUNDARY]) : [BOUNDARY];\n    var s = fieldStatsAll(partsForFit);\n    var sx = (rect.width - pad * 2) \/ (s.maxX - s.minX);\n    var sy = (rect.height - pad * 2) \/ (s.maxY - s.minY);\n    var sc = (sx < sy ? sx : sy) * view.zoom;\n    var baseTx = pad - s.minX * sc + (rect.width - pad * 2 - (s.maxX - s.minX) * sc) \/ 2;\n    var baseTy = pad - s.minY * sc + (rect.height - pad * 2 - (s.maxY - s.minY) * sc) \/ 2;\n    return { sc: sc, tx: baseTx + view.panX, ty: baseTy + view.panY };\n  }\n  function px(proj, x){ return x * proj.sc + proj.tx; }\n  function py(proj, y){ return y * proj.sc + proj.ty; }\n  \/\/ Inverse: screen \u2192 world coordinates (used by ruler + pan).\n  function worldX(proj, sx){ return (sx - proj.tx) \/ proj.sc; }\n  function worldY(proj, sy){ return (sy - proj.ty) \/ proj.sc; }\n  \/\/ Unit + ruler state\n  \/\/ Unit system \u2014 single source of truth for ALL number rendering + input\n  \/\/ parsing. Internal math runs in metric (m, km, ha, L). The display layer\n  \/\/ converts to US (ft, mi, ac, gal) when unitSystem === 'us'. getInputs()\n  \/\/ converts user-entered values BACK to metric so the geometry pipeline\n  \/\/ always sees metric. Switching the toggle ALSO converts input field\n  \/\/ values so the user sees consistent units everywhere (e.g. typing 18 m\n  \/\/ then switching to US shows 59 ft, not 18 ft).\n  var unitSystem = 'metric';\n  var M_TO_FT   = 3.28084;\n  var KM_TO_MI  = 0.621371;\n  var HA_TO_AC  = 2.47105;\n  var L_TO_GAL  = 0.264172;\n  var LPKM_TO_GPMI = L_TO_GAL \/ KM_TO_MI;  \/\/ 1 L\/km in gal\/mi (~0.425)\n  var LPL_TO_DPGAL = 1 \/ L_TO_GAL;          \/\/ 1 $\/L in $\/gal (~3.785)\n  \/\/ Regional road-diesel price defaults (canonical per-litre). EU road diesel\n  \/\/ runs meaningfully higher than the US, so the default follows the chosen\n  \/\/ currency: switching $\/L <-> \u20ac\/L updates it UNLESS the user has already\n  \/\/ typed a custom price. Recent road-diesel averages; user-overridable.\n  var DIESEL_DEFAULT = { usd: 1.20, eur: 1.65 };\n  function fmtArea(ha){\n    if(unitSystem === 'us') return (ha * HA_TO_AC).toFixed(1) + ' ac';\n    return ha.toFixed(1) + ' ha';\n  }\n  function fmtVolume(L){\n    if(unitSystem === 'us') return (L * L_TO_GAL).toFixed(1) + ' gal';\n    return L.toFixed(1) + ' L';\n  }\n  function fmtWidth(m){\n    if(unitSystem === 'us') return (m * M_TO_FT).toFixed(0) + ' ft';\n    return m.toFixed(0) + ' m';\n  }\n  \/\/ Legacy variable kept so the playback stats string (which still embeds\n  \/\/ \"km\" literal in places) doesn't break. The active km vs mi vs ft choice\n  \/\/ is driven by fmtDist below.\n  var unit = 'km';\n  var ruler = { active: false, p1: null, p2: null };\n  \/\/ AB-line tool \u2014 click two points on the canvas to lock the guidance heading\n  \/\/ (a \"vector of main path\"). On second click the angle replaces userAxisDeg\n  \/\/ and a persistent orange dashed vector + arrowhead is rendered until the\n  \/\/ user hits the Clear toolbar button or picks a different sample field.\n  var abTool = { active: false, p1: null, p2: null };\n  function fmtDist(metres){\n    if(unitSystem === 'us'){\n      var mi = metres \/ 1609.344;\n      if(mi >= 0.2) return mi.toFixed(2) + ' mi';\n      var ft = metres * M_TO_FT;\n      return ft.toFixed(0) + ' ft';\n    }\n    if(metres >= 1000) return (metres \/ 1000).toFixed(2) + ' km';\n    return metres.toFixed(0) + ' m';\n  }\n  \/\/ PLAYBACK STATE \u2014 animates a tractor sprite along the drive path with a\n  \/\/ gradually-filling swath band behind it. Speed is \"wall-clock \u00d7 multiplier\";\n  \/\/ we map ~1 km of drive-path per second of wall clock at 1\u00d7 so a typical\n  \/\/ field plays out in 10\u201320 s.\n  var playback = {\n    isPlaying: false,\n    t: 0,              \/\/ 0..1 progress along total drive-path length\n    speed: 1,          \/\/ 1 \/ 2 \/ 4 multiplier\n    drivePath: [],\n    cumLen: [0],       \/\/ cumulative length to each vertex\n    totalLen: 0,       \/\/ metres\n    wM: 18,            \/\/ implement width for swath\n    lastTick: 0\n  };\n  \/\/ Build (or rebuild) the playback path from a layout. Resets progress.\n  \/\/ Subdivides long segments (line-based passes can be 600 m end-to-end) so\n  \/\/ the sprite advances smoothly instead of skipping in big chunks (which\n  \/\/ looked like \"teleports\" at 4\u00d7 speed).\n  function setPlaybackPath(layout, wM){\n    var raw = layout.drivePath ? layout.drivePath : [];\n    var maxStep = Math.max(2, wM);  \/\/ never exceed one implement-width per drive-path segment\n    var dense = [];\n    for(var k=0; k<raw.length; k++){\n      if(k === 0){ dense.push(raw[0]); continue; }\n      var prev = raw[k - 1], cur = raw[k];\n      var ddx = cur.x - prev.x, ddy = cur.y - prev.y;\n      var dlen = Math.sqrt(ddx*ddx + ddy*ddy);\n      if(dlen > maxStep){\n        var nSub = Math.ceil(dlen \/ maxStep);\n        for(var ns=1; ns<nSub; ns++){\n          var f = ns \/ nSub;\n          dense.push({ x: prev.x + ddx * f, y: prev.y + ddy * f });\n        }\n      }\n      dense.push(cur);\n    }\n    playback.drivePath = dense;\n    playback.wM = wM;\n    playback.cumLen = [0];\n    var total = 0;\n    for(var i=1; i<playback.drivePath.length; i++){\n      var dx = playback.drivePath[i].x - playback.drivePath[i-1].x;\n      var dy = playback.drivePath[i].y - playback.drivePath[i-1].y;\n      total += Math.sqrt(dx*dx + dy*dy);\n      playback.cumLen.push(total);\n    }\n    playback.totalLen = total;\n    playback.t = 0;\n    updatePlaybackUI();\n  }\n  \/\/ Return the (x, y, headingRad) at parametric position t (0..1) along the path.\n  function playbackPosAt(t){\n    if(playback.drivePath.length < 2) return null;\n    var target = t * playback.totalLen;\n    var lo = 0, hi = playback.cumLen.length - 1;\n    while(lo < hi){\n      var mid = (lo + hi + 1) >> 1;\n      if(playback.cumLen[mid] <= target) lo = mid; else hi = mid - 1;\n    }\n    if(lo >= playback.drivePath.length - 1) lo = playback.drivePath.length - 2;\n    var segLen = playback.cumLen[lo + 1] - playback.cumLen[lo];\n    var f = segLen > 1e-6 ? (target - playback.cumLen[lo]) \/ segLen : 0;\n    var p0 = playback.drivePath[lo];\n    var p1 = playback.drivePath[lo + 1];\n    return {\n      x: p0.x + (p1.x - p0.x) * f,\n      y: p0.y + (p1.y - p0.y) * f,\n      heading: Math.atan2(p1.y - p0.y, p1.x - p0.x),\n      segIdx: lo\n    };\n  }\n  function updatePlaybackUI(){\n    var fillEl = document.getElementById('gpl-pb-fill');\n    var thumbEl = document.getElementById('gpl-pb-thumb');\n    var statsEl = document.getElementById('gpl-pb-stats');\n    var pct = (playback.t * 100).toFixed(0);\n    if(fillEl) fillEl.style.width = pct + '%';\n    if(thumbEl) thumbEl.style.left = pct + '%';\n    if(statsEl){\n      statsEl.textContent = pct + '% \u00b7 ' + fmtDist(playback.t * playback.totalLen) + ' \/ ' + fmtDist(playback.totalLen);\n    }\n  }\n  function drawSwath(proj){\n    if(playback.drivePath.length < 2 ? true : playback.t <= 0) return;\n    var target = playback.t * playback.totalLen;\n    var halfW = playback.wM * 0.5 * proj.sc;\n    if(halfW < 1) halfW = 1;\n    ctx.save();\n    ctx.strokeStyle = 'rgba(247,106,12,0.32)';\n    ctx.lineWidth = halfW * 2;\n    \/\/ Butt caps (not round) \u2014 round caps extend a semicircle of radius wM\/2\n    \/\/ BEYOND each pass endpoint. Body passes end exactly on the inner-headland\n    \/\/ edge, so a round cap re-paints the headland strip with another wM\/2 of\n    \/\/ swath at every pass end. Rule \u00a76 \u2014 swath paints once per strip, never\n    \/\/ doubled. Butt caps cut the swath flat at endpoints so the body pass +\n    \/\/ headland ring swaths abut cleanly with zero overlap. lineJoin stays\n    \/\/ round for smooth interior joints on curved (AB-curve \/ contour) passes.\n    ctx.lineCap = 'butt';\n    ctx.lineJoin = 'round';\n    \/\/ Transport segments (transport:true on a point) get NO swath \u2014 the\n    \/\/ implement is lifted while the operator drives between disconnected\n    \/\/ parts. We \"break\" the stroke at each transport hop using moveTo.\n    var penDown = !playback.drivePath[0].transport;\n    ctx.beginPath();\n    if(penDown) ctx.moveTo(px(proj, playback.drivePath[0].x), py(proj, playback.drivePath[0].y));\n    for(var i=1; i<playback.drivePath.length; i++){\n      var prev = playback.drivePath[i-1];\n      var here = playback.drivePath[i];\n      var isTransport = here.transport ? true : prev.transport;\n      var capped = playback.cumLen[i] > target;\n      var ex, ey;\n      if(capped){\n        var segLen = playback.cumLen[i] - playback.cumLen[i-1];\n        var f = segLen > 1e-6 ? (target - playback.cumLen[i-1]) \/ segLen : 0;\n        ex = prev.x + (here.x - prev.x) * f;\n        ey = prev.y + (here.y - prev.y) * f;\n      } else {\n        ex = here.x; ey = here.y;\n      }\n      if(isTransport){\n        \/\/ Skip swath for this segment \u2014 lift the pen and move on\n        if(penDown) ctx.stroke();\n        ctx.beginPath();\n        penDown = false;\n      } else {\n        if(!penDown){\n          ctx.moveTo(px(proj, prev.x), py(proj, prev.y));\n          penDown = true;\n        }\n        ctx.lineTo(px(proj, ex), py(proj, ey));\n      }\n      if(capped) break;\n    }\n    if(penDown) ctx.stroke();\n    ctx.restore();\n  }\n  \/\/ GeoPard-brand elevation rainbow: 5 keypoints spanning t \u2208 [0, 1]\n  \/\/   0.00 \u2192 deep purple (low)\n  \/\/   0.25 \u2192 teal \/ cyan\n  \/\/   0.50 \u2192 soft yellow\n  \/\/   0.75 \u2192 orange\n  \/\/   1.00 \u2192 red (high)\n  \/\/ Linearly interpolated between adjacent stops in RGB space. Matches the\n  \/\/ colour scheme used in the GeoPard Platform's elevation legend.\n  var ELEV_STOPS = [\n    [109,  79, 162],   \/\/ 0.00 \u2014 purple\n    [ 78, 192, 167],   \/\/ 0.25 \u2014 teal\n    [255, 232, 130],   \/\/ 0.50 \u2014 yellow\n    [245, 168,  92],   \/\/ 0.75 \u2014 orange\n    [219,  80,  80]    \/\/ 1.00 \u2014 red\n  ];\n  function elevColor(t){\n    if(t <= 0) return ELEV_STOPS[0];\n    if(t >= 1) return ELEV_STOPS[ELEV_STOPS.length - 1];\n    var seg = t * (ELEV_STOPS.length - 1);\n    var i = Math.floor(seg);\n    var f = seg - i;\n    var a = ELEV_STOPS[i], b = ELEV_STOPS[i + 1];\n    return [\n      Math.round(a[0] + (b[0] - a[0]) * f),\n      Math.round(a[1] + (b[1] - a[1]) * f),\n      Math.round(a[2] + (b[2] - a[2]) * f)\n    ];\n  }\n  \/\/ Terrain heatmap \u2014 translucent rainbow gradient showing elevation.\n  \/\/ Higher cell density (48\u00d736) + GeoPard brand colours @ 0.6 alpha so the\n  \/\/ elevation pattern reads clearly while passes \/ arcs \/ sprite stay legible.\n  function drawTerrain(proj){\n    if(currentField === 'custom') return;  \/\/ no synthetic terrain for uploaded\n    \/\/ Grid bbox covers ALL parts of a multi-polygon field. Previously\n    \/\/ only the primary polygon was used \u2192 on the 2-block sample the\n    \/\/ second polygon had no elevation heatmap behind it.\n    var terrainParts = BOUNDARY_PARTS ? (BOUNDARY_PARTS.length > 0 ? BOUNDARY_PARTS : [BOUNDARY]) : [BOUNDARY];\n    var stats = fieldStatsAll(terrainParts);\n    function cellInAnyPart(x, y){\n      for(var pi=0; pi<terrainParts.length; pi++){\n        if(pointInPoly(x, y, terrainParts[pi])) return true;\n      }\n      return false;\n    }\n    var nx = 48, ny = 36;\n    var dx = (stats.maxX - stats.minX) \/ nx;\n    var dy = (stats.maxY - stats.minY) \/ ny;\n    \/\/ Find elevation range across cells inside ANY part of the field.\n    var lo = Infinity, hi = -Infinity;\n    var grid = new Float32Array(nx * ny);\n    for(var iy=0; iy<ny; iy++){\n      for(var ix=0; ix<nx; ix++){\n        var cx = stats.minX + (ix + 0.5) * dx;\n        var cy = stats.minY + (iy + 0.5) * dy;\n        var z = terrainAt(cx, cy);\n        grid[iy * nx + ix] = z;\n        if(cellInAnyPart(cx, cy)){\n          if(z < lo) lo = z;\n          if(z > hi) hi = z;\n        }\n      }\n    }\n    if(hi - lo < 0.5) return;\n    ctx.save();\n    \/\/ Clip to the union of all boundary parts so cells don't leak into\n    \/\/ the gap between disconnected polygons.\n    ctx.clip(buildBoundaryPath(proj, terrainParts), 'evenodd');\n    for(var iy2=0; iy2<ny; iy2++){\n      for(var ix2=0; ix2<nx; ix2++){\n        var cx2 = stats.minX + (ix2 + 0.5) * dx;\n        var cy2 = stats.minY + (iy2 + 0.5) * dy;\n        if(!cellInAnyPart(cx2, cy2)) continue;\n        var z2 = grid[iy2 * nx + ix2];\n        var t = (z2 - lo) \/ (hi - lo);\n        var c = elevColor(t);\n        ctx.fillStyle = 'rgba(' + c[0] + ',' + c[1] + ',' + c[2] + ',0.6)';\n        var x0 = px(proj, stats.minX + ix2 * dx);\n        var y0 = py(proj, stats.minY + iy2 * dy);\n        var x1 = px(proj, stats.minX + (ix2 + 1) * dx);\n        var y1 = py(proj, stats.minY + (iy2 + 1) * dy);\n        ctx.fillRect(x0 - 0.5, y0 - 0.5, (x1 - x0) + 1, (y1 - y0) + 1);\n      }\n    }\n    ctx.restore();\n  }\n  \/\/ Soil compaction zones \u2014 wherever the machine turns around, it's compacting\n  \/\/ the headland strip soil with extra passes. Visualise as translucent violet\n  \/\/ strokes under each turn-arc swath; multiple nearby arcs additively darken\n  \/\/ the same area, making heavy-compaction clusters visible.\n  function drawCompactionZones(proj, layout){\n    var hasPasses = layout.passes ? layout.passes.length > 0 : false;\n    var hasArcs = layout.turnArcs ? layout.turnArcs.length > 0 : false;\n    if(!hasPasses ? !hasArcs : false) return;\n    \/\/ Wheel-track compaction. Painted ONLY for paths that OVERLAP other\n    \/\/ paths \u2014 concretely: headland rings (which every U-turn arc crosses)\n    \/\/ + turn arcs (which all sit inside the headland strip and overlap\n    \/\/ each other at the boundary). Body passes intentionally skipped \u2014 they\n    \/\/ run parallel at wM spacing so their wheel tracks never overlap with\n    \/\/ each other; painting them just adds visual noise (per CLAUDE.md\n    \/\/ rule \u00a76 \"minimise path crossings\"). The result: violet wash is dense\n    \/\/ where wheels actually compact the soil repeatedly (headland strip,\n    \/\/ turn zones, corner clusters) and absent where each pass only laid\n    \/\/ down its single track (body interior).\n    \/\/\n    \/\/ Stroke width is wM \u00d7 0.18 (~realistic wheel-track scale for a 4-wheel\n    \/\/ tractor \u2014 two axles \u2248 18% of the implement width when combined).\n    var wheelW = playback.wM * 0.18 * proj.sc;\n    if(wheelW < 1) wheelW = 1;\n    ctx.save();\n    ctx.lineWidth = wheelW * 2;\n    ctx.lineCap = 'round';\n    ctx.lineJoin = 'round';\n    \/\/ 1) Headland rings (kind === 'headland-ring'). Every U-turn arc sits\n    \/\/    on the same headland strip the ring drove, so the violet wash from\n    \/\/    ring + arcs stacks visibly at the boundary.\n    ctx.strokeStyle = 'rgba(162,28,175,0.10)';\n    if(hasPasses){\n      for(var pi=0; pi<layout.passes.length; pi++){\n        var pa = layout.passes[pi];\n        if(pa.kind !== 'headland-ring') continue;\n        if(pa.samples ? pa.samples.length >= 2 : false){\n          ctx.beginPath();\n          ctx.moveTo(px(proj, pa.samples[0].x), py(proj, pa.samples[0].y));\n          for(var s=1; s<pa.samples.length; s++){\n            ctx.lineTo(px(proj, pa.samples[s].x), py(proj, pa.samples[s].y));\n          }\n          ctx.stroke();\n        }\n      }\n    }\n    \/\/ 2) U-turn arcs (and racetracks). Slightly higher alpha because turns\n    \/\/    concentrate wheel slip + repeated motion in a tight zone, so the\n    \/\/    compaction effect is genuinely worse per pass than a straight body\n    \/\/    pass. Transport hops are skipped (those happen on roads\/tracks\n    \/\/    between disconnected field parts, not on the field itself).\n    ctx.strokeStyle = 'rgba(162,28,175,0.12)';\n    if(hasArcs){\n      for(var ti=0; ti<layout.turnArcs.length; ti++){\n        var arc = layout.turnArcs[ti];\n        if(arc.kind === 'transport') continue;\n        if(arc.coords.length < 2) continue;\n        ctx.beginPath();\n        ctx.moveTo(px(proj, arc.coords[0].x), py(proj, arc.coords[0].y));\n        for(var ac=1; ac<arc.coords.length; ac++){\n          ctx.lineTo(px(proj, arc.coords[ac].x), py(proj, arc.coords[ac].y));\n        }\n        ctx.stroke();\n      }\n    }\n    ctx.restore();\n  }\n  \/\/ Direction arrows \u2014 small chevron at the middle of each pass + arc, pointing\n  \/\/ along the drive direction. Makes the snake\/serpentine flow visible.\n  function drawDirectionArrows(proj, layout){\n    var arrowPx = 8;\n    function drawArrow(midX, midY, headingRad, color){\n      ctx.save();\n      ctx.translate(midX, midY);\n      ctx.rotate(headingRad);\n      ctx.fillStyle = color;\n      ctx.beginPath();\n      ctx.moveTo(arrowPx, 0);\n      ctx.lineTo(-arrowPx * 0.6, -arrowPx * 0.6);\n      ctx.lineTo(-arrowPx * 0.6, arrowPx * 0.6);\n      ctx.closePath();\n      ctx.fill();\n      ctx.restore();\n    }\n    \/\/ Pass arrows \u2014 orange to match the pass strokes. Use orderedPasses\n    \/\/ (post-snake, drive-direction oriented) so chevrons match actual flow:\n    \/\/ odd passes get reversed arrows, mirroring the operator's reverse drive.\n    \/\/\n    \/\/ Ring passes (kind === 'headland-ring') get arrows ONLY when the\n    \/\/ layout is rings-only (Boundary Follow). For approaches that mix\n    \/\/ headland ring + parallel body passes (AB Straight \/ AB Curve \/\n    \/\/ Adaptive), the single perimeter ring's direction is already shown\n    \/\/ by the U-turn chevrons at body-pass endpoints, so skipping it\n    \/\/ keeps the visual clean. Boundary Follow's spiral consists ENTIRELY\n    \/\/ of headland-ring kinds; without this branch the operator sees no\n    \/\/ direction arrows at all (reported as \"arrows not always shown\").\n    var passList = layout.orderedPasses ? layout.orderedPasses : layout.passes;\n    var hasNonRingPass = false;\n    if(passList){\n      for(var pn=0; pn<passList.length; pn++){\n        if(passList[pn].kind !== 'headland-ring'){ hasNonRingPass = true; break; }\n      }\n    }\n    if(passList){\n      for(var p=0; p<passList.length; p++){\n        var pa = passList[p];\n        if(pa.kind === 'headland-ring' ? hasNonRingPass : false) continue;\n        var ax, ay, bx, by;\n        if(pa.samples ? pa.samples.length >= 2 : false){\n          var midI = Math.floor(pa.samples.length \/ 2);\n          ax = pa.samples[midI - 1].x; ay = pa.samples[midI - 1].y;\n          bx = pa.samples[midI].x;     by = pa.samples[midI].y;\n        } else if(pa.x0 !== undefined){\n          ax = pa.x0; ay = pa.y0; bx = pa.x1; by = pa.y1;\n        } else continue;\n        var midX = px(proj, (ax + bx) * 0.5);\n        var midY = py(proj, (ay + by) * 0.5);\n        drawArrow(midX, midY, Math.atan2(by - ay, bx - ax), '#f76a0c');\n      }\n    }\n    \/\/ Turnaround arrows \u2014 violet, halfway through the arc. Transport hops\n    \/\/ get a gray arrow so the user sees direction across the gap.\n    if(layout.turnArcs){\n      for(var ti=0; ti<layout.turnArcs.length; ti++){\n        var arcObj = layout.turnArcs[ti];\n        var arc = arcObj.coords;\n        if(arc.length < 3 ? arc.length < 2 : false) continue;\n        var mi = Math.max(1, Math.floor(arc.length \/ 2));\n        var aX = arc[mi - 1].x, aY = arc[mi - 1].y;\n        var bX = arc[mi].x,     bY = arc[mi].y;\n        var amX = px(proj, (aX + bX) * 0.5);\n        var amY = py(proj, (aY + bY) * 0.5);\n        var arrowColor = arcObj.kind === 'transport' ? '#4c6066' : '#a21caf';\n        drawArrow(amX, amY, Math.atan2(bY - aY, bX - aX), arrowColor);\n      }\n    }\n  }\n  function drawSprite(proj){\n    var pos = playbackPosAt(playback.t);\n    if(!pos) return;\n    var halfW = playback.wM * 0.5 * proj.sc;\n    if(halfW < 6) halfW = 6;\n    var bodyLen = Math.max(10, halfW * 0.6);\n    ctx.save();\n    ctx.translate(px(proj, pos.x), py(proj, pos.y));\n    ctx.rotate(pos.heading);\n    \/\/ Implement bar (the wide piece behind the cab \u2014 orange to match passes)\n    ctx.fillStyle = '#f76a0c';\n    ctx.fillRect(-bodyLen * 0.4, -halfW, bodyLen * 0.35, halfW * 2);\n    \/\/ Tractor cab (compact dark-green block in front of the implement)\n    ctx.fillStyle = '#145328';\n    ctx.fillRect(0, -bodyLen * 0.35, bodyLen * 0.55, bodyLen * 0.7);\n    \/\/ Cab roof\n    ctx.fillStyle = '#1a7951';\n    ctx.fillRect(bodyLen * 0.1, -bodyLen * 0.22, bodyLen * 0.3, bodyLen * 0.44);\n    \/\/ GeoPard \"G\" badge on the cab roof \u2014 counter-rotated so it stays\n    \/\/ upright regardless of tractor heading. Skips when the sprite is\n    \/\/ tiny (zoomed out) \u2014 the letter would be sub-pixel anyway.\n    var roofCx = bodyLen * 0.25;  \/\/ centre of roof in local coords\n    var roofCy = 0;\n    var badgeFontPx = Math.max(6, bodyLen * 0.32);\n    if(badgeFontPx >= 7){\n      ctx.save();\n      ctx.translate(roofCx, roofCy);\n      ctx.rotate(-pos.heading);  \/\/ undo tractor rotation so G reads upright\n      ctx.font = '700 ' + badgeFontPx.toFixed(0) + 'px \"Poppins\", system-ui, sans-serif';\n      ctx.textAlign = 'center';\n      ctx.textBaseline = 'middle';\n      ctx.fillStyle = '#fafbf4';\n      ctx.fillText('G', 0, 0.5);\n      ctx.restore();\n    }\n    ctx.restore();\n  }\n  \/\/ Helper \u2014 trace ALL boundary parts as a single Path2D so we can use it as\n  \/\/ a clip region. The renderer uses this to ensure swath\/passes\/arcs\/turn\n  \/\/ strokes never paint outside the field boundary, even when the underlying\n  \/\/ geometry would extend a fat-line stroke past a concave or curved edge.\n  function buildBoundaryPath(proj, parts){\n    var path = new Path2D();\n    for(var pi=0; pi<parts.length; pi++){\n      var part = parts[pi];\n      if(!part ? true : part.length < 3) continue;\n      path.moveTo(px(proj, part[0].x), py(proj, part[0].y));\n      for(var v=1; v<part.length; v++){\n        path.lineTo(px(proj, part[v].x), py(proj, part[v].y));\n      }\n      path.closePath();\n      \/\/ Holes \u2014 add each as its own subpath. With evenodd fill\/clip the\n      \/\/ hole interior becomes 2-layers = OUTSIDE the region, so swath +\n      \/\/ passes + terrain are masked out of obstacles automatically.\n      if(part.holes ? part.holes.length > 0 : false){\n        for(var hh=0; hh<part.holes.length; hh++){\n          var hole = part.holes[hh];\n          if(!hole ? true : hole.length < 3) continue;\n          path.moveTo(px(proj, hole[0].x), py(proj, hole[0].y));\n          for(var hv=1; hv<hole.length; hv++){\n            path.lineTo(px(proj, hole[hv].x), py(proj, hole[hv].y));\n          }\n          path.closePath();\n        }\n      }\n    }\n    return path;\n  }\n  function draw(layout){\n    var rect = canvas.getBoundingClientRect();\n    \/\/ Re-sync the backing buffer to the canvas's CURRENT CSS size before\n    \/\/ clearing. Reported 2026-06-03: switching fields after the canvas's\n    \/\/ CSS dimensions had changed (sidebar drag, viewport resize, parent\n    \/\/ grid reflow) left a triangular blob of the OLD render at the\n    \/\/ bottom of the canvas. Cause: when CSS dims shrank but\n    \/\/ canvas.width\/height attributes still held the old (larger) backing\n    \/\/ size, clearRect only zeroed the new CSS area while the browser\n    \/\/ continued to scale the larger backing buffer (including the\n    \/\/ un-cleared bottom strip) to fit the smaller display rectangle.\n    \/\/ Re-asserting buffer size on every draw is cheap (a property check)\n    \/\/ and a no-op when dimensions already match.\n    var expectedW = Math.floor(rect.width * DPR);\n    var expectedH = Math.floor(rect.height * DPR);\n    if(canvas.width !== expectedW ? true : canvas.height !== expectedH){\n      canvas.width = expectedW;\n      canvas.height = expectedH;\n      ctx.setTransform(DPR, 0, 0, DPR, 0, 0);\n    }\n    ctx.clearRect(0, 0, rect.width, rect.height);\n    var proj = getScale();\n    var boundary = layout.boundary ? layout.boundary : BOUNDARY;\n    var parts = layout.boundaryParts ? (layout.boundaryParts.length ? layout.boundaryParts : [boundary]) : [boundary];\n    var interiors = layout.interiors ? (layout.interiors.length ? layout.interiors : (layout.interior ? [layout.interior] : [])) : (layout.interior ? [layout.interior] : []);\n    var hasHeadland = interiors.length > 0 ? interiors[0] !== boundary : false;\n    \/\/ Terrain heatmap behind everything\n    drawTerrain(proj);\n    \/\/ Headland strip = boundary minus interior (even-odd fill), per part.\n    \/\/ Self-intersect guard (rule \u00a76): on irregular boundaries with sharp\n    \/\/ narrow corners (lv Landgut at 36 m headland on a pointy upper-\n    \/\/ left tip), the inward-offset polygon folds back on itself \u2192 the\n    \/\/ even-odd fill renders an X-pattern where the inset has crossed\n    \/\/ itself. Skip the strip fill for any part whose interior self-\n    \/\/ intersects so the user sees a clean field outline + body passes\n    \/\/ instead of a broken strip. Reported 2026-06-03.\n    if(hasHeadland){\n      for(var hp=0; hp<parts.length; hp++){\n        var hPart = parts[hp];\n        var hInner = interiors[hp] || hPart;\n        if(hInner === hPart) continue;  \/\/ no headland for this part\n        if(typeof polygonSelfIntersects === 'function' ? polygonSelfIntersects(hInner) : false) continue;\n        ctx.save();\n        ctx.fillStyle = 'rgba(26,121,81,0.18)';\n        ctx.beginPath();\n        for(var i=0; i<hPart.length; i++){\n          var bx = px(proj, hPart[i].x);\n          var by = py(proj, hPart[i].y);\n          if(i === 0) ctx.moveTo(bx, by); else ctx.lineTo(bx, by);\n        }\n        ctx.closePath();\n        for(var j=hInner.length-1; j>=0; j--){\n          var ix = px(proj, hInner[j].x);\n          var iy = py(proj, hInner[j].y);\n          if(j === hInner.length-1) ctx.moveTo(ix, iy); else ctx.lineTo(ix, iy);\n        }\n        ctx.closePath();\n        ctx.fill('evenodd');\n        ctx.restore();\n      }\n    }\n    \/\/ Drivable outline (only if outsideBuffer > 0 \u2192 drivable \u2260 boundary)\n    if(layout.drivable ? layout.drivable !== boundary : false){\n      ctx.save();\n      ctx.strokeStyle = 'rgba(20,83,40,0.35)';\n      ctx.setLineDash([4, 4]);\n      ctx.lineWidth = 1;\n      ctx.beginPath();\n      for(var dv=0; dv<layout.drivable.length; dv++){\n        var dvx = px(proj, layout.drivable[dv].x);\n        var dvy = py(proj, layout.drivable[dv].y);\n        if(dv === 0) ctx.moveTo(dvx, dvy); else ctx.lineTo(dvx, dvy);\n      }\n      ctx.closePath();\n      ctx.stroke();\n      ctx.restore();\n    }\n    \/\/ Field boundary \u2014 one stroke per part\n    ctx.save();\n    ctx.strokeStyle = '#145328';\n    ctx.lineWidth = 2;\n    ctx.lineJoin = 'round';\n    for(var bp=0; bp<parts.length; bp++){\n      var bPart = parts[bp];\n      ctx.beginPath();\n      for(var k=0; k<bPart.length; k++){\n        var bxF = px(proj, bPart[k].x);\n        var byF = py(proj, bPart[k].y);\n        if(k === 0) ctx.moveTo(bxF, byF); else ctx.lineTo(bxF, byF);\n      }\n      ctx.closePath();\n      ctx.stroke();\n    }\n    ctx.restore();\n    \/\/ Obstacle \/ hole outlines \u2014 drawn as a hatched no-work zone so the\n    \/\/ operator reads them as \"drive around, don't enter\". A muted fill +\n    \/\/ dark dashed edge distinguishes them from the field boundary.\n    var anyHole = false;\n    for(var hpC=0; hpC<parts.length; hpC++){ if(parts[hpC].holes ? parts[hpC].holes.length > 0 : false){ anyHole = true; break; } }\n    if(anyHole){\n      ctx.save();\n      for(var hp=0; hp<parts.length; hp++){\n        var hParts = parts[hp].holes;\n        if(!hParts ? true : hParts.length === 0) continue;\n        for(var hi=0; hi<hParts.length; hi++){\n          var hr = hParts[hi];\n          if(!hr ? true : hr.length < 3) continue;\n          ctx.beginPath();\n          for(var hk=0; hk<hr.length; hk++){\n            var hxF = px(proj, hr[hk].x);\n            var hyF = py(proj, hr[hk].y);\n            if(hk === 0) ctx.moveTo(hxF, hyF); else ctx.lineTo(hxF, hyF);\n          }\n          ctx.closePath();\n          \/\/ Muted grey-green fill so the cut-out reads as a no-work zone.\n          ctx.fillStyle = 'rgba(76,96,102,0.16)';\n          ctx.fill();\n          ctx.strokeStyle = 'rgba(76,96,102,0.85)';\n          ctx.lineWidth = 1.4;\n          ctx.setLineDash([5, 3]);\n          ctx.lineJoin = 'round';\n          ctx.stroke();\n          ctx.setLineDash([]);\n        }\n      }\n      ctx.restore();\n    }\n    \/\/ Interior outline (inner edge of headland strip) \u2014 one per part.\n    \/\/ Rule \u00a76 \u2014 skip the stroke when the inset polygon self-intersects.\n    \/\/ On sharp\/acute boundary tips (lv Landgut upper-left V), the\n    \/\/ inward-offset folds back on itself near the apex; drawing the\n    \/\/ raw polyline shows that fold as crossing green lines (reported\n    \/\/ 2026-06-04). The headland-strip fill already has the same\n    \/\/ guard one block above; the stroke needs it too.\n    if(hasHeadland){\n      for(var ip2=0; ip2<interiors.length; ip2++){\n        var inner = interiors[ip2];\n        if(typeof polygonSelfIntersects === 'function' ? polygonSelfIntersects(inner) : false) continue;\n        ctx.save();\n        ctx.strokeStyle = '#1a7951';\n        ctx.lineWidth = 1.5;\n        ctx.beginPath();\n        for(var rp=0; rp<inner.length; rp++){\n          var rx = px(proj, inner[rp].x);\n          var ry = py(proj, inner[rp].y);\n          if(rp === 0) ctx.moveTo(rx, ry); else ctx.lineTo(rx, ry);\n        }\n        ctx.closePath();\n        ctx.stroke();\n        ctx.restore();\n      }\n    }\n    \/\/ Rule \u00a71 \u2014 the machine must NEVER leave the field boundary. Clip every\n    \/\/ fat-line stroke (swath, compaction wash, passes, turn arcs) to the\n    \/\/ boundary so concave \/ curved edges don't visually overshoot. Transport\n    \/\/ legs between disconnected parts are drawn OUTSIDE this clip because\n    \/\/ they explicitly cross the gap between two parts.\n    var boundaryPath = buildBoundaryPath(proj, parts);\n    ctx.save();\n    ctx.clip(boundaryPath, 'evenodd');\n    \/\/ Uploaded existing guidance lines \u2014 drawn UNDER the proposed plan so\n    \/\/ the user can compare what they're doing today vs what we suggest.\n    \/\/ Semi-transparent blue, dashed, so they don't fight visually with the\n    \/\/ orange proposed swath painted on top.\n    if(UPLOADED_LINES ? UPLOADED_LINES.length > 0 : false){\n      ctx.save();\n      ctx.lineCap = 'round';\n      \/\/ Distinguish headland-ring traces (closed loops) from body\n      \/\/ passes so the user can SEE that imported headlands made it\n      \/\/ through. Body passes: dashed thin blue. Headland traces:\n      \/\/ SOLID thicker blue, no dashes \u2014 looks like an actual driven\n      \/\/ perimeter (matches what QGIS \/ John Deere Ops renders).\n      \/\/ Reported 2026-06-04: \"when import existing lines, you need\n      \/\/ also to import headland lines, import all lines\".\n      for(var ul=0; ul<UPLOADED_LINES.length; ul++){\n        var uln = UPLOADED_LINES[ul];\n        if(uln.length < 2) continue;\n        var isLoop = (typeof isImportedLineLoop === 'function') ? isImportedLineLoop(uln) : false;\n        if(isLoop){\n          ctx.strokeStyle = 'rgba(33,102,200,0.85)';\n          ctx.lineWidth = 2.2;\n          ctx.setLineDash([]);\n        } else {\n          ctx.strokeStyle = 'rgba(33,102,200,0.55)';\n          ctx.lineWidth = 1.6;\n          ctx.setLineDash([6, 4]);\n        }\n        ctx.beginPath();\n        ctx.moveTo(px(proj, uln[0].x), py(proj, uln[0].y));\n        for(var uv=1; uv<uln.length; uv++){\n          ctx.lineTo(px(proj, uln[uv].x), py(proj, uln[uv].y));\n        }\n        ctx.stroke();\n      }\n      ctx.restore();\n    }\n    \/\/ Soil compaction zones (always-visible violet glow under each turn arc) \u2014\n    \/\/ skip transport arcs (no work happens during a transport hop).\n    drawCompactionZones(proj, layout);\n    \/\/ Swath coverage band (filled as playback progresses) \u2014 also skips\n    \/\/ transport segments (drawSwath checks point.transport).\n    drawSwath(proj);\n    \/\/ Passes (the worked guidance lines)\n    ctx.strokeStyle = '#f76a0c';\n    ctx.lineWidth = 1.2;\n    ctx.lineCap = 'round';\n    ctx.setLineDash([]);\n    for(var p=0; p<layout.passes.length; p++){\n      var pa = layout.passes[p];\n      ctx.beginPath();\n      if(pa.samples){\n        for(var ps=0; ps<pa.samples.length; ps++){\n          var ssx = px(proj, pa.samples[ps].x);\n          var ssy = py(proj, pa.samples[ps].y);\n          if(ps === 0) ctx.moveTo(ssx, ssy); else ctx.lineTo(ssx, ssy);\n        }\n      } else {\n        ctx.moveTo(px(proj, pa.x0), py(proj, pa.y0));\n        ctx.lineTo(px(proj, pa.x1), py(proj, pa.y1));\n      }\n      ctx.stroke();\n    }\n    ctx.restore();  \/\/ closes the boundary clip\n    \/\/ Turn arcs are rendered OUTSIDE the boundary clip on purpose \u2014 U-turn\n    \/\/ arcs intentionally bulge outward into the headland strip and the\n    \/\/ drivable buffer, and the boundary clip was previously truncating them\n    \/\/ (issue: \"some U-turns are not shown\"). The machine's IMPLEMENT is\n    \/\/ lifted on turns, so it can extend past the worked-area edge; the\n    \/\/ boundary stroke + buffer dashed ring still mark the field outline.\n    if(layout.turnArcs ? layout.turnArcs.length : false){\n      ctx.save();\n      ctx.lineCap = 'round';\n      ctx.lineJoin = 'round';\n      for(var ti=0; ti<layout.turnArcs.length; ti++){\n        var arc = layout.turnArcs[ti];\n        if(arc.kind === 'transport') continue;  \/\/ drawn separately below\n        var isTraverse = arc.kind === 'traverse';\n        var isRingRoute = arc.kind === 'ring-route';\n        var isRingJump = arc.kind === 'ring-jump';\n        if(isTraverse){\n          ctx.strokeStyle = '#a21caf';\n          ctx.lineWidth = 1.8;\n          ctx.setLineDash([6, 4]);\n        } else if(isRingRoute){\n          ctx.strokeStyle = '#a21caf';\n          ctx.lineWidth = 1.6;\n          ctx.setLineDash([3, 3]);\n        } else if(isRingJump){\n          \/\/ Ring-to-ring radial transit (Boundary Follow, multi-ring headland).\n          \/\/ Visualised as a thinner dashed violet so the user sees the machine\n          \/\/ physically moving from one ring to the next instead of teleporting.\n          ctx.strokeStyle = '#a21caf';\n          ctx.lineWidth = 1.4;\n          ctx.setLineDash([4, 3]);\n        } else {\n          ctx.strokeStyle = arc.ok ? '#a21caf' : '#dc2626';\n          ctx.lineWidth = arc.ok ? 2 : 1.4;\n          ctx.setLineDash(arc.ok ? [] : [3, 3]);\n        }\n        ctx.beginPath();\n        for(var ac=0; ac<arc.coords.length; ac++){\n          var aex = px(proj, arc.coords[ac].x);\n          var aey = py(proj, arc.coords[ac].y);\n          if(ac === 0) ctx.moveTo(aex, aey); else ctx.lineTo(aex, aey);\n        }\n        ctx.stroke();\n      }\n      ctx.restore();\n    }\n    \/\/ Transport legs between disconnected parts \u2014 drawn OUTSIDE the clip\n    \/\/ because they cross the gap between two field parts. Gray dashed line\n    \/\/ only; the dash pattern already reads as \"no work here\" (the text\n    \/\/ pill was dropped 2026-06-11 per user feedback \u2014 it cluttered the\n    \/\/ canvas, especially on multi-part fields with several hops).\n    if(layout.turnArcs ? layout.turnArcs.length : false){\n      ctx.save();\n      ctx.strokeStyle = '#4c6066';\n      ctx.lineWidth = 1.6;\n      ctx.lineCap = 'round';\n      ctx.setLineDash([8, 6]);\n      for(var tt=0; tt<layout.turnArcs.length; tt++){\n        var tArc = layout.turnArcs[tt];\n        if(tArc.kind !== 'transport') continue;\n        if(!tArc.coords ? true : tArc.coords.length < 2) continue;\n        ctx.beginPath();\n        ctx.moveTo(px(proj, tArc.coords[0].x), py(proj, tArc.coords[0].y));\n        for(var tc=1; tc<tArc.coords.length; tc++){\n          ctx.lineTo(px(proj, tArc.coords[tc].x), py(proj, tArc.coords[tc].y));\n        }\n        ctx.stroke();\n      }\n      ctx.restore();\n    }\n    \/\/ Direction arrows on passes + turn arcs (above lines, below sprite)\n    drawDirectionArrows(proj, layout);\n    \/\/ Tractor sprite on top\n    drawSprite(proj);\n    \/\/ Scale bar \u2014 pick a nice round metres value for ~60 px on screen and\n    \/\/ adjust the bar width to match the chosen value at current proj.sc.\n    var scaleBarEl = document.getElementById('gpl-scale');\n    var scaleLblEl = document.getElementById('gpl-scale-lbl');\n    if(scaleBarEl ? scaleLblEl : false){\n      var targetPx = 60;\n      var rawMetres = targetPx \/ proj.sc;\n      \/\/ Snap to a 1\/2\/5 \u00d7 10\u207f pattern (standard cartographic scale steps)\n      var pow10 = Math.pow(10, Math.floor(Math.log(rawMetres) \/ Math.LN10));\n      var snapVal;\n      var lead = rawMetres \/ pow10;\n      if(lead < 1.5) snapVal = 1 * pow10;\n      else if(lead < 3.5) snapVal = 2 * pow10;\n      else if(lead < 7.5) snapVal = 5 * pow10;\n      else snapVal = 10 * pow10;\n      var barPx = snapVal * proj.sc;\n      var barEl = scaleBarEl.querySelector('.gpl-scale-bar');\n      if(barEl) barEl.style.width = barPx.toFixed(0) + 'px';\n      scaleLblEl.textContent = fmtDist(snapVal);\n    }\n    \/\/ Ruler line on absolute top\n    if(ruler.p1 ? ruler.p2 : false){\n      ctx.save();\n      ctx.strokeStyle = '#dc2626';\n      ctx.lineWidth = 2;\n      ctx.setLineDash([6, 4]);\n      ctx.beginPath();\n      ctx.moveTo(px(proj, ruler.p1.x), py(proj, ruler.p1.y));\n      ctx.lineTo(px(proj, ruler.p2.x), py(proj, ruler.p2.y));\n      ctx.stroke();\n      ctx.restore();\n      \/\/ Endpoint dots\n      ctx.save();\n      ctx.fillStyle = '#dc2626';\n      ctx.beginPath(); ctx.arc(px(proj, ruler.p1.x), py(proj, ruler.p1.y), 4, 0, Math.PI*2); ctx.fill();\n      ctx.beginPath(); ctx.arc(px(proj, ruler.p2.x), py(proj, ruler.p2.y), 4, 0, Math.PI*2); ctx.fill();\n      ctx.restore();\n      \/\/ Distance label\n      var rdx = ruler.p2.x - ruler.p1.x;\n      var rdy = ruler.p2.y - ruler.p1.y;\n      var rDist = Math.sqrt(rdx*rdx + rdy*rdy);\n      var labelMx = px(proj, (ruler.p1.x + ruler.p2.x) * 0.5);\n      var labelMy = py(proj, (ruler.p1.y + ruler.p2.y) * 0.5);\n      ctx.save();\n      ctx.font = '700 12px \"DM Mono\", ui-monospace, monospace';\n      var label = fmtDist(rDist);\n      var tw = ctx.measureText(label).width + 14;\n      ctx.fillStyle = 'rgba(220,38,38,0.95)';\n      ctx.fillRect(labelMx - tw \/ 2, labelMy - 11, tw, 22);\n      ctx.fillStyle = '#fff';\n      ctx.textAlign = 'center';\n      ctx.textBaseline = 'middle';\n      ctx.fillText(label, labelMx, labelMy);\n      ctx.restore();\n    } else if(ruler.p1){\n      ctx.save();\n      ctx.fillStyle = '#dc2626';\n      ctx.beginPath(); ctx.arc(px(proj, ruler.p1.x), py(proj, ruler.p1.y), 5, 0, Math.PI*2); ctx.fill();\n      ctx.restore();\n    }\n    \/\/ User-drawn AB line (the vector of main path). Persists after the tool\n    \/\/ exits so the user keeps seeing the direction they locked. Brand orange\n    \/\/ dashed line with a solid arrowhead at p2 and an \"AB\" label near the\n    \/\/ midpoint so it's distinguishable from the ruler's red dashed line.\n    if(abTool.p1 ? abTool.p2 : false){\n      ctx.save();\n      ctx.strokeStyle = '#f76a0c';\n      ctx.lineWidth = 2.5;\n      ctx.setLineDash([8, 4]);\n      ctx.lineCap = 'round';\n      var ax1 = px(proj, abTool.p1.x), ay1 = py(proj, abTool.p1.y);\n      var ax2 = px(proj, abTool.p2.x), ay2 = py(proj, abTool.p2.y);\n      ctx.beginPath();\n      ctx.moveTo(ax1, ay1);\n      ctx.lineTo(ax2, ay2);\n      ctx.stroke();\n      ctx.setLineDash([]);\n      \/\/ Endpoint markers (start hollow, end solid arrow)\n      ctx.fillStyle = '#ffffff';\n      ctx.beginPath(); ctx.arc(ax1, ay1, 5, 0, Math.PI * 2); ctx.fill();\n      ctx.strokeStyle = '#f76a0c';\n      ctx.lineWidth = 2;\n      ctx.stroke();\n      \/\/ Arrowhead at p2 \u2014 solid filled triangle along the vector direction\n      var hdx = ax2 - ax1, hdy = ay2 - ay1;\n      var hlen = Math.sqrt(hdx * hdx + hdy * hdy);\n      if(hlen > 1){\n        var ux = hdx \/ hlen, uy = hdy \/ hlen;\n        var arrowLen = 12, arrowWide = 8;\n        var tipX = ax2, tipY = ay2;\n        var baseX = ax2 - ux * arrowLen, baseY = ay2 - uy * arrowLen;\n        var leftX = baseX + (-uy) * arrowWide * 0.5;\n        var leftY = baseY + (ux) * arrowWide * 0.5;\n        var rightX = baseX - (-uy) * arrowWide * 0.5;\n        var rightY = baseY - (ux) * arrowWide * 0.5;\n        ctx.fillStyle = '#f76a0c';\n        ctx.beginPath();\n        ctx.moveTo(tipX, tipY);\n        ctx.lineTo(leftX, leftY);\n        ctx.lineTo(rightX, rightY);\n        ctx.closePath();\n        ctx.fill();\n      }\n      \/\/ Label \"AB\" near midpoint\n      var midX = (ax1 + ax2) * 0.5;\n      var midY = (ay1 + ay2) * 0.5;\n      ctx.font = '700 11px \"DM Mono\", ui-monospace, monospace';\n      var lblW = ctx.measureText('AB').width + 12;\n      ctx.fillStyle = 'rgba(247,106,12,0.95)';\n      ctx.fillRect(midX - lblW \/ 2, midY - 10, lblW, 20);\n      ctx.fillStyle = '#fff';\n      ctx.textAlign = 'center';\n      ctx.textBaseline = 'middle';\n      ctx.fillText('AB', midX, midY);\n      ctx.restore();\n    } else if(abTool.p1){\n      ctx.save();\n      ctx.fillStyle = '#f76a0c';\n      ctx.beginPath(); ctx.arc(px(proj, abTool.p1.x), py(proj, abTool.p1.y), 5, 0, Math.PI * 2); ctx.fill();\n      ctx.restore();\n    }\n  }\n  var current = 'ab-straight';\n  \/\/ Last metric-driven best pick, set by recompute() after recommendByMetrics\n  \/\/ runs. Read by autoPickBestApproach() to decide whether to swap the\n  \/\/ current approach on field-load + initial-page-load.\n  var lastBestPick = null;\n  \/\/ True while autoPickBestApproach is actively switching approaches \u2014\n  \/\/ prevents recursive auto-pick if recompute() re-fires recommendByMetrics.\n  var autoPickInFlight = false;\n  \/\/ True ONLY between (a) initial-page-load or (b) field-switch and the\n  \/\/ first deferred-block autoPickBestApproach call. Cleared after the\n  \/\/ swap fires (or determines no swap is needed). Prevents the deferred\n  \/\/ block + axis sweep from overriding the user's explicit approach choice\n  \/\/ when they manually click a radio button (reported 2026-06-04 \u2014 \"I\n  \/\/ can't select any other approach since it automatically selects the\n  \/\/ best\"). Manual radio clicks clear current to the user's choice\n  \/\/ WITHOUT setting this flag, so subsequent recomputes don't swap back.\n  var pendingAutoPick = false;\n  \/\/ After recompute settles, if the metric-driven best differs from the\n  \/\/ currently-selected approach, swap to it and re-run recompute once.\n  \/\/ Used on (a) initial page load and (b) field-switch \u2014 NOT used after a\n  \/\/ user clicks an approach radio (that's an explicit user choice we honour).\n  function autoPickBestApproach(){\n    if(autoPickInFlight) return;\n    if(!pendingAutoPick) return;  \/\/ Manual radio clicks are sticky\n    if(!lastBestPick){ pendingAutoPick = false; return; }\n    if(lastBestPick === current){ pendingAutoPick = false; return; }\n    autoPickInFlight = true;\n    pendingAutoPick = false;  \/\/ One auto-pick per field-switch \/ initial-load\n    current = lastBestPick;\n    var rs = document.querySelectorAll('#gpl-approach input[type=radio]');\n    var ls = document.querySelectorAll('#gpl-approach label');\n    for(var ix=0; ix<rs.length; ix++){\n      var mt = rs[ix].value === current;\n      rs[ix].checked = mt;\n      if(ls[ix]) ls[ix].classList.toggle('is-on', mt);\n    }\n    try { recompute(); } finally { autoPickInFlight = false; }\n  }\n  \/\/ Plan-view toggle: 'proposed' = play + analyse the algorithm-\n  \/\/ generated layout; 'uploaded' = play + analyse the user's uploaded\n  \/\/ existing guidance lines. Auto-resets to 'proposed' when no upload.\n  \/\/ Legacy. 'uploaded' is now a first-class approach value, so this\n  \/\/ variable is unused at runtime \u2014 kept here as a hint for future\n  \/\/ editors that the upload-as-view mode used to live in a sibling\n  \/\/ toggle.\n  var planView = 'proposed';\n  \/\/ Build a layout that mirrors the generateLines return shape but\n  \/\/ sources its passes from UPLOADED_LINES. Drives the playback by\n  \/\/ chaining lines sequentially with short transport hops between\n  \/\/ them (operator finished line A, lifted, drove to start of line B).\n  function buildUploadedLayout(){\n    if(!UPLOADED_LINES ? true : UPLOADED_LINES.length === 0) return null;\n    \/\/ Separate closed-loop headland-ring traces from body passes.\n    \/\/ Both are DRIVEN by the operator (closed loops = perimeter pass);\n    \/\/ they just need different `kind` tags so the planner treats them\n    \/\/ correctly (rings = headland strip, body = serpentine). Earlier\n    \/\/ revision filtered loops out entirely \u2192 headland uncovered in\n    \/\/ playback + coverage metric undercounted (reported 2026-06-04).\n    var allDriven = drivenLinesOnly(UPLOADED_LINES);\n    var drivenLines = [];\n    var ringLines = [];\n    for(var sli=0; sli<allDriven.length; sli++){\n      if(isImportedLineLoop(allDriven[sli])) ringLines.push(allDriven[sli]);\n      else drivenLines.push(allDriven[sli]);\n    }\n    if(drivenLines.length === 0 ? ringLines.length === 0 : false) return null;\n    \/\/ SERPENTINE INFERENCE \u2014 real operators alternate pass direction\n    \/\/ (pass 0 L\u2192R, pass 1 R\u2192L, pass 2 L\u2192R \u2026) so each U-turn is short\n    \/\/ (~ one swath). But many export files store passes ALL in the\n    \/\/ same coordinate order (sorted by perp position). Iterating in\n    \/\/ file order then produces 200 m diagonal gaps between consecutive\n    \/\/ endpoints \u2014 which mis-classify as transports + miss the turn\n    \/\/ count entirely. Greedy fix: keep pass 0 as-is, then for each\n    \/\/ subsequent pass pick the orientation (forward or reversed) that\n    \/\/ minimises the gap to the previous endpoint.\n    var orderedLines = [];\n    if(drivenLines.length > 0){\n      orderedLines.push(drivenLines[0].slice());\n      orderedLines[0].driven = true;\n    }\n    for(var olp=1; olp<drivenLines.length; olp++){\n      var prevEnd = orderedLines[olp - 1][orderedLines[olp - 1].length - 1];\n      var next = drivenLines[olp];\n      var nFirst = next[0];\n      var nLast = next[next.length - 1];\n      var dFwd = (nFirst.x - prevEnd.x) * (nFirst.x - prevEnd.x) + (nFirst.y - prevEnd.y) * (nFirst.y - prevEnd.y);\n      var dRev = (nLast.x - prevEnd.x) * (nLast.x - prevEnd.x) + (nLast.y - prevEnd.y) * (nLast.y - prevEnd.y);\n      var oriented;\n      if(dRev < dFwd){\n        oriented = next.slice().reverse();\n      } else {\n        oriented = next.slice();\n      }\n      oriented.driven = true;\n      orderedLines.push(oriented);\n    }\n    drivenLines = orderedLines;\n    var upPasses = [];\n    for(var ui=0; ui<drivenLines.length; ui++){\n      upPasses.push({ samples: drivenLines[ui], kind: 'uploaded' });\n    }\n    \/\/ SYNTHETIC TURN ARCS \u2014 uploaded line files only record the BODY\n    \/\/ passes; the operator's actual U-turns between passes aren't in\n    \/\/ the file. But the machine physically MUST turn around between\n    \/\/ each pair of consecutive passes (operator + tractor can't\n    \/\/ teleport). Reported 2026-06-03: \"Your current lines \u2014 if there\n    \/\/ are no turns in lines itself, turns are still there, machine\n    \/\/ can't work w\/o turnarounds\".\n    \/\/\n    \/\/ Synthesise one turn arc per consecutive pass pair:\n    \/\/   \u2022 length = max(straight-line gap, \u03c0 \u00d7 turnR)  \u2014 at least one\n    \/\/     half-circle at the configured turn radius, longer if the\n    \/\/     consecutive endpoints are far apart.\n    \/\/   \u2022 kind = 'turn' so it counts in nTurns + turnLengthM.\n    \/\/   \u2022 kind = 'transport' (skipped from turn count) when the gap\n    \/\/     exceeds ~6 \u00d7 wM \u2014 that's a field-to-field hop, not a turn.\n    \/\/   \u2022 coords = a 3-vertex V-shape polyline with the right total\n    \/\/     length (we don't render uploaded turns on canvas, but the\n    \/\/     coords MUST sum to the intended length for computeMetrics).\n    var wmInpEl = document.getElementById('gpl-wm');\n    var wMRef = parseFloat((wmInpEl || {}).value) || 18;\n    if(unitSystem === 'us') wMRef = wMRef \/ M_TO_FT;\n    var trInpEl = document.getElementById('gpl-turn-r');\n    var turnRRef = parseFloat((trInpEl || {}).value) || 8;\n    if(unitSystem === 'us') turnRRef = turnRRef \/ M_TO_FT;\n    var minTurnLen = Math.PI * turnRRef;\n    var transportGapM = 6 * wMRef;\n    var turnArcs = [];\n    function buildSyntheticArc(p1, p2, minLen){\n      var dx = p2.x - p1.x, dy = p2.y - p1.y;\n      var gap = Math.sqrt(dx * dx + dy * dy);\n      if(gap < 0.001) return [{x: p1.x, y: p1.y}, {x: p2.x, y: p2.y}];\n      \/\/ Half-circle U-turn polyline matching what buildSerpentine\n      \/\/ produces for the proposed approaches. Radius = max(gap\/2, turnR)\n      \/\/ so the chord fits inside the circle. Bulges in the +perp\n      \/\/ direction (away from the chord). 12 sample points = smooth curve\n      \/\/ + lets countDriveCrossings see the actual arc geometry (so\n      \/\/ crossings count fairly when arcs bulge into adjacent passes).\n      \/\/ Previously the synthetic was a single bump-vertex V-shape \u2014\n      \/\/ didn't cross anything, made \"Your current lines\" look cleaner\n      \/\/ than it would in a real comparison (reported 2026-06-03).\n      var midX = (p1.x + p2.x) * 0.5;\n      var midY = (p1.y + p2.y) * 0.5;\n      var perpX = -dy \/ gap, perpY = dx \/ gap;\n      var radius = Math.max(gap * 0.5, minLen \/ Math.PI);\n      var halfGap = gap * 0.5;\n      var centerOff = radius > halfGap ? Math.sqrt(radius * radius - halfGap * halfGap) : 0;\n      \/\/ Arc center is centerOff away from midpoint, on the -perp side\n      \/\/ so the arc itself bulges +perp.\n      var cxA = midX - perpX * centerOff;\n      var cyA = midY - perpY * centerOff;\n      var a1 = Math.atan2(p1.y - cyA, p1.x - cxA);\n      var a2 = Math.atan2(p2.y - cyA, p2.x - cxA);\n      \/\/ Walk from a1 to a2 the LONG way (passing through +perp side).\n      var sweep = a2 - a1;\n      while(sweep < 0) sweep += 2 * Math.PI;\n      \/\/ The midpoint of the arc going CCW from a1 to a2 lands at angle\n      \/\/ a1 + sweep\/2. If that midpoint sits on the -perp side (closer\n      \/\/ to center, i.e. the wrong side), flip direction.\n      var midAng = a1 + sweep * 0.5;\n      var midSampleX = cxA + radius * Math.cos(midAng);\n      var midSampleY = cyA + radius * Math.sin(midAng);\n      var bulgeDot = (midSampleX - midX) * perpX + (midSampleY - midY) * perpY;\n      if(bulgeDot < 0){ sweep = sweep - 2 * Math.PI; }  \/\/ go the other way\n      var nSteps = 12;\n      var coords = [];\n      for(var k=0; k<=nSteps; k++){\n        var t = k \/ nSteps;\n        var theta = a1 + sweep * t;\n        coords.push({ x: cxA + radius * Math.cos(theta), y: cyA + radius * Math.sin(theta) });\n      }\n      return coords;\n    }\n    for(var tai=0; tai<upPasses.length - 1; tai++){\n      var prevLine = upPasses[tai].samples;\n      var nextLine = upPasses[tai + 1].samples;\n      if(!prevLine ? true : prevLine.length < 1) continue;\n      if(!nextLine ? true : nextLine.length < 1) continue;\n      var endPrev = prevLine[prevLine.length - 1];\n      var startNext = nextLine[0];\n      var dgx = startNext.x - endPrev.x, dgy = startNext.y - endPrev.y;\n      var rawGap = Math.sqrt(dgx * dgx + dgy * dgy);\n      var arcKind = rawGap > transportGapM ? 'transport' : 'turn';\n      \/\/ For transport hops keep a straight line; for turns build a\n      \/\/ proper half-circle (matches buildSerpentine's geometry \u2014 fair\n      \/\/ crossings comparison vs proposed approaches).\n      var coords;\n      if(arcKind === 'transport'){\n        coords = [{ x: endPrev.x, y: endPrev.y }, { x: startNext.x, y: startNext.y }];\n      } else {\n        coords = buildSyntheticArc(endPrev, startNext, minTurnLen);\n      }\n      turnArcs.push({ coords: coords, kind: arcKind });\n    }\n    \/\/ Push the closed-loop headland-ring traces FIRST in the playback\n    \/\/ sequence \u2014 operator drives the perimeter, THEN the body (rule \u00a713\n    \/\/ \"work headland first\"). Coverage now includes the ring swath.\n    for(var rli=0; rli<ringLines.length; rli++){\n      upPasses.push({ samples: ringLines[rli], kind: 'headland-ring' });\n    }\n    var driveCoords = [];\n    \/\/ 1) Headland rings (closed loops) \u2014 drive each loop fully before\n    \/\/    transporting to the next ring or to the first body pass.\n    for(var rdi=0; rdi<ringLines.length; rdi++){\n      var rline = ringLines[rdi];\n      if(!rline ? true : rline.length < 3) continue;\n      if(driveCoords.length > 0){\n        var lastPR = driveCoords[driveCoords.length - 1];\n        var firstPR = rline[0];\n        for(var trI=1; trI<=10; trI++){\n          var trT = trI \/ 10;\n          driveCoords.push({\n            x: lastPR.x + (firstPR.x - lastPR.x) * trT,\n            y: lastPR.y + (firstPR.y - lastPR.y) * trT,\n            transport: true\n          });\n        }\n      }\n      for(var rvi=0; rvi<rline.length; rvi++){\n        driveCoords.push({ x: rline[rvi].x, y: rline[rvi].y });\n      }\n    }\n    \/\/ 2) Body passes (with synthetic turn arcs interleaved \u2014 already in\n    \/\/    turnArcs, so we just concat the pass coords with transport\n    \/\/    legs between non-consecutive lines).\n    for(var li=0; li<drivenLines.length; li++){\n      var line = drivenLines[li];\n      if(!line ? true : line.length < 2) continue;\n      if(driveCoords.length > 0){\n        var lastP = driveCoords[driveCoords.length - 1];\n        var firstP = line[0];\n        var nT = 10;\n        for(var ti=1; ti<=nT; ti++){\n          var tt = ti \/ nT;\n          driveCoords.push({\n            x: lastP.x + (firstP.x - lastP.x) * tt,\n            y: lastP.y + (firstP.y - lastP.y) * tt,\n            transport: true\n          });\n        }\n      }\n      for(var vi=0; vi<line.length; vi++){\n        driveCoords.push({ x: line[vi].x, y: line[vi].y });\n      }\n    }\n    return {\n      passes: upPasses,\n      interior: BOUNDARY,\n      drivable: BOUNDARY,\n      boundary: BOUNDARY,\n      boundaryParts: BOUNDARY_PARTS,\n      turnArcs: turnArcs,\n      drivePath: driveCoords,\n      warning: null,\n      orderedPasses: upPasses,\n      isUploadedView: true\n    };\n  }\n  \/\/ Machine type \u2192 realistic MINIMUM turn radius (m, centerline) + typical\n  \/\/ implement width + a per-class scaling factor used by autoTurnRadius. The\n  \/\/ values target the LOWER end of each class's realistic range \u2014 the bare\n  \/\/ tractor's geometric pivot rather than the \"effective\" radius including\n  \/\/ wide implement swing. Two reasons:\n  \/\/   1) Visually cleaner U-turns. The half-circle uturn radius is chord\/2\n  \/\/      = wM\/2 (e.g. 7.5 m for a 15 m implement). When the user-set turn\n  \/\/      radius is \u2264 wM\/2 the natural half-circle gets picked and dominates\n  \/\/      the canvas. Larger radii kick the racetrack candidate in, which is\n  \/\/      uglier per rule \u00a76.\n  \/\/   2) Modern ag steering (front-axle hydraulic assist, rear-wheel steer,\n  \/\/      center-pivot articulation) makes the bare tractor turn tighter\n  \/\/      than the implement swing alone would suggest. A skilled operator\n  \/\/      hits these tighter radii regularly.\n  \/\/ Trailed implements still need more room than mounted ones, so the\n  \/\/ factor reflects that.\n  var MACHINE_SPEC = {\n    'tractor-std':   { r:  6, w: 15, factor: 0.40, label: 'tractor + mounted implement' },\n    'tractor-large': { r: 11, w: 18, factor: 0.60, label: 'tractor + 24-row planter (trailed)' },\n    'sprayer':       { r:  7, w: 36, factor: 0.20, label: 'self-propelled sprayer' },\n    'combine':       { r:  6, w: 12, factor: 0.50, label: 'combine harvester' },\n    'articulated':   { r: 14, w: 24, factor: 0.60, label: 'articulated 4WD + air seeder' },\n    'custom':        { r:  0, w:  0, factor: 0.50, label: 'manual override' }\n  };\n  \/\/ Generic auto-fit (Custom-mode width change with no machine class picked):\n  \/\/ assume mounted-implement style (factor 0.50 \u2014 about half the implement\n  \/\/ width). Floor 5 m (smallest sensible bare-tractor pivot), cap 18 m (above\n  \/\/ that user should explicitly pick a trailed-implement preset).\n  function autoTurnRadius(wM, factor){\n    if(typeof factor !== 'number') factor = 0.50;  \/\/ mounted-implement default\n    var r = wM * factor;\n    if(r < 5) r = 5;          \/\/ tractor's own minimum geometric pivot\n    else if(r > 18) r = 18;   \/\/ cap for the generic case; trailed presets override\n    return Math.round(r * 2) \/ 2;  \/\/ 0.5 m step matches the input's step\n  }\n  \/\/ Back-compat alias (the smoke tests poke MACHINE_RADIUS).\n  var MACHINE_RADIUS = MACHINE_SPEC;\n  var CURRENCY_SYMBOL = { usd: '$', eur: '\u20ac' };\n  function getInputs(){\n    \/\/ Inputs are displayed in the active unit system but the geometry\n    \/\/ pipeline + cost math always run in metric (m, km, ha, L, $\/L). Read\n    \/\/ raw values and divide by the conversion factor when in US mode so the\n    \/\/ returned object is metric. setUnitSystem() handles the inverse trip\n    \/\/ (multiplying input values by the factor when the user toggles).\n    var rawWm = parseFloat(document.getElementById('gpl-wm').value) || 18;\n    var wM = unitSystem === 'us' ? rawWm \/ M_TO_FT : rawWm;\n    \/\/ Safety clamp \u2014 the wM input has min=3 in HTML but partial typing\n    \/\/ (\"1\" while the user is on their way to \"12\") would push hundreds of\n    \/\/ body passes through the pipeline and freeze the browser even with\n    \/\/ debounce, since the synchronous compute itself dominates. Floor at\n    \/\/ 6 m (smallest realistic farm-scale implement \u2014 most are 9 m+) so any\n    \/\/ in-flight value can't drag compute time out. The user's final value\n    \/\/ (>=6 m) is honoured; only sub-6 transients get clamped.\n    if(wM < 6) wM = 6;\n    if(wM > 60) wM = 60;\n    var hlMult = parseFloat(document.getElementById('gpl-hl-mult').value);\n    if(isNaN(hlMult)) hlMult = 1;\n    \/\/ Headland is ALWAYS N \u00d7 implement width (per user \u2014 custom override\n    \/\/ dropped, the multiplier alone defines the strip).\n    var headlandM = hlMult * wM;\n    var turnStyle = document.getElementById('gpl-turn-style').value || 'uturn';\n    var rawTurnR = parseFloat(document.getElementById('gpl-turn-r').value) || 6;\n    var turnR = unitSystem === 'us' ? rawTurnR \/ M_TO_FT : rawTurnR;\n    \/\/ turnBuf removed \u2014 auto-pullBack handles headland-tight cases.\n    var turnBuf = 0;\n    var machineEl = document.getElementById('gpl-machine');\n    var machineKey = machineEl ? machineEl.value : 'tractor-std';\n    var curEl = document.getElementById('gpl-currency');\n    var currency = curEl ? curEl.value : 'usd';\n    var farmEl = document.getElementById('gpl-roi-farm');\n    var appsEl = document.getElementById('gpl-roi-apps');\n    var rawFarm = farmEl ? parseFloat(farmEl.value) : 200;\n    if(isNaN(rawFarm) ? true : rawFarm <= 0) rawFarm = 200;\n    var farmHa = unitSystem === 'us' ? rawFarm \/ HA_TO_AC : rawFarm;\n    var apps = appsEl ? parseFloat(appsEl.value) : 4;\n    if(isNaN(apps) ? true : apps <= 0) apps = 4;\n    var rawDiesel = parseFloat(document.getElementById('gpl-fuel').value) || 1.2;\n    var dieselPerL = unitSystem === 'us' ? rawDiesel \/ LPL_TO_DPGAL : rawDiesel;\n    var rawCons = parseFloat(document.getElementById('gpl-cons').value) || 0.6;\n    var consLPerKm = unitSystem === 'us' ? rawCons \/ LPKM_TO_GPMI : rawCons;\n    return {\n      wM: wM,\n      diesel: dieselPerL,\n      cons: consLPerKm,\n      headlandM: headlandM,\n      headlandMult: hlMult,\n      turnStyle: turnStyle,\n      turnR: turnR,\n      turnBuf: turnBuf,\n      machine: machineKey,\n      currency: currency,\n      farmHa: farmHa,\n      apps: apps\n    };\n  }\n  \/\/ PERF \u2014 input bursts (slider drags 30-60\u00d7\/sec, typing in number inputs)\n  \/\/ each trigger a recompute that does ~30 generateLines passes (current\n  \/\/ + baseline + 3 other approaches + axis sweep). Unthrottled, that melts\n  \/\/ the main thread and freezes the browser when the user is mid-typing\n  \/\/ (e.g. on the way from \"12\" they pass through \"1\" which would emit\n  \/\/ thousands of passes without the wM clamp in getInputs).\n  \/\/\n  \/\/ scheduleRecompute:\n  \/\/   1) Debounces input bursts into ONE recompute per 250 ms (long enough\n  \/\/      that typing \"12\" doesn't recompute at \"1\").\n  \/\/   2) Shows the loader overlay AND yields one animation frame before\n  \/\/      starting the synchronous compute, so the browser actually paints\n  \/\/      the loader before the main thread freezes. Without this yield\n  \/\/      the loader stays invisible because the paint never gets scheduled\n  \/\/      between showLoader() and the start of recompute().\n  \/\/   3) Always hides the loader in a finally block so an exception in\n  \/\/      recompute can't leave the page stuck behind a spinner.\n  var recomputeTimer = null;\n  var loaderEl = null;  \/\/ resolved lazily \u2014 element may not exist on first call\n  var loaderSubEl = null;\n  function showLoader(msg){\n    if(!loaderEl) loaderEl = document.getElementById('gpl-loader');\n    if(!loaderSubEl) loaderSubEl = document.getElementById('gpl-loader-sub');\n    if(loaderEl){\n      if(msg ? loaderSubEl : false) loaderSubEl.textContent = msg;\n      loaderEl.classList.add('is-on');\n      loaderEl.setAttribute('aria-hidden', 'false');\n    }\n  }\n  function hideLoader(){\n    if(loaderEl){\n      loaderEl.classList.remove('is-on');\n      loaderEl.setAttribute('aria-hidden', 'true');\n      if(loaderSubEl) loaderSubEl.textContent = 'Planning guidance lines\u2026';\n    }\n    \/\/ Clear the field-switch blur\/fade once the new plan has rendered \u2014 every\n    \/\/ render path ends here, so the canvas always eases back to sharp.\n    var sh = document.querySelector('.gpl-canvas-shell');\n    if(sh) sh.classList.remove('is-switching');\n  }\n  \/\/ Remote-only stripped embed (Phase 4): when the planner API is unreachable\n  \/\/ there is no local core to fall back to, so prompt a retry instead of\n  \/\/ showing a blank\/failed plan. Reuses the #gpl-warn banner. Dormant in the\n  \/\/ full build (planLayout always has the core). Tap\/click clears the broken\n  \/\/ latch plus any stale token and reschedules immediately.\n  function showPlannerRetry(){\n    var warnEl = document.getElementById('gpl-warn');\n    if(!warnEl) return;\n    warnEl.textContent = 'Could not reach the planner just now. Tap to try again.';\n    warnEl.classList.add('is-on');\n    warnEl.style.cursor = 'pointer';\n    warnEl.style.minHeight = '44px';\n    warnEl.onclick = function(){\n      warnEl.onclick = null;\n      warnEl.style.cursor = '';\n      warnEl.style.minHeight = '';\n      plannerBroken = false;\n      plannerToken = null;\n      scheduleRecompute(0);\n    };\n  }\n  \/\/ Run a heavy synchronous task BEHIND the branded loader. Shows the\n  \/\/ loader, yields one paint frame (rAF + setTimeout 0) so the spinner\n  \/\/ actually appears before the main thread freezes, runs the task, then\n  \/\/ always hides the loader. Used for boundary import \u2014 projecting +\n  \/\/ assigning holes + the first full recompute on a big field can freeze\n  \/\/ the thread for ~1 s, and without this the user saw a frozen page with\n  \/\/ no \"calculating\" indication (reported 2026-06-15).\n  function runWithLoader(msg, fn){\n    showLoader(msg);\n    requestAnimationFrame(function(){\n      setTimeout(function(){\n        try { fn(); }\n        finally { hideLoader(); }\n      }, 0);\n    });\n  }\n  \/\/ True trailing-edge debounce. Every call CLEARS the previous pending\n  \/\/ timer and starts a fresh one \u2014 so the recompute fires `delay` ms after\n  \/\/ the LAST input event, not the first. Without clearTimeout the original\n  \/\/ code was a throttle: type \"1\", a timer was set, slow typers paused,\n  \/\/ and the timer fired with the partial value before \"2\" was even pressed.\n  \/\/ Two delay presets:\n  \/\/   500 ms \u2014 keystrokes in number inputs (Width, Turn r, Diesel, etc.).\n  \/\/            Two-digit values like \"12\" land within ~200 ms so the user\n  \/\/            sees ONE recompute at \"12\" instead of one at \"1\" + one at \"12\".\n  \/\/   120 ms \u2014 slider drags (AB direction, headland multiplier). Still feels\n  \/\/            live (~8 recomputes\/sec during a drag) without melting the\n  \/\/            main thread.\n  function scheduleRecompute(delay){\n    if(typeof delay !== 'number') delay = 500;\n    if(recomputeTimer){\n      clearTimeout(recomputeTimer);\n      recomputeTimer = null;\n    }\n    recomputeTimer = setTimeout(function(){\n      recomputeTimer = null;\n      if(plannerRemoteEnabled() ? !plannerBroken : false){\n        \/\/ Remote-first: fetch the plan bundle while the loader spins, then\n        \/\/ run the unchanged synchronous recompute against the warm cache.\n        \/\/ On fetch failure plannerBroken is set and recompute() degrades\n        \/\/ to local compute through the planLayout seam \u2014 never a blank UI.\n        \/\/ The axis comes from currentAxisForPlan(), the SAME helper\n        \/\/ recompute derives its axis from, so fetch + consume agree.\n        showLoader('Calculating your field');\n        plannerFetchPlans(getInputs(), currentAxisForPlan(), function(){\n          \/\/ Stripped embed + remote unreachable: no local core to fall back\n          \/\/ to, so prompt a retry instead of running recompute (which would\n          \/\/ consume the null planLayout returns). Full builds never take this\n          \/\/ branch \u2014 the local core is present.\n          if(plannerBroken ? (typeof generateLinesAll !== 'function') : false){\n            hideLoader();\n            showPlannerRetry();\n            return;\n          }\n          runWithLoader('Calculating your field', recompute);\n        });\n      } else {\n        showLoader();\n        requestAnimationFrame(function(){\n          \/\/ setTimeout(0) inside rAF puts the heavy work AFTER the next paint\n          \/\/ so the loader actually appears before we block the thread.\n          setTimeout(function(){\n            try { recompute(); }\n            finally { hideLoader(); }\n          }, 0);\n        });\n      }\n    }, delay);\n  }\n  \/\/ Slider-specific wrapper \u2014 sliders fire 30-60 input events\/sec so we\n  \/\/ want a much shorter debounce to keep drag feedback live. Anything that\n  \/\/ hands input through to scheduleRecomputeFast is treating that input as\n  \/\/ a continuous-drag stream rather than discrete typing.\n  function scheduleRecomputeFast(){ scheduleRecompute(120); }\n  \/\/ Axis sweep runs AFTER the main recompute settles. The sweep tries 6\n  \/\/ additional AB-line angles per approach (~28 extra layout builds) to\n  \/\/ find a higher-coverage axis on irregular fields. That work is too heavy\n  \/\/ to run synchronously inside every typed-input recompute \u2014 so the main\n  \/\/ recompute now just leaves a sweep request queued via this helper, and\n  \/\/ the sweep actually runs ~600 ms after the LAST recompute. If the user\n  \/\/ keeps typing\/dragging, the sweep keeps getting rescheduled and never\n  \/\/ runs until they pause; that means heavy typing feels snappy and the\n  \/\/ refined Compare All numbers + Best recommendation update once the user\n  \/\/ stops interacting.\n  var sweepTimer = null;\n  \/\/ `paint` is recompute's paintCmpRow, threaded in because the sweep refreshes\n  \/\/ Compare All cells but paintCmpRow is a recompute-local (it closes over `sym`).\n  \/\/ Without this the sweep threw \"paintCmpRow is not defined\" and silently left\n  \/\/ the swept metrics unpainted.\n  function scheduleAxisSweep(inp, axis, approaches, apMetrics, sym, paint){\n    if(sweepTimer) clearTimeout(sweepTimer);\n    sweepTimer = setTimeout(function(){\n      sweepTimer = null;\n      runAxisSweep(inp, axis, approaches, apMetrics, sym, paint);\n    }, 600);\n  }\n  function runAxisSweep(inp, axis, approaches, apMetrics, sym, paint){\n    if(userAxisDeg !== null) return;  \/\/ user pinned an axis, sweep is moot\n    \/\/ Field-size-aware sweep granularity. The sweep is the heaviest\n    \/\/ recompute work (every angle \u00d7 every approach runs a full\n    \/\/ generateLinesAll + computeMetrics). On big real-world fields\n    \/\/ (~500 ha, 200+ passes per layout) the full 6-angle sweep across\n    \/\/ 5 approaches takes 4+ seconds of synchronous CPU \u2014 even debounced\n    \/\/ it locks the UI. Coarse fall-back: 3 angles only.\n    var stTotalVerts = 0;\n    for(var svp=0; svp<BOUNDARY_PARTS.length; svp++) stTotalVerts += BOUNDARY_PARTS[svp].length;\n    var stSpan = 0;\n    for(var svq=0; svq<BOUNDARY_PARTS.length; svq++){\n      var pq = BOUNDARY_PARTS[svq], mxX = -Infinity, mnX = Infinity, mxY = -Infinity, mnY = Infinity;\n      for(var svr=0; svr<pq.length; svr++){\n        if(pq[svr].x < mnX) mnX = pq[svr].x; if(pq[svr].x > mxX) mxX = pq[svr].x;\n        if(pq[svr].y < mnY) mnY = pq[svr].y; if(pq[svr].y > mxY) mxY = pq[svr].y;\n      }\n      var sp = Math.max(mxX - mnX, mxY - mnY);\n      if(sp > stSpan) stSpan = sp;\n    }\n    var heavyField = stSpan \/ inp.wM > 100 ? true : stTotalVerts > 100;\n    var sweepAngles = heavyField ? [0, 45] : [0, 15, 30, 45, 60, 75];\n    var pcaDeg = Math.round(Math.atan2(fieldAxis(BOUNDARY).uy, fieldAxis(BOUNDARY).ux) * 180 \/ Math.PI);\n    while(pcaDeg < 0) pcaDeg += 180;\n    while(pcaDeg >= 90) pcaDeg -= 90;\n    if(sweepAngles.indexOf(pcaDeg) < 0) sweepAngles.push(pcaDeg);\n    \/\/ Remote mode: the plan fetch already carried a metrics-only server\n    \/\/ sweep for the CURRENT approach across the standard trial angles.\n    \/\/ Those rows rank the angles without regenerating a layout per angle;\n    \/\/ only the WINNING angle is recomputed locally so apMetrics keeps the\n    \/\/ full metrics shape paintCmpRow needs. Approaches or angles the cache\n    \/\/ doesn't carry fall back to local compute \u2014 never a per-angle\n    \/\/ network call.\n    var remoteSweep = remoteCacheFresh(inp, axis) ? REMOTE_PLAN_CACHE.sweep : null;\n    function remoteSweepRow(apR, deg){\n      if(!remoteSweep) return null;\n      if(remoteSweep.approach !== apR) return null;\n      var rowsR = remoteSweep.rows || [];\n      for(var rwi=0; rwi<rowsR.length; rwi++){\n        if(rowsR[rwi].angleDeg === deg) return rowsR[rwi];\n      }\n      return null;\n    }\n    var bestApAxes = {};\n    for(var aps=0; aps<approaches.length; aps++){\n      var apS = approaches[aps];\n      var bestMet = apMetrics[apS];\n      var bestCov = bestMet ? (bestMet.coveragePct || 0) : 0;\n      var bestAng = null;\n      var bestNeedsLocal = false;\n      for(var sa=0; sa<sweepAngles.length; sa++){\n        var swRow = remoteSweepRow(apS, sweepAngles[sa]);\n        if(swRow){\n          if((swRow.coveragePct || 0) > bestCov){\n            bestCov = swRow.coveragePct || 0;\n            bestAng = sweepAngles[sa];\n            bestNeedsLocal = true;   \/\/ server row is metrics-lite \u2014 rebuild full metrics below\n          }\n          continue;\n        }\n        var ang = sweepAngles[sa] * Math.PI \/ 180;\n        var trialAxis = { ux: Math.cos(ang), uy: Math.sin(ang) };\n        var trialLay = planLayout(apS, inp.wM, BOUNDARY_PARTS, trialAxis, inp.headlandM, inp.turnStyle, inp.turnR, inp.turnBuf);\n        var trialMet = computeMetrics(trialLay, inp.wM, inp.diesel, inp.cons);\n        if((trialMet.coveragePct || 0) > bestCov){\n          bestCov = trialMet.coveragePct || 0;\n          bestMet = trialMet;\n          bestAng = sweepAngles[sa];\n          bestNeedsLocal = false;\n        }\n      }\n      if(bestAng !== null ? bestNeedsLocal : false){\n        \/\/ Winning angle came from a metrics-lite server row: one local\n        \/\/ rebuild gives the full metrics object the Compare All repaint\n        \/\/ reads. Without the core (stripped embeds, Task 6) keep the\n        \/\/ pre-sweep metrics and just record the angle hint.\n        if(typeof generateLinesAll === 'function'){\n          var bwAng = bestAng * Math.PI \/ 180;\n          var bwAxis = { ux: Math.cos(bwAng), uy: Math.sin(bwAng) };\n          var bwLay = planLayout(apS, inp.wM, BOUNDARY_PARTS, bwAxis, inp.headlandM, inp.turnStyle, inp.turnR, inp.turnBuf);\n          bestMet = computeMetrics(bwLay, inp.wM, inp.diesel, inp.cons);\n        } else {\n          console.warn('[GPL] sweep winner from server row; full metrics need the local core \u2014 keeping pre-sweep metrics');\n        }\n      }\n      if(bestAng !== null){\n        if(bestMet) apMetrics[apS] = bestMet;\n        bestApAxes[apS] = bestAng;\n      }\n    }\n    \/\/ Refresh recommendation badges + bottom \"Best for this field\" panel\n    \/\/ with the swept-axis metrics. Both views read from the SAME source\n    \/\/ (recommendByMetrics(apMetrics)) so the left-panel BEST badge and\n    \/\/ the bottom table's recommendation stay in sync \u2014 previously the\n    \/\/ axis sweep updated badges only, leaving the bottom panel stale at\n    \/\/ the earlier laterReco pick (reported 2026-06-03).\n    var recoMetric = pickBestApproach(apMetrics);\n    var recoBadges = document.querySelectorAll('.gpl-reco');\n    for(var rb=0; rb<recoBadges.length; rb++){\n      var rkey = recoBadges[rb].getAttribute('data-reco');\n      recoBadges[rb].classList.toggle('is-on', rkey === recoMetric.pick);\n    }\n    var recoSwBestName = document.getElementById('gpl-cmp-best-name');\n    var recoSwBestWhy = document.getElementById('gpl-cmp-best-why');\n    if(recoSwBestName) recoSwBestName.textContent = AP_LABELS[recoMetric.pick] || recoMetric.pick;\n    if(recoSwBestWhy) recoSwBestWhy.textContent = recoMetric.why || '';\n    var recoSwRows = document.querySelectorAll('#gpl-cmp-table .gpl-cmp-row');\n    for(var rsr=0; rsr<recoSwRows.length; rsr++) recoSwRows[rsr].classList.remove('is-best');\n    var recoSwBestRow = document.querySelector('#gpl-cmp-table .gpl-cmp-row[data-cmp=\"' + recoMetric.pick + '\"]');\n    if(recoSwBestRow) recoSwBestRow.classList.add('is-best');\n    var recoHintEl = document.getElementById('gpl-reco-hint');\n    if(recoHintEl){\n      var apLabel = AP_LABELS[recoMetric.pick] || 'Best';\n      var hintTxt = 'Best: ' + apLabel + ' \u2014 ' + recoMetric.why;\n      if(bestApAxes[recoMetric.pick] !== undefined){\n        hintTxt += ' \u00b7 best at ' + bestApAxes[recoMetric.pick] + '\u00b0';\n      }\n      var bestCov = (apMetrics[recoMetric.pick] || {}).coveragePct || 0;\n      if(bestCov < 70){\n        hintTxt += ' \u2014 split field for full coverage';\n      }\n      recoHintEl.textContent = hintTxt;\n    }\n    \/\/ Refresh Compare All cells with swept metrics using paintCmpRow so\n    \/\/ the 10-column layout stays consistent. Previously this block wrote\n    \/\/ to a 4-cell layout (cov\/overlap\/fuel\/cost) that no longer exists\n    \/\/ \u2014 fuel was landing in the Area-at-risk column and cost in Repeats\n    \/\/ (\"Soil compaction in liters??\" \u2014 reported 2026-06-03).\n    for(var aa=0; aa<approaches.length; aa++){\n      var apX = approaches[aa];\n      if(apMetrics[apX] ? typeof paint === 'function' : false) paint(apX, apMetrics[apX]);\n    }\n    \/\/ The axis sweep can flip the best approach (e.g. AB-curve at a\n    \/\/ refined angle beats the PCA-default approach). Update lastBestPick\n    \/\/ + auto-swap the radio so the page shows the actually-best plan\n    \/\/ 600 ms after the user changed field \/ width \/ headland \u2014 without\n    \/\/ this hook the BEST badge moves to the swept winner but the radio\n    \/\/ sticks to the synchronous-recompute pick.\n    lastBestPick = recoMetric.pick;\n    autoPickBestApproach();\n  }\n  \/\/ Compute the 3 optimization-target angles (longest edge, fewest\n  \/\/ crossings, shortest distance) for the CURRENT approach. Coarse\n  \/\/ sweep at 15\u00b0 increments \u2014 enough granularity for the picker's\n  \/\/ visible angle, cheap enough to run on every recompute. Results\n  \/\/ cache to `headingAngles` and feed the picker UI.\n  function computeHeadingStrategies(inp){\n    \/\/ Longest edge \u2014 already the geometric default.\n    var pca = fieldAxis(BOUNDARY);\n    var longestDeg = Math.round(Math.atan2(pca.uy, pca.ux) * 180 \/ Math.PI);\n    while(longestDeg < 0) longestDeg += 180;\n    while(longestDeg >= 180) longestDeg -= 180;\n    headingAngles.longest = longestDeg;\n    \/\/ Skip the cross\/dist sweep when the user has pinned Custom \u2014 the\n    \/\/ sweep is purely informational at that point and the user doesn't\n    \/\/ want recompute cost on every slider tick. Also skip for Boundary\n    \/\/ (spiral, no heading) and Topography (algorithm-set heading) \u2014 the\n    \/\/ picker is hidden in those modes so the swept angles aren't shown.\n    if(headingStrategy === 'custom' ? true : current === 'boundary' ? true : current === 'adaptive' ? true : current === 'concentric'){\n      updateHeadingPickerUI();\n      return;\n    }\n    \/\/ Working + turn speed for the \"shortest time\" strategy. Turn-arounds\n    \/\/ are driven slower than the working pass (engine + hydraulics under a\n    \/\/ tight pivot) \u2014 same 0.6\u00d7 factor the analytics time breakdown uses.\n    var speedKmhH = parseFloat((document.getElementById('gpl-speed') || {}).value) || 10;\n    var speedMs = unitSystem === 'us' ? speedKmhH \/ KM_TO_MI : speedKmhH;\n    var workMpsH = speedMs * 1000 \/ 3600;\n    var turnMpsH = workMpsH * 0.6;\n    \/\/ Heavy fields (large multi-part imports like the 207 ha \/ 9-part\n    \/\/ 41.22\u2026 field) make each generateLinesAll cost ~0.6-1.6 s \u2014 a 12-angle\n    \/\/ synchronous sweep then freezes the UI for 10-20 s. Drop to a coarse\n    \/\/ 4-angle sweep when the field is big (span\/wM > 60 or > 100 verts) so\n    \/\/ the picker still shows sensible optimised angles without the freeze.\n    var hgTotalVerts = 0, hgSpan = 0;\n    for(var hgp=0; hgp<BOUNDARY_PARTS.length; hgp++){\n      hgTotalVerts += BOUNDARY_PARTS[hgp].length;\n      var hmnx=Infinity,hmxx=-Infinity,hmny=Infinity,hmxy=-Infinity;\n      for(var hgv=0; hgv<BOUNDARY_PARTS[hgp].length; hgv++){\n        var hpt = BOUNDARY_PARTS[hgp][hgv];\n        if(hpt.x<hmnx)hmnx=hpt.x; if(hpt.x>hmxx)hmxx=hpt.x;\n        if(hpt.y<hmny)hmny=hpt.y; if(hpt.y>hmxy)hmxy=hpt.y;\n      }\n      var hsp = Math.max(hmxx-hmnx, hmxy-hmny);\n      if(hsp > hgSpan) hgSpan = hsp;\n    }\n    var hgHeavy = (inp.wM > 0 ? hgSpan \/ inp.wM > 60 : false) ? true : hgTotalVerts > 100;\n    var sweepDegs = hgHeavy\n      ? [0, 45, 90, 135]\n      : [0, 15, 30, 45, 60, 75, 90, 105, 120, 135, 150, 165];\n    if(sweepDegs.indexOf(longestDeg) < 0) sweepDegs.push(longestDeg);\n    \/\/ Remote mode: consume the metrics-only server sweep rows fetched with\n    \/\/ the plan bundle (same freshness key as every other cache consumer).\n    \/\/ Angles the rows don't carry fall back to local compute per angle.\n    var hsSweep = remoteCacheFresh(inp, currentAxisForPlan()) ? REMOTE_PLAN_CACHE.sweep : null;\n    var bestCrossDeg = longestDeg, bestCross = Infinity;\n    var bestDistDeg = longestDeg, bestDist = Infinity;\n    var bestTimeDeg = longestDeg, bestTime = Infinity;\n    for(var s=0; s<sweepDegs.length; s++){\n      var rowH = null;\n      if(hsSweep ? hsSweep.approach === current : false){\n        var hRows = hsSweep.rows || [];\n        for(var hri=0; hri<hRows.length; hri++){\n          if(hRows[hri].angleDeg === sweepDegs[s]){ rowH = hRows[hri]; break; }\n        }\n      }\n      \/\/ Reject candidates that lose meaningful coverage \u2014 minimising\n      \/\/ crossings or distance is pointless if it leaves half the field\n      \/\/ unworked. 5 % cov drop vs Longest Edge is the cap.\n      var trialCov, crossN, distM, timeS;\n      if(rowH){\n        \/\/ Server sweep rows carry coverage \/ crossings \/ distance but no\n        \/\/ pass-vs-turn split, so the time strategy ranks by total distance\n        \/\/ at working speed \u2014 a proxy that tracks the distance optimum.\n        \/\/ TODO(Task 7): add passKm\/turnKm to server sweep rows so the\n        \/\/ remote time ranking matches the local formula exactly.\n        trialCov = rowH.coveragePct || 0;\n        if(trialCov < 70) continue;\n        crossN = rowH.crossings || 0;\n        distM = (rowH.distanceKm || 0) * 1000;\n        timeS = workMpsH > 0 ? distM \/ workMpsH : 0;\n      } else {\n        var r = sweepDegs[s] * Math.PI \/ 180;\n        var trialAxis = { ux: Math.cos(r), uy: Math.sin(r) };\n        var trialLay = planLayout(current, inp.wM, BOUNDARY_PARTS, trialAxis, inp.headlandM, inp.turnStyle, inp.turnR, inp.turnBuf);\n        var trialMet = computeMetrics(trialLay, inp.wM, inp.diesel, inp.cons);\n        trialCov = trialMet.coveragePct || 0;\n        if(trialCov < 70) continue;\n        crossN = trialMet.crossings || 0;\n        distM = trialMet.totalDriveM || 0;\n        \/\/ Time = work-time (pass length \/ work speed) + turn-time\n        \/\/ ((turn + transport length) \/ turn speed). Differs from distance\n        \/\/ because turn metres cost more wall-clock than working metres, so\n        \/\/ the time-optimal heading can trade a bit of extra distance for\n        \/\/ fewer\/shorter turnarounds.\n        timeS = 0;\n        if(workMpsH > 0){\n          timeS = (trialMet.passLengthM || 0) \/ workMpsH\n                + ((trialMet.turnLengthM || 0) + (trialMet.transportLengthM || 0)) \/ (turnMpsH > 0 ? turnMpsH : workMpsH);\n        }\n      }\n      if(crossN < bestCross){ bestCross = crossN; bestCrossDeg = sweepDegs[s]; }\n      if(distM > 0 ? distM < bestDist : false){ bestDist = distM; bestDistDeg = sweepDegs[s]; }\n      if(timeS > 0 ? timeS < bestTime : false){ bestTime = timeS; bestTimeDeg = sweepDegs[s]; }\n    }\n    headingAngles.crossings = bestCrossDeg;\n    headingAngles.distance = bestDistDeg;\n    headingAngles.time = bestTimeDeg;\n    updateHeadingPickerUI();\n  }\n  \/\/ Show the picker block that matches the current approach. AB Straight\n  \/\/ and AB Curve use the full radio picker; Boundary Follow shows a \"not\n  \/\/ used\" message; Topography Follow shows the algorithm-set heading\n  \/\/ (perpendicular to mean gradient) read-only.\n  function updateHeadingCardMode(){\n    var na = document.getElementById('gpl-heading-na');\n    var auto = document.getElementById('gpl-heading-auto');\n    var picker = document.getElementById('gpl-heading-picker');\n    var blocksList = document.getElementById('gpl-blocks-list');\n    if(!na ? true : !auto ? true : !picker) return;\n    if(current === 'auto-blocks'){\n      \/\/ Show the per-block list. Rows get populated by renderBlocksList\n      \/\/ AFTER recompute, so the heading-card transitions stay snappy\n      \/\/ (decomposition runs inside generateLinesAll, which has already\n      \/\/ produced layout.blocks by the time recompute calls us).\n      na.hidden = true; auto.hidden = true; picker.hidden = true;\n      if(blocksList) blocksList.hidden = false;\n    } else if(current === 'boundary'){\n      na.hidden = false; auto.hidden = true; picker.hidden = true;\n      if(blocksList) blocksList.hidden = true;\n      na.innerHTML = \"<strong>Not used.<\/strong> Boundary Follow spirals from the field edge inward \u2014 there's no AB heading to set.\";\n    } else if(current === 'concentric'){\n      na.hidden = false; auto.hidden = true; picker.hidden = true;\n      if(blocksList) blocksList.hidden = true;\n      na.innerHTML = \"<strong>Not used.<\/strong> Concentric drives circular loops around the field centre, so there's no AB heading to set.\";\n    } else if(current === 'adaptive'){\n      na.hidden = true; auto.hidden = false; picker.hidden = true;\n      if(blocksList) blocksList.hidden = true;\n      var deg = computeTopographyHeading();\n      var degEl = document.getElementById('gpl-heading-auto-deg');\n      if(degEl) degEl.textContent = (deg !== null ? deg.toFixed(0) : '\u2014') + '\u00b0';\n    } else {\n      na.hidden = true; auto.hidden = true; picker.hidden = false;\n      if(blocksList) blocksList.hidden = true;\n    }\n  }\n  \/\/ Re-render the per-block list with the latest decomposition. Called\n  \/\/ by recompute() after generateLinesAll returns its block descriptors.\n  \/\/ Each row: \"Block N \u00b7 X.X ha   [\u00b0input]   \u21bareset-one\"\n  function renderBlocksList(blocks){\n    var rowsEl = document.getElementById('gpl-blocks-rows');\n    if(!rowsEl) return;\n    rowsEl.innerHTML = '';\n    if(!blocks ? true : blocks.length === 0){\n      rowsEl.innerHTML = '<div class=\"gpl-hint\">No blocks detected \u2014 field is a single convex shape.<\/div>';\n      return;\n    }\n    for(var i=0; i<blocks.length; i++){\n      var bk = blocks[i];\n      var row = document.createElement('div');\n      row.className = 'gpl-blocks-row' + (bk.isOverride ? ' is-override' : '');\n      row.setAttribute('data-block-key', bk.key);\n      row.innerHTML =\n        '<span class=\"gpl-blocks-lbl\">Block ' + (i + 1) + '<\/span>' +\n        '<span class=\"gpl-blocks-area\">' + bk.areaHa.toFixed(1) + ' ha<\/span>' +\n        '<input type=\"number\" min=\"0\" max=\"179\" step=\"1\" value=\"' + Math.round(bk.axisDeg) + '\" data-block-input>' +\n        '<span class=\"gpl-blocks-deg-unit\">\u00b0<\/span>' +\n        '<button type=\"button\" class=\"gpl-blocks-reset-one\" title=\"Reset to algorithmic axis\" data-block-reset' + (bk.isOverride ? '' : ' disabled') + '>\u21ba<\/button>';\n      rowsEl.appendChild(row);\n    }\n    \/\/ Wire change + reset events\n    var inputs = rowsEl.querySelectorAll('input[data-block-input]');\n    for(var ii=0; ii<inputs.length; ii++){\n      inputs[ii].addEventListener('change', function(){\n        var rowEl = this.closest('.gpl-blocks-row');\n        var key = rowEl.getAttribute('data-block-key');\n        var v = parseFloat(this.value);\n        if(isNaN(v)) return;\n        while(v < 0) v += 180;\n        while(v >= 180) v -= 180;\n        BLOCK_AXIS_OVERRIDES[key] = v;\n        recompute();\n      });\n    }\n    var resets = rowsEl.querySelectorAll('button[data-block-reset]');\n    for(var ri=0; ri<resets.length; ri++){\n      resets[ri].addEventListener('click', function(){\n        var rowEl = this.closest('.gpl-blocks-row');\n        var key = rowEl.getAttribute('data-block-key');\n        delete BLOCK_AXIS_OVERRIDES[key];\n        recompute();\n      });\n    }\n  }\n  \/\/ Mirror the gradient-perpendicular AB-Straight rotation that\n  \/\/ generateLines() does for 'adaptive'. Returns the heading angle in\n  \/\/ degrees (0..179) the user will actually see drawn.\n  function computeTopographyHeading(){\n    var pca = fieldAxis(BOUNDARY);\n    var ux = pca.ux, uy = pca.uy;\n    var stats = fieldStats(BOUNDARY);\n    var gxSum = 0, gySum = 0, gMagSum = 0;\n    var GRID = 5;\n    for(var gxi=0; gxi<GRID; gxi++){\n      for(var gyi=0; gyi<GRID; gyi++){\n        var sx = stats.minX + (gxi + 0.5) * (stats.maxX - stats.minX) \/ GRID;\n        var sy = stats.minY + (gyi + 0.5) * (stats.maxY - stats.minY) \/ GRID;\n        if(!pointInPoly(sx, sy, BOUNDARY)) continue;\n        var gxS = (terrainAt(sx + 1, sy) - terrainAt(sx - 1, sy)) * 0.5;\n        var gyS = (terrainAt(sx, sy + 1) - terrainAt(sx, sy - 1)) * 0.5;\n        var gMagS = Math.sqrt(gxS * gxS + gyS * gyS);\n        gxSum += gxS * gMagS;\n        gySum += gyS * gMagS;\n        gMagSum += gMagS;\n      }\n    }\n    var gAvgMag = gMagSum > 0 ? Math.sqrt(gxSum * gxSum + gySum * gySum) \/ gMagSum : 0;\n    if(gAvgMag > 0.005){\n      var gN = Math.sqrt(gxSum * gxSum + gySum * gySum);\n      var gNx = gxSum \/ gN, gNy = gySum \/ gN;\n      ux = -gNy; uy = gNx;\n    }\n    var deg = Math.round(Math.atan2(uy, ux) * 180 \/ Math.PI);\n    while(deg < 0) deg += 180;\n    while(deg >= 180) deg -= 180;\n    return deg;\n  }\n  function updateHeadingPickerUI(){\n    updateHeadingCardMode();\n    var L = document.getElementById('gpl-h-longest');\n    var C = document.getElementById('gpl-h-crossings');\n    var D = document.getElementById('gpl-h-distance');\n    var T = document.getElementById('gpl-h-time');\n    var Cu = document.getElementById('gpl-h-custom');\n    if(L) L.textContent = headingAngles.longest + '\u00b0';\n    if(C) C.textContent = headingAngles.crossings !== null ? headingAngles.crossings + '\u00b0' : '\u2014';\n    if(D) D.textContent = headingAngles.distance !== null ? headingAngles.distance + '\u00b0' : '\u2014';\n    if(T) T.textContent = headingAngles.time !== null ? headingAngles.time + '\u00b0' : '\u2014';\n    if(Cu) Cu.textContent = (headingStrategy === 'custom' ? (userAxisDeg !== null ? userAxisDeg.toFixed(0) + '\u00b0' : 'slider') : 'slider');\n    \/\/ Mark \"\u2261\" suffix on strategies whose angle matches another \u2014 useful\n    \/\/ visual cue that the optimisers converged to the same heading,\n    \/\/ so picking one over another is moot.\n    var angles = { longest: headingAngles.longest, crossings: headingAngles.crossings, distance: headingAngles.distance, time: headingAngles.time };\n    var keys = ['longest', 'crossings', 'distance', 'time'];\n    for(var ki=0; ki<keys.length; ki++){\n      var k = keys[ki], same = false;\n      for(var kj=0; kj<keys.length; kj++){\n        if(kj === ki) continue;\n        if(angles[k] !== null ? angles[keys[kj]] === angles[k] : false){ same = true; break; }\n      }\n      var optEl = document.querySelector('.gpl-heading-opt[data-strategy=\"' + k + '\"]');\n      if(optEl) optEl.classList.toggle('is-converge', same);\n    }\n    var opts = document.querySelectorAll('.gpl-heading-opt');\n    for(var i=0; i<opts.length; i++){\n      var optStrat = opts[i].getAttribute('data-strategy');\n      var on = optStrat === headingStrategy;\n      opts[i].classList.toggle('is-on', on);\n      var radio = opts[i].querySelector('input[type=\"radio\"]');\n      if(radio) radio.checked = on;\n    }\n    var picker = document.querySelector('.gpl-heading-picker');\n    if(picker) picker.setAttribute('data-mode', headingStrategy);\n    \/\/ Update the slider's label too\n    var valHint = document.getElementById('gpl-ab-val');\n    if(valHint){\n      var d = currentHeadingDeg();\n      valHint.textContent = (d !== null ? d.toFixed(0) : '0') + '\u00b0';\n    }\n  }\n  function currentHeadingDeg(){\n    if(headingStrategy === 'custom') return userAxisDeg;\n    return headingAngles[headingStrategy];\n  }\n  function applyHeadingStrategy(strategy){\n    headingStrategy = strategy;\n    if(strategy === 'custom'){\n      \/\/ Keep userAxisDeg as-is (slider value); if it was null, seed it\n      \/\/ with Longest Edge so the slider has a starting point.\n      if(userAxisDeg === null) userAxisDeg = headingAngles.longest;\n    } else {\n      var ang = headingAngles[strategy];\n      userAxisDeg = ang !== null ? ang : null;\n    }\n    updateHeadingPickerUI();\n    recompute();\n  }\n  \/\/ Axis recompute plans with: user manual override beats PCA when set.\n  \/\/ Extracted so the remote-first scheduleRecompute fetch and recompute()\n  \/\/ itself derive the axis from ONE place and can never disagree (the\n  \/\/ remote cache key folds the axis in \u2014 a divergence here would turn\n  \/\/ every remote hit into a silent local fallback).\n  function currentAxisForPlan(){\n    if(userAxisDeg !== null){\n      var r = userAxisDeg * Math.PI \/ 180;\n      return { ux: Math.cos(r), uy: Math.sin(r) };\n    }\n    return fieldAxis(BOUNDARY);\n  }\n  function recompute(){\n    var inp = getInputs();\n    \/\/ Compute strategy angles ONCE per recompute, then resolve userAxisDeg\n    \/\/ from the current strategy. 'longest' resets userAxisDeg to null so\n    \/\/ downstream code falls back to fieldAxis() like before.\n    if(headingStrategy !== 'custom'){\n      \/\/ Will be resolved from headingAngles below.\n    }\n    var axis = currentAxisForPlan();\n    \/\/ Remote-only stripped-embed self-heal. Any interaction that changes the\n    \/\/ field, approach, or a plan parameter re-enters recompute() with a cold\n    \/\/ cache and no local core to fall back to \u2014 a field switch brings new\n    \/\/ geometry, an approach switch asks for a plan the last fetch did not\n    \/\/ cache. Fetch the plan bundle for the CURRENT inputs + approach, then\n    \/\/ re-enter recompute() against the warm cache. The full embed skips this\n    \/\/ entirely (core present \u2192 local fallback already covers the miss). resize\n    \/\/ and the post-fetch re-entry find the cache fresh and fall straight\n    \/\/ through; plannerFetchInFlight blocks overlapping fetches.\n    if(plannerRemoteEnabled() ? (typeof generateLinesAll !== 'function' ? !plannerBroken : false) : false){\n      var reqApNow = current === 'uploaded' ? 'ab-straight' : current;\n      var haveCurrentPlan = remoteCacheFresh(inp, axis) ? !!REMOTE_PLAN_CACHE.byApproach[reqApNow] : false;\n      if(!haveCurrentPlan){\n        \/\/ Cache not ready for the current field\/approach. Start a fetch if none\n        \/\/ is running; either way BAIL \u2014 the in-flight fetch's callback re-enters\n        \/\/ recompute against the warm cache. A concurrent recompute (auto-pick,\n        \/\/ deferred sweep) must never fall through to a null layout here, or it\n        \/\/ would flash the retry banner + blank the canvas while a fetch is still\n        \/\/ in flight. The old plan stays on screen (blurred) until the new one lands.\n        if(!plannerFetchInFlight){\n          plannerFetchInFlight = true;\n          showLoader('Calculating your field');\n          plannerFetchPlans(inp, axis, function(){\n            plannerFetchInFlight = false;\n            if(plannerBroken ? (typeof generateLinesAll !== 'function') : false){ hideLoader(); showPlannerRetry(); return; }\n            runWithLoader('Calculating your field', recompute);\n          });\n        }\n        return;\n      }\n    }\n    \/\/ Sync the AB-direction slider label to the active angle (so the user\n    \/\/ sees the auto-PCA result reflected in the UI).\n    var abLabelEl = document.getElementById('gpl-ab-val');\n    var abSliderEl = document.getElementById('gpl-ab-deg');\n    if(abLabelEl ? abSliderEl : false){\n      var degNow = Math.round(Math.atan2(axis.uy, axis.ux) * 180 \/ Math.PI);\n      \/\/ Wrap into [0,180) \u2014 the axis is bidirectional (a line, not a vector).\n      while(degNow < 0) degNow += 180;\n      while(degNow >= 180) degNow -= 180;\n      if(userAxisDeg === null) abSliderEl.value = String(degNow);\n      abLabelEl.textContent = (userAxisDeg === null ? 'auto \u00b7 ' : 'manual \u00b7 ') + degNow + '\u00b0';\n    }\n    var stats = fieldStatsAll(BOUNDARY_PARTS);\n    var areaHa = stats.area \/ 10000;\n    var baseLayout = planLayout('ab-straight', inp.wM, BOUNDARY_PARTS, axis, inp.headlandM, inp.turnStyle, inp.turnR, inp.turnBuf);\n    \/\/ Remote-only stripped embed (Phase 4) with an empty cache or an\n    \/\/ unreachable API: planLayout returned null (no local core). Degrade to\n    \/\/ the retry banner instead of throwing on the null below \u2014 tapping it\n    \/\/ re-fetches through scheduleRecompute. Full builds + today's embed never\n    \/\/ hit this: planLayout always returns a layout.\n    if(!baseLayout){ hideLoader(); showPlannerRetry(); return; }\n    \/\/ Remote layouts carry the server's metrics (full computeMetrics\n    \/\/ shape) \u2014 prefer them so a stripped embed needs no local metrics\n    \/\/ pass; local layouts have no __remoteMetrics and compute as before.\n    var baseMet = baseLayout.__remoteMetrics ? baseLayout.__remoteMetrics : computeMetrics(baseLayout, inp.wM, inp.diesel, inp.cons);\n    \/\/ 'uploaded' is a special approach \u2014 its layout is synthesised from\n    \/\/ the imported guidance lines rather than generated. Treat it as\n    \/\/ a first-class approach: same plumbing for analytics, playback,\n    \/\/ and Compare All highlighting.\n    var hasUploadForView = UPLOADED_LINES ? UPLOADED_LINES.length > 0 : false;\n    var layout;\n    if(current === 'uploaded' ? hasUploadForView : false){\n      layout = buildUploadedLayout();\n    }\n    if(!layout) layout = planLayout(current === 'uploaded' ? 'ab-straight' : current, inp.wM, BOUNDARY_PARTS, axis, inp.headlandM, inp.turnStyle, inp.turnR, inp.turnBuf);\n    var met = layout.__remoteMetrics ? layout.__remoteMetrics : computeMetrics(layout, inp.wM, inp.diesel, inp.cons);\n    \/\/ Auto-blocks \u2014 populate the per-block heading list from the\n    \/\/ descriptors the planner attached to the layout. Called here\n    \/\/ (after layout) so the UI rows always reflect the LATEST\n    \/\/ decomposition (which can change with wM, simplification, or\n    \/\/ input field switch).\n    if(current === 'auto-blocks') renderBlocksList(layout.blocks || []);\n    document.getElementById('gpl-r-area').textContent = fmtArea(areaHa);\n    var covEl = document.getElementById('gpl-r-cov');\n    if(covEl){\n      var covVal = met.coveragePct || 0;\n      covEl.textContent = covVal.toFixed(0) + ' %';\n      \/\/ Color: green \u2265 95%, orange 85-95%, red < 85%\n      covEl.style.color = covVal >= 95 ? '#15701e' : (covVal >= 85 ? '#f76a0c' : '#dc2626');\n    }\n    \/\/ Overlap area in hectares (rebuilt UI dropped the % row in favour\n    \/\/ of absolute area, matching the field-summary panel format Ag\n    \/\/ operators expect). Green at < 2 % of field, orange 2-10 %, red > 10 %.\n    var ovHaEl = document.getElementById('gpl-r-overlap-ha');\n    if(ovHaEl){\n      var ovHa = (met.overlapM2 || 0) \/ 10000;\n      var ovFrac = met.fieldM2 > 0 ? (met.overlapM2 \/ met.fieldM2) : 0;\n      ovHaEl.textContent = fmtArea(ovHa);\n      ovHaEl.style.color = ovFrac <= 0.02 ? '#15701e' : (ovFrac <= 0.10 ? '#f76a0c' : '#dc2626');\n    }\n    \/\/ Missed area \u2014 field area NOT touched by any swath. Green < 5 %,\n    \/\/ orange 5\u201315 %, red > 15 %.\n    var msEl = document.getElementById('gpl-r-missed');\n    if(msEl){\n      \/\/ Derive missed area from coverage % (raster-based) \u00d7 fieldM2\n      \/\/ (polygon area). Subtracting raster coveredM2 from polygon\n      \/\/ fieldM2 mixes two grids and produces a 3\u20138 % phantom miss even\n      \/\/ at 95 %+ coverage (raster boundary cells systematically\n      \/\/ under-count vs polygon area).\n      var covPct = met.coveragePct || 0;\n      var missedFrac = Math.max(0, 1 - covPct \/ 100);\n      var missedHa = ((met.fieldM2 || 0) * missedFrac) \/ 10000;\n      msEl.textContent = fmtArea(missedHa);\n      msEl.style.color = missedFrac <= 0.05 ? '#15701e' : (missedFrac <= 0.15 ? '#f76a0c' : '#dc2626');\n    }\n    \/\/ Productivity (ha\/h) + working\/non-working time breakdown. Working\n    \/\/ distance = pass length (implement engaged). Non-working = turns +\n    \/\/ transports. Speeds: configurable working speed (default 10 km\/h),\n    \/\/ non-working at 1.5\u00d7 working (industry rule for headland transit\n    \/\/ with implement lifted). Productivity = field area \/ working time.\n    var speedKmh = parseFloat((document.getElementById('gpl-speed') || {}).value) || 10;\n    if(speedKmh < 2) speedKmh = 2;\n    if(unitSystem === 'us') speedKmh = speedKmh * 1.609344;  \/\/ input was mph\n    \/\/ Working speed \u2248 what the operator does on body passes. The\n    \/\/ previous code multiplied this by 1.5\u00d7 for non-working time\n    \/\/ (= turnarounds + multi-part transports) which is backwards \u2014\n    \/\/ real operators SLOW DOWN for U-turns at the headland (5-8 km\/h\n    \/\/ typical), not speed up. Turn time is now passLength-implied\n    \/\/ working speed \u00d7 0.6 (\u2248 6 km\/h when working speed is 10 km\/h),\n    \/\/ matching industry-published headland-turn speeds.\n    var workMps = speedKmh * 1000 \/ 3600;\n    var turnMps = workMps * 0.6;  \/\/ headland turn-arounds: slower\n    var workSec = workMps > 0 ? (met.passLengthM || 0) \/ workMps : 0;\n    var transSec = turnMps > 0 ? ((met.turnLengthM || 0) + (met.transportLengthM || 0)) \/ turnMps : 0;\n    var totalSec = workSec + transSec;\n    function fmtTime(sec){\n      if(sec <= 0) return '\u2014 h \u2014';\n      var h = Math.floor(sec \/ 3600);\n      var m = Math.round((sec % 3600) \/ 60);\n      if(m === 60){ h += 1; m = 0; }\n      return h + ' h ' + (m < 10 ? '0' : '') + m + ' min';\n    }\n    var ttotEl = document.getElementById('gpl-r-time-total');\n    var twkEl  = document.getElementById('gpl-r-time-work');\n    var tntEl  = document.getElementById('gpl-r-time-trans');\n    if(ttotEl) ttotEl.textContent = fmtTime(totalSec);\n    if(twkEl)  twkEl.textContent  = fmtTime(workSec);\n    if(tntEl)  tntEl.textContent  = fmtTime(transSec);\n    var prodEl = document.getElementById('gpl-r-prod');\n    if(prodEl){\n      var prod = workSec > 0 ? (areaHa \/ (workSec \/ 3600)) : 0;\n      if(unitSystem === 'us'){\n        prodEl.textContent = (prod * HA_TO_AC).toFixed(1) + ' ac\/h';\n      } else {\n        prodEl.textContent = prod.toFixed(2) + ' ha\/h';\n      }\n    }\n    \/\/ Path-crossings count. Green \u2264 4, orange 5\u20138, red > 8 (rule \u00a76\n    \/\/ threshold above which recommendByMetrics refuses to flag \"Best\").\n    var crEl = document.getElementById('gpl-r-crossings');\n    if(crEl){\n      var crVal = met.crossings || 0;\n      crEl.textContent = String(crVal);\n      crEl.style.color = crVal <= 4 ? '#15701e' : (crVal <= 8 ? '#f76a0c' : '#dc2626');\n    }\n    document.getElementById('gpl-r-passes').textContent = met.passes;\n    document.getElementById('gpl-r-len').textContent = fmtDist(met.passLengthM);\n    document.getElementById('gpl-r-turns').textContent = met.turns;\n    var turnLenEl = document.getElementById('gpl-r-turnlen');\n    if(turnLenEl) turnLenEl.textContent = fmtDist(met.turnLengthM || 0);\n    var driveEl = document.getElementById('gpl-r-drive');\n    if(driveEl) driveEl.textContent = fmtDist(met.totalDriveM || (met.passLengthM + (met.turnLengthM || 0)));\n    document.getElementById('gpl-r-fuel').textContent = fmtVolume(met.fuelL);\n    var gradeEl = document.getElementById('gpl-r-grade');\n    if(gradeEl){\n      var g = met.avgGradePct || 0;\n      gradeEl.textContent = g.toFixed(1) + ' %';\n      gradeEl.style.color = g > 5 ? '#f76a0c' : (g > 3 ? '#a21caf' : '#4c6066');\n    }\n    var slopeEl = document.getElementById('gpl-r-slope');\n    if(slopeEl){\n      var slopePct = (met.slopePenalty || 0) * 100;\n      slopeEl.textContent = '+ ' + slopePct.toFixed(0) + '%';\n      slopeEl.style.color = slopePct > 15 ? '#dc2626' : (slopePct > 5 ? '#f76a0c' : '#4c6066');\n    }\n    var turnFuelEl = document.getElementById('gpl-r-turnfuel');\n    if(turnFuelEl){\n      var tf = met.turnFuelL || 0;\n      var totF = met.fuelL || 0;\n      var pct = totF > 0 ? (tf \/ totF * 100) : 0;\n      turnFuelEl.textContent = fmtVolume(tf) + ' \u00b7 ' + pct.toFixed(0) + '%';\n      turnFuelEl.style.color = pct > 25 ? '#f76a0c' : (pct > 15 ? '#a21caf' : '#4c6066');\n    }\n    var sym = CURRENCY_SYMBOL[inp.currency] || '$';\n    document.getElementById('gpl-r-cost').textContent = sym + ' ' + met.costUSD.toFixed(2);\n    \/\/ CO\u2082 from diesel burned. 2.68 kg CO\u2082 \/ litre is the standard\n    \/\/ agricultural-diesel emission factor (DEFRA \/ EPA midpoint). Surfaced\n    \/\/ because operators increasingly report Scope-1 farm emissions, and\n    \/\/ the competitor benchmark deck called it out on every field.\n    var co2El = document.getElementById('gpl-r-co2');\n    if(co2El){\n      var co2kg = (met.fuelL || 0) * 2.68;\n      co2El.textContent = co2kg.toFixed(1) + ' kg';\n    }\n    var savFuel = baseMet.fuelL - met.fuelL;\n    var savPct = baseMet.fuelL > 0 ? (savFuel \/ baseMet.fuelL) * 100 : 0;\n    var savEl = document.getElementById('gpl-r-sav');\n    if(current === 'ab-straight'){\n      savEl.textContent = 'baseline';\n      savEl.style.color = '#4c6066';\n    } else {\n      var sign = savFuel >= 0 ? '\u2212' : '+';\n      var absSav = Math.abs(savFuel);\n      savEl.textContent = sign + fmtVolume(absSav) + ' (' + (savPct >= 0 ? '\u2212' : '+') + Math.abs(savPct).toFixed(0) + '%)';\n      savEl.style.color = savFuel >= 0 ? '#15701e' : '#f76a0c';\n    }\n    trayEl.textContent = fmtArea(areaHa) + ' \u00b7 ' + fmtWidth(inp.wM) + ' \u00b7 ' + met.passes + ' passes \u00b7 headland ' + fmtWidth(inp.headlandM);\n    \/\/ BEST badge is driven by recommendByMetrics \u2014 fired in the deferred\n    \/\/ setTimeout below AFTER all per-approach metrics are computed AND\n    \/\/ re-fired by the axis-sweep callback. Both update the SAME source\n    \/\/ (recommendByMetrics) so the left-panel badge stays in sync with\n    \/\/ the bottom \"Best for this field\" panel (reported 2026-06-03 \u2014 the\n    \/\/ old shape-driven sync reco here was overwriting the metric pick\n    \/\/ with a different shape-based pick, leaving the two views\n    \/\/ disagreeing for the entire recompute cycle).\n    \/\/ Build the per-approach metrics map up FRONT so downstream code (ROI\n    \/\/ block, Compare All table, warning banner, deferred axis sweep) can all\n    \/\/ read from one source. Previously this lived AFTER the ROI block which\n    \/\/ crashed when ROI tried to look up apMetrics['ab-curve'] before the map\n    \/\/ had been populated.\n    \/\/ Imported fields don't have real elevation data \u2192 Topography\n    \/\/ follow degenerates to AB Straight. Skip it from Compare All to\n    \/\/ (a) avoid showing duplicate metrics and (b) save the ~70 ms\n    \/\/ computeMetrics + per-angle sweep cost on heavy fields.\n    var isImportedField = currentField === 'custom' ? true : currentField === 'uploaded-lines';\n    var hasUploadedLines = UPLOADED_LINES ? UPLOADED_LINES.length > 0 : false;\n    var approaches = isImportedField\n      ? ['ab-straight', 'ab-curve', 'boundary', 'auto-blocks', 'concentric']\n      : ['ab-straight', 'ab-curve', 'boundary', 'adaptive', 'auto-blocks', 'concentric'];\n    if(hasUploadedLines) approaches.push('uploaded');\n    \/\/ Hide the adaptive row from the Compare All table for imports.\n    var cmpAdaptiveRow = document.querySelector('.gpl-cmp-row[data-cmp=\"adaptive\"]');\n    if(cmpAdaptiveRow) cmpAdaptiveRow.hidden = isImportedField;\n    \/\/ Seed apMetrics with the current approach (already computed) so the\n    \/\/ ROI block + recoMetric below have at least one entry to read.\n    \/\/ OTHER approaches' Compare All cells fill in via deferred\n    \/\/ computation below \u2014 this lets the canvas paint first (~70 ms after\n    \/\/ import) before the heavier ~3 \u00d7 70 ms compare cycle blocks the UI.\n    var apMetrics = {};\n    apMetrics[current] = met;\n    \/\/ Render the current row immediately with \"\u2014\" placeholders in\n    \/\/ other rows (existing default state). The deferred loop populates\n    \/\/ the rest after the first paint.\n    \/\/ Paint a single Compare All row. 10 numeric columns:\n    \/\/   Coverage \/ Missed area \/ Compaction \/ Repeats \/ Path crossings\n    \/\/   \/ Distance \/ Turnarounds \/ Time \/ Fuel \/ Cost\n    \/\/ All units respect the unitSystem (metric vs US) and currency\n    \/\/ (sym) sourced from the Analytics inputs. Raw numbers stored\n    \/\/ in dataset.* attrs so the column sort can compare them directly.\n    function paintCmpRow(ap, mObj){\n      var rowEl = document.querySelector('.gpl-cmp-row[data-cmp=\"' + ap + '\"]');\n      if(!rowEl) return;\n      var vals = rowEl.querySelectorAll('.gpl-cmp-val');\n      var cov = mObj.coveragePct || 0;\n      var fieldM2 = mObj.fieldM2 || 0;\n      var overlapM2 = mObj.overlapM2 || 0;\n      \/\/ Missed area MUST be derived from coverage % \u00d7 fieldM2, NOT from\n      \/\/ (fieldM2 \u2212 coveredM2). The two source figures are computed\n      \/\/ differently (fieldM2 = polygon area; coveredM2 = raster cell\n      \/\/ count \u00d7 cellArea \u2014 raster discretization under-counts boundary\n      \/\/ cells), and subtracting them produces a spurious \"missed\" of\n      \/\/ 3\u20138 % even at 95 %+ coverage. Deriving from the percentage keeps\n      \/\/ the two columns mathematically consistent on every row.\n      var missedFrac = Math.max(0, 1 - cov \/ 100);\n      var missedM2 = missedFrac * fieldM2;\n      var missedHa = missedM2 \/ 10000;\n      var compactionHa = overlapM2 \/ 10000;\n      var repeats = mObj.overlapPct || 0;\n      var cross = mObj.crossings || 0;\n      var totalKm = (mObj.totalDriveM || (mObj.passLengthM + (mObj.turnLengthM || 0))) \/ 1000;\n      var passKm = (mObj.passLengthM || 0) \/ 1000;\n      var turnKm = (mObj.turnLengthM || 0) \/ 1000;\n      var turns = mObj.turns || 0;\n      var fuelL = mObj.fuelL || 0;\n      var costUSD = mObj.costUSD || 0;\n      \/\/ Time = working km \/ working speed + non-working km \/ 0.6 \u00d7\n      \/\/ working speed (matches the Analytics panel formula).\n      var speedKmh = parseFloat((document.getElementById('gpl-speed') || {}).value) || 10;\n      if(unitSystem === 'us') speedKmh = speedKmh * 1.609344;\n      if(speedKmh < 2) speedKmh = 2;\n      var workSec = passKm * 1000 \/ (speedKmh * 1000 \/ 3600);\n      var turnSec = turnKm * 1000 \/ (speedKmh * 0.6 * 1000 \/ 3600);\n      var totalSec = workSec + turnSec;\n      var hours = Math.floor(totalSec \/ 3600);\n      var mins = Math.round((totalSec % 3600) \/ 60);\n      if(mins === 60){ hours += 1; mins = 0; }\n      var timeStr = hours + ' h ' + (mins < 10 ? '0' : '') + mins;\n      \/\/ Cell 0: Coverage (%)\n      vals[0].textContent = cov.toFixed(0) + ' %';\n      vals[0].style.color = cov >= 95 ? '#15701e' : (cov >= 85 ? '#f76a0c' : '#dc2626');\n      \/\/ Cell 1: Missed area (ha\/ac)\n      vals[1].textContent = fmtArea(missedHa);\n      var missedFrac = fieldM2 > 0 ? missedM2 \/ fieldM2 : 0;\n      vals[1].style.color = missedFrac <= 0.05 ? '#15701e' : (missedFrac <= 0.15 ? '#f76a0c' : '#dc2626');\n      \/\/ Cell 2: Compaction (ha\/ac of overlapping wheel work)\n      vals[2].textContent = fmtArea(compactionHa);\n      var compactionFrac = fieldM2 > 0 ? overlapM2 \/ fieldM2 : 0;\n      vals[2].style.color = compactionFrac <= 0.02 ? '#15701e' : (compactionFrac <= 0.10 ? '#f76a0c' : '#dc2626');\n      \/\/ Cell 3: Repeats (% overlap)\n      vals[3].textContent = repeats.toFixed(0) + ' %';\n      vals[3].style.color = repeats <= 5 ? '#15701e' : (repeats <= 20 ? '#f76a0c' : '#dc2626');\n      \/\/ Cell 4: Path crossings (count)\n      vals[4].textContent = String(cross);\n      vals[4].style.color = cross <= 4 ? '#15701e' : (cross <= 8 ? '#f76a0c' : '#dc2626');\n      \/\/ Cell 5: Distance (km\/mi)\n      vals[5].textContent = unitSystem === 'us' ? (totalKm * KM_TO_MI).toFixed(1) + ' mi' : totalKm.toFixed(1) + ' km';\n      \/\/ Cell 6: Turnarounds (count)\n      vals[6].textContent = String(turns);\n      \/\/ Cell 7: Time\n      vals[7].textContent = timeStr;\n      \/\/ Cell 8: Fuel (L\/gal)\n      vals[8].textContent = unitSystem === 'us' ? (fuelL * L_TO_GAL).toFixed(0) + ' gal' : fuelL.toFixed(0) + ' L';\n      \/\/ Cell 9: Cost (currency from Analytics)\n      vals[9].textContent = sym + ' ' + costUSD.toFixed(0);\n      \/\/ Raw numbers for sorting\n      rowEl.dataset.cov = String(cov);\n      rowEl.dataset.missed = String(missedHa);\n      rowEl.dataset.compaction = String(compactionHa);\n      rowEl.dataset.repeats = String(repeats);\n      rowEl.dataset.cross = String(cross);\n      rowEl.dataset.km = String(totalKm);\n      rowEl.dataset.turns = String(turns);\n      rowEl.dataset.time = String(totalSec);\n      rowEl.dataset.fuel = String(fuelL);\n      rowEl.dataset.cost = String(costUSD);\n    }\n    (function paintCurrentRow(){\n      paintCmpRow(current, met);\n      for(var rci=0; rci<approaches.length; rci++){\n        var otherRow = document.querySelector('.gpl-cmp-row[data-cmp=\"' + approaches[rci] + '\"]');\n        if(otherRow) otherRow.classList.toggle('gpl-cmp-current', approaches[rci] === current);\n      }\n    })();\n    \/\/ Defer the heavier other-approach cells (each is a full\n    \/\/ generateLinesAll + computeMetrics). They render asynchronously\n    \/\/ after the canvas has painted the current approach \u2014 UI feels\n    \/\/ instant on big fields. The schedule cancels itself on next\n    \/\/ recompute so input-drag inputs don't queue up stale work.\n    if(compareTimer) clearTimeout(compareTimer);\n    compareTimer = setTimeout(function(){\n      compareTimer = null;\n      \/\/ Remote mode: the server computed every approach's metrics in the\n      \/\/ same round-trip (compare rows) \u2014 feed the table from them instead\n      \/\/ of regenerating each layout locally. 'uploaded' always stays\n      \/\/ local: it's synthesised from the user's imported lines, which\n      \/\/ never leave the browser.\n      var remoteRows = remoteCacheFresh(inp, axis) ? REMOTE_PLAN_CACHE.compare : null;\n      for(var a=0; a<approaches.length; a++){\n        var ap = approaches[a];\n        if(ap === current) continue;\n        if(remoteRows ? ap !== 'uploaded' : false){\n          var rRow = null;\n          for(var rri=0; rri<remoteRows.length; rri++){\n            if(remoteRows[rri].approach === ap){ rRow = remoteRows[rri]; break; }\n          }\n          if(rRow){\n            var rMet = metricsFromCompareRow(rRow, inp);\n            apMetrics[ap] = rMet;\n            paintCmpRow(ap, rMet);\n            continue;\n          }\n        }\n        var apLayout;\n        try {\n          if(ap === 'uploaded'){\n            apLayout = buildUploadedLayout();\n            if(!apLayout) continue;\n          } else {\n            apLayout = planLayout(ap, inp.wM, BOUNDARY_PARTS, axis, inp.headlandM, inp.turnStyle, inp.turnR, inp.turnBuf);\n          }\n        } catch(e){\n          \/\/ generateLinesAll can throw on degenerate inputs (irregular\n          \/\/ imported boundaries via concaveHullFromLines, self-intersecting\n          \/\/ offset rings on thin polygons). Reported 2026-06-03: Boundary\n          \/\/ Follow row stayed empty after uploading lv Eichwiese lines.\n          \/\/ Log + skip so the OTHER approaches still render \u2014 silently\n          \/\/ dropping the row is the regression we're guarding against.\n          console.warn('[GPL] ' + ap + ' generation failed on imported boundary: ' + (e ? (e.message ? e.message : e) : 'unknown'));\n          continue;\n        }\n        \/\/ Defensive: a layout with zero passes means the algorithm\n        \/\/ produced nothing for this boundary (often happens when the\n        \/\/ imported polygon is too small or pathological for the chosen\n        \/\/ approach). Skip painting \u2192 the row stays at \"\u2014\" instead of\n        \/\/ showing misleading zeros.\n        if(!apLayout ? true : !apLayout.passes ? true : apLayout.passes.length === 0){\n          console.warn('[GPL] ' + ap + ' produced empty layout on imported boundary');\n          continue;\n        }\n        var apMet = apLayout.__remoteMetrics ? apLayout.__remoteMetrics : computeMetrics(apLayout, inp.wM, inp.diesel, inp.cons);\n        apMetrics[ap] = apMet;\n        paintCmpRow(ap, apMet);\n      }\n      \/\/ Refine the \"Best\" recommendation now that all metrics are in.\n      var laterReco = pickBestApproach(apMetrics);\n      var laterBadges = document.querySelectorAll('.gpl-reco');\n      for(var lb=0; lb<laterBadges.length; lb++){\n        var lkey = laterBadges[lb].getAttribute('data-reco');\n        laterBadges[lb].classList.toggle('is-on', lkey === laterReco.pick);\n      }\n      \/\/ Highlight the best row + write the why-this-one description\n      var allRows = document.querySelectorAll('#gpl-cmp-table .gpl-cmp-row');\n      for(var brI=0; brI<allRows.length; brI++) allRows[brI].classList.remove('is-best');\n      var bestRow = document.querySelector('#gpl-cmp-table .gpl-cmp-row[data-cmp=\"' + laterReco.pick + '\"]');\n      if(bestRow) bestRow.classList.add('is-best');\n      var bestNameEl = document.getElementById('gpl-cmp-best-name');\n      var bestWhyEl = document.getElementById('gpl-cmp-best-why');\n      if(bestNameEl) bestNameEl.textContent = AP_LABELS[laterReco.pick] || laterReco.pick;\n      if(bestWhyEl) bestWhyEl.textContent = laterReco.why || '';\n      var lhint = document.getElementById('gpl-reco-hint');\n      if(lhint){\n        var lapLabel = AP_LABELS[laterReco.pick] || 'Best';\n        lhint.textContent = 'Best: ' + lapLabel + ' \u2014 ' + laterReco.why;\n      }\n      \/\/ CRITICAL: this deferred block is where the TRUE best is known \u2014 the\n      \/\/ synchronous recompute only knows about the CURRENT approach because\n      \/\/ computing every approach inline is too slow on big fields.\n      \/\/ Update lastBestPick here + trigger the auto-pick so the radio +\n      \/\/ canvas swap to the actual best within ~0 ms of the deferred fill.\n      \/\/ Without this hook, the earlier autoPickBestApproach call sees\n      \/\/ lastBestPick === current (only one approach in apMetrics) and\n      \/\/ never swaps \u2014 reported 2026-06-04 \"Best is not auto-selected on\n      \/\/ any field\". Reported across hex, rect, lshape, twoblock \u2014 the\n      \/\/ common cause is the deferred metrics pattern.\n      lastBestPick = laterReco.pick;\n      \/\/ Guard: only auto-pick if the document has finished loading. The\n      \/\/ harness's noop DOM can throw during init when the swap triggers\n      \/\/ a recursive recompute \u2192 draw \u2192 before resize() has set canvas\n      \/\/ backing dimensions.\n      if(typeof document !== 'undefined' ? document.readyState !== 'loading' : false){\n        autoPickBestApproach();\n      }\n      \/\/ Re-apply the current sort now that every row has fresh stats.\n      \/\/ Default is cost ascending so the best-value approach lands on\n      \/\/ top automatically; if the user clicked a header earlier, that\n      \/\/ sort key + direction is preserved across recomputes.\n      if(typeof applyCmpSort === 'function') applyCmpSort();\n    }, 0);\n    \/\/ Uploaded existing guidance lines \u2014 compute a parallel set of metrics\n    \/\/ (drive km, fuel, cost, approximate coverage) so the user can compare\n    \/\/ their EXISTING plan against the four generated proposals in Compare\n    \/\/ All. Layout is synthesised on the fly so computeMetrics's existing\n    \/\/ pipeline can run unmodified.\n    var uploadedRow = document.querySelector('.gpl-cmp-row[data-cmp=\"uploaded\"]');\n    var uploadedLegend = document.getElementById('gpl-lg-uploaded');\n    var uploadedRingLegend = document.getElementById('gpl-lg-uploaded-ring');\n    var hasUploaded = UPLOADED_LINES ? UPLOADED_LINES.length > 0 : false;\n    if(uploadedLegend) uploadedLegend.hidden = !hasUploaded;\n    \/\/ Show the headland-trace legend row only when at least one closed\n    \/\/ loop made it through the import (so the user sees what the solid\n    \/\/ blue line means).\n    var hasUploadedRing = false;\n    if(hasUploaded ? typeof isImportedLineLoop === 'function' : false){\n      for(var ulr=0; ulr<UPLOADED_LINES.length; ulr++){\n        if(isImportedLineLoop(UPLOADED_LINES[ulr])){ hasUploadedRing = true; break; }\n      }\n    }\n    if(uploadedRingLegend) uploadedRingLegend.hidden = !hasUploadedRing;\n    \/\/ Hint banner under the Compare All table: hidden when the user\n    \/\/ HAS uploaded lines (the row itself is enough); shown when they\n    \/\/ haven't, prompting them to upload their existing plan so we can\n    \/\/ benchmark the 5 proposed approaches against it head-to-head.\n    var cmpHint = document.getElementById('gpl-cmp-uploaded-hint');\n    if(cmpHint) cmpHint.hidden = hasUploaded;\n    if(uploadedRow){\n      if(hasUploaded){\n        \/\/ Reuse buildUploadedLayout() so Compare All's \"Your current lines\"\n        \/\/ row inherits the same closed-loop filter + synthetic turn arcs as\n        \/\/ the playback view. computeMetrics then reads passes + turnArcs +\n        \/\/ drivable \u2192 identical numbers in both places.\n        var upLayout = buildUploadedLayout();\n        if(upLayout){\n          var upMet = computeMetrics(upLayout, inp.wM, inp.diesel, inp.cons);\n          apMetrics['uploaded'] = upMet;\n          uploadedRow.hidden = false;\n          paintCmpRow('uploaded', upMet);\n        } else {\n          uploadedRow.hidden = true;\n        }\n      } else {\n        uploadedRow.hidden = true;\n      }\n    }\n    \/\/ Annual ROI extrapolation. Per-field fuel savings \u00d7 applications \u00d7 (farm\/field).\n    \/\/ When the user is on the baseline (AB Straight), \"savings vs self\" is 0 by\n    \/\/ definition. To still show a useful number, surface the potential savings\n    \/\/ from switching to the BEST non-baseline approach available on this field.\n    \/\/ That way the ROI panel always carries a real number instead of \"\u2014\".\n    var roiFuelEl = document.getElementById('gpl-roi-fuel');\n    var roiCostEl = document.getElementById('gpl-roi-cost');\n    var roiPerhaEl = document.getElementById('gpl-roi-perha');\n    if(roiFuelEl ? roiCostEl : false){\n      var scale = areaHa > 0 ? (inp.farmHa \/ areaHa) : 0;\n      \/\/ Pick the \"comparison approach\" \u2014 whose fuel we compare to AB Straight:\n      \/\/   - If user is on baseline, find the cheapest non-baseline approach\n      \/\/     (Contour-follow is most common, but boundary\/curve can win on\n      \/\/     specific shapes).\n      \/\/   - Otherwise, use the user's currently-selected approach.\n      var cmpAp = current;\n      var cmpFuel = met.fuelL;\n      var hintLine = null;\n      if(current === 'ab-straight'){\n        var bestAp = null, bestFuel = baseMet.fuelL;\n        var others = ['ab-curve','boundary','adaptive'];\n        for(var oi=0; oi<others.length; oi++){\n          var oap = others[oi];\n          var oMet = apMetrics[oap];\n          if(oMet ? oMet.fuelL < bestFuel : false){\n            bestFuel = oMet.fuelL;\n            bestAp = oap;\n          }\n        }\n        if(bestAp){\n          cmpAp = bestAp;\n          cmpFuel = bestFuel;\n          hintLine = 'vs ' + (AP_LABELS[bestAp] || bestAp);\n        } else {\n          \/\/ No alternative beats baseline on this field.\n          hintLine = 'AB Straight is already optimal here';\n        }\n      }\n      var perFieldFuel = baseMet.fuelL - cmpFuel;\n      var annualFuel = perFieldFuel * inp.apps * scale;\n      var annualCost = annualFuel * inp.diesel;\n      var perAreaLbl = unitSystem === 'us' ? '\/ac' : '\/ha';\n      var farmDispArea = unitSystem === 'us' ? inp.farmHa * HA_TO_AC : inp.farmHa;\n      var perAreaVal = farmDispArea > 0 ? (annualCost \/ farmDispArea) : 0;\n      var muted = '#4c6066', win = '#15701e', loss = '#f76a0c';\n      function setRoi(fuelTxt, costTxt, perTxt, color){\n        roiFuelEl.textContent = fuelTxt;\n        roiCostEl.textContent = costTxt;\n        if(roiPerhaEl) roiPerhaEl.textContent = perTxt;\n        roiFuelEl.style.color = color;\n        roiCostEl.style.color = color;\n        if(roiPerhaEl) roiPerhaEl.style.color = color;\n      }\n      if(Math.abs(perFieldFuel) < 0.05){\n        setRoi(fmtVolume(0), sym + ' 0', hintLine || 'No measurable savings on this field', muted);\n      } else {\n        var signR = annualFuel >= 0 ? '' : '\u2212';\n        var fuelTxt = signR + fmtVolume(Math.abs(annualFuel));\n        var costTxt = (annualCost >= 0 ? sym + ' ' : '\u2212' + sym + ' ') + Math.abs(annualCost).toFixed(0);\n        var perTxt = (perAreaVal >= 0 ? sym + ' ' : '\u2212' + sym + ' ') + Math.abs(perAreaVal).toFixed(2) + perAreaLbl + (hintLine ? ' \u00b7 ' + hintLine : '');\n        setRoi(fuelTxt, costTxt, perTxt, annualFuel >= 0 ? win : loss);\n      }\n    }\n    \/\/ Warning banner from validation cascade. Beyond showing the banner, we\n    \/\/ also pulse the control most likely to fix the warning so the user knows\n    \/\/ WHERE to click. Heuristic on the warning text:\n    \/\/   \"headland\" \/ \"U-turn dips\" \u2192 Headland card\n    \/\/   \"turn radius\" \/ \"turn fits\" \/ \"boundary\" \u2192 Turnarounds card\n    \/\/   \"axis\" \/ \"AB\" \/ \"PCA\" \u2192 AB line direction card\n    \/\/   anything else \u2192 Approach card (the highest-level lever)\n    var warnEl = document.getElementById('gpl-warn');\n    var sideEl = document.querySelector('.gpl-side');\n    function pulseCard(matchH3){\n      if(!sideEl) return;\n      var cards = sideEl.querySelectorAll('.gpl-card');\n      \/\/ Clear previous pulses first so consecutive warnings re-trigger animation.\n      for(var pc=0; pc<cards.length; pc++){\n        cards[pc].classList.remove('gpl-card-attention');\n      }\n      if(!matchH3) return;\n      \/\/ Force a reflow before re-adding the class so the animation actually restarts.\n      void sideEl.offsetWidth;\n      for(var qc=0; qc<cards.length; qc++){\n        var h3 = cards[qc].querySelector('h3');\n        if(h3 ? h3.textContent.toLowerCase().indexOf(matchH3) >= 0 : false){\n          cards[qc].classList.add('gpl-card-attention');\n          \/\/ Pulse animation alone is the attention signal. scrollIntoView\n          \/\/ was here originally, but on every approach click it scrolled\n          \/\/ the WHOLE PAGE (because the matched card sits in the same\n          \/\/ scroll container as the canvas) \u2014 user saw the map \"jump\"\n          \/\/ up\/down as they cycled through approaches with different\n          \/\/ warnings (reported 2026-06-03).\n          break;\n        }\n      }\n    }\n    if(warnEl){\n      if(layout.warning){\n        warnEl.textContent = layout.warning;\n        warnEl.classList.add('is-on');\n        var w = layout.warning.toLowerCase();\n        var target = null;\n        if(w.indexOf('headland') >= 0 ? true : w.indexOf('u-turn dips') >= 0) target = 'headland';\n        else if(w.indexOf('turn radius') >= 0 ? true : (w.indexOf('turn fits') >= 0 ? true : (w.indexOf('no turn') >= 0 ? true : w.indexOf('boundary') >= 0))) target = 'turnarounds';\n        else if(w.indexOf('axis') >= 0 ? true : (w.indexOf('ab line') >= 0 ? true : w.indexOf('pca') >= 0)) target = 'ab line direction';\n        else target = 'approach';\n        pulseCard(target);\n      } else {\n        warnEl.textContent = '';\n        warnEl.classList.remove('is-on');\n        pulseCard(null);\n      }\n    }\n    \/\/ (apMetrics + Compare All cells already populated above, before the ROI\n    \/\/ block \u2014 moved earlier so the ROI logic can read apMetrics without\n    \/\/ tripping the \"Cannot read properties of undefined\" error.)\n    \/\/ Initial recommendation: use the per-approach metrics at the CURRENT\n    \/\/ axis (PCA or user override). The full axis sweep \u2014 which tries 6 extra\n    \/\/ angles per approach for higher-coverage axes \u2014 is deferred to an idle\n    \/\/ window so interactive recomputes stay fast (the sweep is 28 layout\n    \/\/ builds, ~6x more work than the main recompute). scheduleAxisSweep\n    \/\/ below kicks off the sweep 600 ms after the LAST recompute settles.\n    var recoMetric = pickBestApproach(apMetrics);\n    \/\/ Expose the latest metric-picked best so the field-switch flow + the\n    \/\/ initial-page-load bootstrap can auto-select it after this recompute\n    \/\/ settles. Without this hook the BEST badge highlights the right\n    \/\/ approach but the radio + canvas stay on whatever the user last\n    \/\/ chose, forcing them to click the badge to actually see the plan.\n    lastBestPick = recoMetric.pick;\n    var recoBadges2 = document.querySelectorAll('.gpl-reco');\n    for(var rb2=0; rb2<recoBadges2.length; rb2++){\n      var rkey2 = recoBadges2[rb2].getAttribute('data-reco');\n      recoBadges2[rb2].classList.toggle('is-on', rkey2 === recoMetric.pick);\n    }\n    var recoHintEl2 = document.getElementById('gpl-reco-hint');\n    if(recoHintEl2){\n      var apLabel = AP_LABELS[recoMetric.pick] || 'Best';\n      var hintTxt = 'Best: ' + apLabel + ' \u2014 ' + recoMetric.why;\n      var bestCov = (apMetrics[recoMetric.pick] || {}).coveragePct || 0;\n      if(bestCov < 70){\n        hintTxt += ' \u2014 split field for full coverage';\n      }\n      recoHintEl2.textContent = hintTxt;\n    }\n    setPlaybackPath(layout, inp.wM);\n    updatePlaybackUI();\n    draw(layout);\n    \/\/ Compute heading-strategy angles for the picker (Longest Edge \/ Fewest\n    \/\/ Crossings \/ Shortest Distance). Coarse sweep against the current\n    \/\/ approach \u2014 re-runs on every recompute so the picker stays in sync\n    \/\/ with field + approach + width changes.\n    computeHeadingStrategies(inp);\n    \/\/ Defer the expensive axis sweep \u2014 it refines the Compare All cells +\n    \/\/ recommendation hint by trying alternate AB-line angles. Runs after\n    \/\/ 600 ms of input idle; if another recompute fires first the sweep\n    \/\/ resets.\n    scheduleAxisSweep(inp, axis, approaches, apMetrics, sym, paintCmpRow);\n  }\n  \/\/ Update the headland slider label. Headland = mult \u00d7 wM (sole source of\n  \/\/ truth \u2014 no separate custom-width input anymore).\n  function updateHeadlandLabel(){\n    \/\/ Implement width input is in the active unit system (m or ft). Convert\n    \/\/ it to metric for the arithmetic, then format the result via fmtWidth\n    \/\/ so the displayed strip width respects the unit system.\n    var rawW = parseFloat(document.getElementById('gpl-wm').value) || 18;\n    var wM = unitSystem === 'us' ? rawW \/ M_TO_FT : rawW;\n    var hlMult = parseFloat(document.getElementById('gpl-hl-mult').value);\n    if(isNaN(hlMult)) hlMult = 1;\n    var labelEl = document.getElementById('gpl-hl-val');\n    if(!labelEl) return;\n    var m = hlMult * wM;\n    labelEl.textContent = fmtWidth(m) + ' \u00b7 ' + hlMult + '\u00d7 pass';\n  }\n  var radios = document.querySelectorAll('#gpl-approach input[type=radio]');\n  for(var ri=0; ri<radios.length; ri++){\n    radios[ri].addEventListener('change', function(){\n      var labs = document.querySelectorAll('#gpl-approach label');\n      for(var li=0; li<labs.length; li++) labs[li].classList.remove('is-on');\n      this.parentNode.classList.add('is-on');\n      current = this.value;\n      \/\/ Explicit user choice \u2014 cancel any pending auto-pick from a recent\n      \/\/ field switch so the deferred Compare All fill doesn't override.\n      pendingAutoPick = false;\n      recompute();\n    });\n  }\n  \/\/ Info-icon tooltips on each approach row. Without these handlers, clicking\n  \/\/ the icon would also click its enclosing label (browser-default label\n  \/\/ behaviour), toggling the radio button. preventDefault + stopPropagation\n  \/\/ on mousedown blocks both the label activation AND the synthetic click\n  \/\/ that fires after. tabindex=0 lets touch users + keyboard users tap or\n  \/\/ Tab to the icon so :focus reveals the tooltip on platforms without\n  \/\/ hover (mobile Safari, Chrome touch).\n  var infoIcons = document.querySelectorAll('#gpl-approach .gpl-info-ico');\n  for(var ic=0; ic<infoIcons.length; ic++){\n    infoIcons[ic].setAttribute('tabindex', '0');\n    infoIcons[ic].setAttribute('role', 'button');\n    var tipTxt = infoIcons[ic].getAttribute('data-tip') || '';\n    infoIcons[ic].setAttribute('aria-label', tipTxt);\n    infoIcons[ic].addEventListener('mousedown', function(ev){\n      ev.preventDefault();\n      ev.stopPropagation();\n    });\n    infoIcons[ic].addEventListener('click', function(ev){\n      ev.preventDefault();\n      ev.stopPropagation();\n      \/\/ On touch devices a click also focuses; re-focus explicitly so :focus\n      \/\/ reveals the tooltip and a second tap elsewhere dismisses it.\n      try { this.focus(); } catch(_){}\n    });\n    infoIcons[ic].addEventListener('keydown', function(ev){\n      \/\/ Block Enter \/ Space from \"clicking\" the label (which would toggle\n      \/\/ the radio). The icon is informational only \u2014 never activates a state.\n      if(ev.key === ' ' ? true : ev.key === 'Enter'){\n        ev.preventDefault();\n        ev.stopPropagation();\n      }\n    });\n  }\n  \/\/ Equipment + economics inputs \u2014 all live updates go through\n  \/\/ scheduleRecompute (debounced), see comment above the helper definition.\n  var basicIds = ['gpl-wm', 'gpl-fuel', 'gpl-cons', 'gpl-speed'];\n  for(var ii=0; ii<basicIds.length; ii++){\n    var el = document.getElementById(basicIds[ii]);\n    if(el){\n      \/\/ Implement width gets a special handler: when wM changes, also\n      \/\/ auto-update the min turn radius via autoTurnRadius(wM). Ag-equipment\n      \/\/ turn radius scales with implement width up to ~24 m (after which the\n      \/\/ tractor's own steering geometry caps it). Keeping r in sync with w\n      \/\/ means a user dragging the width input gets realistic U-turns at every\n      \/\/ size, not a stuck \"9 m default\" that produced racetrack-looking turns\n      \/\/ on narrow implements.\n      var isW = basicIds[ii] === 'gpl-wm';\n      var widthHandler = function(){\n        var wEl = document.getElementById('gpl-wm');\n        var rEl = document.getElementById('gpl-turn-r');\n        var hintEl = document.getElementById('gpl-turn-r-hint');\n        var machineEl = document.getElementById('gpl-machine');\n        if(wEl ? rEl : false){\n          var wDisp = parseFloat(wEl.value);\n          if(!isNaN(wDisp) ? wDisp > 0 : false){\n            var wM = unitSystem === 'us' ? wDisp \/ M_TO_FT : wDisp;\n            \/\/ Use the active machine class's width-to-turn-radius factor when\n            \/\/ available, so changing the width on a \"tractor + 24-row planter\"\n            \/\/ preset gives a TRAILED-PLANTER-realistic turn radius (~1.0 \u00d7 w)\n            \/\/ instead of the generic mounted-implement 0.75 \u00d7 w.\n            var mSpec = machineEl ? MACHINE_SPEC[machineEl.value] : null;\n            var factor = mSpec ? (typeof mSpec.factor === 'number' ? mSpec.factor : 0.75) : 0.75;\n            var autoRm = autoTurnRadius(wM, factor);\n            var autoRdisp = unitSystem === 'us' ? autoRm * M_TO_FT : autoRm;\n            rEl.value = String(unitSystem === 'us' ? autoRdisp.toFixed(1) : autoRm);\n            if(hintEl){\n              hintEl.textContent = 'auto \u00b7 ' + fmtWidth(autoRm) + ' for ' + fmtWidth(wM) + ' implement';\n            }\n          }\n        }\n        updateHeadlandLabel();\n        scheduleRecompute();\n      };\n      var basicHandler = function(){\n        updateHeadlandLabel();\n        scheduleRecompute();\n      };\n      if(isW){\n        el.addEventListener('input', widthHandler);\n        \/\/ 'change' fires on blur, Enter, and spinner clicks \u2014 in case the\n        \/\/ user commits a value without triggering the debounced 'input'\n        \/\/ path, run recompute immediately so the lines visibly redraw.\n        el.addEventListener('change', function(){\n          widthHandler();\n          \/\/ 'change' implies the user is done editing \u2014 bypass the\n          \/\/ 500 ms debounce so they see the result without lag.\n          if(recomputeTimer){\n            clearTimeout(recomputeTimer);\n            recomputeTimer = null;\n          }\n          recompute();\n        });\n      } else {\n        el.addEventListener('input', basicHandler);\n        el.addEventListener('change', basicHandler);\n      }\n    }\n  }\n  \/\/ Headland slider \u2014 drives headland = mult \u00d7 wM. Slider drag fires bursts,\n  \/\/ use the fast (120 ms) debounce so the drag feels live.\n  var hlSlider = document.getElementById('gpl-hl-mult');\n  if(hlSlider){\n    hlSlider.addEventListener('input', function(){\n      updateHeadlandLabel();\n      scheduleRecomputeFast();\n    });\n  }\n  \/\/ ROI inputs \u2014 purely informational, debounce so typing doesn't thrash.\n  var roiIds = ['gpl-roi-farm', 'gpl-roi-apps'];\n  for(var roi=0; roi<roiIds.length; roi++){\n    var roiEl = document.getElementById(roiIds[roi]);\n    if(roiEl) roiEl.addEventListener('input', scheduleRecompute);\n  }\n  \/\/ Turn-around controls \u2014 'input' is the live-typing event (debounce),\n  \/\/ 'change' fires on blur \/ dropdown commit (run immediately so the user\n  \/\/ sees the final state without waiting for the trailing 80 ms timeout).\n  \/\/ For the turn-r input we additionally surface an \"unrealistic-tight\"\n  \/\/ hint when the typed radius is well below the machine's realistic\n  \/\/ minimum (8 m for tractors \/ trailed implements). Small radii produce\n  \/\/ visually clean U-turns but don't reflect what a real trailed planter\n  \/\/ can physically execute.\n  function updateTurnRadiusHint(){\n    var rEl = document.getElementById('gpl-turn-r');\n    var hintEl = document.getElementById('gpl-turn-r-hint');\n    var mEl = document.getElementById('gpl-machine');\n    if(!rEl ? true : !hintEl) return;\n    var raw = parseFloat(rEl.value);\n    if(isNaN(raw) ? true : raw <= 0) return;\n    var rM = unitSystem === 'us' ? raw \/ M_TO_FT : raw;\n    var spec = mEl ? MACHINE_SPEC[mEl.value] : null;\n    var safeMin = spec ? (spec.r > 0 ? spec.r * 0.7 : 8) : 8;  \/\/ 70% of the preset minimum is the \"tight but possible\" threshold\n    if(rM < safeMin){\n      hintEl.textContent = '\u26a0 Tight turn \u2014 may be unrealistic for this machine';\n      hintEl.style.color = '#dc2626';\n    } else {\n      hintEl.textContent = spec ? ('auto \u00b7 ' + spec.label) : ('auto \u00b7 ' + fmtWidth(rM));\n      hintEl.style.color = '';\n    }\n  }\n  var turnIds = ['gpl-turn-style', 'gpl-turn-r'];\n  for(var ti=0; ti<turnIds.length; ti++){\n    var tEl = document.getElementById(turnIds[ti]);\n    if(tEl){\n      var isTurnR = turnIds[ti] === 'gpl-turn-r';\n      tEl.addEventListener('input', isTurnR ? function(){\n        updateTurnRadiusHint();\n        scheduleRecompute();\n      } : scheduleRecompute);\n      tEl.addEventListener('change', isTurnR ? function(){\n        updateTurnRadiusHint();\n        recompute();\n      } : recompute);\n    }\n  }\n  \/\/ Machine type \u2014 auto-fills BOTH turn radius and implement width (unless\n  \/\/ 'custom'). Each machine class has typical specs from manufacturer averages.\n  function applyMachineRadius(){\n    var mEl = document.getElementById('gpl-machine');\n    var rEl = document.getElementById('gpl-turn-r');\n    var wEl = document.getElementById('gpl-wm');\n    var hintEl = document.getElementById('gpl-turn-r-hint');\n    if(!mEl ? true : !rEl) return;\n    var spec = MACHINE_SPEC[mEl.value] || MACHINE_SPEC['tractor-std'];\n    \/\/ Spec is in metric; convert to display units if the user has picked US.\n    if(mEl.value !== 'custom' ? spec.r > 0 : false){\n      var rDisp = unitSystem === 'us' ? spec.r * M_TO_FT : spec.r;\n      rEl.value = String(unitSystem === 'us' ? rDisp.toFixed(1) : spec.r);\n      rEl.disabled = false;\n    }\n    if(mEl.value !== 'custom' ? (spec.w > 0 ? wEl : false) : false){\n      var wDisp = unitSystem === 'us' ? spec.w * M_TO_FT : spec.w;\n      wEl.value = String(unitSystem === 'us' ? wDisp.toFixed(0) : spec.w);\n    }\n    \/\/ Sync headland slider\/label to the new width so headlandM stays at the\n    \/\/ user's intended multiplier of pass width.\n    updateHeadlandLabel();\n    if(hintEl){\n      hintEl.textContent = mEl.value === 'custom' ? 'manual override' : ('auto \u00b7 ' + spec.label);\n    }\n  }\n  var machineSelEl = document.getElementById('gpl-machine');\n  if(machineSelEl){\n    machineSelEl.addEventListener('change', function(){\n      applyMachineRadius();\n      recompute();\n    });\n  }\n  \/\/ Currency select \u2014 purely a label switch; user types their local price\n  var curSelEl = document.getElementById('gpl-currency');\n  if(curSelEl) curSelEl.addEventListener('change', function(){\n    var fuelEl = document.getElementById('gpl-fuel');\n    if(fuelEl){\n      \/\/ Canonical per-litre value: the data-metric snapshot (set in US mode)\n      \/\/ or the displayed value (metric mode is already per-litre).\n      var savedM = parseFloat(fuelEl.getAttribute('data-metric'));\n      var canon = isNaN(savedM) ? parseFloat(fuelEl.value) : savedM;\n      var other = this.value === 'eur' ? DIESEL_DEFAULT.usd : DIESEL_DEFAULT.eur;\n      var mine = this.value === 'eur' ? DIESEL_DEFAULT.eur : DIESEL_DEFAULT.usd;\n      \/\/ Swap in this region's default only when the field still holds the\n      \/\/ OTHER region's default (i.e. the user has not typed a custom price).\n      if(isNaN(canon) ? false : Math.abs(canon - other) < 0.005){\n        fuelEl.value = (unitSystem === 'us' ? mine * LPL_TO_DPGAL : mine).toFixed(2);\n        fuelEl.setAttribute('data-metric', String(mine));\n      }\n    }\n    recompute();\n  });\n  \/\/ File-import helpers \u2014 accept GeoJSON, KML, or zipped shapefile. Extract\n  \/\/ ALL polygon outer rings (so users can iterate through fields in a\n  \/\/ multi-feature file), project lat\/lng to local meters with each polygon's\n  \/\/ own centroid as origin, fit into the existing canvas coordinate scheme.\n  \/\/ Projection origin captured when a real upload comes in. Used by the\n  \/\/ GIS export to invert canvas coords back to lng\/lat. For sample\n  \/\/ fields we seed a plausible US-corn-belt origin so the export is\n  \/\/ still a valid geographic file (relative geometry preserved; the\n  \/\/ operator's machine doesn't care about absolute position as long as\n  \/\/ pass spacing + heading are correct).\n  var EXPORT_ORIGIN_LNG = -92.5;   \/\/ central Iowa default\n  var EXPORT_ORIGIN_LAT = 41.5;\n  var EXPORT_COSLAT = Math.cos(EXPORT_ORIGIN_LAT * Math.PI \/ 180);\n  var EXPORT_SHIFT_X = 380;        \/\/ canvas centre default\n  var EXPORT_SHIFT_Y = 290;\n  function canvasToLngLat(x, y){\n    var mx = x - EXPORT_SHIFT_X;\n    var my = y - EXPORT_SHIFT_Y;\n    var lng = EXPORT_ORIGIN_LNG + mx \/ (111320 * EXPORT_COSLAT);\n    var lat = EXPORT_ORIGIN_LAT - my \/ 111320;\n    return [lng, lat];\n  }\n  \/\/ Imported polygons live in `importedFields` (array of { name, coords }\n  \/\/ where coords is canvas-meter {x,y} points). Iterator UI lets the user\n  \/\/ step through them.\n  var importedFields = [];\n  var importedIdx = 0;\n  \/\/ Lazy-load shpjs from jsDelivr on demand for .zip \/ .shp inputs. The\n  \/\/ promise is cached so subsequent uploads do not re-fetch the library.\n  \/\/ Load a classic UMD script from the first working CDN in `urls`, in order.\n  \/\/ Falls through to the next source on a network error OR a per-source timeout\n  \/\/ (a flaky CDN sometimes hangs the request instead of erroring \u2014 a bare\n  \/\/ script-src element would then stall the whole import). Resolves once `ready()`\n  \/\/ confirms the expected global is defined. The single-CDN load used to mean one\n  \/\/ provider outage (observed: jsdelivr 503 on shpjs while jszip on the same host\n  \/\/ was fine) silently killed shapefile import. The mirror chain makes the lazy\n  \/\/ loaders resilient to any one provider failing. Rule 1 safe: no ampersands below.\n  function loadScriptChain(urls, ready, errMsg){\n    if(ready()) return Promise.resolve();\n    return new Promise(function(resolve, reject){\n      var idx = 0;\n      function tryNext(){\n        if(ready()){ resolve(); return; }\n        if(idx >= urls.length){ reject(new Error(errMsg)); return; }\n        var url = urls[idx++];\n        var done = false;\n        var s = document.createElement('script');\n        var timer = setTimeout(function(){\n          if(done) return;\n          done = true;\n          try { s.onload = null; s.onerror = null; } catch(_){}\n          try { console.warn('[GPL] CDN timeout, trying fallback:', url); } catch(_){}\n          tryNext();\n        }, 8000);\n        s.onload = function(){\n          if(done) return;\n          done = true;\n          clearTimeout(timer);\n          if(ready()){ resolve(); return; }\n          try { console.warn('[GPL] CDN loaded but global missing, trying fallback:', url); } catch(_){}\n          tryNext();\n        };\n        s.onerror = function(){\n          if(done) return;\n          done = true;\n          clearTimeout(timer);\n          try { console.warn('[GPL] CDN failed, trying fallback:', url); } catch(_){}\n          tryNext();\n        };\n        s.src = url;\n        document.head.appendChild(s);\n      }\n      tryNext();\n    });\n  }\n  \/\/ Shapefile parser \u2014 opt-in via upload. First working CDN wins (jsdelivr \u2192 unpkg).\n  var shpjsPromise = null;\n  function loadShpjs(){\n    if(typeof window.shp === 'function') return Promise.resolve();\n    if(shpjsPromise) return shpjsPromise;\n    shpjsPromise = loadScriptChain([\n      'https:\/\/cdn.jsdelivr.net\/npm\/shpjs@4.0.4\/dist\/shp.min.js',\n      'https:\/\/unpkg.com\/shpjs@4.0.4\/dist\/shp.min.js'\n    ], function(){ return typeof window.shp === 'function'; }, 'Could not load shapefile parser').catch(function(err){\n      shpjsPromise = null;\n      throw err;\n    });\n    return shpjsPromise;\n  }\n  \/\/ Extract every polygon outer ring from any GeoJSON object. Returns\n  \/\/ [{ name, coords: [[lng,lat], \u2026] }, \u2026] sorted by area descending so the\n  \/\/ biggest polygon (usually the field of interest) comes first.\n  \/\/ Extract LineString \/ MultiLineString features from a GeoJSON object.\n  \/\/ Used when the user uploads existing GUIDANCE LINES (not a boundary):\n  \/\/ the tool derives a field boundary via convex hull of the line endpoints\n  \/\/ and visualises the lines + compares vs the proposed plan.\n  function extractLinesGeoJSON(obj){\n    var lines = [];\n    \/\/ Real ag files store guidance lines in two common shapes:\n    \/\/\n    \/\/   \u2022 AB-line plans \u2014 N parallel straight 2-vertex LineStrings (just\n    \/\/     pass start + end coords, no GPS noise). This is the OPERATOR's\n    \/\/     PLAN: where they intended to drive. Each line is a real body\n    \/\/     pass at the configured implement width.\n    \/\/\n    \/\/   \u2022 Recorded GPS tracks \u2014 many-vertex LineStrings (sometimes a\n    \/\/     single feature winds through headland + body, sometimes one\n    \/\/     feature per pass).\n    \/\/\n    \/\/ We don't try to infer which is which any more (the earlier\n    \/\/ segment_type filter was wrong on the lv Eichwiese sample where\n    \/\/ 16 two-vertex lines are the actual body-pass plan and the one\n    \/\/ 287-vertex feature is a separate headland trace). All LineString\n    \/\/ features are treated as driven passes. Stats reflect the total\n    \/\/ line geometry \u2014 short straight passes give low total km because\n    \/\/ the file IS short, not because we're filtering. The Analytics\n    \/\/ hint surfaces that limitation honestly.\n    function pushFeature(f){\n      if(!f ? true : !f.geometry) return;\n      var gt = f.geometry.type;\n      if(gt === 'LineString' ? f.geometry.coordinates.length >= 2 : false){\n        lines.push(f.geometry.coordinates.slice());\n      } else if(gt === 'MultiLineString'){\n        for(var ii=0; ii<f.geometry.coordinates.length; ii++){\n          var seg = f.geometry.coordinates[ii];\n          if(seg.length >= 2) lines.push(seg.slice());\n        }\n      }\n    }\n    if(obj.type === 'FeatureCollection' ? obj.features : false){\n      for(var k=0; k<obj.features.length; k++) pushFeature(obj.features[k]);\n    } else if(obj.type === 'Feature'){\n      pushFeature(obj);\n    } else if(obj.type === 'LineString' ? true : obj.type === 'MultiLineString'){\n      pushFeature({ geometry: obj });\n    }\n    return lines;\n  }\n  \/\/ Host wiring: default closed-loop tolerance for isImportedLineLoop \u2014\n  \/\/ one swath of slop from the implement-width input (converted to metric).\n  gapTolDefaultProvider = function(){\n    var wmEl = document.getElementById('gpl-wm');\n    var wMRef = parseFloat((wmEl || {}).value) || 18;\n    if(unitSystem === 'us') wMRef = wMRef \/ M_TO_FT;\n    return wMRef;\n  };\n  function detectImplementWidthFromLines(lines){\n    lines = drivenLinesOnly(lines);\n    var bodyLines = [];\n    for(var i=0; i<lines.length; i++){\n      if(lines[i].length >= 2 ? lines[i].length <= 10 : false) bodyLines.push(lines[i]);\n    }\n    if(bodyLines.length < 3) return null;\n    var sumDx = 0, sumDy = 0;\n    for(var li=0; li<bodyLines.length; li++){\n      var ln = bodyLines[li];\n      var dx = ln[ln.length - 1].x - ln[0].x;\n      var dy = ln[ln.length - 1].y - ln[0].y;\n      var len = Math.sqrt(dx * dx + dy * dy);\n      if(len < 1) continue;\n      if(sumDx * dx + sumDy * dy < 0){ dx = -dx; dy = -dy; }\n      sumDx += dx; sumDy += dy;\n    }\n    var axLen = Math.sqrt(sumDx * sumDx + sumDy * sumDy);\n    if(axLen < 1) return null;\n    var ux = sumDx \/ axLen, uy = sumDy \/ axLen;\n    var px = -uy, py = ux;\n    var perps = [];\n    for(var li2=0; li2<bodyLines.length; li2++){\n      var ln2 = bodyLines[li2];\n      var mx = (ln2[0].x + ln2[ln2.length - 1].x) * 0.5;\n      var my = (ln2[0].y + ln2[ln2.length - 1].y) * 0.5;\n      perps.push(mx * px + my * py);\n    }\n    perps.sort(function(a, b){ return a - b; });\n    var gaps = [];\n    for(var g=1; g<perps.length; g++){\n      var gap = perps[g] - perps[g - 1];\n      if(gap > 1) gaps.push(gap);  \/\/ drop forward+back duplicates of same pass\n    }\n    if(gaps.length === 0) return null;\n    gaps.sort(function(a, b){ return a - b; });\n    \/\/ Median of the smallest 1\/3 \u2014 adjacent-pass spacings cluster at the\n    \/\/ low end; bigger gaps are skipped passes \/ obstacle bypasses.\n    var smallCount = Math.max(1, Math.floor(gaps.length \/ 3));\n    var medianSmall = gaps[Math.floor(smallCount \/ 2)];\n    if(medianSmall < 3 ? true : medianSmall > 60) return null;\n    return medianSmall;\n  }\n  \/\/ Count closed-loop headland-ring polylines in the upload. Each\n  \/\/ closed loop represents ONE driven perimeter pass the operator\n  \/\/ recorded. Auto-feeds the headland-multiplier slider so the\n  \/\/ simulator reproduces the same headland coverage as the file.\n  \/\/ Reported 2026-06-03: lv Eichwiese has 1 closed loop and the\n  \/\/ slider stayed at 0 \u2014 should be 1 (the loop = one headland ring).\n  \/\/ The earlier `n - 1` formula subtracted the outer loop as \"the\n  \/\/ boundary itself\", but with concave-hull boundary derivation the\n  \/\/ loop is BOTH the boundary AND a driven ring (operator drove the\n  \/\/ perimeter once). Counting it as 1 matches operator intent.\n  function countInnerHeadlandRings(lines){\n    lines = drivenLinesOnly(lines);\n    var n = 0;\n    for(var i=0; i<lines.length; i++){\n      var ln = lines[i];\n      if(ln.length < 20) continue;\n      var f = ln[0], l = ln[ln.length - 1];\n      var closeGap2 = (f.x - l.x) * (f.x - l.x) + (f.y - l.y) * (f.y - l.y);\n      if(closeGap2 < 25) n++;  \/\/ closed loop = headland ring\n    }\n    return n;\n  }\n  \/\/ Detect file types we DO NOT support so we can reject them with a\n  \/\/ clear reason \u2014 most common mistake is uploading a zoning prescription\n  \/\/ file (hundreds of small polygons, pixel grids) or a sampling layer\n  \/\/ (Point features for soil samples, yield) instead of a field boundary\n  \/\/ or guidance line file. Reported 2026-06-03.\n  function classifyImport(obj){\n    var pointCount = 0;\n    var polyCount = 0;\n    var lineCount = 0;\n    function visit(g){\n      if(!g) return;\n      if(g.type === 'Point' ? true : g.type === 'MultiPoint'){\n        pointCount += (g.type === 'Point' ? 1 : (g.coordinates ? g.coordinates.length : 0));\n      } else if(g.type === 'LineString'){\n        lineCount++;\n      } else if(g.type === 'MultiLineString'){\n        lineCount += g.coordinates ? g.coordinates.length : 0;\n      } else if(g.type === 'Polygon'){\n        polyCount++;\n      } else if(g.type === 'MultiPolygon'){\n        polyCount += g.coordinates ? g.coordinates.length : 0;\n      } else if(g.type === 'GeometryCollection'){\n        if(g.geometries) for(var gi=0; gi<g.geometries.length; gi++) visit(g.geometries[gi]);\n      }\n    }\n    function pushF(f){ if(f) visit(f.geometry ? f.geometry : f); }\n    if(!obj) return { pointCount: 0, polyCount: 0, lineCount: 0 };\n    if(obj.type === 'FeatureCollection' ? obj.features : false){\n      for(var k=0; k<obj.features.length; k++) pushF(obj.features[k]);\n    } else if(obj.type === 'Feature'){\n      pushF(obj);\n    } else {\n      visit(obj);\n    }\n    return { pointCount: pointCount, polyCount: polyCount, lineCount: lineCount };\n  }\n  \/\/ Hard-reject thresholds \u2014 anything above is almost certainly a\n  \/\/ zoning \/ pixel \/ sampling layer mis-uploaded as a boundary. Real\n  \/\/ operational fields have \u2264 ~10 parts (creek-split, road-split arms);\n  \/\/ anything heavier reads as a prescription \/ zone export.\n  var IMPORT_MAX_POLYGONS = 20;\n  var IMPORT_MAX_POINTS = 5;\n  function explainImportRejection(stats, name){\n    if(stats.pointCount > IMPORT_MAX_POINTS){\n      return name + ' looks like a sampling \/ pixel layer (' + stats.pointCount + ' Point features). The simulator needs a FIELD BOUNDARY polygon or guidance LINES \u2014 not a soil-sampling, yield, or zone-centroid file. Open the layer in GeoPard or QGIS, export the field boundary as a polygon, then re-import.';\n    }\n    if(stats.polyCount > IMPORT_MAX_POLYGONS){\n      return name + ' contains ' + stats.polyCount + ' polygons \u2014 that looks like a zoning \/ prescription \/ pixel map, not a field boundary. The simulator needs ONE field outline (a few parts at most). Dissolve the zones into a single boundary polygon in GeoPard or QGIS, then re-import.';\n    }\n    return null;\n  }\n  function extractAllPolygonsKML(text){\n    if(typeof DOMParser === 'undefined') return [];\n    var doc;\n    try { doc = new DOMParser().parseFromString(text, 'application\/xml'); } catch(e){ return []; }\n    var placemarks = doc.getElementsByTagName('Placemark');\n    var polys = [];\n    function coordsFromText(raw){\n      var pairs = raw.split(\/\\s+\/).map(function(s){ return s.trim(); }).filter(function(s){ return s.length > 0; });\n      var out = [];\n      for(var i=0; i<pairs.length; i++){\n        var parts = pairs[i].split(',');\n        if(parts.length < 2) continue;\n        var lng = parseFloat(parts[0]), lat = parseFloat(parts[1]);\n        if(isNaN(lng) ? true : isNaN(lat)) continue;\n        out.push([lng, lat]);\n      }\n      return out;\n    }\n    if(placemarks.length > 0){\n      for(var pi=0; pi<placemarks.length; pi++){\n        var pm = placemarks[pi];\n        var nameEl = pm.getElementsByTagName('name');\n        var name = (nameEl.length > 0 ? nameEl[0].textContent : 'placemark ' + (pi + 1)) || ('placemark ' + (pi + 1));\n        var coordsEls = pm.getElementsByTagName('coordinates');\n        for(var ci=0; ci<coordsEls.length; ci++){\n          var c = coordsFromText(coordsEls[ci].textContent || '');\n          if(c.length >= 3) polys.push({ name: name + (coordsEls.length > 1 ? ' #' + (ci + 1) : ''), coords: c });\n        }\n      }\n    } else {\n      \/\/ Fallback: bare KML with <coordinates> elsewhere\n      var coordsAll = doc.getElementsByTagName('coordinates');\n      for(var ca=0; ca<coordsAll.length; ca++){\n        var c2 = coordsFromText(coordsAll[ca].textContent || '');\n        if(c2.length >= 3) polys.push({ name: 'polygon ' + (ca + 1), coords: c2 });\n      }\n    }\n    return polys;\n  }\n  \/\/ Convert [lng, lat] array to local canvas-meter coords. Center the field at\n  \/\/ the canvas centroid (~380, 290) so the existing scale logic fits it.\n  function lngLatToCanvas(coords){\n    if(!coords ? true : coords.length < 3) return null;\n    \/\/ Use centroid (average) as the projection origin\n    var oLat = 0, oLng = 0;\n    for(var i=0; i<coords.length; i++){ oLng += coords[i][0]; oLat += coords[i][1]; }\n    oLat \/= coords.length; oLng \/= coords.length;\n    var cosLat = Math.cos(oLat * Math.PI \/ 180);\n    var pts = [];\n    for(var j=0; j<coords.length; j++){\n      var lng = coords[j][0], lat = coords[j][1];\n      var mx = (lng - oLng) * 111320 * cosLat;\n      var my = -(lat - oLat) * 111320;  \/\/ canvas y is +down, so flip\n      pts.push({ x: 380 + mx, y: 290 + my });\n    }\n    \/\/ Strip a trailing duplicate vertex (LinearRings close themselves)\n    var last = pts[pts.length - 1], first = pts[0];\n    if(Math.abs(last.x - first.x) < 0.01 ? Math.abs(last.y - first.y) < 0.01 : false){\n      pts.pop();\n    }\n    return pts.length >= 3 ? pts : null;\n  }\n  \/\/ Project MULTIPLE rings using a COMMON centroid so all parts share the\n  \/\/ same canvas space (so a multi-part field lays out correctly relative to\n  \/\/ itself). Returns array of point arrays, one per input ring (skipping rings\n  \/\/ with < 3 vertices).\n  function lngLatToCanvasMulti(coordsList){\n    if(!coordsList ? true : coordsList.length === 0) return [];\n    var oLat = 0, oLng = 0, total = 0;\n    for(var r=0; r<coordsList.length; r++){\n      var ring = coordsList[r];\n      for(var k=0; k<ring.length; k++){ oLng += ring[k][0]; oLat += ring[k][1]; total++; }\n    }\n    if(total === 0) return [];\n    oLat \/= total; oLng \/= total;\n    var cosLat = Math.cos(oLat * Math.PI \/ 180);\n    \/\/ First pass: project every ring to raw metres.\n    var ringsM = [];\n    for(var rr=0; rr<coordsList.length; rr++){\n      var raw = coordsList[rr];\n      var mpts = [];\n      for(var j=0; j<raw.length; j++){\n        var lng = raw[j][0], lat = raw[j][1];\n        var mx = (lng - oLng) * 111320 * cosLat;\n        var my = -(lat - oLat) * 111320;\n        mpts.push({ x: mx, y: my });\n      }\n      \/\/ Strip trailing duplicate vertex\n      var lastM = mpts[mpts.length - 1], firstM = mpts[0];\n      if(Math.abs(lastM.x - firstM.x) < 0.01 ? Math.abs(lastM.y - firstM.y) < 0.01 : false){\n        mpts.pop();\n      }\n      if(mpts.length >= 3) ringsM.push(mpts);\n    }\n    if(ringsM.length === 0) return [];\n    \/\/ Second pass: compute global bbox + a single offset so EVERY ring is\n    \/\/ translated to the canvas with the SAME origin. (Built-in fields live\n    \/\/ around canvas centre (380, 290) \u2014 the existing fit() in getScale()\n    \/\/ scales the whole layout into the canvas window, so we just need the\n    \/\/ multi-part layout to start centred-ish.)\n    var minX=Infinity,minY=Infinity,maxX=-Infinity,maxY=-Infinity;\n    for(var rg=0; rg<ringsM.length; rg++){\n      for(var rj=0; rj<ringsM[rg].length; rj++){\n        var p = ringsM[rg][rj];\n        if(p.x < minX) minX = p.x; if(p.x > maxX) maxX = p.x;\n        if(p.y < minY) minY = p.y; if(p.y > maxY) maxY = p.y;\n      }\n    }\n    var cx = 380 - (minX + maxX) * 0.5;\n    var cy = 290 - (minY + maxY) * 0.5;\n    \/\/ Capture the projection origin so the GIS export can invert canvas\n    \/\/ coords back to (lng, lat). The shift is relative to the projection\n    \/\/ origin (oLng\/oLat) \u2014 so canvas(x) - shiftX = mx, and\n    \/\/ lng = oLng + mx \/ (111320 * cosLat).\n    EXPORT_ORIGIN_LNG = oLng;\n    EXPORT_ORIGIN_LAT = oLat;\n    EXPORT_COSLAT = cosLat;\n    EXPORT_SHIFT_X = cx;\n    EXPORT_SHIFT_Y = cy;\n    var out = [];\n    for(var rk=0; rk<ringsM.length; rk++){\n      var shifted = [];\n      for(var sk=0; sk<ringsM[rk].length; sk++){\n        shifted.push({ x: ringsM[rk][sk].x + cx, y: ringsM[rk][sk].y + cy });\n      }\n      out.push(shifted);\n    }\n    return out;\n  }\n  \/\/ GIS export \u2014 convert the current layout's passes (body + headland\n  \/\/ ring centerlines) into a downloadable file. Formats:\n  \/\/   \u2022 GeoJSON \u2014 universal, accepted by John Deere Operations Center,\n  \/\/     CNH FieldOps, AGCO PTx, QGIS, ArcGIS, Trimble Ag Software.\n  \/\/   \u2022 KML \u2014 Google Earth + most field-management viewers.\n  \/\/   \u2022 Shapefile (zipped .shp\/.shx\/.dbf\/.prj) \u2014 universal GIS standard,\n  \/\/     accepted by every major ag platform.\n  \/\/ Coordinates exported in EPSG:4326 (WGS84 lng\/lat) via the inverse\n  \/\/ of the upload projection. Sample fields use the default Iowa\n  \/\/ origin \u2014 relative geometry preserved.\n  function layoutToLineFeatures(){\n    if(!LAST_LAYOUT) return [];\n    var features = [];\n    var passes = LAST_LAYOUT.passes || [];\n    for(var i=0; i<passes.length; i++){\n      var p = passes[i];\n      var coords = [];\n      if(p.samples ? p.samples.length >= 2 : false){\n        for(var s=0; s<p.samples.length; s++) coords.push(canvasToLngLat(p.samples[s].x, p.samples[s].y));\n      } else if(p.x0 !== undefined){\n        coords.push(canvasToLngLat(p.x0, p.y0));\n        coords.push(canvasToLngLat(p.x1, p.y1));\n      } else continue;\n      if(coords.length < 2) continue;\n      features.push({\n        type: 'Feature',\n        properties: {\n          id: i + 1,\n          name: 'Pass ' + (i + 1),\n          kind: p.kind || 'pass',\n          part: typeof p.partIdx === 'number' ? p.partIdx : 0\n        },\n        geometry: { type: 'LineString', coordinates: coords }\n      });\n    }\n    return features;\n  }\n  function triggerDownload(blob, filename){\n    var url = URL.createObjectURL(blob);\n    var a = document.createElement('a');\n    a.href = url;\n    a.download = filename;\n    document.body.appendChild(a);\n    a.click();\n    document.body.removeChild(a);\n    setTimeout(function(){ URL.revokeObjectURL(url); }, 1500);\n  }\n  function exportGeoJSON(){\n    var features = layoutToLineFeatures();\n    if(features.length === 0){ showUploadStatus('error', 'No lines to export. Generate a layout first.'); return; }\n    var fc = { type: 'FeatureCollection', name: 'geopard-guidance-lines', crs: { type: 'name', properties: { name: 'urn:ogc:def:crs:OGC:1.3:CRS84' } }, features: features };\n    triggerDownload(new Blob([JSON.stringify(fc, null, 2)], { type: 'application\/geo+json' }), 'geopard-guidance-lines.geojson');\n    showUploadStatus('success', features.length + ' lines exported as GeoJSON');\n  }\n  function exportKML(){\n    var features = layoutToLineFeatures();\n    if(features.length === 0){ showUploadStatus('error', 'No lines to export. Generate a layout first.'); return; }\n    var parts = [];\n    parts.push('<?xml version=\"1.0\" encoding=\"UTF-8\"?>');\n    parts.push('<kml xmlns=\"http:\/\/www.opengis.net\/kml\/2.2\"><Document><name>GeoPard guidance lines<\/name>');\n    parts.push('<Style id=\"gpline\"><LineStyle><color>ff0c6af7<\/color><width>3<\/width><\/LineStyle><\/Style>');\n    for(var i=0; i<features.length; i++){\n      var f = features[i];\n      var coords = f.geometry.coordinates.map(function(c){ return c[0] + ',' + c[1] + ',0'; }).join(' ');\n      parts.push('<Placemark><name>' + (f.properties.name || ('Pass ' + (i + 1))) + '<\/name>');\n      parts.push('<styleUrl>#gpline<\/styleUrl>');\n      parts.push('<LineString><coordinates>' + coords + '<\/coordinates><\/LineString><\/Placemark>');\n    }\n    parts.push('<\/Document><\/kml>');\n    triggerDownload(new Blob([parts.join('\\n')], { type: 'application\/vnd.google-earth.kml+xml' }), 'geopard-guidance-lines.kml');\n    showUploadStatus('success', features.length + ' lines exported as KML');\n  }\n  \/\/ Shapefile export \u2014 custom polyline writer. @mapbox\/shp-write 0.4.3\n  \/\/ (the latest release) has a bug emitting LineString geometry: it\n  \/\/ writes numParts equal to numPoints with arbitrary part-start\n  \/\/ offsets, producing a \"Corrupted .shp\" error in ogr2ogr\/QGIS. The\n  \/\/ library hasn't been updated in years so we generate the four\n  \/\/ shapefile components (.shp, .shx, .dbf, .prj) in pure JS and bundle\n  \/\/ them with JSZip ourselves. Format reference: ESRI Shapefile\n  \/\/ Technical Description (1998).\n  var JSZIP_PROMISE = null;\n  function loadJSZip(){\n    if(window.JSZip) return Promise.resolve();\n    if(JSZIP_PROMISE) return JSZIP_PROMISE;\n    JSZIP_PROMISE = loadScriptChain([\n      'https:\/\/cdn.jsdelivr.net\/npm\/jszip@3.10.1\/dist\/jszip.min.js',\n      'https:\/\/unpkg.com\/jszip@3.10.1\/dist\/jszip.min.js'\n    ], function(){ return !!window.JSZip; }, 'jszip CDN load failed').catch(function(err){\n      JSZIP_PROMISE = null;\n      throw err;\n    });\n    return JSZIP_PROMISE;\n  }\n  \/\/ Build a polyline shapefile (.shp), index (.shx), attribute table\n  \/\/ (.dbf), and projection (.prj) from an array of GeoJSON LineString\n  \/\/ features. All multi-byte ints little-endian unless noted. Numbers\n  \/\/ are float64 LE (x,y for each point). DBF stores the feature\n  \/\/ properties as fixed-width ASCII fields.\n  function buildPolylineShapefile(features){\n    \/\/ \u2500\u2500 Pre-compute record sizes + global bbox \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n    var globalBbox = { xmin: Infinity, ymin: Infinity, xmax: -Infinity, ymax: -Infinity };\n    var records = [];\n    for(var fi=0; fi<features.length; fi++){\n      var f = features[fi];\n      var coords = f.geometry.coordinates;\n      if(!coords ? true : coords.length < 2) continue;\n      var bx = Infinity, by = Infinity, BX = -Infinity, BY = -Infinity;\n      for(var ci=0; ci<coords.length; ci++){\n        var cx = coords[ci][0], cy = coords[ci][1];\n        if(cx < bx) bx = cx; if(cx > BX) BX = cx;\n        if(cy < by) by = cy; if(cy > BY) BY = cy;\n      }\n      if(bx < globalBbox.xmin) globalBbox.xmin = bx;\n      if(by < globalBbox.ymin) globalBbox.ymin = by;\n      if(BX > globalBbox.xmax) globalBbox.xmax = BX;\n      if(BY > globalBbox.ymax) globalBbox.ymax = BY;\n      \/\/ Content length per record (in 16-bit words):\n      \/\/   4 (shape type) + 32 (bbox) + 4 (numParts) + 4 (numPoints) +\n      \/\/   4*numParts + 16*numPoints = 44 + 4 + 16*numPoints bytes\n      var contentBytes = 44 + 4 + 16 * coords.length;  \/\/ numParts=1 fixed\n      records.push({\n        properties: f.properties || {},\n        coords: coords,\n        bbox: { xmin: bx, ymin: by, xmax: BX, ymax: BY },\n        contentBytes: contentBytes,\n        recordOffsetWords: 0  \/\/ filled in below\n      });\n    }\n    \/\/ \u2500\u2500 Write .shp \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n    var shpHeaderBytes = 100;\n    var shpBodyBytes = 0;\n    for(var ri=0; ri<records.length; ri++) shpBodyBytes += 8 + records[ri].contentBytes;\n    var shpBuf = new ArrayBuffer(shpHeaderBytes + shpBodyBytes);\n    var shpView = new DataView(shpBuf);\n    \/\/ File header (100 bytes)\n    shpView.setInt32(0, 9994, false);                       \/\/ file code (BE)\n    \/\/ bytes 4-23 unused (zeros)\n    shpView.setInt32(24, (shpHeaderBytes + shpBodyBytes) \/ 2, false);  \/\/ file length, words (BE)\n    shpView.setInt32(28, 1000, true);                       \/\/ version (LE)\n    shpView.setInt32(32, 3, true);                          \/\/ shape type POLYLINE (LE)\n    shpView.setFloat64(36, globalBbox.xmin === Infinity ? 0 : globalBbox.xmin, true);\n    shpView.setFloat64(44, globalBbox.ymin === Infinity ? 0 : globalBbox.ymin, true);\n    shpView.setFloat64(52, globalBbox.xmax === -Infinity ? 0 : globalBbox.xmax, true);\n    shpView.setFloat64(60, globalBbox.ymax === -Infinity ? 0 : globalBbox.ymax, true);\n    \/\/ zMin\/zMax\/mMin\/mMax all zero for 2D polyline\n    \/\/ Records\n    var pos = shpHeaderBytes;\n    for(var rj=0; rj<records.length; rj++){\n      var r = records[rj];\n      r.recordOffsetWords = pos \/ 2;\n      \/\/ Record header (8 bytes, BE)\n      shpView.setInt32(pos, rj + 1, false);                  \/\/ record number\n      shpView.setInt32(pos + 4, r.contentBytes \/ 2, false);  \/\/ content length, words\n      pos += 8;\n      \/\/ Record content (LE)\n      shpView.setInt32(pos, 3, true);                        \/\/ shape type POLYLINE\n      shpView.setFloat64(pos + 4,  r.bbox.xmin, true);\n      shpView.setFloat64(pos + 12, r.bbox.ymin, true);\n      shpView.setFloat64(pos + 20, r.bbox.xmax, true);\n      shpView.setFloat64(pos + 28, r.bbox.ymax, true);\n      shpView.setInt32(pos + 36, 1, true);                   \/\/ numParts (single part)\n      shpView.setInt32(pos + 40, r.coords.length, true);     \/\/ numPoints\n      shpView.setInt32(pos + 44, 0, true);                   \/\/ parts[0] = 0\n      var po = pos + 48;\n      for(var pi=0; pi<r.coords.length; pi++){\n        shpView.setFloat64(po, r.coords[pi][0], true);       \/\/ x\n        shpView.setFloat64(po + 8, r.coords[pi][1], true);   \/\/ y\n        po += 16;\n      }\n      pos += r.contentBytes;\n    }\n    \/\/ \u2500\u2500 Write .shx (index, 100B header + 8B per record) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n    var shxBuf = new ArrayBuffer(100 + 8 * records.length);\n    var shxView = new DataView(shxBuf);\n    shxView.setInt32(0, 9994, false);\n    shxView.setInt32(24, (100 + 8 * records.length) \/ 2, false);\n    shxView.setInt32(28, 1000, true);\n    shxView.setInt32(32, 3, true);\n    shxView.setFloat64(36, globalBbox.xmin === Infinity ? 0 : globalBbox.xmin, true);\n    shxView.setFloat64(44, globalBbox.ymin === Infinity ? 0 : globalBbox.ymin, true);\n    shxView.setFloat64(52, globalBbox.xmax === -Infinity ? 0 : globalBbox.xmax, true);\n    shxView.setFloat64(60, globalBbox.ymax === -Infinity ? 0 : globalBbox.ymax, true);\n    for(var rk=0; rk<records.length; rk++){\n      shxView.setInt32(100 + rk * 8,     records[rk].recordOffsetWords, false);\n      shxView.setInt32(100 + rk * 8 + 4, records[rk].contentBytes \/ 2,  false);\n    }\n    \/\/ \u2500\u2500 Write .dbf (dBase III) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n    \/\/ Fixed schema: id (N,10,0), name (C,30), kind (C,20), part (N,5,0)\n    var fields = [\n      { name: 'id',   type: 'N', len: 10, dec: 0 },\n      { name: 'name', type: 'C', len: 30, dec: 0 },\n      { name: 'kind', type: 'C', len: 20, dec: 0 },\n      { name: 'part', type: 'N', len: 5,  dec: 0 }\n    ];\n    var dbfHeaderBytes = 32 + 32 * fields.length + 1;\n    var dbfRecBytes = 1;  \/\/ deletion flag\n    for(var fk=0; fk<fields.length; fk++) dbfRecBytes += fields[fk].len;\n    var dbfBytes = dbfHeaderBytes + dbfRecBytes * records.length + 1; \/\/ +1 EOF marker\n    var dbfBuf = new ArrayBuffer(dbfBytes);\n    var dbfView = new DataView(dbfBuf);\n    var dbfBytesU8 = new Uint8Array(dbfBuf);\n    var now = new Date();\n    dbfView.setUint8(0, 0x03);\n    dbfView.setUint8(1, now.getUTCFullYear() - 1900);\n    dbfView.setUint8(2, now.getUTCMonth() + 1);\n    dbfView.setUint8(3, now.getUTCDate());\n    dbfView.setInt32(4, records.length, true);\n    dbfView.setUint16(8,  dbfHeaderBytes, true);\n    dbfView.setUint16(10, dbfRecBytes,    true);\n    \/\/ bytes 12-31 zero\n    \/\/ Field descriptors\n    var fPos = 32;\n    for(var fdi=0; fdi<fields.length; fdi++){\n      var fd = fields[fdi];\n      \/\/ Field name (11 bytes, null-terminated\/padded)\n      var nameBytes = new Array(11).fill(0);\n      for(var nb=0; nb<Math.min(10, fd.name.length); nb++) nameBytes[nb] = fd.name.charCodeAt(nb) % 128;\n      for(var nbi=0; nbi<11; nbi++) dbfView.setUint8(fPos + nbi, nameBytes[nbi]);\n      dbfView.setUint8(fPos + 11, fd.type.charCodeAt(0));\n      \/\/ Bytes 12-15 zero\n      dbfView.setUint8(fPos + 16, fd.len);\n      dbfView.setUint8(fPos + 17, fd.dec);\n      \/\/ Bytes 18-31 zero\n      fPos += 32;\n    }\n    dbfView.setUint8(fPos, 0x0D);  \/\/ header terminator\n    fPos += 1;\n    \/\/ Records\n    function writeFixed(view, offset, len, str, alignRight){\n      for(var k=0; k<len; k++) view.setUint8(offset + k, 0x20);\n      var s = String(str || '');\n      if(s.length > len) s = s.slice(0, len);\n      var startK = alignRight ? (len - s.length) : 0;\n      for(var sk=0; sk<s.length; sk++) view.setUint8(offset + startK + sk, s.charCodeAt(sk) % 128);\n    }\n    for(var ri2=0; ri2<records.length; ri2++){\n      var props = records[ri2].properties;\n      dbfView.setUint8(fPos, 0x20);  \/\/ not deleted\n      var oPos = fPos + 1;\n      for(var fi2=0; fi2<fields.length; fi2++){\n        var fdef = fields[fi2];\n        var rawVal = props[fdef.name];\n        if(rawVal === undefined ? true : rawVal === null) rawVal = '';\n        writeFixed(dbfView, oPos, fdef.len, String(rawVal), fdef.type === 'N');\n        oPos += fdef.len;\n      }\n      fPos += dbfRecBytes;\n    }\n    dbfView.setUint8(fPos, 0x1A);  \/\/ EOF marker\n    \/\/ \u2500\u2500 .prj (WGS84 WKT, text) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n    var prj = 'GEOGCS[\"WGS 84\",DATUM[\"WGS_1984\",SPHEROID[\"WGS 84\",6378137,298.257223563,AUTHORITY[\"EPSG\",\"7030\"]],AUTHORITY[\"EPSG\",\"6326\"]],PRIMEM[\"Greenwich\",0,AUTHORITY[\"EPSG\",\"8901\"]],UNIT[\"degree\",0.0174532925199433,AUTHORITY[\"EPSG\",\"9122\"]],AUTHORITY[\"EPSG\",\"4326\"]]';\n    return {\n      shp: new Uint8Array(shpBuf),\n      shx: new Uint8Array(shxBuf),\n      dbf: dbfBytesU8,\n      prj: prj\n    };\n  }\n  function exportShapefile(){\n    var features = layoutToLineFeatures();\n    if(features.length === 0){ showUploadStatus('error', 'No lines to export. Generate a layout first.'); return; }\n    showUploadStatus('success', 'Building shapefile\u2026');\n    loadJSZip().then(function(){\n      var files = buildPolylineShapefile(features);\n      var zip = new window.JSZip();\n      zip.file('geopard_lines.shp', files.shp);\n      zip.file('geopard_lines.shx', files.shx);\n      zip.file('geopard_lines.dbf', files.dbf);\n      zip.file('geopard_lines.prj', files.prj);\n      zip.generateAsync({ type: 'blob', compression: 'STORE' }).then(function(blob){\n        triggerDownload(blob, 'geopard-guidance-lines.zip');\n        showUploadStatus('success', features.length + ' lines exported as zipped shapefile');\n      }).catch(function(err){\n        showUploadStatus('error', 'Shapefile zip failed: ' + (err.message || err));\n        try { console.error('[GPL export]', err); } catch(_){}\n      });\n    }).catch(function(){\n      showUploadStatus('error', 'Could not load zip library \u2014 check internet connection');\n    });\n  }\n  function showUploadStatus(state, msg){\n    var lbl = document.getElementById('gpl-upload-lbl');\n    var hint = document.getElementById('gpl-upload-hint');\n    if(!lbl) return;\n    lbl.classList.remove('is-error', 'is-success');\n    if(state === 'error') lbl.classList.add('is-error');\n    else if(state === 'success') lbl.classList.add('is-success');\n    if(hint) hint.textContent = msg;\n  }\n  \/\/ Apply ALL imported polygons from the uploaded file as ONE multi-part field.\n  \/\/ Real-world rule: if a file contains multiple polygons, they belong to the\n  \/\/ same operational field (e.g. parts split by a creek or a road). The\n  \/\/ machine works each part in turn with a transport leg between them.\n  \/\/ The iterator (\u2039 \/ \u203a) is no longer needed because there is exactly ONE\n  \/\/ field per upload \u2014 kept hidden.\n  \/\/ Topography follow needs real elevation data we don't have for\n  \/\/ uploaded fields (the demo synthesises terrain only for the 4 sample\n  \/\/ fields). Lock the option behind a \"PRO\" badge on uploaded fields\n  \/\/ and route the user to GeoPard sign-up when they click it.\n  function updateAdaptiveLock(){\n    var label = document.getElementById('gpl-ap-adaptive-label');\n    var lock = document.getElementById('gpl-ap-adaptive-lock');\n    if(!label ? true : !lock) return;\n    var isImported = currentField === 'custom' ? true : currentField === 'uploaded-lines';\n    label.classList.toggle('is-pro-locked', isImported);\n    lock.hidden = !isImported;\n    var radio = label.querySelector('input[type=\"radio\"]');\n    if(radio) radio.disabled = isImported;\n    \/\/ If user has 'adaptive' selected when they import, auto-switch\n    \/\/ to 'ab-straight' so the layout actually computes.\n    if(isImported ? current === 'adaptive' : false){\n      current = 'ab-straight';\n      var abRadio = document.querySelector('#gpl-approach input[value=\"ab-straight\"]');\n      if(abRadio){\n        abRadio.checked = true;\n        var labs2 = document.querySelectorAll('#gpl-approach label');\n        for(var li2=0; li2<labs2.length; li2++) labs2[li2].classList.remove('is-on');\n        if(abRadio.parentNode ? abRadio.parentNode.classList : false) abRadio.parentNode.classList.add('is-on');\n      }\n    }\n  }\n  function applyImportedField(){\n    if(!importedFields.length) return;\n    importedIdx = 0;\n    \/\/ Project all polygons using a SHARED origin so they sit in the same\n    \/\/ canvas space (preserves relative position between parts).\n    var allCoords = importedFields.map(function(p){ return p.coords; });\n    var partsPts = lngLatToCanvasMulti(allCoords);\n    if(!partsPts.length){ showUploadStatus('error', 'No polygons with \u22653 vertices found'); return; }\n    \/\/ Freeze protection \u2014 real-world shapefiles often have 100s to\n    \/\/ 1000s of vertices per ring (satellite-derived boundaries, GPS-\n    \/\/ recorded perimeter walks). Every geometry pass scales with N and\n    \/\/ gets multiplied by: 5 approaches \u00d7 axis sweep (12 angles) \u00d7\n    \/\/ buildSerpentine (O(passes\u00b2) for U-turn arc validation). At 500+\n    \/\/ verts on a 300 ha field the browser locks up. Simplify ALL\n    \/\/ imported rings to a tolerance proportional to the implement\n    \/\/ width \u2014 sub-wM\/4 vertex spacing is below the operator's\n    \/\/ visible resolution anyway. Read the current wM from the input\n    \/\/ since this runs before recompute().\n    var rawWmIn = parseFloat((document.getElementById('gpl-wm') || {}).value) || 18;\n    var wMIn = unitSystem === 'us' ? rawWmIn \/ M_TO_FT : rawWmIn;\n    if(wMIn < 6) wMIn = 18;\n    \/\/ Simplify tolerance wM\/4 (was wM\/2). wM\/2 was so aggressive it\n    \/\/ erased the natural reflex bends that define multi-arm fields\n    \/\/ (3-direction sample \u2192 1 block instead of 3), defeating the\n    \/\/ auto-blocks approach. wM\/4 keeps those bends while still\n    \/\/ reducing 100+ vertex inputs to ~30 verts \u2014 fast enough to\n    \/\/ dodge the freeze without losing structure.\n    var simpDropTol = wMIn * 0.25;      \/\/ merge vertices closer than wM\/4\n    var perpTol     = wMIn * 0.1;       \/\/ strip collinear within wM\/10\n    for(var spi=0; spi<partsPts.length; spi++){\n      var p = partsPts[spi];\n      var simp = simplifyPolygon(p, simpDropTol, perpTol);\n      if(simp ? simp.length >= 4 : false) partsPts[spi] = simp;\n    }\n    \/\/ Obstacle \/ hole detection (rule \u00a76 `obstacle_boundary`). A ring\n    \/\/ fully contained inside a larger ring is a hole, not a separate\n    \/\/ field. assignHoles re-parents them so r038-style fields (1 outer +\n    \/\/ 9 inner exclusion zones) install as ONE part with 9 holes instead\n    \/\/ of 10 disconnected \"fields\".\n    partsPts = assignHoles(partsPts);\n    \/\/ Hard guard on field-size \u00d7 resolution: if the estimated total\n    \/\/ pass count would exceed CAP (the threshold above which the\n    \/\/ synchronous Compare-All recompute starts feeling laggy), bump\n    \/\/ the implement width displayed to the user so the demo stays\n    \/\/ responsive. Real planners can handle thousands of passes; this\n    \/\/ is a lead-magnet tool, not the full GeoPard platform.\n    var totalArea = 0;\n    var maxSpan = 0;\n    for(var saI=0; saI<partsPts.length; saI++){\n      totalArea += polyArea(partsPts[saI]);\n      var minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;\n      for(var saV=0; saV<partsPts[saI].length; saV++){\n        var pt = partsPts[saI][saV];\n        if(pt.x < minX) minX = pt.x; if(pt.x > maxX) maxX = pt.x;\n        if(pt.y < minY) minY = pt.y; if(pt.y > maxY) maxY = pt.y;\n      }\n      var span = Math.max(maxX - minX, maxY - minY);\n      if(span > maxSpan) maxSpan = span;\n    }\n    var estPasses = maxSpan \/ wMIn;\n    var LAG_CAP_PASSES = 150;\n    if(estPasses > LAG_CAP_PASSES){\n      \/\/ Suggested width that would land at LAG_CAP_PASSES on the\n      \/\/ largest dimension. Show as a warning hint; don't force-change\n      \/\/ the input (operator should know what they're picking).\n      var suggestedW = maxSpan \/ LAG_CAP_PASSES;\n      if(unitSystem === 'us') suggestedW = suggestedW * M_TO_FT;\n      showUploadStatus('error', 'Large field (~' + (totalArea \/ 10000).toFixed(0) + ' ha, ' + Math.round(estPasses) + ' passes at current width). Increase implement width above ' + suggestedW.toFixed(0) + ' ' + (unitSystem === 'us' ? 'ft' : 'm') + ' for smooth playback.');\n    }\n    \/\/ Pick the largest part as the \"primary\" boundary for legacy single-poly\n    \/\/ call sites (PCA axis, terrain heatmap, shape-recommendation heuristic).\n    var largestIdx = 0, largestA = -Infinity;\n    for(var li=0; li<partsPts.length; li++){\n      var a = polyArea(partsPts[li]);\n      if(a > largestA){ largestA = a; largestIdx = li; }\n    }\n    var primary = partsPts[largestIdx];\n    FIELDS.custom = primary;\n    currentField = 'custom';\n    BOUNDARY = primary;\n    BOUNDARY_PARTS = partsPts;\n    userAxisDeg = null;\n    headingStrategy = 'longest';\n    \/\/ Remember for the Imported chip (boundary upload path \u2014 no lines).\n    IMPORTED_BOUNDARY = BOUNDARY;\n    IMPORTED_BOUNDARY_PARTS = BOUNDARY_PARTS;\n    IMPORTED_LINES = null;\n    IMPORTED_NAME = importedFields[0].name || 'boundary';\n    activateImportedChip(IMPORTED_NAME);\n    var navEl = document.getElementById('gpl-import-nav');\n    if(navEl) navEl.hidden = true;  \/\/ iterator no longer used\n    var partLabel = partsPts.length > 1 ? (partsPts.length + ' parts') : (primary.length + ' vertices');\n    showUploadStatus('success', (importedFields[0].name || 'boundary') + ' \u00b7 ' + partLabel);\n    \/\/ Reset pan\/zoom so the new field fits fresh (see applyCombinedGeoImport\n    \/\/ \u2014 leftover transform from a prior import hid the 2nd field on mobile).\n    view.zoom = 1; view.panX = 0; view.panY = 0;\n    updateAdaptiveLock();\n    updatePlanViewToggle();\n    recompute();\n  }\n  \/\/ Uploaded existing guidance lines (LineString[]). When the user uploads\n  \/\/ a guidance-line file (instead of a boundary), each line gets projected\n  \/\/ to canvas coords here. Set by handleParsedLines, cleared when the user\n  \/\/ picks a built-in sample field. recompute reads this to compute existing\n  \/\/ metrics + draw renders these as a blue semi-transparent underlay so the\n  \/\/ user can compare their existing plan against the proposed plan.\n  var UPLOADED_LINES = null;\n  \/\/ Saved import for the \"Imported\" chip in the top field-picker. Stays\n  \/\/ populated after the user clicks across to a sample field so the chip\n  \/\/ can restore the imported state without re-uploading the file.\n  var IMPORTED_BOUNDARY = null;\n  var IMPORTED_BOUNDARY_PARTS = null;\n  var IMPORTED_LINES = null;  \/\/ null when the import was a boundary, not lines\n  var IMPORTED_NAME = null;\n  \/\/ Raw lng\/lat cache \u2014 preserved so a SECOND import (lines after\n  \/\/ boundary, or boundary after lines) can re-project everything with\n  \/\/ a shared origin and have the two layers align in canvas space.\n  \/\/ Without this, the second import either over-writes the first or\n  \/\/ lands in a different coordinate frame.\n  var IMPORTED_BOUNDARY_GEO = null;  \/\/ Array<Array<[lng,lat]>>  rings\n  var IMPORTED_LINES_GEO = null;     \/\/ Array<Array<[lng,lat]>>  polylines\n  var IMPORTED_LINES_DRIVEN = null;  \/\/ Array<boolean>  per-line driven flag\n  function activateImportedChip(label){\n    var chip = document.getElementById('gpl-fld-imported');\n    if(!chip) return;\n    chip.hidden = false;\n    if(label) chip.title = 'Restore imported field: ' + label;\n    var picker = document.querySelectorAll('#gpl-field-picker .gpl-fld');\n    for(var pp=0; pp<picker.length; pp++) picker[pp].classList.remove('is-on');\n    chip.classList.add('is-on');\n  }\n  \/\/ Convert uploaded GUIDANCE LINES (LineString array, each line is an\n  \/\/ array of [lon, lat]) into the same canvas coord space as the built-in\n  \/\/ fields, then derive a field boundary via convex hull of the line\n  \/\/ endpoints. The resulting boundary becomes BOUNDARY \/ BOUNDARY_PARTS so\n  \/\/ every existing path-planning algorithm runs against it unchanged.\n  function handleParsedLines(lines, filename){\n    if(!lines ? true : lines.length === 0){\n      showUploadStatus('error', 'No guidance lines found in ' + filename);\n      return;\n    }\n    \/\/ Shared projection origin so all lines share the same canvas space.\n    var oLat = 0, oLng = 0, total = 0;\n    for(var i=0; i<lines.length; i++){\n      for(var j=0; j<lines[i].length; j++){\n        oLng += lines[i][j][0];\n        oLat += lines[i][j][1];\n        total++;\n      }\n    }\n    if(total === 0){\n      showUploadStatus('error', 'Empty geometry in ' + filename);\n      return;\n    }\n    oLat \/= total; oLng \/= total;\n    var cosLat = Math.cos(oLat * Math.PI \/ 180);\n    var projectedLines = [];\n    var allPts = [];\n    for(var l=0; l<lines.length; l++){\n      var line = [];\n      \/\/ Preserve the .driven tag from extractLinesGeoJSON so the\n      \/\/ boundary derivation can still hull every endpoint while\n      \/\/ metrics + playback only count truly driven passes.\n      line.driven = lines[l].driven !== false;\n      for(var v=0; v<lines[l].length; v++){\n        var lng = lines[l][v][0], lat = lines[l][v][1];\n        var mx = (lng - oLng) * 111320 * cosLat;\n        var my = -(lat - oLat) * 111320;\n        line.push({ x: mx, y: my });\n        allPts.push({ x: mx, y: my });\n      }\n      projectedLines.push(line);\n    }\n    if(allPts.length < 3){\n      showUploadStatus('error', 'Not enough points to derive a boundary from ' + filename);\n      return;\n    }\n    \/\/ Concave hull of line endpoints \u2192 field boundary. The concave\n    \/\/ algorithm uses the two endpoints of each line (start + end), orders\n    \/\/ them by perpendicular position, and traces the headland-edge polygon\n    \/\/ \u2014 which is much closer to the real field shape than a convex hull\n    \/\/ (convex would round off L-shaped or trapezoidal fields). Falls\n    \/\/ back to convex hull if the lines aren't parallel enough to produce\n    \/\/ a non-self-intersecting polygon.\n    var hull = concaveHullFromLines(projectedLines, allPts);\n    if(hull.length < 3){\n      showUploadStatus('error', 'Could not derive a boundary from ' + filename);\n      return;\n    }\n    \/\/ Centre the layout at (380, 290) like the other imports do.\n    var minX=Infinity, maxX=-Infinity, minY=Infinity, maxY=-Infinity;\n    for(var hi=0; hi<hull.length; hi++){\n      if(hull[hi].x < minX) minX = hull[hi].x;\n      if(hull[hi].x > maxX) maxX = hull[hi].x;\n      if(hull[hi].y < minY) minY = hull[hi].y;\n      if(hull[hi].y > maxY) maxY = hull[hi].y;\n    }\n    var dx = 380 - (minX + maxX) * 0.5;\n    var dy = 290 - (minY + maxY) * 0.5;\n    var shiftedHull = hull.map(function(p){ return { x: p.x + dx, y: p.y + dy }; });\n    var shiftedLines = projectedLines.map(function(ln){\n      var out = ln.map(function(p){ return { x: p.x + dx, y: p.y + dy }; });\n      out.driven = ln.driven !== false;\n      return out;\n    });\n    \/\/ Install\n    BOUNDARY = shiftedHull;\n    BOUNDARY_PARTS = [shiftedHull];\n    UPLOADED_LINES = shiftedLines;\n    currentField = 'uploaded-lines';\n    userAxisDeg = null;\n    \/\/ Remember for the Imported chip (so the user can switch away to a\n    \/\/ sample field and back without re-uploading).\n    IMPORTED_BOUNDARY = BOUNDARY;\n    IMPORTED_BOUNDARY_PARTS = BOUNDARY_PARTS;\n    IMPORTED_LINES = UPLOADED_LINES;\n    IMPORTED_NAME = filename;\n    activateImportedChip(filename);\n    \/\/ Auto-detect implement width + headland-ring count from the uploaded\n    \/\/ lines so the user sees the proposed plan with parameters that match\n    \/\/ their actual operation \u2014 no manual tweaking needed. Each detection\n    \/\/ updates its respective input AND triggers updateHeadlandLabel so the\n    \/\/ slider readout stays in sync. Falls back silently when detection\n    \/\/ doesn't have enough lines (existing input values stick).\n    var autoBits = [];\n    var detectedWm = detectImplementWidthFromLines(shiftedLines);\n    if(detectedWm){\n      var wmEl = document.getElementById('gpl-wm');\n      if(wmEl){\n        var showVal = unitSystem === 'us' ? detectedWm * M_TO_FT : detectedWm;\n        \/\/ Round to nearest WHOLE unit (35.9 \u2192 36, 12.1 \u2192 12, 11.9 \u2192 12).\n        \/\/ Median-of-smallest-third detection has \u00b15 % noise from GPS jitter\n        \/\/ and feature parsing; sub-unit precision implies false accuracy.\n        \/\/ Snap further when very close to a standard width (12, 18, 24,\n        \/\/ 27, 30, 36 m \/ 30, 40, 60, 90, 120 ft) so the user sees the\n        \/\/ canonical number, not 36 instead of 35.\n        var rounded = Math.round(showVal);\n        var standards = unitSystem === 'us'\n          ? [20, 30, 40, 45, 60, 80, 90, 120]\n          : [6, 9, 12, 15, 18, 24, 27, 30, 36, 40, 45];\n        var snapTol = unitSystem === 'us' ? 2 : 1;\n        for(var snpi=0; snpi<standards.length; snpi++){\n          if(Math.abs(showVal - standards[snpi]) <= snapTol){ rounded = standards[snpi]; break; }\n        }\n        wmEl.value = String(rounded);\n        \/\/ Also reflect the snapped width back into the metres value so\n        \/\/ downstream fmtWidth + analytics use the rounded number.\n        detectedWm = unitSystem === 'us' ? rounded \/ M_TO_FT : rounded;\n        autoBits.push(fmtWidth(detectedWm) + ' width');\n      }\n    }\n    var ringCount = countInnerHeadlandRings(shiftedLines);\n    var hlEl = document.getElementById('gpl-hl-mult');\n    if(hlEl){\n      hlEl.value = String(Math.min(4, ringCount));\n      autoBits.push(ringCount + ' headland ring' + (ringCount === 1 ? '' : 's'));\n    }\n    if(typeof updateHeadlandLabel === 'function') updateHeadlandLabel();\n    var statusMsg = filename + ' \u00b7 ' + lines.length + ' line' + (lines.length === 1 ? '' : 's');\n    if(autoBits.length > 0) statusMsg += ' \u00b7 auto ' + autoBits.join(' + ');\n    showUploadStatus('success', statusMsg);\n    updateAdaptiveLock();\n    updatePlanViewToggle();\n    recompute();\n  }\n  \/\/ Unified geo-import path. Accepts raw lng\/lat data for the boundary\n  \/\/ (polygon rings) AND the lines (polylines), then projects everything\n  \/\/ into canvas space with a SHARED origin so the two layers align even\n  \/\/ when imported as separate files (boundary first, lines later, or\n  \/\/ vice versa \u2014 reported 2026-06-03). Either argument may be null.\n  function applyCombinedGeoImport(boundaryGeo, linesGeo, linesDriven, name){\n    var oLng = 0, oLat = 0, total = 0;\n    function tallyPts(coordsList){\n      if(!coordsList) return;\n      for(var r=0; r<coordsList.length; r++){\n        var ring = coordsList[r];\n        for(var k=0; k<ring.length; k++){ oLng += ring[k][0]; oLat += ring[k][1]; total++; }\n      }\n    }\n    tallyPts(boundaryGeo);\n    tallyPts(linesGeo);\n    if(total === 0){\n      showUploadStatus('error', 'No usable geometry in ' + name);\n      return;\n    }\n    oLat \/= total; oLng \/= total;\n    var cosLat = Math.cos(oLat * Math.PI \/ 180);\n    \/\/ Project boundary rings (raw metres around origin).\n    var projBoundary = null;\n    if(boundaryGeo ? boundaryGeo.length > 0 : false){\n      projBoundary = [];\n      for(var b=0; b<boundaryGeo.length; b++){\n        var ring = [];\n        for(var bv=0; bv<boundaryGeo[b].length; bv++){\n          var p = boundaryGeo[b][bv];\n          ring.push({ x: (p[0] - oLng) * 111320 * cosLat, y: -(p[1] - oLat) * 111320 });\n        }\n        \/\/ Strip trailing duplicate vertex (common in GeoJSON polygon rings).\n        if(ring.length > 1){\n          var f = ring[0], l = ring[ring.length - 1];\n          if(Math.abs(l.x - f.x) < 0.01 ? Math.abs(l.y - f.y) < 0.01 : false) ring.pop();\n        }\n        if(ring.length >= 3) projBoundary.push(ring);\n      }\n      if(projBoundary.length === 0) projBoundary = null;\n    }\n    \/\/ Project lines (raw metres around origin).\n    var projLines = null;\n    if(linesGeo ? linesGeo.length > 0 : false){\n      projLines = [];\n      for(var li=0; li<linesGeo.length; li++){\n        var ln = [];\n        for(var lv=0; lv<linesGeo[li].length; lv++){\n          var lp = linesGeo[li][lv];\n          ln.push({ x: (lp[0] - oLng) * 111320 * cosLat, y: -(lp[1] - oLat) * 111320 });\n        }\n        ln.driven = linesDriven ? (linesDriven[li] !== false) : true;\n        if(ln.length >= 2) projLines.push(ln);\n      }\n      if(projLines.length === 0) projLines = null;\n    }\n    \/\/ No explicit boundary? Derive one from the line endpoints (concave hull).\n    if(!projBoundary ? projLines : false){\n      var allPts = [];\n      for(var hi=0; hi<projLines.length; hi++){\n        for(var hv=0; hv<projLines[hi].length; hv++){\n          allPts.push({ x: projLines[hi][hv].x, y: projLines[hi][hv].y });\n        }\n      }\n      \/\/ Derive boundary from line endpoints. When the file has a\n      \/\/ closed-loop headland trace (operator's wheel-track centerline),\n      \/\/ expand that loop OUTWARD by wM\/2 so the boundary lands at the\n      \/\/ true field edge instead of the wheel-track line. Use the\n      \/\/ auto-detected wM (from line spacing) if available; fall back to\n      \/\/ the slider's value otherwise.\n      var detectedWmFirst = detectImplementWidthFromLines(projLines);\n      var rawWmIn0 = parseFloat((document.getElementById('gpl-wm') || {}).value) || 18;\n      var wMIn0 = unitSystem === 'us' ? rawWmIn0 \/ M_TO_FT : rawWmIn0;\n      var wMForExpand = detectedWmFirst ? detectedWmFirst : wMIn0;\n      var hull = concaveHullFromLines(projLines, allPts, wMForExpand * 0.5);\n      if(hull.length >= 3) projBoundary = [hull];\n    }\n    if(!projBoundary){\n      showUploadStatus('error', 'Could not derive a boundary from ' + name);\n      return;\n    }\n    \/\/ Compute global bbox + a single shift so everything lands centred-ish.\n    var minX=Infinity, maxX=-Infinity, minY=Infinity, maxY=-Infinity;\n    function bumpBB(arr){\n      for(var s=0; s<arr.length; s++){\n        if(arr[s].x < minX) minX = arr[s].x;\n        if(arr[s].x > maxX) maxX = arr[s].x;\n        if(arr[s].y < minY) minY = arr[s].y;\n        if(arr[s].y > maxY) maxY = arr[s].y;\n      }\n    }\n    for(var bi=0; bi<projBoundary.length; bi++) bumpBB(projBoundary[bi]);\n    if(projLines) for(var lj=0; lj<projLines.length; lj++) bumpBB(projLines[lj]);\n    var cx = 380 - (minX + maxX) * 0.5;\n    var cy = 290 - (minY + maxY) * 0.5;\n    function shift(arr){ for(var s=0; s<arr.length; s++){ arr[s].x += cx; arr[s].y += cy; } }\n    for(var bsi=0; bsi<projBoundary.length; bsi++) shift(projBoundary[bsi]);\n    if(projLines) for(var lsi=0; lsi<projLines.length; lsi++) shift(projLines[lsi]);\n    \/\/ No simplification on import \u2014 preserve every vertex of the user's\n    \/\/ imported boundary + lines so the displayed geometry matches their\n    \/\/ source file exactly (QGIS-equivalent rendering). Rule \u00a720 (added\n    \/\/ 2026-06-03 \u2014 \"lets not simplify anythin on import and export\").\n    \/\/ The previous wM-based simplify collapsed dense boundaries on\n    \/\/ lv-class fields, shrinking them out of alignment with the lines.\n    \/\/ Obstacle \/ hole detection \u2014 re-parent contained rings as holes\n    \/\/ (rule \u00a76 `obstacle_boundary`) so an Rx-style multi-ring boundary\n    \/\/ with interior exclusion zones installs as ONE part + holes, not\n    \/\/ as many disconnected fields.\n    projBoundary = assignHoles(projBoundary);\n    \/\/ Largest part = primary boundary (legacy single-poly call sites).\n    var largestI = 0, largestA = -Infinity;\n    for(var lpi=0; lpi<projBoundary.length; lpi++){\n      var a = polyArea(projBoundary[lpi]);\n      if(a > largestA){ largestA = a; largestI = lpi; }\n    }\n    var primary = projBoundary[largestI];\n    FIELDS.custom = primary;\n    BOUNDARY = primary;\n    BOUNDARY_PARTS = projBoundary;\n    \/\/ UPLOADED_LINES keeps EVERY imported line (including closed-loop\n    \/\/ headland traces) so the dashed-blue canvas overlay matches what\n    \/\/ QGIS \/ John Deere Ops would render \u2014 visible perimeter trace +\n    \/\/ body passes. The headland-ring loop was filtered out in an\n    \/\/ earlier revision but the user's QGIS reference clearly shows the\n    \/\/ loop as part of the imported features (reported 2026-06-03).\n    \/\/ `bodyPassesOnly` still runs inside buildUploadedLayout +\n    \/\/ Compare All so playback \/ metrics don't double-count the loop\n    \/\/ as a driven body pass.\n    UPLOADED_LINES = (projLines ? projLines.length > 0 : false) ? projLines : null;\n    currentField = UPLOADED_LINES ? 'uploaded-lines' : 'custom';\n    userAxisDeg = null;\n    userAxisAnchor = null;  \/\/ new import \u2192 drop any prior GPS anchor\n    headingStrategy = 'longest';\n    IMPORTED_BOUNDARY = BOUNDARY;\n    IMPORTED_BOUNDARY_PARTS = BOUNDARY_PARTS;\n    IMPORTED_LINES = UPLOADED_LINES;\n    IMPORTED_BOUNDARY_GEO = boundaryGeo;\n    IMPORTED_LINES_GEO = linesGeo;\n    IMPORTED_LINES_DRIVEN = linesDriven;\n    IMPORTED_NAME = name;\n    \/\/ Capture origin for GIS export.\n    EXPORT_ORIGIN_LNG = oLng;\n    EXPORT_ORIGIN_LAT = oLat;\n    EXPORT_COSLAT = cosLat;\n    EXPORT_SHIFT_X = cx;\n    EXPORT_SHIFT_Y = cy;\n    activateImportedChip(name);\n    var navEl0 = document.getElementById('gpl-import-nav');\n    if(navEl0) navEl0.hidden = true;\n    \/\/ Auto-detect implement width + headland rings when lines present.\n    var autoBits = [];\n    if(projLines){\n      var detectedWm = detectImplementWidthFromLines(projLines);\n      if(detectedWm){\n        var wmEl = document.getElementById('gpl-wm');\n        if(wmEl){\n          var showVal = unitSystem === 'us' ? detectedWm * M_TO_FT : detectedWm;\n          var rounded = Math.round(showVal);\n          var standards = unitSystem === 'us' ? [20,30,40,45,60,80,90,120] : [6,9,12,15,18,24,27,30,36,40,45];\n          var snapTol = unitSystem === 'us' ? 2 : 1;\n          for(var sti=0; sti<standards.length; sti++){\n            if(Math.abs(showVal - standards[sti]) <= snapTol){ rounded = standards[sti]; break; }\n          }\n          wmEl.value = String(rounded);\n          detectedWm = unitSystem === 'us' ? rounded \/ M_TO_FT : rounded;\n          autoBits.push(fmtWidth(detectedWm) + ' width');\n        }\n      }\n      \/\/ Headland-ring count auto-detected from closed loops in the\n      \/\/ import. Fires for both lines-only and lines+boundary imports\n      \/\/ \u2014 operator-driven headland traces (closed loops) are equally\n      \/\/ meaningful in both cases (reported 2026-06-04 \u2014 \"import all\n      \/\/ lines, headland too if there are headland lines\").\n      var ringCount = countInnerHeadlandRings(projLines);\n      if(ringCount > 0){\n        var hlEl = document.getElementById('gpl-hl-mult');\n        if(hlEl){\n          hlEl.value = String(Math.min(4, ringCount));\n          autoBits.push(ringCount + ' headland ring' + (ringCount === 1 ? '' : 's'));\n        }\n        if(typeof updateHeadlandLabel === 'function') updateHeadlandLabel();\n      }\n    }\n    \/\/ Lag guard for large boundary imports. applyImportedField has one;\n    \/\/ the combined path (which lone-polygon zips route through) did not \u2014\n    \/\/ a 207 ha \/ 9-part field (41.22\u2026) produced ~290 body passes at the\n    \/\/ 18 m default, and the synchronous recompute + Compare-All + axis\n    \/\/ sweep froze the browser for ~100 s, so the field never appeared\n    \/\/ (\"not shown in UI\", reported 2026-06-10). Estimate total passes at\n    \/\/ the current width from each part's perpendicular span; if it would\n    \/\/ lag, raise the implement width to the smallest standard that keeps\n    \/\/ the count near the target, and tell the user (they can dial it back\n    \/\/ for a narrower implement \u2014 it just won't be as snappy).\n    (function lagGuard(){\n      var wmElG = document.getElementById('gpl-wm');\n      if(!wmElG) return;\n      var rawWmG = parseFloat(wmElG.value) || 18;\n      var wMG = unitSystem === 'us' ? rawWmG \/ M_TO_FT : rawWmG;\n      if(wMG <= 0) return;\n      var sumPerp = 0;\n      for(var lg=0; lg<projBoundary.length; lg++){\n        var prt = projBoundary[lg];\n        var axg = fieldAxis(prt);\n        var pmin = Infinity, pmax = -Infinity;\n        for(var pv=0; pv<prt.length; pv++){\n          var perp = -prt[pv].x * axg.uy + prt[pv].y * axg.ux;\n          if(perp < pmin) pmin = perp;\n          if(perp > pmax) pmax = perp;\n        }\n        sumPerp += (pmax - pmin);\n      }\n      var TARGET_PASSES = 140;\n      var passEst = sumPerp \/ wMG;\n      if(passEst <= 170) return;  \/\/ current width is fine\n      var needWmM = sumPerp \/ TARGET_PASSES;  \/\/ metric width needed\n      var needWmDisp = unitSystem === 'us' ? needWmM * M_TO_FT : needWmM;\n      var stds = unitSystem === 'us' ? [20,30,40,45,60,80,90,120] : [12,15,18,24,27,30,36,40,45,54,60];\n      var pickDisp = stds[stds.length - 1];\n      for(var si=0; si<stds.length; si++){ if(stds[si] >= needWmDisp){ pickDisp = stds[si]; break; } }\n      \/\/ Only raise, never lower the user's width.\n      if(pickDisp > rawWmG){\n        wmElG.value = String(pickDisp);\n        if(typeof updateHeadlandLabel === 'function') updateHeadlandLabel();\n        autoBits.push('width raised to ' + fmtWidth(unitSystem === 'us' ? pickDisp \/ M_TO_FT : pickDisp) + ' for smooth playback (large field \u2014 dial down if your implement is narrower)');\n      }\n    })();\n    var partLabel = projBoundary.length > 1 ? (projBoundary.length + ' parts') : (primary.length + ' vertices');\n    var combo = [];\n    if(boundaryGeo){\n      \/\/ Imported polygon \u2014 the AUTHORITATIVE boundary. All planners,\n      \/\/ playback, and metrics run against it (rule \u00a76 \u2014 machine never\n      \/\/ leaves the boundary). Surface \"imported\" so the user can tell\n      \/\/ the simulator is honouring their file vs deriving its own hull.\n      combo.push('imported boundary \u00b7 ' + partLabel);\n    } else if(projLines){\n      \/\/ No real boundary provided \u2192 concaveHullFromLines derived one\n      \/\/ from line endpoints. Flag this clearly so the user knows the\n      \/\/ outline is approximate (upload an actual boundary file to use\n      \/\/ the authoritative one instead).\n      combo.push('derived boundary \u00b7 ' + partLabel);\n    }\n    if(projLines) combo.push(projLines.length + ' line' + (projLines.length === 1 ? '' : 's'));\n    var statusMsg = name + ' \u00b7 ' + combo.join(' + ');\n    if(autoBits.length > 0) statusMsg += ' \u00b7 auto ' + autoBits.join(' + ');\n    showUploadStatus('success', statusMsg);\n    \/\/ Reset the pan\/zoom transform so the NEW field fits the canvas from\n    \/\/ scratch. Without this, a leftover pan\/zoom from inspecting the\n    \/\/ previous import (trivial to trigger on mobile \u2014 any touch-pan\n    \/\/ leaves residual offset) is applied on top of the new field's\n    \/\/ fit, pushing it off-screen \u2192 \"imported 2nd field, nothing shows\"\n    \/\/ (reported 2026-06-14). Field-button switches already reset the\n    \/\/ view; the import path must too.\n    view.zoom = 1; view.panX = 0; view.panY = 0;\n    updateAdaptiveLock();\n    updatePlanViewToggle();\n    recompute();\n  }\n  \/\/ Convert raw file \u2192 list of polygons \u2192 install + apply first one.\n  function handleParsedPolygons(polys, filename){\n    if(!polys ? true : polys.length === 0){\n      showUploadStatus('error', 'No polygons found in ' + filename);\n      return;\n    }\n    importedFields = polys;\n    importedIdx = 0;\n    showUploadStatus('success', filename + ' \u00b7 ' + polys.length + (polys.length > 1 ? ' parts (one field)' : ' boundary'));\n    runWithLoader('Reading your field\u2026', applyImportedField);\n  }\n  \/\/ Detect format by extension + content, dispatch to parser. Async because\n  \/\/ .zip needs shpjs which loads lazily from CDN.\n  \/\/ Route an import (polys + lines + name) through applyCombinedGeoImport.\n  \/\/ Preserves prior-import state when the new file only has ONE of the\n  \/\/ two layers \u2014 so the user can drop a boundary, THEN drop lines, and\n  \/\/ the simulator keeps both (rather than wiping one with the next).\n  function dispatchImported(polys, lines, name){\n    var polysRaw = polys ? polys.map(function(p){ return p.coords; }).filter(function(c){ return c ? c.length >= 3 : false; }) : [];\n    var linesRaw = lines || [];\n    var linesDriven = linesRaw.map(function(l){ return l.driven !== false; });\n    var bGeo = polysRaw.length > 0 ? polysRaw : IMPORTED_BOUNDARY_GEO;\n    var lGeo = linesRaw.length > 0 ? linesRaw : IMPORTED_LINES_GEO;\n    var lDriven = linesRaw.length > 0 ? linesDriven : IMPORTED_LINES_DRIVEN;\n    if(!bGeo ? !lGeo : false){\n      showUploadStatus('error', 'No boundary or guidance lines found in ' + name);\n      return;\n    }\n    runWithLoader('Reading your field\u2026', function(){\n      applyCombinedGeoImport(bGeo, lGeo, lDriven, name);\n    });\n  }\n  \/\/ Parse a single file and invoke cb(polys, lines, errOrNull). No\n  \/\/ side-effects beyond loading shpjs lazily. Used by importFiles to\n  \/\/ batch multiple files together before dispatching.\n  function parseFileToShapes(file, cb){\n    var name = (file.name || '').toLowerCase();\n    function finish(polys, lines, err){ try { cb(polys, lines, err); } catch(_){} }\n    if(name.indexOf('.zip') >= 0 ? true : name.indexOf('.shp') >= 0){\n      var reader = new FileReader();\n      reader.onload = function(){\n        loadShpjs().then(function(){\n          try {\n            var p = window.shp(reader.result);\n            (p ? (p.then ? p : Promise.resolve(p)) : Promise.resolve(p)).then(function(res){\n              var collections = Array.isArray(res) ? res : [res];\n              var aggStats = { pointCount: 0, polyCount: 0, lineCount: 0 };\n              for(var cc=0; cc<collections.length; cc++){\n                var s = classifyImport(collections[cc]);\n                aggStats.pointCount += s.pointCount;\n                aggStats.polyCount += s.polyCount;\n                aggStats.lineCount += s.lineCount;\n              }\n              var rej = explainImportRejection(aggStats, file.name);\n              if(rej){ finish(null, null, rej); return; }\n              var polys = [], lines = [];\n              for(var c=0; c<collections.length; c++){\n                polys = polys.concat(extractAllPolygonsGeoJSON(collections[c]));\n                lines = lines.concat(extractLinesGeoJSON(collections[c]));\n              }\n              finish(polys, lines, null);\n            }).catch(function(err){\n              finish(null, null, 'Shapefile parse failed: ' + (err.message || err));\n            });\n          } catch(err){\n            finish(null, null, 'Shapefile parse failed: ' + (err.message || err));\n          }\n        }).catch(function(err){\n          finish(null, null, err.message || 'Could not load shapefile parser');\n        });\n      };\n      reader.onerror = function(){ finish(null, null, 'Could not read file'); };\n      reader.readAsArrayBuffer(file);\n    } else {\n      var reader2 = new FileReader();\n      reader2.onload = function(){\n        var text = reader2.result;\n        var polys = [], lines = [];\n        if(name.indexOf('.kml') >= 0){\n          polys = extractAllPolygonsKML(text);\n        } else {\n          try {\n            var obj = JSON.parse(text);\n            var stats = classifyImport(obj);\n            var rej = explainImportRejection(stats, file.name);\n            if(rej){ finish(null, null, rej); return; }\n            polys = extractAllPolygonsGeoJSON(obj);\n            lines = extractLinesGeoJSON(obj);\n          } catch(_){ polys = extractAllPolygonsKML(text); }\n        }\n        finish(polys, lines, null);\n      };\n      reader2.onerror = function(){ finish(null, null, 'Could not read file'); };\n      reader2.readAsText(file);\n    }\n  }\n  \/\/ Batch multiple files into ONE dispatch so a poly + lines pair are\n  \/\/ installed in a single applyCombinedGeoImport call. Previously each\n  \/\/ file ran its own dispatch sequentially \u2192 the lines file (faster\n  \/\/ to parse) would land first, derive a hull as the \"simulated\"\n  \/\/ boundary, then the boundary file would land second and replace the\n  \/\/ hull. The user saw the hull flash by + (when WP cache + canvas\n  \/\/ residue lined up wrong) thought the hull was still showing\n  \/\/ (reported 2026-06-03: \"in case both lines and boundary for the\n  \/\/ same field imported \u2014 don't show simulated boundary\").\n  function importFiles(files){\n    if(!files ? true : files.length === 0) return;\n    showUploadStatus('default', 'Reading ' + files.length + ' file' + (files.length === 1 ? '' : 's') + '\u2026');\n    var pending = files.length;\n    var allPolys = [];\n    var allLines = [];\n    var fileNames = [];\n    var firstError = null;\n    for(var fi=0; fi<files.length; fi++){\n      (function(file){\n        parseFileToShapes(file, function(polys, lines, err){\n          fileNames.push(file.name);\n          if(err){ if(!firstError) firstError = err; }\n          else {\n            if(polys) allPolys = allPolys.concat(polys);\n            if(lines) allLines = allLines.concat(lines);\n          }\n          if(--pending === 0){\n            if(firstError){ showUploadStatus('error', firstError); return; }\n            dispatchImported(allPolys, allLines, fileNames.join(' + '));\n          }\n        });\n      })(files[fi]);\n    }\n  }\n  function importFile(file){ importFiles([file]); }\n  var fileInputEl = document.getElementById('gpl-upload');\n  if(fileInputEl){\n    fileInputEl.addEventListener('change', function(){\n      var files = this.files ? this.files : null;\n      if(!files ? true : files.length === 0) return;\n      \/\/ importFiles batches ALL selected files into a single dispatch so\n      \/\/ a boundary + lines pair installs together (no transient hull).\n      importFiles(files);\n      try { this.value = ''; } catch(_){}\n    });\n  }\n  \/\/ Iterator buttons \u2014 step through importedFields\n  \/\/ Multi-polygon files now collapse to ONE field with N parts, so the\n  \/\/ \u2039 \/ \u203a iterator buttons are no longer needed. Kept in the DOM (hidden via\n  \/\/ applyImportedField \u2192 nav.hidden = true) so the markup stays compatible,\n  \/\/ but no click handlers are attached.\n  \/\/ Field picker \u2014 swap BOUNDARY in place, auto-pick the recommended approach,\n  \/\/ then re-fit \/ re-render. Resets the AB-line override so each new field\n  \/\/ starts from its PCA-derived natural axis.\n  var fldBtns = document.querySelectorAll('#gpl-field-picker .gpl-fld');\n  for(var fb=0; fb<fldBtns.length; fb++){\n    fldBtns[fb].addEventListener('click', function(){\n      var key = this.getAttribute('data-field');\n      \/\/ Imported chip \u2014 restore the saved upload state. Independent path\n      \/\/ from sample-field selection so we don't go through FIELDS[key].\n      if(key === 'imported'){\n        if(!IMPORTED_BOUNDARY) return;\n        currentField = IMPORTED_LINES ? 'uploaded-lines' : 'custom';\n        BOUNDARY = IMPORTED_BOUNDARY;\n        BOUNDARY_PARTS = IMPORTED_BOUNDARY_PARTS;\n        UPLOADED_LINES = IMPORTED_LINES;\n        userAxisDeg = null;\n        abTool.active = false; abTool.p1 = null; abTool.p2 = null;\n        var abBtnImp = document.getElementById('gpl-tool-ab');\n        var abHintImp = document.getElementById('gpl-ab-hint');\n        if(abBtnImp) abBtnImp.classList.remove('is-on');\n        if(abHintImp) abHintImp.classList.remove('is-on');\n        var autoBtnImp = document.getElementById('gpl-ab-auto');\n        if(autoBtnImp) autoBtnImp.classList.add('is-auto');\n        for(var fi0=0; fi0<fldBtns.length; fi0++) fldBtns[fi0].classList.remove('is-on');\n        this.classList.add('is-on');\n        showUploadStatus('success', IMPORTED_NAME + ' \u00b7 restored');\n        updateAdaptiveLock();\n    updatePlanViewToggle();\n        recompute();\n        return;\n      }\n      if(!key ? true : !FIELDS[key]) return;\n      currentField = key;\n      BOUNDARY = FIELDS[key];\n      BOUNDARY_PARTS = FIELD_PARTS[key] ? FIELD_PARTS[key] : [BOUNDARY];\n      UPLOADED_LINES = null;  \/\/ clear any prior guidance-lines upload\n      userAxisDeg = null;  \/\/ back to Auto on every field change\n      userAxisAnchor = null;  \/\/ GPS-coords anchor is field-specific too\n      headingStrategy = \"longest\";\n      BLOCK_AXIS_OVERRIDES = {};  \/\/ per-block overrides scoped to one field\n      abTool.active = false; abTool.p1 = null; abTool.p2 = null;\n      var abBtnSw = document.getElementById('gpl-tool-ab');\n      var abHintSw = document.getElementById('gpl-ab-hint');\n      if(abBtnSw) abBtnSw.classList.remove('is-on');\n      if(abHintSw) abHintSw.classList.remove('is-on');\n      var autoBtn = document.getElementById('gpl-ab-auto');\n      if(autoBtn) autoBtn.classList.add('is-auto');\n      for(var fi=0; fi<fldBtns.length; fi++) fldBtns[fi].classList.remove('is-on');\n      this.classList.add('is-on');\n      \/\/ Reset the upload card status so it doesn't keep the success-state badge\n      showUploadStatus('default', 'GeoJSON \/ KML \/ Shapefile zip');\n      if(fileInputEl) fileInputEl.value = '';\n      var nav2 = document.getElementById('gpl-import-nav');\n      if(nav2) nav2.hidden = true;\n      importedFields = [];\n      importedIdx = 0;\n      \/\/ Pre-pick with the shape-based heuristic for instant feedback; the\n      \/\/ first recompute() below will then re-evaluate with real metrics\n      \/\/ (coverage + turns + fuel) and may flip to a better approach.\n      var pick = recommendApproach(BOUNDARY).pick;\n      current = pick;\n      var apRadios = document.querySelectorAll('#gpl-approach input[type=radio]');\n      var apLabels = document.querySelectorAll('#gpl-approach label');\n      for(var ar=0; ar<apRadios.length; ar++){\n        var match = apRadios[ar].value === pick;\n        apRadios[ar].checked = match;\n        if(apLabels[ar]) apLabels[ar].classList.toggle('is-on', match);\n      }\n      updateAdaptiveLock();\n    updatePlanViewToggle();\n      \/\/ Mark the upcoming recompute as eligible for auto-best-pick. The\n      \/\/ deferred Compare All fill will then call autoPickBestApproach\n      \/\/ ONCE with full apMetrics. Cleared automatically by the swap.\n      pendingAutoPick = true;\n      \/\/ Show the map loader immediately on field switch, then compute on the\n      \/\/ next frame so the spinner paints first. In remote-only mode recompute\n      \/\/ kicks off an async fetch (plannerFetchInFlight) whose callback owns\n      \/\/ hiding the loader; when we render locally we hide it here in finally.\n      showLoader('Loading field');\n      \/\/ Blur + fade the current plan immediately so the switch reads as a\n      \/\/ smooth transition, not a hard swap. hideLoader() clears it once the\n      \/\/ new plan renders (local finally below, or the remote fetch callback).\n      var switchShell = document.querySelector('.gpl-canvas-shell');\n      if(switchShell) switchShell.classList.add('is-switching');\n      requestAnimationFrame(function(){\n        setTimeout(function(){\n          try { recompute(); autoPickBestApproach(); }\n          finally { if(!plannerFetchInFlight) hideLoader(); }\n        }, 0);\n      });\n    });\n  }\n  \/\/ AB-direction controls\n  var abSliderEl2 = document.getElementById('gpl-ab-deg');\n  var abAutoBtnEl = document.getElementById('gpl-ab-auto');\n  if(abSliderEl2){\n    abSliderEl2.addEventListener('input', function(){\n      var deg = parseFloat(this.value);\n      if(isNaN(deg)) deg = 0;\n      userAxisDeg = deg;\n      \/\/ Manual slider clears the AB-coords anchor \u2014 the user is now\n      \/\/ dialling a bare angle, not a coords-based line.\n      userAxisAnchor = null;\n      \/\/ Slider input always means custom mode \u2014 switch into it if not\n      \/\/ already there, so the picker UI reflects what the user just did.\n      if(headingStrategy !== 'custom'){\n        headingStrategy = 'custom';\n        updateHeadingPickerUI();\n      }\n      if(abAutoBtnEl) abAutoBtnEl.classList.remove('is-auto');\n      scheduleRecomputeFast();\n    });\n  }\n  if(abAutoBtnEl){\n    abAutoBtnEl.classList.add('is-auto');\n    abAutoBtnEl.addEventListener('click', function(){\n      \/\/ Reset \u2192 Longest Edge (the previous \"auto\" behaviour)\n      headingStrategy = 'longest';\n      userAxisDeg = null;\n      userAxisAnchor = null;  \/\/ Auto reset also drops the GPS anchor.\n      this.classList.add('is-auto');\n      updateHeadingPickerUI();\n      recompute();\n    });\n  }\n  \/\/ AB direction by GPS coordinates \u2014 useful when the operator has the\n  \/\/ exact A and B GPS waypoints from a prior season and wants to lock\n  \/\/ the simulator's axis to that recorded line. Computes the bearing\n  \/\/ (in canvas coords, accounting for y inversion) and applies it as\n  \/\/ the custom AB angle.\n  var abCoordsApplyEl = document.getElementById('gpl-ab-coords-apply');\n  var abCoordsHintEl = document.getElementById('gpl-ab-coords-hint');\n  function setAbCoordsHint(text, kind){\n    if(!abCoordsHintEl) return;\n    abCoordsHintEl.textContent = text || '';\n    abCoordsHintEl.classList.remove('is-error', 'is-success');\n    if(kind) abCoordsHintEl.classList.add('is-' + kind);\n  }\n  if(abCoordsApplyEl){\n    abCoordsApplyEl.addEventListener('click', function(){\n      var aLatEl = document.getElementById('gpl-ab-a-lat');\n      var aLngEl = document.getElementById('gpl-ab-a-lng');\n      var bLatEl = document.getElementById('gpl-ab-b-lat');\n      var bLngEl = document.getElementById('gpl-ab-b-lng');\n      var aLat = parseFloat((aLatEl || {}).value);\n      var aLng = parseFloat((aLngEl || {}).value);\n      var bLat = parseFloat((bLatEl || {}).value);\n      var bLng = parseFloat((bLngEl || {}).value);\n      function badLat(v){ return isNaN(v) ? true : (v < -90 ? true : v > 90); }\n      function badLng(v){ return isNaN(v) ? true : (v < -180 ? true : v > 180); }\n      if(badLat(aLat) ? true : badLng(aLng) ? true : badLat(bLat) ? true : badLng(bLng)){\n        setAbCoordsHint('Enter valid lat (\u221290 to 90) + lng (\u2212180 to 180) for both points.', 'error');\n        return;\n      }\n      \/\/ Same chord vector check both points use \u2014 reject zero-length\n      \/\/ (A and B at the same point can't define a direction).\n      var cosLatAvg = Math.cos((aLat + bLat) * 0.5 * Math.PI \/ 180);\n      var dx = (bLng - aLng) * 111320 * cosLatAvg;\n      var dy = -(bLat - aLat) * 111320;  \/\/ canvas y axis is inverted vs latitude\n      var dist = Math.sqrt(dx * dx + dy * dy);\n      if(dist < 1){\n        setAbCoordsHint('A and B are essentially the same point. Move them at least a few metres apart.', 'error');\n        return;\n      }\n      var deg = Math.atan2(dy, dx) * 180 \/ Math.PI;\n      while(deg < 0) deg += 180;\n      while(deg >= 180) deg -= 180;\n      userAxisDeg = deg;\n      headingStrategy = 'custom';\n      if(abSliderEl2) abSliderEl2.value = String(Math.round(deg));\n      var customRadio = document.querySelector('input[name=gpl-heading][value=custom]');\n      if(customRadio) customRadio.checked = true;\n      if(abAutoBtnEl) abAutoBtnEl.classList.remove('is-auto');\n      if(typeof updateHeadingPickerUI === 'function') updateHeadingPickerUI();\n      \/\/ Project A into canvas coords so the pass grid snaps THROUGH A.\n      \/\/ Only meaningful for fields with a geographic projection origin\n      \/\/ (imported boundaries \/ lines). For built-in sample fields the\n      \/\/ EXPORT_ORIGIN_LNG is null and the anchor is left unset \u2192 grid\n      \/\/ only borrows the bearing.\n      userAxisAnchor = null;\n      var hasOrigin = (typeof EXPORT_ORIGIN_LNG === 'number' ? typeof EXPORT_ORIGIN_LAT === 'number' : false);\n      if(hasOrigin){\n        var aMx = (aLng - EXPORT_ORIGIN_LNG) * 111320 * EXPORT_COSLAT;\n        var aMy = -(aLat - EXPORT_ORIGIN_LAT) * 111320;\n        \/\/ The same shift that applyCombinedGeoImport applied to bring\n        \/\/ the field to (380, 290). Stored in EXPORT_SHIFT_X \/ _Y.\n        var ax = aMx + (typeof EXPORT_SHIFT_X === 'number' ? EXPORT_SHIFT_X : 0);\n        var ay = aMy + (typeof EXPORT_SHIFT_Y === 'number' ? EXPORT_SHIFT_Y : 0);\n        userAxisAnchor = { x: ax, y: ay };\n      }\n      var snapMsg = hasOrigin ? ' \u00b7 grid snapped through A' : '';\n      setAbCoordsHint('AB direction set to ' + deg.toFixed(1) + '\u00b0 (chord ' + dist.toFixed(0) + ' m)' + snapMsg + '.', 'success');\n      recompute();\n    });\n  }\n  \/\/ \"Reset all blocks\" button \u2014 clears every per-block angle override\n  \/\/ and recomputes. Each block falls back to its algorithmic PCA axis.\n  var blocksResetAll = document.getElementById('gpl-blocks-reset');\n  if(blocksResetAll){\n    blocksResetAll.addEventListener('click', function(){\n      BLOCK_AXIS_OVERRIDES = {};\n      recompute();\n    });\n  }\n  \/\/ Sortable Compare All table. Click any header cell to sort rows by\n  \/\/ that column. Toggle ascending \/ descending on repeated clicks. The\n  \/\/ header row + the \"Your current lines\" \/ current-approach rows stay\n  \/\/ in their natural position; only the algorithm rows shuffle.\n  \/\/ Sortable Compare All table. Defaults to \"cost ascending\" (cheapest\n  \/\/ approach on top) per client feedback \u2014 the user lands on the table\n  \/\/ and immediately sees the best-value option without clicking. Any\n  \/\/ header click overrides; same column twice flips direction.\n  var __cmpSortKey = 'cost';\n  var __cmpSortDir = 1;\n  function applyCmpSort(){\n    var table = document.getElementById('gpl-cmp-table');\n    if(!table) return;\n    if(!__cmpSortKey) return;\n    var rows = Array.prototype.slice.call(table.querySelectorAll('.gpl-cmp-row'));\n    if(rows.length === 0) return;\n    var head = rows.shift();\n    if(!head) return;\n    var sortable = rows.filter(function(r){ return !r.hidden; });\n    sortable.sort(function(a, b){\n      if(__cmpSortKey === 'name'){\n        var an = (a.querySelector('.gpl-cmp-name') || {}).textContent || '';\n        var bn = (b.querySelector('.gpl-cmp-name') || {}).textContent || '';\n        return an.localeCompare(bn) * __cmpSortDir;\n      }\n      var av = parseFloat(a.dataset[__cmpSortKey] || 'NaN');\n      var bv = parseFloat(b.dataset[__cmpSortKey] || 'NaN');\n      if(isNaN(av)) av = __cmpSortDir > 0 ? Infinity : -Infinity;\n      if(isNaN(bv)) bv = __cmpSortDir > 0 ? Infinity : -Infinity;\n      return (av - bv) * __cmpSortDir;\n    });\n    for(var i=0; i<sortable.length; i++) table.appendChild(sortable[i]);\n    var sortEls = head.querySelectorAll('.gpl-cmp-sort');\n    for(var si=0; si<sortEls.length; si++){\n      sortEls[si].classList.remove('is-sort-asc', 'is-sort-desc');\n      if(sortEls[si].getAttribute('data-sort') === __cmpSortKey){\n        sortEls[si].classList.add(__cmpSortDir > 0 ? 'is-sort-asc' : 'is-sort-desc');\n      }\n    }\n  }\n  (function wireCmpSort(){\n    var table = document.getElementById('gpl-cmp-table');\n    if(!table) return;\n    var headers = table.querySelectorAll('.gpl-cmp-head .gpl-cmp-sort');\n    for(var hi=0; hi<headers.length; hi++){\n      headers[hi].addEventListener('click', function(ev){\n        \/\/ Click on the info icon inside the header \u2192 show tooltip\n        \/\/ only, don't trigger sort.\n        if(ev.target ? ev.target.classList.contains('gpl-info-ico') : false) return;\n        var k = this.getAttribute('data-sort');\n        if(!k) return;\n        if(__cmpSortKey === k) __cmpSortDir = -__cmpSortDir;\n        else { __cmpSortKey = k; __cmpSortDir = 1; }\n        applyCmpSort();\n      });\n    }\n    \/\/ Make every info icon inside the Compare All table tooltip-only:\n    \/\/ tabindex so :focus reveals the data-tip on touch, mousedown\n    \/\/ preventDefault blocks the label-style sort activation, click\n    \/\/ stopPropagation seals it.\n    var cmpIcons = table.querySelectorAll('.gpl-info-ico');\n    for(var ci=0; ci<cmpIcons.length; ci++){\n      cmpIcons[ci].setAttribute('tabindex', '0');\n      cmpIcons[ci].setAttribute('role', 'button');\n      cmpIcons[ci].setAttribute('aria-label', cmpIcons[ci].getAttribute('data-tip') || '');\n      cmpIcons[ci].addEventListener('mousedown', function(ev){ ev.preventDefault(); ev.stopPropagation(); });\n      cmpIcons[ci].addEventListener('click', function(ev){ ev.preventDefault(); ev.stopPropagation(); try { this.focus(); } catch(_){} });\n    }\n    \/\/ Make every NON-header row clickable: clicking a row selects that\n    \/\/ approach (syncs the left-panel radio + recomputes), so the user\n    \/\/ can switch approaches from the Compare All table without\n    \/\/ scrolling back to the Approach picker. Reported 2026-06-04.\n    var cmpRows = table.querySelectorAll('.gpl-cmp-row:not(.gpl-cmp-head)');\n    for(var rci=0; rci<cmpRows.length; rci++){\n      cmpRows[rci].style.cursor = 'pointer';\n      cmpRows[rci].setAttribute('role', 'button');\n      cmpRows[rci].setAttribute('tabindex', '0');\n      cmpRows[rci].addEventListener('click', function(ev){\n        \/\/ Ignore clicks on the info icon (tooltip-only)\n        if(ev.target ? ev.target.classList.contains('gpl-info-ico') : false) return;\n        var ap = this.getAttribute('data-cmp');\n        if(!ap) return;\n        \/\/ Skip uploaded if no upload available (the row is hidden anyway).\n        var apRadio = document.querySelector('#gpl-approach input[value=\"' + ap + '\"]');\n        if(!apRadio) return;\n        if(apRadio.disabled) return;\n        \/\/ Skip the adaptive PRO lock for imported fields \u2014 pick another row.\n        var lab = apRadio.parentNode;\n        if(lab ? lab.classList.contains('is-pro-locked') : false) return;\n        \/\/ Save scroll positions so neither the page nor the side panel\n        \/\/ jumps when we programmatically check the radio + recompute\n        \/\/ (radio is above the fold; programmatic check + change handler\n        \/\/ can pull the viewport to the top in some browsers \u2014 reported\n        \/\/ 2026-06-04 \"when i click on the table - don't jump page\").\n        var savedY = window.pageYOffset ? window.pageYOffset : (document.documentElement.scrollTop || document.body.scrollTop || 0);\n        var savedX = window.pageXOffset ? window.pageXOffset : (document.documentElement.scrollLeft || document.body.scrollLeft || 0);\n        var sideEl = document.querySelector('.gpl-side');\n        var sideSavedY = sideEl ? sideEl.scrollTop : 0;\n        apRadio.checked = true;\n        \/\/ Sync the .is-on highlight + fire the change handler so the\n        \/\/ app's existing approach-select logic runs.\n        var ev2 = document.createEvent ? document.createEvent('HTMLEvents') : null;\n        if(ev2){ ev2.initEvent('change', true, true); apRadio.dispatchEvent(ev2); }\n        else { try { apRadio.dispatchEvent(new Event('change', { bubbles: true })); } catch(_){} }\n        \/\/ Restore page + side-panel scroll on the next frame (after the\n        \/\/ browser's implicit \"scroll focused element into view\" runs).\n        var restoreScroll = function(){\n          try { window.scrollTo(savedX, savedY); } catch(_){}\n          if(sideEl) sideEl.scrollTop = sideSavedY;\n        };\n        if(typeof requestAnimationFrame === 'function') requestAnimationFrame(restoreScroll);\n        else setTimeout(restoreScroll, 0);\n      });\n      cmpRows[rci].addEventListener('keydown', function(ev){\n        \/\/ Keyboard accessibility: Enter \/ Space activate the row.\n        if(ev.key === 'Enter' ? true : ev.key === ' '){\n          ev.preventDefault();\n          try { this.click(); } catch(_){}\n        }\n      });\n    }\n    \/\/ Apply default sort on page load (cost ascending).\n    applyCmpSort();\n  })();\n  \/\/ PRO badge on Topography follow opens the GeoPard signup CTA. The\n  \/\/ badge only shows for uploaded fields (where we don't have real\n  \/\/ elevation data); on the built-in sample fields the badge is\n  \/\/ hidden and adaptive works against the synthetic terrain.\n  var proLockEl = document.getElementById('gpl-ap-adaptive-lock');\n  if(proLockEl){\n    proLockEl.addEventListener('click', function(ev){\n      ev.stopPropagation();\n      ev.preventDefault();\n      window.open('https:\/\/app.geopard.tech\/signup?utm_source=guidance-lines\\x26utm_medium=wp-embed\\x26utm_campaign=topography-pro', '_blank', 'noopener');\n    });\n  }\n  \/\/ Plan-view toggle (Proposed vs Your current). Shown only when the\n  \/\/ user has uploaded their existing guidance lines. Switching to\n  \/\/ \"Your current\" plays back + analyses the upload in the same UI as\n  \/\/ the proposed plan, so the user gets a side-by-side comparison\n  \/\/ without manually reading the Compare All table.\n  \/\/ Show \/ hide the \"Your current lines\" radio in the Approach picker\n  \/\/ based on whether the user has uploaded existing guidance lines.\n  \/\/ When the upload is cleared (back to a sample field), auto-bounce\n  \/\/ off 'uploaded' if it was selected so the picker doesn't sit on a\n  \/\/ hidden option.\n  function updatePlanViewToggle(){\n    var lab = document.getElementById('gpl-ap-uploaded-label');\n    var has = UPLOADED_LINES ? UPLOADED_LINES.length > 0 : false;\n    if(lab) lab.hidden = !has;\n    if(!has ? current === 'uploaded' : false){\n      current = 'ab-straight';\n      var ab = document.querySelector('#gpl-approach input[value=\"ab-straight\"]');\n      if(ab){\n        ab.checked = true;\n        var ls = document.querySelectorAll('#gpl-approach label');\n        for(var lsi=0; lsi<ls.length; lsi++) ls[lsi].classList.remove('is-on');\n        ab.parentNode.classList.add('is-on');\n      }\n    }\n  }\n  \/\/ Heading-picker strategy radios. Listen on the radio's change event so\n  \/\/ both keyboard nav (tab + arrows) and click work consistently.\n  var headingRadios = document.querySelectorAll('.gpl-heading-opt input[type=\"radio\"]');\n  for(var ho=0; ho<headingRadios.length; ho++){\n    headingRadios[ho].addEventListener('change', function(){\n      if(this.checked) applyHeadingStrategy(this.value);\n    });\n  }\n  \/\/ Playback controls\n  var lastLayout = null;\n  var LAST_LAYOUT = null;  \/\/ shared with the export functions\n  var pbBtnEl = document.getElementById('gpl-pb-play');\n  var pbTrackEl = document.getElementById('gpl-pb-track');\n  var pbSpdEls = document.querySelectorAll('.gpl-pb-spd');\n  \/\/ Cache the current layout so the RAF loop can redraw without recomputing\n  \/\/ the whole geometry pipeline on every frame.\n  var origDraw = draw;\n  draw = function(layout){ lastLayout = layout; LAST_LAYOUT = layout; origDraw(layout); };\n  function tick(now){\n    if(!playback.isPlaying){ playback.lastTick = now; return; }\n    var dt = playback.lastTick > 0 ? (now - playback.lastTick) \/ 1000 : 0;\n    playback.lastTick = now;\n    \/\/ Path-relative playback speed. Two regimes:\n    \/\/\n    \/\/   1. Small\/medium fields (\u2264 ~24 km drive): target 12 s at the\n    \/\/      floor and ~1500 m\/s baseline, so the 5-ha sample fields\n    \/\/      don't drag and a typical 10-ha plot finishes in ~15 s.\n    \/\/\n    \/\/   2. Large fields (200 km+ drive): the old 25 s upper cap blew\n    \/\/      through them at 8000 m\/s \u2014 each pass took < 0.1 s and the\n    \/\/      operator couldn't see what was happening (reported on\n    \/\/      460 ha real client field). Now: m\/s drops with sqrt(field\n    \/\/      size) so big fields slow down to ~700 m\/s, and the upper\n    \/\/      duration cap is 90 s so a 200 km plan plays in ~90 s\n    \/\/      instead of 25 s.\n    var bigness = playback.totalLen \/ 50000;  \/\/ 1.0 at ~50 km, >1 huge\n    var mpsTarget = bigness > 1\n      ? 1500 \/ Math.sqrt(bigness)  \/\/ 1500 \u2192 ~500 m\/s as field grows\n      : 1500;\n    if(mpsTarget < 500) mpsTarget = 500;\n    var targetSec = playback.totalLen \/ mpsTarget;\n    if(targetSec < 12) targetSec = 12;\n    if(targetSec > 90) targetSec = 90;\n    var metresPerSec = playback.totalLen > 0 ? (playback.totalLen \/ targetSec) * playback.speed : 0;\n    var dT = playback.totalLen > 0 ? (dt * metresPerSec \/ playback.totalLen) : 0;\n    playback.t += dT;\n    if(playback.t >= 1){\n      playback.t = 1;\n      playback.isPlaying = false;\n      if(pbBtnEl){\n        pbBtnEl.classList.remove('is-playing');\n        pbBtnEl.textContent = '\u25b6';\n      }\n    }\n    updatePlaybackUI();\n    if(lastLayout) origDraw(lastLayout);\n    if(playback.isPlaying) requestAnimationFrame(tick);\n  }\n  if(pbBtnEl){\n    pbBtnEl.addEventListener('click', function(){\n      if(playback.totalLen < 1) return;\n      if(playback.t >= 1) playback.t = 0;\n      playback.isPlaying = !playback.isPlaying;\n      pbBtnEl.classList.toggle('is-playing', playback.isPlaying);\n      pbBtnEl.textContent = playback.isPlaying ? '' : '\u25b6';\n      if(playback.isPlaying){\n        playback.lastTick = 0;\n        requestAnimationFrame(tick);\n      }\n    });\n  }\n  if(pbTrackEl){\n    pbTrackEl.addEventListener('click', function(ev){\n      var rect = pbTrackEl.getBoundingClientRect();\n      var f = (ev.clientX - rect.left) \/ rect.width;\n      if(f < 0) f = 0; if(f > 1) f = 1;\n      playback.t = f;\n      updatePlaybackUI();\n      if(lastLayout) origDraw(lastLayout);\n    });\n  }\n  for(var sb=0; sb<pbSpdEls.length; sb++){\n    pbSpdEls[sb].addEventListener('click', function(){\n      var s = parseFloat(this.getAttribute('data-spd')) || 1;\n      playback.speed = s;\n      for(var sb2=0; sb2<pbSpdEls.length; sb2++) pbSpdEls[sb2].classList.remove('on');\n      this.classList.add('on');\n    });\n  }\n  \/\/ Unit-system toggle (Metric \/ US). Switches:\n  \/\/   \u2022 All display formatters (fmtArea \/ fmtVolume \/ fmtWidth \/ fmtDist)\n  \/\/   \u2022 Input field VALUES \u2014 multiplied\/divided by the conversion factor so\n  \/\/     the user sees the same physical quantity expressed in the new units.\n  \/\/     Example: 18 m implement width becomes 59 ft on switch to US.\n  \/\/   \u2022 Input field unit LABELS (the small \"m\" \/ \"ft\", \"L\/km\" \/ \"gal\/mi\",\n  \/\/     \"$\/L\" \/ \"$\/gal\" badges next to each field).\n  \/\/ After conversion, recompute() reads the converted values via getInputs(),\n  \/\/ which reverses the conversion so the geometry pipeline stays metric.\n  function setUnitSystem(sys){\n    if(sys !== 'metric' ? sys !== 'us' : false) sys = 'metric';\n    if(sys === unitSystem) return;\n    unitSystem = sys;\n    \/\/ Convert + display, preserving the canonical METRIC value across\n    \/\/ toggles so a round-trip never drifts. Without this, wM 15 m \u2192 49 ft\n    \/\/ \u2192 14.93 m (display rounding), and the slightly different wM tipped\n    \/\/ the metric-based BEST recommender on borderline fields (reported\n    \/\/ 2026-06-04 \u2014 \"switching US \u2194 metric changes the best approach\").\n    \/\/ factorMetricToUS is the metric\u2192US scale; pass the SAME value for\n    \/\/ both directions, the helper reads it directionally.\n    function convertInput(id, factorMetricToUS, decimals){\n      var el = document.getElementById(id);\n      if(!el) return;\n      if(sys === 'us'){\n        \/\/ metric \u2192 US: snapshot the metric value, display rounded US.\n        var v = parseFloat(el.value);\n        if(isNaN(v)) return;\n        el.setAttribute('data-metric', String(v));\n        var usVal = v * factorMetricToUS;\n        el.value = decimals > 0 ? usVal.toFixed(decimals) : Math.round(usVal).toString();\n      } else {\n        \/\/ US \u2192 metric: restore the snapshotted metric value if present\n        \/\/ (lossless round-trip); else fall back to parsing the displayed\n        \/\/ US and converting (first-run case, no snapshot yet).\n        var saved = parseFloat(el.getAttribute('data-metric'));\n        var mVal;\n        if(isNaN(saved)){\n          var v2 = parseFloat(el.value);\n          if(isNaN(v2)) return;\n          mVal = v2 \/ factorMetricToUS;\n        } else {\n          mVal = saved;\n        }\n        el.value = decimals > 0 ? mVal.toFixed(decimals) : Math.round(mVal).toString();\n      }\n    }\n    convertInput('gpl-wm',       M_TO_FT,      0);\n    convertInput('gpl-turn-r',   M_TO_FT,      1);\n    convertInput('gpl-cons',     LPKM_TO_GPMI, 2);\n    convertInput('gpl-fuel',     LPL_TO_DPGAL, 2);\n    convertInput('gpl-roi-farm', HA_TO_AC,     0);\n    convertInput('gpl-speed',    KM_TO_MI,     1);\n    \/\/ Swap unit labels.\n    var us = sys === 'us';\n    var setLabel = function(sel, txt){\n      var el = document.querySelector(sel);\n      if(el) el.textContent = txt;\n    };\n    setLabel('[data-u=\"width\"]',            us ? 'ft' : 'm');\n    setLabel('[data-u=\"turn-r\"]',           us ? 'ft' : 'm');\n    setLabel('[data-u=\"farm\"]',             us ? 'ac' : 'ha');\n    setLabel('[data-u=\"consumption-label\"]', us ? 'gal\/mi' : 'L\/km');\n    setLabel('[data-u=\"speed\"]',            us ? 'mph' : 'km\/h');\n    \/\/ Currency dropdown options carry \/L or \/gal \u2014 adjust both options.\n    var curSel = document.getElementById('gpl-currency');\n    if(curSel){\n      var opts = curSel.querySelectorAll('option');\n      for(var oi=0; oi<opts.length; oi++){\n        var v = opts[oi].value;\n        opts[oi].textContent = (v === 'usd' ? '$' : '\u20ac') + (us ? '\/gal' : '\/L');\n      }\n    }\n    \/\/ Step size on the diesel input \u2014 finer in $\/L (0.05), coarser in\n    \/\/ $\/gal (since 0.05 $\/L \u2248 0.19 $\/gal). Keep precision symmetric.\n    var fuelEl = document.getElementById('gpl-fuel');\n    if(fuelEl) fuelEl.step = us ? '0.05' : '0.05';\n    \/\/ Headland slider label (currently \"X m \u00b7 Nx pass\") needs a refresh.\n    updateHeadlandLabel();\n    \/\/ Re-trigger any text that depends on unit (e.g. Turn-r hint).\n    var trHintEl = document.getElementById('gpl-turn-r-hint');\n    if(trHintEl ? trHintEl.textContent.indexOf('auto') === 0 : false){\n      \/\/ leave the \"auto \u00b7 std tractor\" hint alone; numeric auto hints get\n      \/\/ refreshed naturally by the wM-input handler on next change\n    }\n    recompute();\n  }\n  var unitTabs = document.querySelectorAll('.gpl-unit-tab');\n  for(var ut=0; ut<unitTabs.length; ut++){\n    unitTabs[ut].addEventListener('click', function(){\n      var sys = this.getAttribute('data-unit-system') || 'metric';\n      for(var ut2=0; ut2<unitTabs.length; ut2++) unitTabs[ut2].classList.remove('is-on');\n      this.classList.add('is-on');\n      setUnitSystem(sys);\n    });\n  }\n  \/\/ Invalidate the metric snapshot whenever the user manually edits an\n  \/\/ input \u2014 otherwise the saved metric value would override their new\n  \/\/ entry on the next unit toggle. Pairs with setUnitSystem's\n  \/\/ data-metric round-trip preservation logic.\n  var unitInvIds = ['gpl-wm', 'gpl-turn-r', 'gpl-cons', 'gpl-fuel', 'gpl-roi-farm', 'gpl-speed'];\n  for(var uii=0; uii<unitInvIds.length; uii++){\n    var elInv = document.getElementById(unitInvIds[uii]);\n    if(elInv) elInv.addEventListener('input', function(){ this.removeAttribute('data-metric'); });\n  }\n  \/\/ Zoom + fit buttons\n  \/\/ Zoom-around-cursor helper. Adjusts pan so the world point under\n  \/\/ (mx, my) stays at the same screen position after the zoom change.\n  function zoomAroundCursor(mx, my, factor){\n    var projBefore = getScale();\n    var wx = worldX(projBefore, mx);\n    var wy = worldY(projBefore, my);\n    var newZoom = view.zoom * factor;\n    if(newZoom < 0.2) newZoom = 0.2;\n    if(newZoom > 50) newZoom = 50;\n    view.zoom = newZoom;\n    var projAfter = getScale();\n    view.panX += mx - px(projAfter, wx);\n    view.panY += my - py(projAfter, wy);\n    if(lastLayout) origDraw(lastLayout);\n  }\n  function zoomCenter(factor){\n    var rect = canvas.getBoundingClientRect();\n    zoomAroundCursor(rect.width * 0.5, rect.height * 0.5, factor);\n  }\n  var zInEl = document.getElementById('gpl-tool-zoom-in');\n  var zOutEl = document.getElementById('gpl-tool-zoom-out');\n  var zFitEl = document.getElementById('gpl-tool-fit');\n  if(zInEl) zInEl.addEventListener('click', function(){ zoomCenter(1.3); });\n  if(zOutEl) zOutEl.addEventListener('click', function(){ zoomCenter(1\/1.3); });\n  if(zFitEl) zFitEl.addEventListener('click', function(){\n    view.zoom = 1; view.panX = 0; view.panY = 0;\n    if(lastLayout) origDraw(lastLayout);\n  });\n  \/\/ Export button \u2014 toggles a small menu with format options. Each\n  \/\/ option calls the matching exporter, which produces a download via\n  \/\/ an anchor + Blob URL.\n  var exportBtnEl = document.getElementById('gpl-tool-export');\n  var exportMenuEl = document.getElementById('gpl-export-menu');\n  if(exportBtnEl ? exportMenuEl : false){\n    exportBtnEl.addEventListener('click', function(ev){\n      ev.stopPropagation();\n      exportMenuEl.hidden = !exportMenuEl.hidden;\n    });\n    var fmtBtns = exportMenuEl.querySelectorAll('button[data-fmt]');\n    for(var fb=0; fb<fmtBtns.length; fb++){\n      fmtBtns[fb].addEventListener('click', function(){\n        var fmt = this.getAttribute('data-fmt');\n        exportMenuEl.hidden = true;\n        if(fmt === 'geojson') exportGeoJSON();\n        else if(fmt === 'kml') exportKML();\n        else if(fmt === 'shp') exportShapefile();\n      });\n    }\n    \/\/ Dismiss the menu on click-outside.\n    document.addEventListener('click', function(ev){\n      if(exportMenuEl.hidden) return;\n      if(exportBtnEl.contains(ev.target) ? true : exportMenuEl.contains(ev.target)) return;\n      exportMenuEl.hidden = true;\n    });\n  }\n  \/\/ Floating CTA: always shown on page load (no dismiss, no persistence).\n  \/\/ Previously a \u00d7 button persisted \"hidden\" in sessionStorage; per\n  \/\/ product direction the CTA stays visible the whole session.\n  \/\/ Mouse wheel \u2014 smooth exponential zoom (matches field-data-explorer feel)\n  canvas.addEventListener('wheel', function(ev){\n    ev.preventDefault();\n    var rect = canvas.getBoundingClientRect();\n    var mx = ev.clientX - rect.left;\n    var my = ev.clientY - rect.top;\n    var factor = Math.exp(-ev.deltaY * 0.0015);\n    zoomAroundCursor(mx, my, factor);\n  }, { passive: false });\n  \/\/ Drag-to-pan (when ruler is not active)\n  var dragState = null;\n  canvas.addEventListener('mousedown', function(ev){\n    var rect = canvas.getBoundingClientRect();\n    var mx = ev.clientX - rect.left;\n    var my = ev.clientY - rect.top;\n    if(ruler.active){\n      var proj = getScale();\n      var wx = worldX(proj, mx);\n      var wy = worldY(proj, my);\n      if(!ruler.p1){\n        ruler.p1 = { x: wx, y: wy }; ruler.p2 = null;\n      } else if(!ruler.p2){\n        ruler.p2 = { x: wx, y: wy };\n      } else {\n        ruler.p1 = { x: wx, y: wy }; ruler.p2 = null;\n      }\n      if(lastLayout) origDraw(lastLayout);\n      return;\n    }\n    if(abTool.active){\n      var projA = getScale();\n      var wxA = worldX(projA, mx);\n      var wyA = worldY(projA, my);\n      if(!abTool.p1){\n        abTool.p1 = { x: wxA, y: wyA };\n        abTool.p2 = null;\n        var hintAB1 = document.getElementById('gpl-ab-hint');\n        if(hintAB1) hintAB1.textContent = 'Click the second point to lock the AB line \u00b7 ESC to cancel';\n        if(lastLayout) origDraw(lastLayout);\n      } else {\n        abTool.p2 = { x: wxA, y: wyA };\n        \/\/ Compute the angle from the two points and push it into userAxisDeg\n        \/\/ so generateLinesAll uses this axis instead of the PCA default.\n        var abdx = abTool.p2.x - abTool.p1.x;\n        var abdy = abTool.p2.y - abTool.p1.y;\n        if(abdx * abdx + abdy * abdy < 1){\n          \/\/ Same click twice \u2014 ignore (need a real vector)\n          abTool.p2 = null;\n          return;\n        }\n        var deg = Math.atan2(abdy, abdx) * 180 \/ Math.PI;\n        while(deg < 0) deg += 180;\n        while(deg >= 180) deg -= 180;\n        userAxisDeg = deg;\n        var abAutoBtn2 = document.getElementById('gpl-ab-auto');\n        if(abAutoBtn2) abAutoBtn2.classList.remove('is-auto');\n        var abSliderEl3 = document.getElementById('gpl-ab-deg');\n        if(abSliderEl3) abSliderEl3.value = String(Math.round(deg));\n        \/\/ Exit AB-tool mode but keep the vector visible so the user sees what they set.\n        abTool.active = false;\n        var btnAB = document.getElementById('gpl-tool-ab');\n        if(btnAB) btnAB.classList.remove('is-on');\n        var hintAB2 = document.getElementById('gpl-ab-hint');\n        if(hintAB2) hintAB2.classList.remove('is-on');\n        canvas.style.cursor = 'default';\n        recompute();\n      }\n      return;\n    }\n    dragState = { startX: mx, startY: my, startPanX: view.panX, startPanY: view.panY };\n    canvas.style.cursor = 'grabbing';\n  });\n  canvas.addEventListener('mousemove', function(ev){\n    if(!dragState) return;\n    var rect = canvas.getBoundingClientRect();\n    var mx = ev.clientX - rect.left;\n    var my = ev.clientY - rect.top;\n    view.panX = dragState.startPanX + (mx - dragState.startX);\n    view.panY = dragState.startPanY + (my - dragState.startY);\n    if(lastLayout) origDraw(lastLayout);\n  });\n  function endDrag(){\n    dragState = null;\n    canvas.style.cursor = ruler.active ? 'crosshair' : 'default';\n  }\n  canvas.addEventListener('mouseup', endDrag);\n  canvas.addEventListener('mouseleave', endDrag);\n  \/\/ Touch \u2014 single-finger pan, two-finger pinch-zoom (mirrors field-explorer)\n  var touchState = null;\n  function _tDist(a, b){ var dx = a.clientX - b.clientX, dy = a.clientY - b.clientY; return Math.sqrt(dx*dx + dy*dy); }\n  function _tCenter(a, b){ return [(a.clientX + b.clientX) * 0.5, (a.clientY + b.clientY) * 0.5]; }\n  canvas.addEventListener('touchstart', function(ev){\n    if(ev.touches.length === 1){\n      ev.preventDefault();\n      var rect = canvas.getBoundingClientRect();\n      touchState = { mode: 'pan', x: ev.touches[0].clientX, y: ev.touches[0].clientY, sx: ev.touches[0].clientX - rect.left, sy: ev.touches[0].clientY - rect.top, panX: view.panX, panY: view.panY, moved: false };\n    } else if(ev.touches.length >= 2){\n      ev.preventDefault();\n      var c = _tCenter(ev.touches[0], ev.touches[1]);\n      touchState = { mode: 'pinch', dist: _tDist(ev.touches[0], ev.touches[1]), cx: c[0], cy: c[1] };\n    }\n  }, { passive: false });\n  canvas.addEventListener('touchmove', function(ev){\n    if(!touchState) return;\n    ev.preventDefault();\n    if(touchState.mode === 'pan' ? ev.touches.length === 1 : false){\n      var t = ev.touches[0];\n      var dx = t.clientX - touchState.x;\n      var dy = t.clientY - touchState.y;\n      view.panX = touchState.panX + dx;\n      view.panY = touchState.panY + dy;\n      if(Math.abs(dx) > 4 ? true : Math.abs(dy) > 4) touchState.moved = true;\n      if(lastLayout) origDraw(lastLayout);\n    } else if(touchState.mode === 'pinch' ? ev.touches.length >= 2 : false){\n      var newDist = _tDist(ev.touches[0], ev.touches[1]);\n      var newC = _tCenter(ev.touches[0], ev.touches[1]);\n      var rectT = canvas.getBoundingClientRect();\n      var ncx = newC[0] - rectT.left, ncy = newC[1] - rectT.top;\n      zoomAroundCursor(ncx, ncy, newDist \/ touchState.dist);\n      touchState.dist = newDist; touchState.cx = newC[0]; touchState.cy = newC[1];\n    }\n  }, { passive: false });\n  canvas.addEventListener('touchend', function(ev){\n    \/\/ Ruler tap support: if ruler mode and no drag occurred, drop a point at the touch position\n    if(touchState ? (touchState.mode === 'pan' ? (!touchState.moved ? ruler.active : false) : false) : false){\n      var proj = getScale();\n      var wx = worldX(proj, touchState.sx);\n      var wy = worldY(proj, touchState.sy);\n      if(!ruler.p1){ ruler.p1 = { x: wx, y: wy }; ruler.p2 = null; }\n      else if(!ruler.p2){ ruler.p2 = { x: wx, y: wy }; }\n      else { ruler.p1 = { x: wx, y: wy }; ruler.p2 = null; }\n      if(lastLayout) origDraw(lastLayout);\n    }\n    if(ev.touches.length === 0) touchState = null;\n  });\n  \/\/ Ruler toggle \u2014 mutually exclusive with the AB-line tool.\n  var rulerBtnEl = document.getElementById('gpl-tool-ruler');\n  var rulerHintEl = document.getElementById('gpl-ruler-hint');\n  var abBtnEl = document.getElementById('gpl-tool-ab');\n  var abHintEl = document.getElementById('gpl-ab-hint');\n  if(rulerBtnEl){\n    rulerBtnEl.addEventListener('click', function(){\n      ruler.active = !ruler.active;\n      this.classList.toggle('is-on', ruler.active);\n      if(rulerHintEl) rulerHintEl.classList.toggle('is-on', ruler.active);\n      \/\/ Cancel AB tool when activating ruler\n      if(ruler.active){\n        abTool.active = false;\n        if(abBtnEl) abBtnEl.classList.remove('is-on');\n        if(abHintEl) abHintEl.classList.remove('is-on');\n      }\n      canvas.style.cursor = ruler.active ? 'crosshair' : 'default';\n      if(!ruler.active){\n        ruler.p1 = null; ruler.p2 = null;\n        if(lastLayout) origDraw(lastLayout);\n      }\n    });\n  }\n  \/\/ Set AB line \u2014 click two points; angle replaces userAxisDeg.\n  if(abBtnEl){\n    abBtnEl.addEventListener('click', function(){\n      abTool.active = !abTool.active;\n      this.classList.toggle('is-on', abTool.active);\n      if(abHintEl){\n        abHintEl.classList.toggle('is-on', abTool.active);\n        if(abTool.active) abHintEl.textContent = 'Click the first point of your AB line \u00b7 ESC to cancel';\n      }\n      if(abTool.active){\n        ruler.active = false;\n        if(rulerBtnEl) rulerBtnEl.classList.remove('is-on');\n        if(rulerHintEl) rulerHintEl.classList.remove('is-on');\n        abTool.p1 = null; abTool.p2 = null;\n      }\n      canvas.style.cursor = abTool.active ? 'crosshair' : 'default';\n      if(lastLayout) origDraw(lastLayout);\n    });\n  }\n  \/\/ ESC key cancels either active tool.\n  window.addEventListener('keydown', function(ev){\n    if(ev.key !== 'Escape') return;\n    var dirty = false;\n    if(ruler.active){\n      ruler.active = false;\n      ruler.p1 = null; ruler.p2 = null;\n      if(rulerBtnEl) rulerBtnEl.classList.remove('is-on');\n      if(rulerHintEl) rulerHintEl.classList.remove('is-on');\n      dirty = true;\n    }\n    if(abTool.active){\n      abTool.active = false;\n      \/\/ Keep the previously-committed AB vector visible \u2014 only cancel the in-progress click capture.\n      if(abBtnEl) abBtnEl.classList.remove('is-on');\n      if(abHintEl) abHintEl.classList.remove('is-on');\n      dirty = true;\n    }\n    if(dirty){\n      canvas.style.cursor = 'default';\n      if(lastLayout) origDraw(lastLayout);\n    }\n  });\n  \/\/ Clear toolbar button \u2014 wipes ruler + custom AB line, resets the view to fit.\n  var clearBtnEl = document.getElementById('gpl-tool-clear');\n  if(clearBtnEl){\n    clearBtnEl.addEventListener('click', function(){\n      ruler.active = false; ruler.p1 = null; ruler.p2 = null;\n      abTool.active = false; abTool.p1 = null; abTool.p2 = null;\n      userAxisDeg = null;  \/\/ back to auto-PCA\n      headingStrategy = \"longest\";\n      var abAutoBtn3 = document.getElementById('gpl-ab-auto');\n      if(abAutoBtn3) abAutoBtn3.classList.add('is-auto');\n      if(rulerBtnEl) rulerBtnEl.classList.remove('is-on');\n      if(abBtnEl) abBtnEl.classList.remove('is-on');\n      if(rulerHintEl) rulerHintEl.classList.remove('is-on');\n      if(abHintEl) abHintEl.classList.remove('is-on');\n      view.zoom = 1; view.panX = 0; view.panY = 0;\n      canvas.style.cursor = 'default';\n      recompute();\n    });\n  }\n  \/\/ Resizable left panel \u2014 drag splitter to set --gpl-left-w on the wrap.\n  \/\/ Clamped to [200, 420] px so the panel never collapses or eats the canvas.\n  var splitterEl = document.getElementById('gpl-splitter');\n  if(splitterEl){\n    var spState = null;\n    splitterEl.addEventListener('mousedown', function(ev){\n      var current = parseFloat(getComputedStyle(root).getPropertyValue('--gpl-left-w')) || 320;\n      spState = { startX: ev.clientX, startW: current };\n      splitterEl.classList.add('is-drag');\n      ev.preventDefault();\n    });\n    window.addEventListener('mousemove', function(ev){\n      if(!spState) return;\n      var w = spState.startW + (ev.clientX - spState.startX);\n      if(w < 200) w = 200;\n      if(w > 420) w = 420;\n      root.style.setProperty('--gpl-left-w', w + 'px');\n      \/\/ Canvas size changed \u2192 refit and redraw\n      resize();\n      if(lastLayout) origDraw(lastLayout);\n    });\n    window.addEventListener('mouseup', function(){\n      if(!spState) return;\n      spState = null;\n      splitterEl.classList.remove('is-drag');\n    });\n  }\n  \/\/ Resizable right (analytics) panel \u2014 drag splitter to set --gpl-right-w.\n  \/\/ Same UX as the left splitter; dragging LEFT widens the panel (so the\n  \/\/ Compare All table + ROI numbers get more room when needed).\n  var splitterRightEl = document.getElementById('gpl-splitter-right');\n  if(splitterRightEl){\n    var srState = null;\n    splitterRightEl.addEventListener('mousedown', function(ev){\n      var current = parseFloat(getComputedStyle(root).getPropertyValue('--gpl-right-w')) || 320;\n      srState = { startX: ev.clientX, startW: current };\n      splitterRightEl.classList.add('is-drag');\n      ev.preventDefault();\n    });\n    window.addEventListener('mousemove', function(ev){\n      if(!srState) return;\n      \/\/ Drag LEFT widens (analytics panel expands toward the canvas), drag\n      \/\/ RIGHT narrows \u2014 invert the delta vs. the left splitter so the\n      \/\/ expand-toward-content gesture feels natural on both sides.\n      var w = srState.startW - (ev.clientX - srState.startX);\n      if(w < 200) w = 200;\n      if(w > 420) w = 420;\n      root.style.setProperty('--gpl-right-w', w + 'px');\n      resize();\n      if(lastLayout) origDraw(lastLayout);\n    });\n    window.addEventListener('mouseup', function(){\n      if(!srState) return;\n      srState = null;\n      splitterRightEl.classList.remove('is-drag');\n    });\n  }\n  \/\/ Initial recommendation \u2014 pre-select the shape-optimal approach so the\n  \/\/ page lands on the agronomically sensible default, not arbitrary AB Straight.\n  (function(){\n    var initialPick = recommendApproach(BOUNDARY).pick;\n    current = initialPick;\n    var apRadios0 = document.querySelectorAll('#gpl-approach input[type=radio]');\n    var apLabels0 = document.querySelectorAll('#gpl-approach label');\n    for(var ar0=0; ar0<apRadios0.length; ar0++){\n      var match0 = apRadios0[ar0].value === initialPick;\n      apRadios0[ar0].checked = match0;\n      if(apLabels0[ar0]) apLabels0[ar0].classList.toggle('is-on', match0);\n    }\n  })();\n  try {\n    resize();\n    updateHeadlandLabel();\n    applyMachineRadius();\n    \/\/ Initial page load is an auto-pick context \u2014 the deferred Compare All\n    \/\/ fill will then swap the radio to the metric-recommended best.\n    pendingAutoPick = true;\n    \/\/ Remote-first init for a stripped (remote-only) embed: with no local core\n    \/\/ the first paint must come from the planner API, so fetch the plan bundle\n    \/\/ and run recompute + auto-pick against the warm cache. The full embed\n    \/\/ keeps its instant local first paint (core present \u2192 direct recompute,\n    \/\/ remote fires on the next interaction). If the API is unreachable at boot,\n    \/\/ degrade to the retry banner instead of a blank canvas.\n    if(plannerRemoteEnabled() ? (typeof generateLinesAll !== 'function') : false){\n      showLoader('Calculating your field');\n      plannerFetchPlans(getInputs(), currentAxisForPlan(), function(){\n        if(plannerBroken ? (typeof generateLinesAll !== 'function') : false){\n          hideLoader();\n          showPlannerRetry();\n          return;\n        }\n        runWithLoader('Calculating your field', function(){\n          recompute();\n          autoPickBestApproach();\n        });\n      });\n    } else {\n      recompute();\n      autoPickBestApproach();\n    }\n    window.addEventListener('resize', function(){\n      try { resize(); recompute(); } catch(e2){ try { console.error('[GPL-RESIZE]', e2.message); } catch(_){} }\n    });\n  } catch(e1){\n    try { console.error('[GPL-INIT]', e1.message, e1.stack); } catch(_){}\n  }\n})();\n<\/script>\n\n<!-- Indexable SEO section: prose + FAQ (P3 of seo\/PLAN.md). Server-rendered\n     HTML inside the embed, so search engines read it without executing JS. -->\n<style>\n.gpl-wrap .gpl-seo{max-width:900px;margin:34px auto 8px;padding:0 4px;font-size:15px;line-height:1.65;color:#243024}\n.gpl-wrap .gpl-seo h2{font-family:var(--wp--preset--font-family--poppins,\"Poppins\",system-ui,sans-serif);font-size:1.35rem;color:#0e3a1c;margin:0 0 12px}\n.gpl-wrap .gpl-seo h3{font-family:var(--wp--preset--font-family--poppins,\"Poppins\",system-ui,sans-serif);font-size:1.05rem;color:#0e3a1c;margin:22px 0 8px}\n.gpl-wrap .gpl-seo p{margin:0 0 12px}\n.gpl-wrap .gpl-seo a{color:#15701e;font-weight:600;text-decoration:underline;text-underline-offset:2px}\n.gpl-wrap .gpl-seo details{border:1px solid #e2e8dc;border-radius:10px;background:#fff;margin:0 0 8px}\n.gpl-wrap .gpl-seo summary{cursor:pointer;padding:12px 14px;font-weight:700;color:#0e3a1c;font-family:var(--wp--preset--font-family--poppins,\"Poppins\",sans-serif);font-size:.95rem;min-height:44px;box-sizing:border-box;display:flex;align-items:center}\n.gpl-wrap .gpl-seo details p{padding:0 14px 12px;margin:0}\n@media (max-width:480px){ .gpl-wrap .gpl-seo{font-size:14px} }\n<\/style>\n<section class=\"gpl-seo\" aria-label=\"About the guidance lines simulator\">\n  <h2>Plan field guidance lines before the tractor moves<\/h2>\n  <p>Guidance lines decide how much of your season goes into productive passes and how much is burned\n  on turns, overlap, and re-driven ground. This simulator reads your <strong>field boundary<\/strong>\n  (GeoJSON, KML, or zipped shapefile) or a built-in sample field, generates a complete drive plan for\n  five approaches - AB Straight, AB Curve, Boundary Follow, Topography Follow, and Auto-blocks - and\n  scores every plan on coverage, overlap, path crossings, fuel, and estimated annual savings.<\/p>\n  <p>Headland passes, interior obstacles, and multi-part fields are handled the way an operator would\n  drive them: the machine never leaves the boundary, works around exclusion zones, and finishes one\n  block before moving to the next. The playback shows the full route with wheel-track and swath\n  rendering, so a bad plan is visible before it costs diesel.<\/p>\n  <p>When the plan looks right, export it as <strong>GeoJSON, KML, or a zipped shapefile<\/strong> and\n  load it into your display. The full GeoPard platform goes further: it connects the same fields to\n  <a href=\"https:\/\/geopard.tech\/field-data-explorer\/\">yield and soil data<\/a>, builds variable rate\n  prescriptions, and lets the <a href=\"https:\/\/geopard.tech\/ai\/\">GeoPard AI Assistant<\/a> reason\n  through the agronomy with you.<\/p>\n  <h3>Frequently asked questions<\/h3>\n  <details>\n    <summary>What are AB guidance lines?<\/summary>\n    <p>AB lines are the reference lines a tractor autosteer system follows: you set point A and point B,\n    and the machine drives parallel passes offset by the implement width. Straight AB lines suit\n    rectangular fields; curved AB lines, boundary-following passes, and terrain-aware passes suit\n    irregular shapes, contoured land, and fields with obstacles.<\/p>\n  <\/details>\n  <details>\n    <summary>Which file formats can I upload?<\/summary>\n    <p>GeoJSON (Polygon or MultiPolygon), KML, and zipped shapefiles in EPSG:4326 (WGS84). Boundaries\n    with interior holes such as ponds or pylons are recognized and driven around. Existing AB lines can\n    be imported alongside the boundary.<\/p>\n  <\/details>\n  <details>\n    <summary>Does my field data leave the browser?<\/summary>\n    <p>Your uploaded files (GeoJSON, KML, shapefiles) are read entirely in your browser and are never\n    uploaded. To build the plan, only the field boundary shape (its outline coordinates, no file, no\n    name) is sent to GeoPard, used to compute the guidance lines, and then discarded. It is not\n    stored. If the planner is ever unreachable, the tool falls back to computing the plan locally in\n    your browser.<\/p>\n  <\/details>\n  <details>\n    <summary>Can I use the plan in my tractor?<\/summary>\n    <p>Yes. Export the generated lines as GeoJSON, KML, or a zipped shapefile and import them into your\n    terminal. From the full GeoPard platform you can also exchange files with John Deere Operations\n    Center, CNH FieldOps, and AGCO \/ PTx FarmEngage, plus ISO-XML exports for other monitors.<\/p>\n  <\/details>\n  <details>\n    <summary>How is each guidance approach scored?<\/summary>\n    <p>Every plan reports total drive distance, coverage percent, overlap percent, path crossings, turn\n    count, fuel estimate, and an annual savings estimate from published industry midpoints. The\n    recommended approach is the one you can execute with the best balance of coverage and cost, never\n    a plan that hides gaps or double-driven ground in the totals.<\/p>\n  <\/details>\n<\/section>\n<script type=\"application\/ld+json\" nowprocket data-no-optimize=\"1\" data-no-defer=\"1\" data-no-minify=\"1\">\n{\n  \"@context\": \"https:\/\/schema.org\",\n  \"@type\": \"FAQPage\",\n  \"mainEntity\": [\n    {\"@type\": \"Question\", \"name\": \"What are AB guidance lines?\", \"acceptedAnswer\": {\"@type\": \"Answer\", \"text\": \"AB lines are the reference lines a tractor autosteer system follows: you set point A and point B, and the machine drives parallel passes offset by the implement width. Straight AB lines suit rectangular fields; curved AB lines, boundary-following passes, and terrain-aware passes suit irregular shapes, contoured land, and fields with obstacles.\"}},\n    {\"@type\": \"Question\", \"name\": \"Which file formats can I upload?\", \"acceptedAnswer\": {\"@type\": \"Answer\", \"text\": \"GeoJSON (Polygon or MultiPolygon), KML, and zipped shapefiles in EPSG:4326 (WGS84). Boundaries with interior holes such as ponds or pylons are recognized and driven around. Existing AB lines can be imported alongside the boundary.\"}},\n    {\"@type\": \"Question\", \"name\": \"Does my field data leave the browser?\", \"acceptedAnswer\": {\"@type\": \"Answer\", \"text\": \"Your uploaded files (GeoJSON, KML, shapefiles) are read entirely in your browser and are never uploaded. To build the plan, only the field boundary shape (its outline coordinates, no file, no name) is sent to GeoPard, used to compute the guidance lines, and then discarded. It is not stored. If the planner is ever unreachable, the tool falls back to computing the plan locally in your browser.\"}},\n    {\"@type\": \"Question\", \"name\": \"Can I use the plan in my tractor?\", \"acceptedAnswer\": {\"@type\": \"Answer\", \"text\": \"Yes. Export the generated lines as GeoJSON, KML, or a zipped shapefile and import them into your terminal. From the full GeoPard platform you can also exchange files with John Deere Operations Center, CNH FieldOps, and AGCO \/ PTx FarmEngage, plus ISO-XML exports for other monitors.\"}},\n    {\"@type\": \"Question\", \"name\": \"How is each guidance approach scored?\", \"acceptedAnswer\": {\"@type\": \"Answer\", \"text\": \"Every plan reports total drive distance, coverage percent, overlap percent, path crossings, turn count, fuel estimate, and an annual savings estimate from published industry midpoints. The recommended approach is the one you can execute with the best balance of coverage and cost, never a plan that hides gaps or double-driven ground in the totals.\"}}\n  ]\n}\n<\/script>\n<\/div>\n\n\n\n<div class=\"gp-lead\">\n<section class=\"gpx-aiband\">\n  <style>\n    .gp-lead .gpx-aiband{display:flex;align-items:center;gap:24px;flex-wrap:wrap;background:#f2f7ee;border:1px solid #d6e2c5;border-radius:14px;padding:22px 26px;margin:26px 0}\n    .gp-lead .gpx-aiband-txt{flex:1 1 380px}\n    .gp-lead .gpx-aiband-new{display:inline-block;font-size:11px;font-weight:700;letter-spacing:.05em;color:#ffffff;background:#f76a0c;border-radius:999px;padding:3px 9px;vertical-align:3px;margin-left:10px}\n    .gp-lead .gpx-aiband h2{margin:0;font-size:1.3rem}\n    .gp-lead .gpx-aiband p{margin:8px 0 0;color:#4c6066;font-size:.98rem;line-height:1.55}\n    .gp-lead .gpx-aiband-btn{flex:0 0 auto;display:inline-flex;align-items:center;min-height:46px;background:#15701e;color:#ffffff;font-weight:600;padding:0 22px;border-radius:999px;text-decoration:none}\n    .gp-lead .gpx-aiband-btn:hover{background:#145328;color:#ffffff}\n    @media (max-width:640px){ .gp-lead .gpx-aiband{padding:18px} .gp-lead .gpx-aiband-btn{width:100%;justify-content:center} }\n  <\/style>\n  <div class=\"gpx-aiband-txt\">\n    <h2>Meet the GeoPard AI Assistant<span class=\"gpx-aiband-new\">NEW<\/span><\/h2>\n    <p>Reasoning AI for precision agriculture: it asks the right agronomic questions, shows its math, and builds dry-run validated prescriptions from your data. You approve every step.<\/p>\n  <\/div>\n  <a class=\"gpx-aiband-btn\" href=\"https:\/\/geopard.tech\/ai\/\">Watch how the AI thinks &rarr;<\/a>\n<\/section>\n<\/div>\n\n","protected":false},"excerpt":{"rendered":"<p>\u0645\u062d\u0627\u0643\u064a \u062e\u0637\u0648\u0637 \u0627\u0644\u062a\u0648\u062c\u064a\u0647 \u00b7 \u0645\u062c\u0627\u0646\u064a \u00b7 \u0644\u0627 \u064a\u062a\u0637\u0644\u0628 \u062a\u0633\u062c\u064a\u0644\u064b\u0627 \u00b7 \u0665 \u062e\u0637\u0637 \u0642\u064a\u0627\u062f\u0629. \u0627\u062e\u062a\u0631 \u0627\u0644\u062e\u0637\u0629 \u0627\u0644\u062a\u064a \u062a\u0648\u0641\u0631 \u0623\u0643\u0628\u0631 \u0642\u062f\u0631 \u0645\u0646 \u0627\u0644\u0648\u0642\u0648\u062f. \u062d\u062f\u062f \u062d\u062f\u0648\u062f\u0643 \u0648\u062e\u0637\u0648\u0637 AB\u2026<\/p>","protected":false},"author":210157960,"featured_media":0,"parent":0,"menu_order":0,"comment_status":"closed","ping_status":"closed","template":"","meta":{"_coblocks_attr":"","_coblocks_dimensions":"","_coblocks_responsive_height":"","_coblocks_accordion_ie_support":"","_eb_attr":"","content-type":"","_wpcom_ai_launchpad_about_page":false,"_wpcom_ai_launchpad_gallery_page":false,"_wpcom_ai_launchpad_contact_page":false,"_wpcom_ai_launchpad_events_page":false,"_wpcom_ai_launchpad_video_page":false,"_wpcom_ai_launchpad_portfolio_piece":false,"footnotes":"","big_sky_generated":false},"class_list":["post-13171","page","type-page","status-publish","hentry"],"acf":[],"yoast_head":"<!-- This site is optimized with the Yoast SEO plugin v28.3 - https:\/\/yoast.com\/product\/yoast-seo-wordpress\/ -->\n<title>Guidance Lines Simulator - GeoPard Agriculture<\/title>\n<meta name=\"robots\" content=\"index, follow, max-snippet:-1, max-image-preview:large, max-video-preview:-1\" \/>\n<link rel=\"canonical\" href=\"https:\/\/geopard.tech\/ar\/\u062e\u0637\u0648\u0637-\u0625\u0631\u0634\u0627\u062f\u064a\u0629\/\" \/>\n<meta property=\"og:locale\" content=\"ar_AR\" \/>\n<meta property=\"og:type\" content=\"article\" \/>\n<meta property=\"og:title\" content=\"Guidance Lines Simulator - GeoPard Agriculture\" \/>\n<meta property=\"og:description\" content=\"Guidance Lines Simulator \u00b7 Free \u00b7 No signup 5 drive plans. Pick the one that saves the most fuel. Drop your boundary + AB lines...\" \/>\n<meta property=\"og:url\" content=\"https:\/\/geopard.tech\/ar\/\u062e\u0637\u0648\u0637-\u0625\u0631\u0634\u0627\u062f\u064a\u0629\/\" \/>\n<meta property=\"og:site_name\" content=\"GeoPard - Precision agriculture Mapping software\" \/>\n<meta property=\"article:publisher\" content=\"https:\/\/www.facebook.com\/geopardAgriculture\/\" \/>\n<meta property=\"article:modified_time\" content=\"2026-08-17T14:26:42+00:00\" \/>\n<meta property=\"og:image\" content=\"https:\/\/i0.wp.com\/geopard.tech\/wp-content\/uploads\/2026\/03\/GeoPard-Background-Precision-Ag-software-Do-more-with-your-data.png?fit=3116%2C1754&ssl=1\" \/>\n\t<meta property=\"og:image:width\" content=\"3116\" \/>\n\t<meta property=\"og:image:height\" content=\"1754\" \/>\n\t<meta property=\"og:image:type\" content=\"image\/png\" \/>\n<meta name=\"twitter:card\" content=\"summary_large_image\" \/>\n<meta name=\"twitter:site\" content=\"@geopardagri\" \/>\n<meta name=\"twitter:label1\" content=\"\u0648\u0642\u062a \u0627\u0644\u0642\u0631\u0627\u0621\u0629 \u0627\u0644\u0645\u064f\u0642\u062f\u0651\u0631\" \/>\n\t<meta name=\"twitter:data1\" content=\"4 \u062f\u0642\u0627\u0626\u0642\" \/>\n<script type=\"application\/ld+json\" class=\"yoast-schema-graph\">{\"@context\":\"https:\\\/\\\/schema.org\",\"@graph\":[{\"@type\":\"WebPage\",\"@id\":\"https:\\\/\\\/geopard.tech\\\/guidance-lines\\\/\",\"url\":\"https:\\\/\\\/geopard.tech\\\/guidance-lines\\\/\",\"name\":\"Guidance Lines Simulator - GeoPard Agriculture\",\"isPartOf\":{\"@id\":\"https:\\\/\\\/geopard.tech\\\/#website\"},\"datePublished\":\"2026-05-19T13:00:20+00:00\",\"dateModified\":\"2026-08-17T14:26:42+00:00\",\"breadcrumb\":{\"@id\":\"https:\\\/\\\/geopard.tech\\\/guidance-lines\\\/#breadcrumb\"},\"inLanguage\":\"ar\",\"potentialAction\":[{\"@type\":\"ReadAction\",\"target\":[\"https:\\\/\\\/geopard.tech\\\/guidance-lines\\\/\"]}]},{\"@type\":\"BreadcrumbList\",\"@id\":\"https:\\\/\\\/geopard.tech\\\/guidance-lines\\\/#breadcrumb\",\"itemListElement\":[{\"@type\":\"ListItem\",\"position\":1,\"name\":\"Home\",\"item\":\"https:\\\/\\\/geopard.tech\\\/\"},{\"@type\":\"ListItem\",\"position\":2,\"name\":\"Guidance Lines Simulator\"}]},{\"@type\":\"WebSite\",\"@id\":\"https:\\\/\\\/geopard.tech\\\/#website\",\"url\":\"https:\\\/\\\/geopard.tech\\\/\",\"name\":\"GeoPard - Precision agriculture software\",\"description\":\"Precision agriculture Mapping software\",\"publisher\":{\"@id\":\"https:\\\/\\\/geopard.tech\\\/#organization\"},\"alternateName\":\"GeoPard\",\"potentialAction\":[{\"@type\":\"SearchAction\",\"target\":{\"@type\":\"EntryPoint\",\"urlTemplate\":\"https:\\\/\\\/geopard.tech\\\/?s={search_term_string}\"},\"query-input\":{\"@type\":\"PropertyValueSpecification\",\"valueRequired\":true,\"valueName\":\"search_term_string\"}}],\"inLanguage\":\"ar\"},{\"@type\":\"Organization\",\"@id\":\"https:\\\/\\\/geopard.tech\\\/#organization\",\"name\":\"GeoPard Agriculture\",\"alternateName\":\"GeoPard\",\"url\":\"https:\\\/\\\/geopard.tech\\\/\",\"logo\":{\"@type\":\"ImageObject\",\"inLanguage\":\"ar\",\"@id\":\"https:\\\/\\\/geopard.tech\\\/#\\\/schema\\\/logo\\\/image\\\/\",\"url\":\"https:\\\/\\\/i0.wp.com\\\/geopard.tech\\\/wp-content\\\/uploads\\\/2022\\\/03\\\/favicon.png?fit=200%2C200&ssl=1\",\"contentUrl\":\"https:\\\/\\\/i0.wp.com\\\/geopard.tech\\\/wp-content\\\/uploads\\\/2022\\\/03\\\/favicon.png?fit=200%2C200&ssl=1\",\"width\":200,\"height\":200,\"caption\":\"GeoPard Agriculture\"},\"image\":{\"@id\":\"https:\\\/\\\/geopard.tech\\\/#\\\/schema\\\/logo\\\/image\\\/\"},\"sameAs\":[\"https:\\\/\\\/www.facebook.com\\\/geopardAgriculture\\\/\",\"https:\\\/\\\/x.com\\\/geopardagri\",\"https:\\\/\\\/www.linkedin.com\\\/company\\\/geopard-agriculture\\\/\",\"https:\\\/\\\/www.instagram.com\\\/geopardagriculture\\\/\"]}]}<\/script>\n<!-- \/ Yoast SEO plugin. -->","yoast_head_json":{"title":"\u0645\u062d\u0627\u0643\u064a \u062e\u0637\u0648\u0637 \u0627\u0644\u062a\u0648\u062c\u064a\u0647 - \u0627\u0644\u0632\u0631\u0627\u0639\u0629 \u0627\u0644\u062c\u063a\u0631\u0627\u0641\u064a\u0629","robots":{"index":"index","follow":"follow","max-snippet":"max-snippet:-1","max-image-preview":"max-image-preview:large","max-video-preview":"max-video-preview:-1"},"canonical":"https:\/\/geopard.tech\/ar\/\u062e\u0637\u0648\u0637-\u0625\u0631\u0634\u0627\u062f\u064a\u0629\/","og_locale":"ar_AR","og_type":"article","og_title":"Guidance Lines Simulator - GeoPard Agriculture","og_description":"Guidance Lines Simulator \u00b7 Free \u00b7 No signup 5 drive plans. Pick the one that saves the most fuel. Drop your boundary + AB lines...","og_url":"https:\/\/geopard.tech\/ar\/\u062e\u0637\u0648\u0637-\u0625\u0631\u0634\u0627\u062f\u064a\u0629\/","og_site_name":"GeoPard - Precision agriculture Mapping software","article_publisher":"https:\/\/www.facebook.com\/geopardAgriculture\/","article_modified_time":"2026-08-17T14:26:42+00:00","og_image":[{"width":3116,"height":1754,"url":"https:\/\/i0.wp.com\/geopard.tech\/wp-content\/uploads\/2026\/03\/GeoPard-Background-Precision-Ag-software-Do-more-with-your-data.png?fit=3116%2C1754&ssl=1","type":"image\/png"}],"twitter_card":"summary_large_image","twitter_site":"@geopardagri","twitter_misc":{"\u0648\u0642\u062a \u0627\u0644\u0642\u0631\u0627\u0621\u0629 \u0627\u0644\u0645\u064f\u0642\u062f\u0651\u0631":"4 \u062f\u0642\u0627\u0626\u0642"},"schema":{"@context":"https:\/\/schema.org","@graph":[{"@type":"WebPage","@id":"https:\/\/geopard.tech\/guidance-lines\/","url":"https:\/\/geopard.tech\/guidance-lines\/","name":"\u0645\u062d\u0627\u0643\u064a \u062e\u0637\u0648\u0637 \u0627\u0644\u062a\u0648\u062c\u064a\u0647 - \u0627\u0644\u0632\u0631\u0627\u0639\u0629 \u0627\u0644\u062c\u063a\u0631\u0627\u0641\u064a\u0629","isPartOf":{"@id":"https:\/\/geopard.tech\/#website"},"datePublished":"2026-05-19T13:00:20+00:00","dateModified":"2026-08-17T14:26:42+00:00","breadcrumb":{"@id":"https:\/\/geopard.tech\/guidance-lines\/#breadcrumb"},"inLanguage":"ar","potentialAction":[{"@type":"ReadAction","target":["https:\/\/geopard.tech\/guidance-lines\/"]}]},{"@type":"BreadcrumbList","@id":"https:\/\/geopard.tech\/guidance-lines\/#breadcrumb","itemListElement":[{"@type":"ListItem","position":1,"name":"Home","item":"https:\/\/geopard.tech\/"},{"@type":"ListItem","position":2,"name":"Guidance Lines Simulator"}]},{"@type":"WebSite","@id":"https:\/\/geopard.tech\/#website","url":"https:\/\/geopard.tech\/","name":"\u062c\u064a\u0648\u0628\u0627\u0631\u062f - \u0628\u0631\u0627\u0645\u062c \u0627\u0644\u0632\u0631\u0627\u0639\u0629 \u0627\u0644\u062f\u0642\u064a\u0642\u0629","description":"\u0628\u0631\u0646\u0627\u0645\u062c \u0631\u0633\u0645 \u062e\u0631\u0627\u0626\u0637 \u0627\u0644\u0632\u0631\u0627\u0639\u0629 \u0627\u0644\u062f\u0642\u064a\u0642\u0629","publisher":{"@id":"https:\/\/geopard.tech\/#organization"},"alternateName":"GeoPard","potentialAction":[{"@type":"SearchAction","target":{"@type":"EntryPoint","urlTemplate":"https:\/\/geopard.tech\/?s={search_term_string}"},"query-input":{"@type":"PropertyValueSpecification","valueRequired":true,"valueName":"search_term_string"}}],"inLanguage":"ar"},{"@type":"Organization","@id":"https:\/\/geopard.tech\/#organization","name":"\u062c\u064a\u0648 \u0628\u0627\u0631\u062f \u0627\u0644\u0632\u0631\u0627\u0639\u0629","alternateName":"GeoPard","url":"https:\/\/geopard.tech\/","logo":{"@type":"ImageObject","inLanguage":"ar","@id":"https:\/\/geopard.tech\/#\/schema\/logo\/image\/","url":"https:\/\/i0.wp.com\/geopard.tech\/wp-content\/uploads\/2022\/03\/favicon.png?fit=200%2C200&ssl=1","contentUrl":"https:\/\/i0.wp.com\/geopard.tech\/wp-content\/uploads\/2022\/03\/favicon.png?fit=200%2C200&ssl=1","width":200,"height":200,"caption":"GeoPard Agriculture"},"image":{"@id":"https:\/\/geopard.tech\/#\/schema\/logo\/image\/"},"sameAs":["https:\/\/www.facebook.com\/geopardAgriculture\/","https:\/\/x.com\/geopardagri","https:\/\/www.linkedin.com\/company\/geopard-agriculture\/","https:\/\/www.instagram.com\/geopardagriculture\/"]}]}},"jetpack_likes_enabled":true,"jetpack_sharing_enabled":true,"jetpack_shortlink":"https:\/\/wp.me\/PdiCPa-3qr","_links":{"self":[{"href":"https:\/\/geopard.tech\/ar\/wp-json\/wp\/v2\/pages\/13171","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/geopard.tech\/ar\/wp-json\/wp\/v2\/pages"}],"about":[{"href":"https:\/\/geopard.tech\/ar\/wp-json\/wp\/v2\/types\/page"}],"author":[{"embeddable":true,"href":"https:\/\/geopard.tech\/ar\/wp-json\/wp\/v2\/users\/210157960"}],"replies":[{"embeddable":true,"href":"https:\/\/geopard.tech\/ar\/wp-json\/wp\/v2\/comments?post=13171"}],"version-history":[{"count":0,"href":"https:\/\/geopard.tech\/ar\/wp-json\/wp\/v2\/pages\/13171\/revisions"}],"wp:attachment":[{"href":"https:\/\/geopard.tech\/ar\/wp-json\/wp\/v2\/media?parent=13171"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}