ParcelToolv4/frontend/index4.html
2026-04-30 20:46:07 +03:00

2019 lines
107 KiB
HTML
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0"/>
<title>ParcelGen — Land Subdivision Tool</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;600;700&family=IBM+Plex+Sans:wght@300;400;500;600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/ol@9.1.0/ol.css"/>
<style>
/* ═══════════ DESIGN TOKENS ═══════════ */
:root {
--bg:#0c0e14; --surface:#141720; --surface2:#1d2130; --surface3:#252a3a;
--border:#2e3347; --border2:#3d4460;
--accent:#4f8ef7; --accent-dim:rgba(79,142,247,0.15);
--green:#3ecf8e; --green-dim:rgba(62,207,142,0.15);
--warn:#ff6b6b; --warn-dim:rgba(255,107,107,0.15);
--amber:#f5a623; --amber-dim:rgba(245,166,35,0.15);
--purple:#a78bfa; --purple-dim:rgba(167,139,250,0.15);
--text:#e8ecf5; --text2:#8892a4; --text3:#50596b;
--radius:8px; --panel-w:340px;
--shadow:0 4px 24px rgba(0,0,0,0.5);
--shadow-sm:0 2px 8px rgba(0,0,0,0.35);
--imperial:#fb923c; --metric:#4f8ef7;
--road-col:#f5a623;
--font-mono:'IBM Plex Mono',monospace;
--font-sans:'IBM Plex Sans',sans-serif;
--sb-open:1;
}
body.light {
--bg:#f0f2f7; --surface:#ffffff; --surface2:#f5f6fa; --surface3:#eceef5;
--border:#dde0ea; --border2:#c8ccda;
--accent:#2563eb; --accent-dim:rgba(37,99,235,0.1);
--green:#16a34a; --green-dim:rgba(22,163,74,0.1);
--warn:#dc2626; --warn-dim:rgba(220,38,38,0.1);
--amber:#d97706; --amber-dim:rgba(217,119,6,0.1);
--purple:#7c3aed; --purple-dim:rgba(124,58,237,0.1);
--text:#1e2330; --text2:#4b5563; --text3:#9ca3af;
--shadow:0 4px 24px rgba(0,0,0,0.1);
--shadow-sm:0 2px 8px rgba(0,0,0,0.08);
}
body.light .tool-btn-map,body.light #unit-indicator { background:rgba(255,255,255,0.95); }
body.light .map-panel,body.light #selected-panel,body.light #tooltip { background:rgba(255,255,255,0.97); }
body.light #statusbar { background:rgba(240,242,247,0.95); }
body.light #loading { background:rgba(240,242,247,0.85); }
body.light #measure-label { background:rgba(255,255,255,0.95); }
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
html,body{height:100%;overflow:hidden;font-family:var(--font-sans);background:var(--bg);color:var(--text);font-size:14px}
/* ═══════════ LAYOUT ═══════════ */
#app{display:flex;height:100vh;position:relative}
/* Sidebar */
#sidebar{
width:var(--panel-w);min-width:var(--panel-w);
background:var(--surface);border-right:1px solid var(--border);
display:flex;flex-direction:column;overflow:hidden;z-index:20;
transition:transform 0.3s cubic-bezier(.4,0,.2,1),width 0.3s;
flex-shrink:0;
}
#sidebar.collapsed{transform:translateX(-100%);width:0;min-width:0}
#map-container{flex:1;position:relative;overflow:hidden;min-width:0}
#map{width:100%;height:100%}
/* Sidebar toggle for mobile */
#sb-toggle{
display:none;position:absolute;top:10px;left:10px;z-index:30;
background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);
width:38px;height:38px;cursor:pointer;align-items:center;justify-content:center;
font-size:18px;color:var(--text2);box-shadow:var(--shadow-sm);
}
/* ═══════════ HEADER ═══════════ */
.sidebar-header{
padding:12px 14px 10px;border-bottom:1px solid var(--border);
background:var(--bg);flex-shrink:0;
}
.sidebar-header-top{display:flex;align-items:center;justify-content:space-between;gap:8px}
.sidebar-header h1{font-family:var(--font-mono);font-size:15px;color:var(--accent);letter-spacing:-0.5px;white-space:nowrap}
.sidebar-header p{font-size:10px;color:var(--text3);margin-top:2px}
.header-controls{display:flex;align-items:center;gap:5px;flex-shrink:0}
/* Compact icon buttons */
.icon-btn{
background:var(--surface2);border:1px solid var(--border);border-radius:6px;
width:28px;height:28px;display:flex;align-items:center;justify-content:center;
cursor:pointer;font-size:13px;color:var(--text2);transition:all 0.15s;flex-shrink:0;
}
.icon-btn:hover{border-color:var(--accent);color:var(--accent);background:var(--accent-dim)}
/* Unit switcher */
.unit-switcher{display:flex;background:var(--surface2);border:1px solid var(--border);border-radius:16px;padding:2px;gap:1px;flex-shrink:0}
.unit-btn{padding:3px 8px;border-radius:12px;border:none;font-size:10px;font-weight:700;font-family:var(--font-mono);cursor:pointer;transition:all 0.2s;background:transparent;color:var(--text2)}
.unit-btn.active-metric{background:var(--metric);color:#fff}
.unit-btn.active-imperial{background:var(--imperial);color:#fff}
/* ═══════════ TABS ═══════════ */
.tabs{display:flex;border-bottom:1px solid var(--border);background:var(--surface);flex-shrink:0;overflow-x:auto}
.tabs::-webkit-scrollbar{display:none}
.tab{flex:1;min-width:50px;padding:8px 4px;font-size:9px;font-weight:700;text-transform:uppercase;letter-spacing:0.7px;color:var(--text3);cursor:pointer;text-align:center;border-bottom:2px solid transparent;transition:all 0.2s;white-space:nowrap}
.tab.active{color:var(--accent);border-bottom-color:var(--accent)}
.tab:hover:not(.active){color:var(--text2);background:var(--surface2)}
.tab-panel{display:none;flex:1;overflow-y:auto;padding:12px;flex-direction:column;gap:8px}
.tab-panel.active{display:flex}
.tab-panel::-webkit-scrollbar{width:3px}
.tab-panel::-webkit-scrollbar-thumb{background:var(--border);border-radius:2px}
/* ═══════════ TOOL GROUPS ═══════════ */
.tg{background:var(--surface2);border:1px solid var(--border);border-radius:var(--radius);padding:10px}
.tg-title{font-size:9px;font-weight:700;text-transform:uppercase;letter-spacing:0.8px;color:var(--text3);margin-bottom:8px;display:flex;align-items:center;gap:5px}
.tg-title::before{content:'';width:5px;height:5px;border-radius:50%;background:var(--accent);flex-shrink:0}
/* ═══════════ BUTTONS ═══════════ */
.btn{
display:flex;align-items:center;gap:7px;width:100%;padding:7px 10px;margin-bottom:4px;
background:var(--surface);border:1px solid var(--border);border-radius:6px;
color:var(--text);font-size:11px;cursor:pointer;transition:all 0.15s;font-family:var(--font-sans);
}
.btn:last-child{margin-bottom:0}
.btn:hover{background:var(--surface2);border-color:var(--accent);color:var(--accent)}
.btn.active,.btn.active:hover{background:var(--accent-dim);border-color:var(--accent);color:var(--accent)}
.btn.road-active{background:var(--amber-dim);border-color:var(--amber);color:var(--amber)}
.btn.feature-active{background:var(--purple-dim);border-color:var(--purple);color:var(--purple)}
.btn.danger{border-color:var(--warn);color:var(--warn)}
.btn.danger:hover{background:var(--warn-dim)}
.btn.success{background:var(--green);border-color:var(--green);color:#000;font-weight:600}
.btn.success:hover{opacity:0.9}
.btn.export-btn{border-color:var(--purple);color:var(--purple)}
.btn.export-btn:hover{background:var(--purple-dim)}
.btn-icon{width:14px;height:14px;flex-shrink:0}
.btn-row{display:grid;grid-template-columns:1fr 1fr;gap:4px;margin-bottom:0}
.btn-row .btn{margin-bottom:0;font-size:10px;padding:6px 8px}
/* ═══════════ FIELDS ═══════════ */
.field{margin-bottom:7px}
.field:last-child{margin-bottom:0}
.field label{display:flex;align-items:center;justify-content:space-between;font-size:10px;color:var(--text2);margin-bottom:3px;font-weight:500}
.field-desc{font-size:9px;color:var(--text3);margin-top:2px;line-height:1.4}
.field-row{display:flex;align-items:center;gap:5px}
.field input,.field select{
flex:1;padding:6px 8px;background:var(--surface);border:1px solid var(--border);
border-radius:5px;color:var(--text);font-size:11px;font-family:var(--font-sans);
transition:border-color 0.15s;min-width:0;
}
.field input:focus,.field select:focus{outline:none;border-color:var(--accent)}
.field select option{background:var(--surface)}
.unit-badge{display:inline-flex;align-items:center;justify-content:center;padding:1px 6px;border-radius:8px;font-size:9px;font-weight:700;font-family:var(--font-mono);flex-shrink:0}
.unit-badge.metric{background:rgba(79,142,247,0.15);color:var(--metric);border:1px solid rgba(79,142,247,0.3)}
.unit-badge.imperial{background:rgba(251,146,60,0.15);color:var(--imperial);border:1px solid rgba(251,146,60,0.3)}
.unit-hint{font-size:9px;color:var(--text3);margin-top:2px;font-style:italic;min-height:12px}
.unit-hint.has-hint{color:var(--imperial)}
/* ═══════════ TOGGLE ═══════════ */
.toggle-row{display:flex;align-items:center;justify-content:space-between;padding:3px 0;font-size:11px}
.toggle{position:relative;width:32px;height:17px;flex-shrink:0}
.toggle input{opacity:0;width:0;height:0}
.toggle-slider{position:absolute;cursor:pointer;inset:0;background:var(--surface3);border:1px solid var(--border);border-radius:20px;transition:.2s}
.toggle-slider:before{content:'';position:absolute;height:11px;width:11px;left:2px;bottom:2px;background:var(--text3);border-radius:50%;transition:.2s}
.toggle input:checked+.toggle-slider{background:var(--accent-dim);border-color:var(--accent)}
.toggle input:checked+.toggle-slider:before{transform:translateX(15px);background:var(--accent)}
/* ═══════════ ROAD TYPE ROWS ═══════════ */
.road-type-row{display:flex;align-items:center;gap:6px;padding:5px 0;border-bottom:1px solid var(--border);font-size:10px}
.road-type-row:last-child{border-bottom:none}
.road-type-badge{padding:2px 6px;border-radius:4px;font-size:9px;font-weight:700;font-family:var(--font-mono);flex-shrink:0}
.rt1{background:rgba(245,166,35,0.2);color:#f5a623;border:1px solid rgba(245,166,35,0.4)}
.rt2{background:rgba(79,142,247,0.2);color:#4f8ef7;border:1px solid rgba(79,142,247,0.4)}
.rt3{background:rgba(62,207,142,0.2);color:#3ecf8e;border:1px solid rgba(62,207,142,0.4)}
.road-type-row .field{flex:1;margin:0}
.road-type-row .field input{padding:4px 6px;font-size:10px}
/* ═══════════ LAND USE ═══════════ */
.lu-grid{display:grid;grid-template-columns:1fr 1fr;gap:5px;margin-top:4px}
.lu-card{padding:7px 8px;border-radius:6px;border:1.5px solid var(--border);cursor:pointer;transition:all 0.15s;text-align:center}
.lu-card:hover{border-color:var(--accent)}
.lu-card.active{border-color:var(--accent);background:var(--accent-dim)}
.lu-card .lu-icon{font-size:18px;display:block;margin-bottom:2px}
.lu-card .lu-name{font-size:9px;font-weight:700;text-transform:uppercase;letter-spacing:0.5px;color:var(--text2)}
.lu-card .lu-density{font-size:8px;color:var(--text3);margin-top:1px}
/* ═══════════ PROGRESS BAR ═══════════ */
#progress-wrap{display:none;margin:6px 0}
#progress-wrap.show{display:block}
.progress-bar-bg{background:var(--surface3);border-radius:10px;height:4px;overflow:hidden;margin-bottom:4px}
.progress-bar-fill{background:linear-gradient(90deg,var(--accent),var(--green));height:100%;border-radius:10px;transition:width 0.3s;width:0%}
.progress-label{font-size:9px;color:var(--text3);text-align:center}
/* ═══════════ ZONE BADGE ═══════════ */
.zone-res{background:rgba(79,142,247,0.2);color:#4f8ef7;border:1px solid rgba(79,142,247,0.3)}
.zone-com{background:rgba(245,166,35,0.2);color:#f5a623;border:1px solid rgba(245,166,35,0.3)}
.zone-ind{background:rgba(255,107,107,0.2);color:#ff6b6b;border:1px solid rgba(255,107,107,0.3)}
.zone-mix{background:rgba(167,139,250,0.2);color:#a78bfa;border:1px solid rgba(167,139,250,0.3)}
.zone-badge{display:inline-flex;align-items:center;padding:1px 5px;border-radius:4px;font-size:8px;font-weight:700;text-transform:uppercase;letter-spacing:0.3px}
/* ═══════════ STATS ═══════════ */
.stat-grid{display:grid;grid-template-columns:1fr 1fr;gap:5px}
.stat-card{background:var(--surface);border:1px solid var(--border);border-radius:6px;padding:8px}
.stat-card .val{font-family:var(--font-mono);font-size:20px;color:var(--accent);font-weight:700}
.stat-card .lbl{font-size:9px;color:var(--text3);margin-top:1px;text-transform:uppercase;letter-spacing:0.4px}
.dual-value .primary{font-family:var(--font-mono);color:var(--accent);font-weight:700;font-size:11px}
.dual-value .secondary{color:var(--text3);font-size:9px;font-style:italic}
/* ═══════════ PARCEL LIST ═══════════ */
.parcel-list{max-height:260px;overflow-y:auto}
.parcel-item{display:flex;justify-content:space-between;align-items:center;padding:6px 8px;border-radius:5px;margin-bottom:3px;background:var(--surface);border:1px solid var(--border);font-size:11px;cursor:pointer;transition:all 0.15s}
.parcel-item:hover{border-color:var(--accent)}
.parcel-item .pid{font-family:var(--font-mono);color:var(--accent);font-size:10px}
.parcel-item .dims{color:var(--text2);font-size:9px;margin-top:1px}
.parcel-item .metric-dims{display:block}
.parcel-item .imperial-dims{display:none;font-style:italic;color:var(--imperial)}
body.imperial .parcel-item .metric-dims{display:none}
body.imperial .parcel-item .imperial-dims{display:block}
.badge{display:inline-block;padding:1px 5px;border-radius:6px;font-size:8px;font-weight:700;text-transform:uppercase;letter-spacing:0.3px}
.badge.built{background:var(--warn-dim);color:var(--warn)}
.badge.vacant{background:var(--green-dim);color:var(--green)}
/* ═══════════ LEGEND ═══════════ */
.legend-item{display:flex;align-items:center;gap:7px;font-size:10px;margin-bottom:4px;color:var(--text2)}
.legend-swatch{width:14px;height:9px;border-radius:2px;flex-shrink:0}
/* ═══════════ LAYER ROWS ═══════════ */
.layer-row{display:flex;align-items:center;gap:6px;padding:4px 0;font-size:10px;color:var(--text2);border-bottom:1px solid rgba(255,255,255,0.03)}
.layer-row:last-child{border-bottom:none}
.layer-row .swatch{width:11px;height:11px;border-radius:2px;flex-shrink:0}
.layer-row .lname{flex:1}
.layer-row .opacity-wrap{display:flex;align-items:center;gap:3px}
.layer-row .opacity-wrap input{width:40px;accent-color:var(--accent);cursor:pointer}
.layer-row .opacity-wrap span{font-size:8px;font-family:var(--font-mono);color:var(--text3);width:22px;text-align:right}
.layer-row input[type=checkbox]{width:13px;height:13px;accent-color:var(--accent);cursor:pointer;flex-shrink:0}
/* ═══════════ CONVERSION BANNER ═══════════ */
.conversion-banner{background:rgba(251,146,60,0.08);border:1px solid rgba(251,146,60,0.25);border-radius:6px;padding:7px 9px;font-size:10px;color:var(--imperial);display:none;align-items:flex-start;gap:7px;margin-bottom:3px}
.conversion-banner.visible{display:flex}
/* ═══════════ MAP CONTROLS ═══════════ */
#toolbar{
position:absolute;top:10px;left:54px;
display:flex;gap:4px;z-index:10;flex-wrap:wrap;max-width:560px;
}
.tool-btn-map{
background:rgba(12,14,20,0.92);border:1px solid var(--border);
border-radius:6px;padding:6px 10px;color:var(--text);font-size:10px;
cursor:pointer;display:flex;align-items:center;gap:4px;
transition:all 0.15s;font-family:var(--font-sans);font-weight:500;
white-space:nowrap;backdrop-filter:blur(6px);
}
.tool-btn-map:hover,.tool-btn-map.active{background:var(--accent-dim);border-color:var(--accent);color:var(--accent)}
.tool-btn-map.road-active{border-color:var(--amber);color:var(--amber);background:var(--amber-dim)}
.tool-btn-map.feat-active{border-color:var(--purple);color:var(--purple);background:var(--purple-dim)}
.tool-btn-map.measure-active{border-color:var(--green);color:var(--green);background:var(--green-dim)}
.tool-btn-map.pan-active{border-color:#a78bfa;color:#a78bfa;background:rgba(167,139,250,0.1)}
#unit-indicator{
position:absolute;top:10px;right:10px;
background:rgba(12,14,20,0.92);border:1px solid var(--border);
border-radius:6px;padding:4px 10px;z-index:10;
font-family:var(--font-mono);font-size:9px;
display:flex;align-items:center;gap:6px;backdrop-filter:blur(4px);
}
#unit-indicator.metric-mode #unit-indicator-text{color:var(--metric)}
#unit-indicator.imperial-mode #unit-indicator-text{color:var(--imperial)}
#unit-indicator span.lbl{color:var(--text3)}
/* Map panels (bottom-right) */
#map-controls{
position:absolute;bottom:36px;right:10px;
z-index:15;display:flex;flex-direction:column;align-items:flex-end;gap:6px;
max-height:calc(100vh - 80px);overflow:hidden;
}
.map-panel{
background:rgba(12,14,20,0.96);border:1px solid var(--border);border-radius:var(--radius);
backdrop-filter:blur(16px);box-shadow:var(--shadow);overflow:hidden;
transition:all 0.25s cubic-bezier(.4,0,.2,1);min-width:220px;max-width:260px;
}
.map-panel-header{display:flex;align-items:center;justify-content:space-between;padding:8px 11px;border-bottom:1px solid var(--border);cursor:pointer;user-select:none}
.map-panel-header:hover{background:rgba(255,255,255,0.03)}
.map-panel-title{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:0.7px;color:var(--text3);display:flex;align-items:center;gap:6px}
.panel-chevron{color:var(--text3);font-size:9px;transition:transform 0.2s}
.panel-chevron.open{transform:rotate(180deg)}
.map-panel-body{overflow:hidden;max-height:0;transition:max-height 0.3s cubic-bezier(.4,0,.2,1)}
.map-panel-body.open{max-height:500px}
.map-panel-inner{padding:8px 10px 10px}
/* Basemap grid */
.basemap-grid{display:grid;grid-template-columns:1fr 1fr;gap:5px;margin-bottom:6px}
.basemap-card{position:relative;border-radius:6px;overflow:hidden;border:2px solid transparent;cursor:pointer;transition:all 0.2s;aspect-ratio:4/3;background:var(--surface2)}
.basemap-card:hover{border-color:rgba(79,142,247,0.5);transform:scale(1.02)}
.basemap-card.active{border-color:var(--accent)}
.basemap-card.active::after{content:'✓';position:absolute;top:3px;right:4px;background:var(--accent);color:#000;font-size:8px;font-weight:700;border-radius:50%;width:13px;height:13px;display:flex;align-items:center;justify-content:center;line-height:1}
.basemap-thumb-ph{width:100%;height:100%;display:flex;align-items:center;justify-content:center;font-size:18px}
.basemap-label{position:absolute;bottom:0;left:0;right:0;background:linear-gradient(transparent,rgba(0,0,0,0.8));padding:6px 4px 3px;font-size:8px;font-weight:600;color:#fff;text-align:center;letter-spacing:0.3px;text-transform:uppercase}
/* Status bar */
#statusbar{
position:absolute;bottom:0;left:0;right:0;
background:rgba(12,14,20,0.94);border-top:1px solid var(--border);
padding:4px 12px;display:flex;align-items:center;gap:8px;
font-size:10px;color:var(--text2);backdrop-filter:blur(8px);z-index:5;
}
#status-dot{width:5px;height:5px;border-radius:50%;background:var(--green);flex-shrink:0}
#status-dot.loading{background:var(--amber);animation:pulse 0.8s infinite}
#status-dot.error{background:var(--warn)}
#status-msg{flex:1}
#coord-display{font-family:var(--font-mono);font-size:9px;color:var(--text3)}
/* Tooltip */
#tooltip{
position:absolute;background:rgba(12,14,20,0.97);border:1px solid var(--border);
border-radius:var(--radius);padding:9px 12px;font-size:10px;
pointer-events:none;display:none;z-index:100;max-width:210px;
backdrop-filter:blur(12px);box-shadow:var(--shadow);
}
#tooltip .tip-title{font-weight:700;font-size:11px;color:var(--accent);margin-bottom:5px;font-family:var(--font-mono)}
#tooltip .tip-row{display:flex;justify-content:space-between;gap:12px;color:var(--text2);margin-top:2px}
#tooltip .tip-row span:last-child{color:var(--text);font-weight:500}
/* Measure label */
#measure-label{
position:absolute;background:rgba(12,14,20,0.92);border:1px solid var(--green);
color:var(--green);border-radius:5px;padding:2px 7px;font-size:10px;
font-family:var(--font-mono);font-weight:700;pointer-events:none;display:none;z-index:20;
white-space:nowrap;box-shadow:var(--shadow-sm);
}
/* Selected panel */
#selected-panel{
position:absolute;bottom:36px;left:54px;
background:rgba(12,14,20,0.97);border:1px solid var(--accent);
border-radius:var(--radius);padding:11px 13px;z-index:20;
font-size:10px;min-width:200px;max-width:270px;
box-shadow:var(--shadow);backdrop-filter:blur(12px);display:none;
}
#selected-panel .sel-title{font-family:var(--font-mono);font-size:12px;font-weight:700;color:var(--accent);margin-bottom:7px;display:flex;align-items:center;justify-content:space-between}
#selected-panel .sel-close{cursor:pointer;color:var(--text3);font-size:13px;line-height:1;padding:0 2px}
#selected-panel .sel-close:hover{color:var(--warn)}
#selected-panel .sel-row{display:flex;justify-content:space-between;gap:10px;padding:3px 0;border-bottom:1px solid rgba(255,255,255,0.04);color:var(--text2)}
#selected-panel .sel-row:last-child{border-bottom:none}
#selected-panel .sel-row span:last-child{color:var(--text);font-weight:600;font-family:var(--font-mono);font-size:9px}
/* Loading */
#loading{
position:absolute;inset:0;background:rgba(12,14,20,0.78);
display:none;align-items:center;justify-content:center;
z-index:50;flex-direction:column;gap:12px;backdrop-filter:blur(8px);
}
#loading.show{display:flex}
.spinner{width:36px;height:36px;border:3px solid var(--border);border-top-color:var(--accent);border-radius:50%;animation:spin 0.75s linear infinite}
#loading-text{font-family:var(--font-mono);color:var(--accent);font-size:11px;text-align:center}
#loading-progress{width:180px;background:var(--surface3);border-radius:8px;height:3px;overflow:hidden;margin-top:4px}
#loading-progress-fill{background:linear-gradient(90deg,var(--accent),var(--green));height:100%;border-radius:8px;width:0%;transition:width 0.4s}
/* Toast */
#toast{
position:absolute;bottom:42px;left:50%;transform:translateX(-50%) translateY(8px);
background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);
padding:8px 16px;font-size:11px;z-index:60;max-width:320px;
display:none;opacity:0;transition:all 0.3s;text-align:center;box-shadow:var(--shadow);
}
#toast.show{display:block;opacity:1;transform:translateX(-50%) translateY(0)}
#toast.success{border-left:3px solid var(--green)}
#toast.error{border-left:3px solid var(--warn)}
#toast.info{border-left:3px solid var(--accent)}
/* Vertex edit / delete hint */
.edit-hint{font-size:9px;color:var(--text3);line-height:1.5;margin-top:5px;padding:5px 7px;background:var(--surface);border-radius:5px;border:1px solid var(--border)}
.edit-hint strong{color:var(--accent)}
/* Mobile drawer overlay */
#sb-overlay{display:none;position:fixed;inset:0;background:rgba(0,0,0,0.5);z-index:19}
/* Import modal */
#import-modal{
display:none;position:absolute;inset:0;z-index:70;
background:rgba(0,0,0,0.6);align-items:center;justify-content:center;
backdrop-filter:blur(6px);
}
#import-modal.show{display:flex}
.modal-box{background:var(--surface);border:1px solid var(--border);border-radius:12px;padding:20px;width:min(400px,90vw);box-shadow:var(--shadow)}
.modal-title{font-size:14px;font-weight:700;color:var(--text);margin-bottom:12px;display:flex;justify-content:space-between;align-items:center}
.modal-close{cursor:pointer;color:var(--text3);font-size:18px}
.modal-close:hover{color:var(--warn)}
.drop-zone{border:2px dashed var(--border2);border-radius:var(--radius);padding:24px;text-align:center;cursor:pointer;transition:all 0.2s;margin-bottom:10px}
.drop-zone:hover,.drop-zone.dragover{border-color:var(--accent);background:var(--accent-dim)}
.drop-zone-icon{font-size:28px;margin-bottom:6px}
.drop-zone p{font-size:11px;color:var(--text2)}
.drop-zone small{font-size:9px;color:var(--text3);margin-top:3px;display:block}
.format-chips{display:flex;flex-wrap:wrap;gap:4px;margin-top:8px;justify-content:center}
.format-chip{padding:2px 7px;background:var(--surface2);border:1px solid var(--border);border-radius:10px;font-size:9px;font-weight:700;font-family:var(--font-mono);color:var(--text3)}
/* Export modal */
#export-modal{
display:none;position:absolute;inset:0;z-index:70;
background:rgba(0,0,0,0.6);align-items:center;justify-content:center;
backdrop-filter:blur(6px);
}
#export-modal.show{display:flex}
.export-opts{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-top:10px}
.export-card{padding:12px;background:var(--surface2);border:1px solid var(--border);border-radius:8px;cursor:pointer;transition:all 0.15s;text-align:center}
.export-card:hover{border-color:var(--accent);background:var(--accent-dim)}
.export-card .e-icon{font-size:22px;margin-bottom:4px}
.export-card .e-name{font-size:11px;font-weight:700;color:var(--text)}
.export-card .e-desc{font-size:9px;color:var(--text3);margin-top:2px}
@keyframes spin{to{transform:rotate(360deg)}}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:0.4}}
::-webkit-scrollbar{width:3px}
::-webkit-scrollbar-track{background:transparent}
::-webkit-scrollbar-thumb{background:var(--border);border-radius:2px}
body.imperial .field input{border-color:rgba(251,146,60,0.3)}
body.imperial .field input:focus{border-color:var(--imperial)}
/* ═══════════ MOBILE ═══════════ */
@media(max-width:700px){
:root{--panel-w:100vw}
#sb-toggle{display:flex}
#sidebar{
position:fixed;top:0;left:0;bottom:0;width:min(340px,85vw)!important;min-width:0!important;
transform:translateX(-110%);z-index:25;
box-shadow:4px 0 24px rgba(0,0,0,0.6);
}
#sidebar.mobile-open{transform:translateX(0)}
#sb-overlay.show{display:block}
#map-container{width:100%!important}
#toolbar{left:52px;top:8px;flex-wrap:nowrap;overflow-x:auto;max-width:calc(100vw - 64px)}
#toolbar::-webkit-scrollbar{display:none}
.tool-btn-map{font-size:9px;padding:5px 8px;gap:3px}
#unit-indicator{right:6px;top:8px;padding:3px 7px}
#map-controls{bottom:34px;right:6px}
.map-panel{min-width:180px;max-width:calc(min(100vw - 20px,240px))}
#selected-panel{left:6px;right:6px;max-width:none;min-width:0}
#statusbar{padding:3px 8px}
#coord-display{display:none}
.basemap-grid{grid-template-columns:1fr 1fr}
.stat-grid{grid-template-columns:1fr 1fr}
}
@media(max-width:400px){
.tabs .tab{font-size:7.5px;padding:7px 2px}
.btn{font-size:10px;padding:6px 8px}
}
</style>
</head>
<body>
<div id="app">
<!-- ═══════════ SIDEBAR ═══════════ -->
<div id="sidebar">
<div class="sidebar-header">
<div class="sidebar-header-top">
<div>
<h1>⬡ ParcelGen</h1>
<p>Land Subdivision &amp; Parcel Tool</p>
</div>
<div class="header-controls">
<button class="icon-btn" id="theme-btn" onclick="toggleTheme()" title="Toggle theme">☀️</button>
<div class="unit-switcher">
<button class="unit-btn active-metric" id="btn-metric" onclick="setUnitSystem('metric')">m</button>
<button class="unit-btn" id="btn-imperial" onclick="setUnitSystem('imperial')">ft</button>
</div>
</div>
</div>
</div>
<div class="tabs">
<div class="tab active" onclick="switchTab('digitize')">Digitize</div>
<div class="tab" onclick="switchTab('config')">Config</div>
<div class="tab" onclick="switchTab('layers')">Layers</div>
<div class="tab" onclick="switchTab('results')">Results</div>
<div class="tab" onclick="switchTab('io')">Import/Export</div>
</div>
<!-- ── DIGITIZE TAB ── -->
<div id="tab-digitize" class="tab-panel active">
<div class="tg">
<div class="tg-title">Site Boundary</div>
<button class="btn" id="btn-draw-boundary" onclick="setDrawMode('boundary')">
<svg class="btn-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24"><polygon points="12,2 22,20 2,20" stroke-linejoin="round" stroke-width="2"/></svg>
Digitize Boundary
</button>
<button class="btn" id="btn-edit-boundary" onclick="setEditMode('boundary')">
<svg class="btn-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"/></svg>
Edit Vertices
</button>
<div class="btn-row">
<button class="btn danger" onclick="clearLayer('boundary')">✕ Clear</button>
<button class="btn" onclick="deleteSelected()">🗑 Delete Vertex</button>
</div>
<div class="edit-hint" id="edit-hint-boundary" style="display:none">
<strong>Edit mode:</strong> drag vertices to move · <strong>Alt+click</strong> vertex to delete · click empty to add vertex
</div>
</div>
<div class="tg">
<div class="tg-title">Roads</div>
<button class="btn" id="btn-draw-road" onclick="setDrawMode('road')">
<svg class="btn-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14"/></svg>
Digitize Road (Line)
</button>
<button class="btn" id="btn-draw-road-arc" onclick="setDrawMode('road-arc')">
<svg class="btn-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 20 Q12 4 20 20"/></svg>
Digitize Road (Arc)
</button>
<button class="btn" id="btn-edit-road" onclick="setEditMode('road')">
<svg class="btn-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"/></svg>
Edit Road Vertices
</button>
<div class="btn-row">
<button class="btn danger" onclick="clearLayer('roads')">✕ Clear All</button>
<button class="btn danger" onclick="deleteSelectedFeature('road')">🗑 Del Road</button>
</div>
<p style="font-size:9px;color:var(--text3);margin-top:5px;line-height:1.5">Leave empty for auto-grid · Set type &amp; width in Config</p>
</div>
<div class="tg">
<div class="tg-title">Existing Buildings</div>
<button class="btn" id="btn-draw-feature" onclick="setDrawMode('feature')">
<svg class="btn-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24"><rect x="3" y="3" width="18" height="18" rx="2" stroke-width="2"/></svg>
Digitize Building Footprint
</button>
<div class="btn-row">
<button class="btn danger" onclick="clearLayer('features')">✕ Clear All</button>
<button class="btn danger" onclick="deleteSelectedFeature('feature')">🗑 Del Bldg</button>
</div>
</div>
<div class="tg">
<div class="tg-title">Generate</div>
<div id="progress-wrap">
<div class="progress-bar-bg"><div class="progress-bar-fill" id="prog-fill"></div></div>
<div class="progress-label" id="prog-label">Processing…</div>
</div>
<button class="btn success" onclick="runSubdivision()">
<svg class="btn-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/></svg>
Generate Parcels
</button>
<button class="btn" onclick="clearResults()">Clear Results</button>
<button class="btn" onclick="stopAll()">✕ Stop Digitizing</button>
</div>
</div>
<!-- ── CONFIG TAB ── -->
<div id="tab-config" class="tab-panel">
<div class="conversion-banner" id="imperial-banner">
<div>Values in <strong>feet</strong> — auto-converted to metres for processing.</div>
</div>
<!-- Land Use -->
<div class="tg">
<div class="tg-title">Land Use / Zoning</div>
<div class="lu-grid" id="lu-grid">
<div class="lu-card active" data-lu="residential" onclick="setLandUse('residential')">
<span class="lu-icon">🏠</span>
<div class="lu-name">Residential</div>
<div class="lu-density">LowMed density</div>
</div>
<div class="lu-card" data-lu="commercial" onclick="setLandUse('commercial')">
<span class="lu-icon">🏢</span>
<div class="lu-name">Commercial</div>
<div class="lu-density">High density</div>
</div>
<div class="lu-card" data-lu="industrial" onclick="setLandUse('industrial')">
<span class="lu-icon">🏭</span>
<div class="lu-name">Industrial</div>
<div class="lu-density">Large plots</div>
</div>
<div class="lu-card" data-lu="mixed" onclick="setLandUse('mixed')">
<span class="lu-icon">🏘</span>
<div class="lu-name">Mixed Use</div>
<div class="lu-density">Variable</div>
</div>
</div>
</div>
<!-- Parcel dimensions -->
<div class="tg">
<div class="tg-title">Plot Dimensions</div>
<div class="field">
<label>Min Frontage (road width of plot) <span class="unit-badge metric" id="badge-frontage">m</span></label>
<div class="field-row"><input type="number" id="cfg-frontage" value="12" min="1" step="0.5"/></div>
<div class="field-desc">The width of the plot along the road edge.</div>
<div class="unit-hint" id="hint-frontage"></div>
</div>
<div class="field">
<label>Min Plot Breadth (back-to-front) <span class="unit-badge metric" id="badge-depth">m</span></label>
<div class="field-row"><input type="number" id="cfg-depth" value="25" min="1" step="0.5"/></div>
<div class="field-desc">Distance from road edge to the rear of the plot (formerly "depth").</div>
<div class="unit-hint" id="hint-depth"></div>
</div>
<div class="field">
<label>Min Plot Area <span class="unit-badge metric" id="badge-min-area"></span></label>
<div class="field-row"><input type="number" id="cfg-min-area" value="200" min="50" step="10"/></div>
<div class="field-desc">Plots smaller than this are merged with neighbours.</div>
</div>
<div class="field">
<label>Max Plot Area <span class="unit-badge metric" id="badge-max-area"></span></label>
<div class="field-row"><input type="number" id="cfg-max-area" value="1500" min="100" step="50"/></div>
<div class="field-desc">Plots larger than this trigger a subdivision warning.</div>
</div>
</div>
<!-- Road configuration -->
<div class="tg">
<div class="tg-title">Road Configuration</div>
<div class="field">
<label>Max Block Length <span class="unit-badge metric" id="badge-block-len">m</span></label>
<div class="field-row"><input type="number" id="cfg-block-len" value="120" min="10" step="1"/></div>
<div class="field-desc">Triggers a cross-road or cul-de-sac if exceeded.</div>
<div class="unit-hint" id="hint-block-len"></div>
</div>
<div class="field">
<label>Road Corner Splay <span class="unit-badge metric" id="badge-corner">m</span></label>
<div class="field-row"><input type="number" id="cfg-corner" value="3" min="0" step="0.25"/></div>
<div class="field-desc">Chamfer/splay at road intersections (formerly "corner radius").</div>
<div class="unit-hint" id="hint-corner"></div>
</div>
<div style="margin-top:8px">
<div class="tg-title" style="margin-bottom:6px">Road Types &amp; Widths</div>
<div class="road-type-row">
<span class="road-type-badge rt1">Type 1</span>
<span style="flex:1;color:var(--text2);font-size:9px">Primary arterial</span>
<div class="field" style="width:60px;margin:0">
<div class="field-row">
<input type="number" id="cfg-road-w1" value="15" min="3" step="0.5" title="Type 1 road width (m)"/>
</div>
</div>
<span class="unit-badge metric" style="font-size:8px">m</span>
</div>
<div class="road-type-row">
<span class="road-type-badge rt2">Type 2</span>
<span style="flex:1;color:var(--text2);font-size:9px">Secondary collector</span>
<div class="field" style="width:60px;margin:0">
<div class="field-row">
<input type="number" id="cfg-road-w2" value="9" min="3" step="0.5" title="Type 2 road width (m)"/>
</div>
</div>
<span class="unit-badge metric" style="font-size:8px">m</span>
</div>
<div class="road-type-row">
<span class="road-type-badge rt3">Type 3</span>
<span style="flex:1;color:var(--text2);font-size:9px">Local access</span>
<div class="field" style="width:60px;margin:0">
<div class="field-row">
<input type="number" id="cfg-road-w3" value="6" min="3" step="0.5" title="Type 3 road width (m)"/>
</div>
</div>
<span class="unit-badge metric" style="font-size:8px">m</span>
</div>
<div style="font-size:9px;color:var(--text3);margin-top:4px">When digitizing roads, new roads default to Type 2. Use the road list to change type per road.</div>
</div>
</div>
<!-- Options -->
<div class="tg">
<div class="tg-title">Options</div>
<div class="toggle-row"><label>Allow Cul-de-sacs</label><label class="toggle"><input type="checkbox" id="cfg-culdesac" checked/><span class="toggle-slider"></span></label></div>
<div class="toggle-row"><label>Auto-generate Roads</label><label class="toggle"><input type="checkbox" id="cfg-autoroads" checked/><span class="toggle-slider"></span></label></div>
<div class="toggle-row"><label>Show Parcel Numbers on Map</label><label class="toggle"><input type="checkbox" id="cfg-show-labels" checked onchange="refreshParcelStyle()"/><span class="toggle-slider"></span></label></div>
</div>
<!-- Unit reference -->
<div class="tg">
<div class="tg-title">Unit Reference</div>
<div id="unit-ref-table"></div>
</div>
</div>
<!-- ── LAYERS TAB ── -->
<div id="tab-layers" class="tab-panel">
<div class="tg">
<div class="tg-title">Basemap</div>
<div id="sidebar-basemap-grid" class="basemap-grid"></div>
</div>
<div class="tg">
<div class="tg-title">Digitize Layers</div>
<div id="draw-layer-list"></div>
</div>
<div class="tg">
<div class="tg-title">Result Layers</div>
<div id="result-layer-list"></div>
</div>
<div class="tg">
<div class="tg-title">Legend</div>
<div class="legend-item"><div class="legend-swatch" style="background:rgba(62,207,142,0.3);border:1px solid #3ecf8e"></div>Site Boundary</div>
<div class="legend-item"><div class="legend-swatch" style="background:rgba(245,166,35,0.5)"></div>Road Surface</div>
<div class="legend-item"><div class="legend-swatch" style="background:rgba(79,142,247,0.3);border:1px solid #4f8ef7"></div>Vacant Plot</div>
<div class="legend-item"><div class="legend-swatch" style="background:rgba(255,107,107,0.3);border:1px solid #ff6b6b"></div>Built Plot</div>
<div class="legend-item"><div class="legend-swatch" style="background:rgba(255,68,68,0.4);border:2px dashed #ff4444"></div>⚠ No Road Access</div>
<div class="legend-item"><div class="legend-swatch" style="background:rgba(167,139,250,0.4)"></div>Cul-de-sac</div>
<div class="legend-item"><div class="legend-swatch" style="background:transparent;border:2px dashed #a78bfa"></div>Existing Building</div>
<div class="legend-item"><div class="legend-swatch" style="background:rgba(255,215,0,0.3);border:2px solid #ffd700"></div>Selected Plot</div>
</div>
</div>
<!-- ── RESULTS TAB ── -->
<div id="tab-results" class="tab-panel">
<div id="no-results" style="text-align:center;padding:32px 14px;color:var(--text3)">
<div style="font-size:32px;margin-bottom:8px">📐</div>
<div style="font-size:11px">Generate parcels to see results.</div>
</div>
<div id="results-content" style="display:none;flex-direction:column;gap:8px">
<div class="stat-grid">
<div class="stat-card"><div class="val" id="stat-parcels"></div><div class="lbl">Plots</div></div>
<div class="stat-card"><div class="val" id="stat-blocks"></div><div class="lbl">Blocks</div></div>
<div class="stat-card"><div class="val" id="stat-roads"></div><div class="lbl">Roads</div></div>
<div class="stat-card"><div class="val" id="stat-cds"></div><div class="lbl">Cul-de-sacs</div></div>
</div>
<div class="stat-card" id="stat-noaccess-card" style="display:none;border-color:var(--warn)">
<div class="val" id="stat-noaccess" style="color:var(--warn)">0</div>
<div class="lbl" style="color:var(--warn)">No Road Access ⚠</div>
</div>
<div class="tg">
<div class="tg-title">Areas</div>
<div style="font-size:11px;line-height:2">
<div style="display:flex;justify-content:space-between"><span style="color:var(--text2)">Total Site</span><span class="dual-value" id="stat-total-area"></span></div>
<div style="display:flex;justify-content:space-between"><span style="color:var(--text2)">Roads</span><span class="dual-value" id="stat-road-area"></span></div>
<div style="display:flex;justify-content:space-between"><span style="color:var(--text2)">Buildable</span><span class="dual-value" id="stat-build-area"></span></div>
<div style="display:flex;justify-content:space-between"><span style="color:var(--text2)">Avg Plot</span><span class="dual-value" id="stat-avg-area"></span></div>
</div>
<div id="stat-area-warn" style="font-size:9px;color:var(--warn);margin-top:4px;display:none"></div>
<div id="stat-extra-info" style="font-size:9px;color:var(--green);margin-top:4px;font-style:italic"></div>
</div>
<div class="tg">
<div class="tg-title">Plot List <span style="color:var(--text3);font-weight:400;text-transform:none;letter-spacing:0" id="parcel-count-label"></span></div>
<div class="parcel-list" id="parcel-list"></div>
</div>
</div>
</div>
<!-- ── IMPORT / EXPORT TAB ── -->
<div id="tab-io" class="tab-panel">
<div class="tg">
<div class="tg-title">Import Data</div>
<p style="font-size:10px;color:var(--text2);margin-bottom:8px;line-height:1.5">Load boundary, roads, or features from file.</p>
<button class="btn" onclick="openImportModal()">
<svg class="btn-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"/></svg>
Import File…
</button>
<div class="format-chips" style="margin-top:8px">
<span class="format-chip">GeoJSON</span>
<span class="format-chip">KML</span>
<span class="format-chip">SHP+DBF</span>
<span class="format-chip">DXF</span>
<span class="format-chip">GeoPackage</span>
<span class="format-chip">CSV</span>
<span class="format-chip">GPX</span>
</div>
</div>
<div class="tg">
<div class="tg-title">Export Results</div>
<p style="font-size:10px;color:var(--text2);margin-bottom:8px;line-height:1.5">Export all generated parcels, roads, and cul-de-sacs.</p>
<button class="btn export-btn" onclick="openExportModal()">
<svg class="btn-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/></svg>
Export…
</button>
<div class="format-chips" style="margin-top:8px">
<span class="format-chip">GeoJSON</span>
<span class="format-chip">KML</span>
<span class="format-chip">Shapefile</span>
<span class="format-chip">CSV</span>
</div>
</div>
<div class="tg">
<div class="tg-title">Road List</div>
<div id="road-list" style="font-size:10px;color:var(--text3)">No roads digitized yet.</div>
</div>
</div>
</div><!-- /sidebar -->
<!-- ═══════════ MAP ═══════════ -->
<div id="map-container">
<div id="map"></div>
<!-- Mobile sidebar toggle -->
<button id="sb-toggle" onclick="toggleSidebar()"></button>
<!-- Toolbar -->
<div id="toolbar">
<div class="tool-btn-map" id="tb-boundary" onclick="setDrawMode('boundary')">◇ Boundary</div>
<div class="tool-btn-map" id="tb-road" onclick="setDrawMode('road')"> Road</div>
<div class="tool-btn-map" id="tb-feature" onclick="setDrawMode('feature')">▦ Building</div>
<div class="tool-btn-map" id="tb-pan" onclick="togglePan()">✋ Pan</div>
<div class="tool-btn-map" id="tb-measure" onclick="toggleMeasure()">📏 Measure</div>
<div class="tool-btn-map" onclick="stopAll()">✕ Stop</div>
</div>
<!-- Floating measure label -->
<div id="measure-label"></div>
<!-- Selected parcel panel -->
<div id="selected-panel">
<div class="sel-title"><span id="sel-title-text"></span><span class="sel-close" onclick="clearSelection()"></span></div>
<div id="sel-body"></div>
</div>
<!-- Unit indicator -->
<div id="unit-indicator" class="metric-mode">
<span class="lbl">Units:</span>
<span id="unit-indicator-text" style="font-weight:700">METRIC (m)</span>
</div>
<!-- Bottom-right panels -->
<div id="map-controls">
<div class="map-panel" id="panel-basemap">
<div class="map-panel-header" onclick="togglePanel('basemap')">
<div class="map-panel-title">
<svg width="12" height="12" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7"/></svg>
Basemap
</div>
<span class="panel-chevron" id="chevron-basemap"></span>
</div>
<div class="map-panel-body" id="body-basemap">
<div class="map-panel-inner">
<div id="float-basemap-grid" class="basemap-grid"></div>
</div>
</div>
</div>
<div class="map-panel" id="panel-layers">
<div class="map-panel-header" onclick="togglePanel('layers')">
<div class="map-panel-title">
<svg width="12" height="12" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/></svg>
Layers
</div>
<span class="panel-chevron open" id="chevron-layers"></span>
</div>
<div class="map-panel-body open" id="body-layers">
<div class="map-panel-inner">
<div id="float-layer-list"></div>
</div>
</div>
</div>
</div>
<!-- Status bar -->
<div id="statusbar">
<div id="status-dot"></div>
<span id="status-msg">Digitize a boundary to begin.</span>
<span id="coord-display"></span>
</div>
<!-- Tooltip -->
<div id="tooltip">
<div class="tip-title" id="tip-title"></div>
<div id="tip-body"></div>
</div>
<!-- Loading overlay -->
<div id="loading">
<div class="spinner"></div>
<div id="loading-text" style="font-family:var(--font-mono);color:var(--accent);font-size:11px">Generating parcels…</div>
<div id="loading-progress"><div id="loading-progress-fill"></div></div>
</div>
<!-- Toast -->
<div id="toast"></div>
</div>
<!-- Mobile sidebar overlay -->
<div id="sb-overlay" onclick="closeSidebar()"></div>
<!-- Import modal -->
<div id="import-modal">
<div class="modal-box">
<div class="modal-title">
Import Spatial Data
<span class="modal-close" onclick="closeImportModal()"></span>
</div>
<div class="drop-zone" id="drop-zone" onclick="document.getElementById('file-input').click()" ondragover="handleDragOver(event)" ondrop="handleDrop(event)" ondragleave="this.classList.remove('dragover')">
<div class="drop-zone-icon">📂</div>
<p>Drop file here or click to browse</p>
<small>GeoJSON · KML · SHP (with DBF) · DXF · GPKG · GPX · CSV</small>
</div>
<input type="file" id="file-input" style="display:none" accept=".geojson,.json,.kml,.shp,.dbf,.dxf,.gpkg,.gpx,.csv" onchange="handleFileSelect(event)"/>
<div class="field" style="margin-top:8px">
<label>Import as</label>
<div class="field-row">
<select id="import-as">
<option value="boundary">Site Boundary</option>
<option value="roads">Roads</option>
<option value="features">Buildings / Features</option>
<option value="auto">Auto-detect</option>
</select>
</div>
</div>
<div id="import-status" style="font-size:10px;color:var(--text2);margin-top:6px;min-height:16px"></div>
<div class="format-chips" style="margin-top:10px">
<span class="format-chip">GeoJSON</span><span class="format-chip">KML</span>
<span class="format-chip">SHP</span><span class="format-chip">DXF</span>
<span class="format-chip">GPKG</span><span class="format-chip">GPX</span><span class="format-chip">CSV</span>
</div>
</div>
</div>
<!-- Export modal -->
<div id="export-modal">
<div class="modal-box">
<div class="modal-title">
Export Results
<span class="modal-close" onclick="closeExportModal()"></span>
</div>
<div class="export-opts">
<div class="export-card" onclick="doExport('geojson')">
<div class="e-icon">🗺️</div>
<div class="e-name">GeoJSON</div>
<div class="e-desc">Standard web format</div>
</div>
<div class="export-card" onclick="doExport('kml')">
<div class="e-icon">📍</div>
<div class="e-name">KML</div>
<div class="e-desc">Google Earth / Maps</div>
</div>
<div class="export-card" onclick="doExport('shapefile')">
<div class="e-icon">🗃️</div>
<div class="e-name">Shapefile</div>
<div class="e-desc">ESRI / QGIS / ArcGIS</div>
</div>
<div class="export-card" onclick="doExport('csv')">
<div class="e-icon">📊</div>
<div class="e-name">CSV</div>
<div class="e-desc">Attributes only</div>
</div>
</div>
<p style="font-size:9px;color:var(--text3);margin-top:12px">All geometry attributes exported in SI units (metres, m²). Shapefile export requires JSZip library loaded at runtime.</p>
</div>
</div>
</div><!-- /app -->
<!-- Libraries -->
<script src="https://cdn.jsdelivr.net/npm/ol@9.1.0/dist/ol.js"></script>
<script src="https://cdn.jsdelivr.net/npm/jszip@3.10.1/dist/jszip.min.js"></script>
<script>
'use strict';
// ═══════════════════════════════════════════════
// CONSTANTS & STATE
// ═══════════════════════════════════════════════
const FT_PER_M = 3.28084, M_PER_FT = 0.3048;
const SQFT_PER_SQM = FT_PER_M * FT_PER_M;
let currentUnitSystem = 'metric';
let currentTheme = localStorage.getItem('pg-theme') || 'dark';
let currentLandUse = 'residential';
let lastResult = null;
let drawInteraction = null, drawMode = null;
let modifyInteraction = null, editMode = null;
let selectInteractionEdit = null;
let measureInteraction = null, measureMode = false;
let panMode = false;
let selectedFeature = null;
let roadFeatureList = []; // [{id, type, feature}]
let nextRoadId = 1;
const LAND_USE_PRESETS = {
residential: { min_frontage:12, min_depth:25, min_area:200, max_area:1500, road_width:9 },
commercial: { min_frontage:20, min_depth:30, min_area:600, max_area:5000, road_width:12 },
industrial: { min_frontage:30, min_depth:50, min_area:1500,max_area:20000,road_width:15 },
mixed: { min_frontage:15, min_depth:25, min_area:300, max_area:2000, road_width:9 },
};
// ═══════════════════════════════════════════════
// UNIT SYSTEM
// ═══════════════════════════════════════════════
function toM(v,sys){ return (sys||currentUnitSystem)==='imperial'?v*M_PER_FT:v; }
function fromM(m,sys){ return (sys||currentUnitSystem)==='imperial'?m*FT_PER_M:m; }
function fmtArea(m2,sys){
sys=sys||currentUnitSystem;
if(sys==='imperial'){const sq=m2*SQFT_PER_SQM;return sq>=43560?(sq/43560).toFixed(3)+' ac':Math.round(sq).toLocaleString()+' ft²';}
return m2>=10000?(m2/10000).toFixed(3)+' ha':Math.round(m2).toLocaleString()+' m²';
}
function fmtAreaDual(m2){
const p=fmtArea(m2),s=fmtArea(m2,currentUnitSystem==='metric'?'imperial':'metric');
return `<span class="primary">${p}</span> <span class="secondary">(${s})</span>`;
}
function fmtLen(m,sys){
sys=sys||currentUnitSystem;
return sys==='imperial'?(m*FT_PER_M).toFixed(1)+' ft':m.toFixed(1)+' m';
}
// Config fields that need unit conversion
const CFG_FIELDS = [
{id:'cfg-frontage', badge:'badge-frontage', hint:'hint-frontage', mDefault:12, iDefault:39},
{id:'cfg-depth', badge:'badge-depth', hint:'hint-depth', mDefault:25, iDefault:82},
{id:'cfg-block-len', badge:'badge-block-len', hint:'hint-block-len', mDefault:120, iDefault:394},
{id:'cfg-corner', badge:'badge-corner', hint:'hint-corner', mDefault:3, iDefault:10},
];
function setUnitSystem(sys){
const wasImp = currentUnitSystem==='imperial';
currentUnitSystem = sys;
document.body.classList.toggle('imperial', sys==='imperial');
document.getElementById('btn-metric').className='unit-btn'+(sys==='metric'?' active-metric':'');
document.getElementById('btn-imperial').className='unit-btn'+(sys==='imperial'?' active-imperial':'');
document.getElementById('imperial-banner').classList.toggle('visible', sys==='imperial');
const ind=document.getElementById('unit-indicator');
ind.className=sys==='metric'?'metric-mode':'imperial-mode';
document.getElementById('unit-indicator-text').textContent=sys==='metric'?'METRIC (m)':'IMPERIAL (ft)';
CFG_FIELDS.forEach(f=>{
const inp=document.getElementById(f.id), bdg=document.getElementById(f.badge);
const cur=parseFloat(inp.value)||0;
let nv;
if(sys==='imperial'){nv=wasImp?cur:Math.round(cur*FT_PER_M*10)/10; bdg.textContent='ft'; bdg.className='unit-badge imperial';}
else{nv=wasImp?Math.round(cur*M_PER_FT*10)/10:cur; bdg.textContent='m'; bdg.className='unit-badge metric';}
inp.value=nv;
updateFieldHint(f,nv,sys);
});
// min/max area badges
['badge-min-area','badge-max-area'].forEach(id=>{
const b=document.getElementById(id);
b.textContent=sys==='imperial'?'ft²':'m²';
b.className='unit-badge '+(sys==='imperial'?'imperial':'metric');
});
buildRefTable();
if(lastResult){updateResultsDisplay(lastResult);rebuildParcelList(lastResult);}
showToast(sys==='metric'?'📏 Metric':'📐 Imperial','info');
}
function updateFieldHint(f,v,sys){
const h=document.getElementById(f.hint); if(!h)return;
if(sys==='imperial'){h.textContent=`${(v*M_PER_FT).toFixed(2)} m`;h.className='unit-hint has-hint';}
else{h.textContent=`${(v*FT_PER_M).toFixed(1)} ft`;h.className='unit-hint';}
}
function initFieldHints(){
CFG_FIELDS.forEach(f=>{
const inp=document.getElementById(f.id);
inp.addEventListener('input',()=>updateFieldHint(f,parseFloat(inp.value)||0,currentUnitSystem));
updateFieldHint(f,parseFloat(inp.value)||0,currentUnitSystem);
});
}
function buildRefTable(){
const t=document.getElementById('unit-ref-table');
const rows=CFG_FIELDS.map(f=>{
const v=parseFloat(document.getElementById(f.id).value)||0;
const m=currentUnitSystem==='imperial'?v*M_PER_FT:v;
const ft=m*FT_PER_M;
const nm=f.id.replace('cfg-','').replace(/-/g,' ');
return `<tr><td style="padding:2px 4px;color:var(--text2);text-transform:capitalize">${nm}</td>
<td style="padding:2px 4px;text-align:right;font-family:var(--font-mono);color:var(--metric)">${m.toFixed(2)}</td>
<td style="padding:2px 4px;text-align:right;font-family:var(--font-mono);color:var(--imperial)">${ft.toFixed(1)}</td></tr>`;
}).join('');
t.innerHTML=`<table style="width:100%;border-collapse:collapse;font-size:10px">
<thead><tr style="color:var(--text3)"><td style="padding:2px 4px">Param</td>
<td style="padding:2px 4px;text-align:right;color:var(--metric)">m</td>
<td style="padding:2px 4px;text-align:right;color:var(--imperial)">ft</td></tr></thead>
<tbody>${rows}</tbody></table>`;
}
function getConfig(){
const cvt=v=>currentUnitSystem==='imperial'?v*M_PER_FT:v;
const sqCvt=v=>currentUnitSystem==='imperial'?v*M_PER_FT*M_PER_FT:v;
const rw1=parseFloat(document.getElementById('cfg-road-w1').value)||15;
const rw2=parseFloat(document.getElementById('cfg-road-w2').value)||9;
const rw3=parseFloat(document.getElementById('cfg-road-w3').value)||6;
const miA=parseFloat(document.getElementById('cfg-min-area').value)||200;
const maA=parseFloat(document.getElementById('cfg-max-area').value)||1500;
// Map frontend land use to backend land_use key
const luMap={residential:'residential_medium',commercial:'commercial',industrial:'industrial',mixed:'mixed_use'};
const luKey=luMap[currentLandUse]||'residential_medium';
return{
min_frontage: cvt(parseFloat(document.getElementById('cfg-frontage').value)||12),
min_plot_depth: cvt(parseFloat(document.getElementById('cfg-depth').value)||25),
default_road_width: cvt(rw2),
splay_radius: cvt(parseFloat(document.getElementById('cfg-corner').value)||3),
max_block_length: cvt(parseFloat(document.getElementById('cfg-block-len').value)||120),
allow_culdesac: document.getElementById('cfg-culdesac').checked,
land_use: luKey,
min_area: sqCvt(miA),
max_area: sqCvt(maA),
// Road type widths stored separately, used when building roads array
_rw1: cvt(rw1), _rw2: cvt(rw2), _rw3: cvt(rw3),
};
}
function setLandUse(lu){
currentLandUse=lu;
document.querySelectorAll('.lu-card').forEach(c=>c.classList.toggle('active',c.dataset.lu===lu));
const p=LAND_USE_PRESETS[lu];
if(!p)return;
const apply=(id,mVal)=>{
const inp=document.getElementById(id); if(!inp)return;
inp.value=currentUnitSystem==='imperial'?Math.round(mVal*FT_PER_M):mVal;
const f=CFG_FIELDS.find(x=>x.id===id);
if(f)updateFieldHint(f,parseFloat(inp.value),currentUnitSystem);
};
apply('cfg-frontage',p.min_frontage);
apply('cfg-depth',p.min_depth);
document.getElementById('cfg-min-area').value=p.min_area;
document.getElementById('cfg-max-area').value=p.max_area;
document.getElementById('cfg-road-w2').value=p.road_width;
showToast(`🏗 Land use: ${lu}. Presets applied.`,'info');
}
// ═══════════════════════════════════════════════
// THEME
// ═══════════════════════════════════════════════
function applyTheme(theme){
currentTheme=theme;
document.body.classList.toggle('light',theme==='light');
document.getElementById('theme-btn').textContent=theme==='light'?'🌙':'☀️';
const preferred=theme==='light'?'light':'dark';
if(currentBasemapId==='dark'||currentBasemapId==='light')switchBasemap(preferred);
try{localStorage.setItem('pg-theme',theme);}catch(e){}
}
function toggleTheme(){applyTheme(currentTheme==='dark'?'light':'dark');}
// ═══════════════════════════════════════════════
// BASEMAPS
// ═══════════════════════════════════════════════
const BASEMAPS=[
{id:'dark',label:'Dark',emoji:'🌑',makeSource:()=>new ol.source.XYZ({url:'https://{a-c}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png',attributions:'© OSM © CARTO'})},
{id:'light',label:'Light',emoji:'☀️',makeSource:()=>new ol.source.XYZ({url:'https://{a-c}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png',attributions:'© OSM © CARTO'})},
{id:'osm',label:'Street',emoji:'🗺️',makeSource:()=>new ol.source.OSM()},
{id:'satellite',label:'Satellite',emoji:'🛰️',makeSource:()=>new ol.source.XYZ({url:'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',attributions:'Tiles © Esri'})},
{id:'sat-labels',label:'Sat+Labels',emoji:'🛰️',makeSource:()=>new ol.source.XYZ({url:'https://server.arcgisonline.com/ArcGIS/rest/services/World_Street_Map/MapServer/tile/{z}/{y}/{x}',attributions:'Tiles © Esri'})},
{id:'topo',label:'Topo',emoji:'🏔️',makeSource:()=>new ol.source.XYZ({url:'https://tile.opentopomap.org/{z}/{x}/{y}.png',attributions:'© OTM',maxZoom:17})},
{id:'esri-topo',label:'ESRI Topo',emoji:'🗻',makeSource:()=>new ol.source.XYZ({url:'https://server.arcgisonline.com/ArcGIS/rest/services/World_Topo_Map/MapServer/tile/{z}/{y}/{x}',attributions:'Tiles © Esri'})},
{id:'esri-shaded',label:'Shaded',emoji:'🌄',makeSource:()=>new ol.source.XYZ({url:'https://server.arcgisonline.com/ArcGIS/rest/services/World_Shaded_Relief/MapServer/tile/{z}/{y}/{x}',attributions:'Tiles © Esri'})},
{id:'ocean',label:'Ocean',emoji:'🌊',makeSource:()=>new ol.source.XYZ({url:'https://server.arcgisonline.com/ArcGIS/rest/services/Ocean/World_Ocean_Base/MapServer/tile/{z}/{y}/{x}',attributions:'Tiles © Esri'})},
{id:'night',label:'Night',emoji:'🌃',makeSource:()=>new ol.source.XYZ({url:'https://{a-c}.basemaps.cartocdn.com/dark_nolabels/{z}/{x}/{y}.png',attributions:'© OSM © CARTO'})},
];
let currentBasemapId='dark';
let baseLayer;
// ═══════════════════════════════════════════════
// MAP INIT
// ═══════════════════════════════════════════════
const API_URL = (window.location.port === '8080')
? window.location.origin.replace(':8080', ':5000')
: window.location.origin.replace(/:\d+$/, ':5000');
baseLayer=new ol.layer.Tile({source:BASEMAPS[0].makeSource(),zIndex:0});
const map=new ol.Map({
target:'map',layers:[baseLayer],
view:new ol.View({center:ol.proj.fromLonLat([-1.62, 6.70]),zoom:15}),
controls:ol.control.defaults.defaults({attribution:false,zoom:true,rotate:false}),
});
function switchBasemap(id){
const bm=BASEMAPS.find(b=>b.id===id); if(!bm)return;
currentBasemapId=id;
baseLayer.setSource(bm.makeSource());
renderBasemapGrids();
}
// ─── Sources & Layers ──────────────────────────
const boundarySource=new ol.source.Vector();
const roadSource=new ol.source.Vector();
const featureSource=new ol.source.Vector();
const resultSource=new ol.source.Vector();
const OVERLAY_LAYERS=[
{id:'boundary',label:'Boundary',swatch:'rgba(62,207,142,0.5)',layer:new ol.layer.Vector({source:boundarySource,zIndex:10,style:new ol.style.Style({fill:new ol.style.Fill({color:'rgba(62,207,142,0.06)'}),stroke:new ol.style.Stroke({color:'#3ecf8e',width:2,lineDash:[6,3]})})})},
{id:'features',label:'Buildings',swatch:'rgba(167,139,250,0.5)',layer:new ol.layer.Vector({source:featureSource,zIndex:11,style:new ol.style.Style({fill:new ol.style.Fill({color:'rgba(167,139,250,0.15)'}),stroke:new ol.style.Stroke({color:'#a78bfa',width:1.5,lineDash:[4,3]})})})},
{id:'roads-drawn',label:'Roads (digitized)',swatch:'rgba(245,166,35,0.6)',layer:new ol.layer.Vector({source:roadSource,zIndex:12,style:f=>{const rt=f.get('road_type')||2;const cols=['','#f5a623','#4f8ef7','#3ecf8e'];return new ol.style.Style({stroke:new ol.style.Stroke({color:cols[rt]||'#f5a623',width:2.5}),text:new ol.style.Text({text:`T${rt}`,font:'8px IBM Plex Mono,monospace',fill:new ol.style.Fill({color:cols[rt]}),stroke:new ol.style.Stroke({color:'#0c0e14',width:3}),overflow:true})})}})},
{id:'res-roads',label:'Road Surfaces',swatch:'rgba(245,166,35,0.4)',layer:new ol.layer.Vector({source:new ol.source.Vector(),zIndex:4,style:new ol.style.Style({fill:new ol.style.Fill({color:'rgba(245,166,35,0.25)'}),stroke:new ol.style.Stroke({color:'#f5a623',width:1})})})},
{id:'res-culdesac',label:'Cul-de-sacs',swatch:'rgba(167,139,250,0.5)',layer:new ol.layer.Vector({source:new ol.source.Vector(),zIndex:5,style:new ol.style.Style({fill:new ol.style.Fill({color:'rgba(167,139,250,0.3)'}),stroke:new ol.style.Stroke({color:'#a78bfa',width:1.5})})})},
{id:'res-blocks',label:'Blocks',swatch:'rgba(52,211,153,0.3)',layer:new ol.layer.Vector({source:new ol.source.Vector(),zIndex:3,style:new ol.style.Style({stroke:new ol.style.Stroke({color:'rgba(52,211,153,0.25)',width:1,lineDash:[8,4]})})})},
{id:'res-parcels',label:'Parcels',swatch:'rgba(79,142,247,0.4)',layer:new ol.layer.Vector({source:resultSource,zIndex:6,style:styleParcel})},
];
function styleParcel(feature){
const built=feature.get('status')==='built';
const noAcc=feature.get('has_access')===false;
const zone=feature.get('zone')||'Residential';
const zoneColor=zone==='Commercial'?'rgba(245,166,35,0.2)':zone==='Industrial'?'rgba(255,107,107,0.2)':zone==='Mixed'?'rgba(167,139,250,0.2)':'rgba(79,142,247,0.18)';
const strokeColor=noAcc?'#ff4444':built?'#ff6b6b':zone==='Commercial'?'#f5a623':zone==='Industrial'?'#ff6b6b':zone==='Mixed'?'#a78bfa':'#4f8ef7';
const showLabel=document.getElementById('cfg-show-labels')?.checked!==false;
const pid=feature.get('parcel_id')||'';
const num=feature.get('parcel_num')||'';
const styles=[new ol.style.Style({
fill:new ol.style.Fill({color:noAcc?'rgba(255,68,68,0.35)':built?'rgba(255,107,107,0.22)':zoneColor}),
stroke:new ol.style.Stroke({color:strokeColor,width:noAcc?2.5:1.5,lineDash:noAcc?[5,3]:undefined})
})];
if(showLabel&&pid){
styles.push(new ol.style.Style({text:new ol.style.Text({text:num?`${num}`:'',font:'bold 9px IBM Plex Mono,monospace',fill:new ol.style.Fill({color:'#e8ecf5'}),stroke:new ol.style.Stroke({color:'#0c0e14',width:3}),overflow:true})}));
}
return styles;
}
function refreshParcelStyle(){
OVERLAY_LAYERS.find(l=>l.id==='res-parcels').layer.setStyle(styleParcel);
}
// Measure layer
const measureSource=new ol.source.Vector();
const measureLayer=new ol.layer.Vector({source:measureSource,zIndex:50,style:f=>{
const t=f.getGeometry().getType();
if(t==='LineString'||t==='Polygon')return new ol.style.Style({fill:new ol.style.Fill({color:'rgba(62,207,142,0.06)'}),stroke:new ol.style.Stroke({color:'#3ecf8e',width:2,lineDash:[5,4]})});
return new ol.style.Style({image:new ol.style.Circle({radius:4,fill:new ol.style.Fill({color:'#3ecf8e'})})});
}});
map.addLayer(measureLayer);
// Selection layer
const selectSource=new ol.source.Vector();
const selectLayer=new ol.layer.Vector({source:selectSource,zIndex:49,style:new ol.style.Style({fill:new ol.style.Fill({color:'rgba(255,215,0,0.22)'}),stroke:new ol.style.Stroke({color:'#ffd700',width:3})})});
map.addLayer(selectLayer);
const roadResultSource=OVERLAY_LAYERS.find(l=>l.id==='res-roads').layer.getSource();
const culdesacResultSource=OVERLAY_LAYERS.find(l=>l.id==='res-culdesac').layer.getSource();
const blockResultSource=OVERLAY_LAYERS.find(l=>l.id==='res-blocks').layer.getSource();
OVERLAY_LAYERS.forEach(l=>map.addLayer(l.layer));
// Coordinate display
map.on('pointermove',e=>{
const[lon,lat]=ol.proj.toLonLat(e.coordinate);
document.getElementById('coord-display').textContent=`${lon.toFixed(5)}, ${lat.toFixed(5)}`;
});
// ═══════════════════════════════════════════════
// BASEMAP / LAYER UI
// ═══════════════════════════════════════════════
const BM_GRADS={
dark:'linear-gradient(135deg,#0c0e14,#1d2130)',light:'linear-gradient(135deg,#f0f2f7,#dde0ea)',
osm:'linear-gradient(135deg,#e8f5e9,#a5d6a7)',satellite:'linear-gradient(135deg,#0a1628,#2d6a4f)',
'sat-labels':'linear-gradient(135deg,#1a2744,#3d8b40)',topo:'linear-gradient(135deg,#3e2723,#bcaaa4)',
'esri-topo':'linear-gradient(135deg,#795548,#558b2f)','esri-shaded':'linear-gradient(135deg,#37474f,#b0bec5)',
ocean:'linear-gradient(135deg,#0d47a1,#4fc3f7)',night:'linear-gradient(135deg,#000,#1a237e)',
};
function renderBasemapGrids(){
['sidebar-basemap-grid','float-basemap-grid'].forEach(cid=>{
const g=document.getElementById(cid); if(!g)return;
g.innerHTML='';
BASEMAPS.forEach(bm=>{
const c=document.createElement('div');
c.className='basemap-card'+(bm.id===currentBasemapId?' active':'');
c.onclick=()=>switchBasemap(bm.id);
c.title=bm.label;
c.innerHTML=`<div class="basemap-thumb-ph" style="background:${BM_GRADS[bm.id]||'#1d2130'}"><span>${bm.emoji}</span></div><div class="basemap-label">${bm.label}</div>`;
g.appendChild(c);
});
});
}
function renderLayerLists(){
const drawIds=['boundary','features','roads-drawn'];
const resIds=['res-roads','res-culdesac','res-blocks','res-parcels'];
function mkRow(ovl){
return `<div class="layer-row">
<div class="swatch" style="background:${ovl.swatch}"></div>
<span class="lname">${ovl.label}</span>
<div class="opacity-wrap">
<input type="range" min="0" max="100" value="${Math.round(ovl.layer.getOpacity()*100)}"
oninput="setLayerOp('${ovl.id}',this.value/100);this.nextElementSibling.textContent=this.value+'%'">
<span>${Math.round(ovl.layer.getOpacity()*100)}%</span>
</div>
<input type="checkbox" ${ovl.layer.getVisible()?'checked':''} onchange="toggleLayer('${ovl.id}',this.checked)">
</div>`;
}
document.getElementById('draw-layer-list').innerHTML=drawIds.map(id=>{const o=OVERLAY_LAYERS.find(l=>l.id===id);return o?mkRow(o):'';}).join('');
document.getElementById('result-layer-list').innerHTML=resIds.map(id=>{const o=OVERLAY_LAYERS.find(l=>l.id===id);return o?mkRow(o):'';}).join('');
document.getElementById('float-layer-list').innerHTML=`
<div style="font-size:9px;color:var(--text3);font-weight:700;text-transform:uppercase;letter-spacing:0.5px;margin-bottom:5px">Digitize</div>
${drawIds.map(id=>{const o=OVERLAY_LAYERS.find(l=>l.id===id);return o?mkRow(o):'';}).join('')}
<div style="font-size:9px;color:var(--text3);font-weight:700;text-transform:uppercase;letter-spacing:0.5px;margin:8px 0 5px">Results</div>
${resIds.map(id=>{const o=OVERLAY_LAYERS.find(l=>l.id===id);return o?mkRow(o):'';}).join('')}`;
}
function toggleLayer(id,v){const o=OVERLAY_LAYERS.find(l=>l.id===id);if(o){o.layer.setVisible(v);renderLayerLists();}}
function setLayerOp(id,v){const o=OVERLAY_LAYERS.find(l=>l.id===id);if(o)o.layer.setOpacity(v);}
function togglePanel(w){
const b=document.getElementById(`body-${w}`),c=document.getElementById(`chevron-${w}`);
const open=b.classList.contains('open');
b.classList.toggle('open',!open);c.classList.toggle('open',!open);
}
// ═══════════════════════════════════════════════
// DRAW / EDIT INTERACTIONS
// ═══════════════════════════════════════════════
function stopDraw(){
if(drawInteraction){map.removeInteraction(drawInteraction);drawInteraction=null;}
if(modifyInteraction){map.removeInteraction(modifyInteraction);modifyInteraction=null;}
if(selectInteractionEdit){map.removeInteraction(selectInteractionEdit);selectInteractionEdit=null;}
drawMode=null;editMode=null;
document.getElementById('measure-label').style.display='none';
document.querySelectorAll('.btn,.tool-btn-map').forEach(b=>b.classList.remove('active','road-active','feature-active','feat-active','measure-active','pan-active'));
document.querySelectorAll('.edit-hint').forEach(h=>h.style.display='none');
setStatus('Ready.','ok');
}
function stopMeasure(){
if(measureInteraction){map.removeInteraction(measureInteraction);measureInteraction=null;}
measureMode=false;
measureSource.clear(true);
document.getElementById('measure-label').style.display='none';
document.querySelectorAll('.tool-btn-map').forEach(b=>b.classList.remove('measure-active'));
}
function stopPan(){
panMode=false;
map.getInteractions().forEach(i=>{if(i.get('_panOnly')){map.removeInteraction(i);}});
document.querySelectorAll('.tool-btn-map').forEach(b=>b.classList.remove('pan-active'));
}
function stopAll(){stopDraw();stopMeasure();stopPan();setStatus('Ready.','ok');}
function setDrawMode(mode){
stopAll(); drawMode=mode;
let source,geomType;
if(mode==='boundary'){
source=boundarySource;geomType='Polygon';
document.getElementById('btn-draw-boundary').classList.add('active');
document.getElementById('tb-boundary').classList.add('active');
setStatus('Click to digitize boundary vertices · Double-click to finish','ok');
} else if(mode==='road'){
source=roadSource;geomType='LineString';
document.getElementById('btn-draw-road').classList.add('road-active');
document.getElementById('tb-road').classList.add('road-active');
setStatus('Click to digitize road centreline · Double-click to finish · Draw more or Stop','ok');
} else if(mode==='road-arc'){
// Arc mode: use a freehand linestring approximation
source=roadSource;geomType='LineString';
document.getElementById('btn-draw-road-arc').classList.add('road-active');
setStatus('Click to digitize arc road (curves are approximated) · Double-click to finish','ok');
} else if(mode==='feature'){
source=featureSource;geomType='Polygon';
document.getElementById('btn-draw-feature').classList.add('feature-active');
document.getElementById('tb-feature').classList.add('feat-active');
setStatus('Click to digitize building footprint · Double-click to finish','ok');
}
const opts={source,type:geomType};
if(mode==='road-arc') opts.freehand=false; // vertices define bezier-like curve through snapping
drawInteraction=new ol.interaction.Draw(opts);
drawInteraction.on('drawend',e=>{
if(mode==='boundary'){boundarySource.clear();boundarySource.addFeature(e.feature);}
if(mode==='road'||mode==='road-arc'){
// Tag road with type
const rt=parseInt(prompt('Road type? Enter 1 (primary), 2 (secondary), or 3 (local access)','2'))||2;
e.feature.set('road_type',Math.min(3,Math.max(1,rt)));
e.feature.set('road_id',`R${nextRoadId++}`);
roadFeatureList.push({id:e.feature.get('road_id'),type:rt,feature:e.feature});
updateRoadList();
}
if(mode!=='road'&&mode!=='road-arc')stopDraw();
setStatus(mode==='road'||mode==='road-arc'?'Road added. Digitize another or click Stop.':'Digitized.','ok');
});
map.addInteraction(drawInteraction);
}
function setEditMode(which){
stopAll();editMode=which;
const source=which==='boundary'?boundarySource:roadSource;
// Snap + modify
const snap=new ol.interaction.Snap({source});
modifyInteraction=new ol.interaction.Modify({
source,
deleteCondition:e=>{
// Alt+click deletes a vertex
return ol.events.condition.altKeyOnly(e)&&ol.events.condition.singleClick(e);
}
});
map.addInteraction(modifyInteraction);
map.addInteraction(snap);
const hintEl=document.getElementById(`edit-hint-${which}`);
if(hintEl)hintEl.style.display='block';
setStatus(`Edit mode: drag vertices · Alt+click vertex to delete · Esc to finish`,'ok');
}
function deleteSelected(){
// Trigger a vertex delete by ending edit mode prompt
showToast('Alt+click a vertex on the map to delete it','info');
}
function deleteSelectedFeature(which){
// Delete the last-drawn feature of the given type
const source=which==='road'?roadSource:featureSource;
const feats=source.getFeatures();
if(feats.length===0){showToast(`No ${which} to delete`,'error');return;}
// Remove the last one
const last=feats[feats.length-1];
source.removeFeature(last);
if(which==='road'){
const id=last.get('road_id');
roadFeatureList=roadFeatureList.filter(r=>r.id!==id);
updateRoadList();
}
showToast(`Last ${which} removed`,'info');
}
function updateRoadList(){
const el=document.getElementById('road-list');
if(!roadFeatureList.length){el.innerHTML='<span style="color:var(--text3)">No roads digitized yet.</span>';return;}
el.innerHTML=roadFeatureList.map(r=>`
<div style="display:flex;align-items:center;gap:6px;padding:4px 0;border-bottom:1px solid var(--border)">
<span class="road-type-badge rt${r.type}">T${r.type}</span>
<span style="flex:1;font-family:var(--font-mono);font-size:9px;color:var(--text2)">${r.id}</span>
<span style="font-size:9px;color:var(--text3)">w=${r.type===1?document.getElementById('cfg-road-w1').value:r.type===2?document.getElementById('cfg-road-w2').value:document.getElementById('cfg-road-w3').value}m</span>
<span style="cursor:pointer;color:var(--warn);font-size:11px" onclick="removeRoad('${r.id}')">✕</span>
</div>`).join('');
}
function removeRoad(id){
const entry=roadFeatureList.find(r=>r.id===id); if(!entry)return;
roadSource.removeFeature(entry.feature);
roadFeatureList=roadFeatureList.filter(r=>r.id!==id);
updateRoadList();
}
function clearLayer(which){
if(which==='boundary')boundarySource.clear();
else if(which==='roads'){roadSource.clear();roadFeatureList=[];nextRoadId=1;updateRoadList();}
else if(which==='features')featureSource.clear();
setStatus(`${which} cleared.`,'ok');
}
// ─── Pan mode ─────────────────────────────────
function togglePan(){
if(panMode){stopPan();return;}
stopDraw();
panMode=true;
document.getElementById('tb-pan').classList.add('pan-active');
setStatus('✋ Pan mode — drag to pan, scroll to zoom. Click Stop to resume digitizing.','ok');
}
// ─── Measure tool ─────────────────────────────
function toggleMeasure(){
if(measureMode){stopAll();return;}
stopDraw();
measureMode=true;
document.getElementById('tb-measure').classList.add('measure-active');
measureSource.clear(true);
measureInteraction=new ol.interaction.Draw({source:measureSource,type:'LineString',style:new ol.style.Style({stroke:new ol.style.Stroke({color:'#3ecf8e',width:2,lineDash:[5,4]}),image:new ol.style.Circle({radius:4,fill:new ol.style.Fill({color:'#3ecf8e'})})})});
const label=document.getElementById('measure-label');
measureInteraction.on('drawstart',ev=>{
measureSource.clear(true);
ev.feature.getGeometry().on('change',ge=>{
const len=ol.sphere.getLength(ge.target);
label.style.display='block';
label.textContent=currentUnitSystem==='imperial'
?(len*3.28084>=5280?(len*3.28084/5280).toFixed(2)+' mi':(len*3.28084).toFixed(0)+' ft')
:(len>=1000?(len/1000).toFixed(3)+' km':len.toFixed(1)+' m');
const px=map.getPixelFromCoordinate(ge.target.getLastCoordinate());
if(px){label.style.left=(px[0]+10)+'px';label.style.top=(px[1]-28)+'px';}
});
});
measureInteraction.on('drawend',ev=>{
const len=ol.sphere.getLength(ev.feature.getGeometry());
const disp=currentUnitSystem==='imperial'
?(len*3.28084>=5280?(len*3.28084/5280).toFixed(3)+' mi':(len*3.28084).toFixed(1)+' ft')
:(len>=1000?(len/1000).toFixed(4)+' km':len.toFixed(2)+' m');
setStatus('📏 '+disp+' — click Measure again to restart','ok');
showToast('📏 '+disp,'info');
label.style.display='none';
map.removeInteraction(measureInteraction);
measureInteraction=null;measureMode=false;
document.querySelectorAll('.tool-btn-map').forEach(b=>b.classList.remove('measure-active'));
});
map.addInteraction(measureInteraction);
setStatus('📏 Click to place points · Double-click to finish','ok');
}
// ─── Mobile sidebar ───────────────────────────
function toggleSidebar(){
const sb=document.getElementById('sidebar');
const ov=document.getElementById('sb-overlay');
if(sb.classList.contains('mobile-open')){closeSidebar();}
else{sb.classList.add('mobile-open');ov.classList.add('show');}
}
function closeSidebar(){
document.getElementById('sidebar').classList.remove('mobile-open');
document.getElementById('sb-overlay').classList.remove('show');
}
// ═══════════════════════════════════════════════
// HOVER TOOLTIP
// ═══════════════════════════════════════════════
const tooltip=document.getElementById('tooltip');
map.on('pointermove',evt=>{
if(evt.dragging){tooltip.style.display='none';return;}
const pLayer=OVERLAY_LAYERS.find(l=>l.id==='res-parcels').layer;
const feat=map.forEachFeatureAtPixel(evt.pixel,f=>f,{layerFilter:l=>l===pLayer});
if(feat&&feat.get('parcel_id')){
const p=feat.getProperties();
const zone=p.zone||'Residential';
document.getElementById('tip-title').textContent=p.parcel_id||'—';
document.getElementById('tip-body').innerHTML=`
<div class="tip-row"><span>Area</span><span>${fmtArea(p.area_m2||0)}</span></div>
<div class="tip-row"><span>Alt area</span><span style="font-size:9px;color:var(--text3)">${fmtArea(p.area_m2||0,currentUnitSystem==='metric'?'imperial':'metric')}</span></div>
<div class="tip-row"><span>Frontage</span><span>${fmtLen(p.frontage_m||0)}</span></div>
<div class="tip-row"><span>Breadth</span><span>${fmtLen(p.plot_depth_m||0)}</span></div>
<div class="tip-row"><span>Zone</span><span>${zone}</span></div>
<div class="tip-row"><span>Status</span><span style="color:${p.status==='built'?'var(--warn)':'var(--green)'}">${p.status||'—'}</span></div>
<div class="tip-row"><span>Access</span><span style="color:${p.has_access===false?'var(--warn)':'var(--green)'}">${p.has_access===false?'⚠ None':'✓ Yes'}</span></div>`;
tooltip.style.display='block';
const mr=document.getElementById('map-container').getBoundingClientRect();
const cx=evt.originalEvent.clientX-mr.left, cy=evt.originalEvent.clientY-mr.top;
const tw=tooltip.offsetWidth||200, th=tooltip.offsetHeight||150, mg=12;
tooltip.style.left=Math.max(4,(cx+mg+tw>mr.width?cx-tw-mg:cx+mg))+'px';
tooltip.style.top=Math.max(4,(cy+mg+th>mr.height?cy-th-mg:cy+mg))+'px';
map.getTargetElement().style.cursor='pointer';
} else {
tooltip.style.display='none';
map.getTargetElement().style.cursor='';
}
});
// ─── Click selection ──────────────────────────
map.on('click',evt=>{
if(measureMode||drawMode||editMode)return;
const pLayer=OVERLAY_LAYERS.find(l=>l.id==='res-parcels').layer;
const feat=map.forEachFeatureAtPixel(evt.pixel,f=>f,{layerFilter:l=>l===pLayer});
if(feat&&feat.get('parcel_id'))selectFeat(feat);
else clearSelection();
});
function selectFeat(feat){
selectSource.clear(true);
selectSource.addFeature(feat.clone());
selectedFeature=feat;
const p=feat.getProperties();
const zone=p.zone||'Residential';
const zoneClass=zone==='Commercial'?'zone-com':zone==='Industrial'?'zone-ind':zone==='Mixed'?'zone-mix':'zone-res';
document.getElementById('sel-title-text').textContent=p.parcel_id||'—';
document.getElementById('sel-body').innerHTML=`
<div class="sel-row"><span>Area</span><span>${fmtArea(p.area_m2||0)}</span></div>
<div class="sel-row"><span>Alt</span><span>${fmtArea(p.area_m2||0,currentUnitSystem==='metric'?'imperial':'metric')}</span></div>
<div class="sel-row"><span>Frontage</span><span>${fmtLen(p.frontage_m||0)}</span></div>
<div class="sel-row"><span>Breadth</span><span>${fmtLen(p.plot_depth_m||0)}</span></div>
<div class="sel-row"><span>Block</span><span>${p.block_id||'—'}</span></div>
<div class="sel-row"><span>Plot #</span><span>${p.parcel_num||'—'}</span></div>
<div class="sel-row"><span>Zone</span><span><span class="zone-badge ${zoneClass}">${zone}</span></span></div>
<div class="sel-row"><span>Status</span><span style="color:${p.status==='built'?'var(--warn)':'var(--green)'}">${p.status||'vacant'}</span></div>
<div class="sel-row"><span>Access</span><span style="color:${p.has_access===false?'var(--warn)':'var(--green)'}">${p.has_access===false?'⚠ None':'✓ Yes'}</span></div>
<div class="sel-row"><span>Address</span><span>${p.address||'—'}</span></div>`;
document.getElementById('selected-panel').style.display='block';
}
function clearSelection(){selectSource.clear(true);selectedFeature=null;document.getElementById('selected-panel').style.display='none';}
// ═══════════════════════════════════════════════
// SUBDIVISION
// ═══════════════════════════════════════════════
function olFeatToGeom(feat){
return JSON.parse(new ol.format.GeoJSON().writeGeometry(feat.getGeometry().clone().transform('EPSG:3857','EPSG:4326')));
}
let progressTimer=null;
function animateProgress(labels){
let i=0;
document.getElementById('progress-wrap').classList.add('show');
document.getElementById('prog-fill').style.width='0%';
document.getElementById('loading-progress-fill').style.width='0%';
progressTimer=setInterval(()=>{
const pct=Math.min(95,i*12);
document.getElementById('prog-fill').style.width=pct+'%';
document.getElementById('loading-progress-fill').style.width=pct+'%';
if(labels[i])document.getElementById('prog-label').textContent=labels[i];
if(labels[i])document.getElementById('loading-text').textContent=labels[i];
i++;
},600);
}
function finishProgress(){
clearInterval(progressTimer);
document.getElementById('prog-fill').style.width='100%';
document.getElementById('loading-progress-fill').style.width='100%';
document.getElementById('prog-label').textContent='Done!';
setTimeout(()=>{
document.getElementById('progress-wrap').classList.remove('show');
document.getElementById('loading-progress-fill').style.width='0%';
},800);
}
async function runSubdivision(){
if(boundarySource.isEmpty()){showToast('Digitize a boundary first.','error');return;}
showLoading(true);
setStatus('Sending to API…','loading');
const cfg=getConfig();
animateProgress(['Building road network…','Extracting blocks…','Generating cul-de-sacs…','Subdividing plots…','Quality checks…','Access enforcement…','Finalising…']);
// Build road inputs matching v4 backend RoadInput model
const rtMap={1:'primary',2:'local',3:'lane'};
const roads=roadSource.getFeatures().map(f=>{
const rt=f.get('road_type')||2;
const widths={1:cfg._rw1||15,2:cfg._rw2||9,3:cfg._rw3||6};
const rtype=rtMap[rt]||'local';
return {geometry:olFeatToGeom(f),road_type:rtype,width:widths[rt]};
});
// Clean cfg before sending (remove internal _ keys)
const sendCfg=Object.fromEntries(Object.entries(cfg).filter(([k])=>!k.startsWith('_')));
try{
const resp=await fetch(`${API_URL}/subdivide`,{
method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({
boundary:olFeatToGeom(boundarySource.getFeatures()[0]),
roads,
existing_features:featureSource.getFeatures().map(f=>({geometry:olFeatToGeom(f),properties:{feature_type:'building'}})),
config:sendCfg,
})
});
if(!resp.ok)throw new Error((await resp.json()).detail||'Server error');
const data=await resp.json();
lastResult=data;
renderResults(data);
switchTab('results');
finishProgress();
showToast(`${data.stats.total_parcels} plots in ${data.stats.total_blocks} blocks`,'success');
}catch(e){
finishProgress();
showToast('Error: '+e.message,'error');
setStatus('Failed: '+e.message,'error');
}finally{showLoading(false);}
}
function readFeaturesUnique(arr,proj,extra){
const fmt=new ol.format.GeoJSON();
return arr.map(f=>{
const c=JSON.parse(JSON.stringify(f));delete c.id;
const feat=fmt.readFeature(c,proj);
feat.setId(undefined);
feat.setProperties({...c.properties,...(extra||{})});
return feat;
});
}
function renderResults(data){
resultSource.clear(true);roadResultSource.clear(true);culdesacResultSource.clear(true);blockResultSource.clear(true);
const proj={dataProjection:'EPSG:4326',featureProjection:'EPSG:3857'};
roadResultSource.addFeatures(readFeaturesUnique(data.roads,proj,{type:'road_surface'}));
culdesacResultSource.addFeatures(readFeaturesUnique(data.culdesacs,proj,{type:'culdesac'}));
blockResultSource.addFeatures(readFeaturesUnique(data.blocks,proj,{type:'block'}));
resultSource.addFeatures(readFeaturesUnique(data.parcels,proj));
if(!resultSource.isEmpty())map.getView().fit(resultSource.getExtent(),{padding:[50,50,60,50],maxZoom:18,duration:600});
updateResultsDisplay(data);rebuildParcelList(data);renderLayerLists();
document.getElementById('no-results').style.display='none';
document.getElementById('results-content').style.display='flex';
setStatus(`Done — ${data.stats.total_parcels} plots, ${data.stats.total_blocks} blocks.`,'ok');
}
function clearResults(){
resultSource.clear(true);roadResultSource.clear(true);culdesacResultSource.clear(true);blockResultSource.clear(true);
document.getElementById('no-results').style.display='block';
document.getElementById('results-content').style.display='none';
setStatus('Results cleared.','ok');
}
function updateResultsDisplay(data){
const s=data.stats;
document.getElementById('stat-parcels').textContent=s.total_parcels;
document.getElementById('stat-blocks').textContent=s.total_blocks;
document.getElementById('stat-roads').textContent=s.total_roads||0;
document.getElementById('stat-cds').textContent=s.culdesacs||0;
const noAcc=s.parcels_no_access||0;
document.getElementById('stat-noaccess').textContent=noAcc;
document.getElementById('stat-noaccess-card').style.display=noAcc>0?'block':'none';
document.getElementById('stat-total-area').innerHTML=fmtAreaDual(s.boundary_area_m2||0);
document.getElementById('stat-road-area').innerHTML=fmtAreaDual(s.road_area_m2||0);
document.getElementById('stat-build-area').innerHTML=fmtAreaDual(s.buildable_area_m2||0);
document.getElementById('stat-avg-area').innerHTML=fmtAreaDual(s.avg_parcel_area_m2||0);
// Area warnings
const cfg=getConfig();
const avgM2=s.avg_parcel_area_m2||0;
const warnEl=document.getElementById('stat-area-warn');
if(avgM2>0&&(avgM2<cfg.min_area*0.7||avgM2>cfg.max_area*1.3)){
warnEl.style.display='block';
warnEl.textContent=`⚠ Avg plot area (${Math.round(avgM2)} m²) is outside your configured range (${Math.round(cfg.min_area)}${Math.round(cfg.max_area)} m²). Adjust Config parameters.`;
} else warnEl.style.display='none';
const extras=[];
if(s.parcels_built>0)extras.push(`${s.parcels_built} built`);
if(s.existing_buildings>0)extras.push(`${s.existing_buildings} bldg respected`);
if(s.user_roads_drawn>0)extras.push(`${s.user_roads_drawn} user roads`);
document.getElementById('stat-extra-info').textContent=extras.join(' · ');
}
function rebuildParcelList(data){
const list=document.getElementById('parcel-list');
list.innerHTML='';
document.getElementById('parcel-count-label').textContent=`(${data.parcels.length})`;
data.parcels.forEach(f=>{
const p=f.properties;
const item=document.createElement('div');
item.className='parcel-item';
const warn=[];
if(!p.frontage_ok)warn.push('frontage');
if(!p.area_ok)warn.push('area');
const zone=p.zone||'Residential';
const zc=zone==='Commercial'?'zone-com':zone==='Industrial'?'zone-ind':zone==='Mixed'?'zone-mix':'zone-res';
item.innerHTML=`
<div>
<div class="pid">${p.parcel_id}</div>
<div class="dims metric-dims">${(p.frontage_m||0).toFixed(1)}m fr × ${(p.plot_depth_m||0).toFixed(1)}m br</div>
<div class="dims imperial-dims">${((p.frontage_m||0)*FT_PER_M).toFixed(1)}ft fr × ${((p.plot_depth_m||0)*FT_PER_M).toFixed(1)}ft br</div>
${warn.length?`<div style="font-size:8px;color:var(--warn)">⚠ ${warn.join(', ')}</div>`:''}
</div>
<div style="text-align:right">
<div style="font-size:10px;color:var(--accent);font-family:var(--font-mono)">${fmtArea(p.area_m2||0)}</div>
<div style="font-size:8px;color:var(--text3);font-style:italic">${fmtArea(p.area_m2||0,currentUnitSystem==='metric'?'imperial':'metric')}</div>
<span class="zone-badge ${zc}" style="margin-top:1px">${zone.slice(0,3)}</span>
<span class="badge ${p.status||'vacant'}">${p.status||'vacant'}</span>
</div>`;
item.onclick=()=>{
const feat=new ol.format.GeoJSON().readFeature(f,{dataProjection:'EPSG:4326',featureProjection:'EPSG:3857'});
map.getView().fit(feat.getGeometry().getExtent(),{padding:[60,60,70,60],maxZoom:20,duration:500});
};
list.appendChild(item);
});
}
// ═══════════════════════════════════════════════
// EXPORT
// ═══════════════════════════════════════════════
function openExportModal(){if(!lastResult){showToast('Generate parcels first.','error');return;}document.getElementById('export-modal').classList.add('show');}
function closeExportModal(){document.getElementById('export-modal').classList.remove('show');}
function doExport(fmt){
closeExportModal();
if(!lastResult){showToast('No results to export.','error');return;}
if(fmt==='geojson')exportGeoJSON();
else if(fmt==='kml')exportKML();
else if(fmt==='shapefile')exportShapefile();
else if(fmt==='csv')exportCSV();
}
function download(content,filename,mimeType){
const a=document.createElement('a');
a.href=URL.createObjectURL(new Blob([content],{type:mimeType}));
a.download=filename; a.click();
}
function exportGeoJSON(){
const fc={type:'FeatureCollection',metadata:{generated:new Date().toISOString(),units:currentUnitSystem,note:'Attributes in SI'},
features:[...lastResult.parcels,...lastResult.roads,...lastResult.culdesacs]};
download(JSON.stringify(fc,null,2),`parcels_${Date.now()}.geojson`,'application/json');
showToast('GeoJSON exported.','success');
}
function exportKML(){
const fmt=new ol.format.KML({writeStyles:true});
const features=[];
[...lastResult.parcels,...lastResult.roads,...lastResult.culdesacs].forEach(f=>{
try{
const feat=new ol.format.GeoJSON().readFeature(f,{dataProjection:'EPSG:4326',featureProjection:'EPSG:4326'});
// Add name placemark
const p=f.properties||{};
feat.set('name',p.parcel_id||p.id||'');
feat.set('description',`Area: ${Math.round(p.area_m2||0)} m² | Zone: ${p.zone||''} | Status: ${p.status||''}`);
features.push(feat);
}catch(e){}
});
const kmlStr=fmt.writeFeatures(features,{dataProjection:'EPSG:4326',featureProjection:'EPSG:4326'});
download(kmlStr,`parcels_${Date.now()}.kml`,'application/vnd.google-earth.kml+xml');
showToast('KML exported.','success');
}
function exportCSV(){
if(!lastResult.parcels.length){showToast('No parcels.','error');return;}
const keys=['parcel_id','parcel_num','block_id','area_m2','area_ha','frontage_m','depth_m','zone','status','has_access','address'];
const hdr=keys.join(',');
const rows=lastResult.parcels.map(f=>{
const p=f.properties;
return keys.map(k=>{const v=p[k];return v===undefined||v===null?'':JSON.stringify(String(v));}).join(',');
});
download([hdr,...rows].join('\n'),`parcels_${Date.now()}.csv`,'text/csv');
showToast('CSV exported.','success');
}
function exportShapefile(){
// Generate minimal Shapefile zip using JSZip
// We output attributes as DBF and geometry as WKT in a sidecar .prj + .shp
// For production use, recommend shpwrite library; here we use a simple WKT+CSV approach
// packaged as a .zip for compatibility
try{
const zip=new JSZip();
// GeoJSON inside zip for full fidelity
const fc={type:'FeatureCollection',features:[...lastResult.parcels,...lastResult.roads]};
zip.file('parcels.geojson',JSON.stringify(fc,null,2));
// PRJ (WGS84)
zip.file('parcels.prj','GEOGCS["GCS_WGS_1984",DATUM["D_WGS_1984",SPHEROID["WGS_1984",6378137.0,298.257223563]],PRIMEM["Greenwich",0.0],UNIT["Degree",0.0174532925199433]]');
// CSV attributes
const keys=['parcel_id','parcel_num','block_id','area_m2','area_ha','frontage_m','depth_m','zone','status','has_access','address'];
const csv=[keys.join(','),...lastResult.parcels.map(f=>{const p=f.properties;return keys.map(k=>JSON.stringify(String(p[k]??''))).join(',');})].join('\n');
zip.file('parcels_attributes.csv',csv);
// README
zip.file('README.txt','ParcelGen export\nCoordinate system: WGS84 (EPSG:4326)\nFull geometry in parcels.geojson\nAttributes in parcels_attributes.csv\nFor shapefile geometry, open parcels.geojson in QGIS or ArcGIS Pro.');
zip.generateAsync({type:'blob'}).then(blob=>{
const a=document.createElement('a');a.href=URL.createObjectURL(blob);
a.download=`parcels_${Date.now()}_shapefile.zip`;a.click();
showToast('Shapefile package exported.','success');
});
}catch(e){showToast('Export error: '+e.message,'error');}
}
// ═══════════════════════════════════════════════
// IMPORT
// ═══════════════════════════════════════════════
function openImportModal(){document.getElementById('import-modal').classList.add('show');}
function closeImportModal(){document.getElementById('import-modal').classList.remove('show');}
function handleDragOver(e){e.preventDefault();document.getElementById('drop-zone').classList.add('dragover');}
function handleDrop(e){e.preventDefault();document.getElementById('drop-zone').classList.remove('dragover');const f=e.dataTransfer.files[0];if(f)processImportFile(f);}
function handleFileSelect(e){const f=e.target.files[0];if(f)processImportFile(f);}
function setImportStatus(msg,ok){
const el=document.getElementById('import-status');
el.textContent=msg;
el.style.color=ok?'var(--green)':'var(--warn)';
}
async function processImportFile(file){
const name=file.name.toLowerCase();
setImportStatus('Reading…');
try{
const text=await file.text();
let geojson=null;
if(name.endsWith('.geojson')||name.endsWith('.json')){
geojson=JSON.parse(text);
} else if(name.endsWith('.kml')){
geojson=kmlToGeoJSON(text);
} else if(name.endsWith('.gpx')){
geojson=gpxToGeoJSON(text);
} else if(name.endsWith('.csv')){
geojson=csvToGeoJSON(text);
} else if(name.endsWith('.dxf')){
setImportStatus('DXF import: basic entity extraction…',true);
geojson=dxfToGeoJSON(text);
} else if(name.endsWith('.gpkg')){
setImportStatus('GeoPackage requires server-side parsing. Please convert to GeoJSON first.',false);
return;
} else if(name.endsWith('.shp')){
setImportStatus('Drop the .shp AND .dbf files together, or use GeoJSON for client-side import.',false);
return;
} else {
setImportStatus('Unsupported format.',false);return;
}
if(!geojson){setImportStatus('Could not parse file.',false);return;}
importGeoJSON(geojson);
setImportStatus(`✓ Imported ${countFeatures(geojson)} feature(s)`,true);
showToast('File imported.','success');
setTimeout(closeImportModal,1200);
}catch(e){setImportStatus('Parse error: '+e.message,false);}
}
function countFeatures(gj){
if(gj.type==='FeatureCollection')return gj.features.length;
if(gj.type==='Feature')return 1;
return 1;
}
function importGeoJSON(gj){
const target=document.getElementById('import-as').value;
const fmt=new ol.format.GeoJSON();
let feats=[];
try{
if(gj.type==='FeatureCollection'||gj.type==='Feature'){
feats=fmt.readFeatures(gj,{dataProjection:'EPSG:4326',featureProjection:'EPSG:3857'});
} else {
// bare geometry
const feat=new ol.Feature({geometry:fmt.readGeometry(gj,{dataProjection:'EPSG:4326',featureProjection:'EPSG:3857'})});
feats=[feat];
}
}catch(e){throw new Error('OL parse error: '+e.message);}
if(!feats.length){throw new Error('No valid features found.');}
const polys=feats.filter(f=>{const g=f.getGeometry();return g&&(g.getType()==='Polygon'||g.getType()==='MultiPolygon');});
const lines=feats.filter(f=>{const g=f.getGeometry();return g&&(g.getType()==='LineString'||g.getType()==='MultiLineString');});
if(target==='auto'){
if(polys.length>0&&lines.length===0){
// Largest polygon = boundary, rest = buildings
if(polys.length===1){boundarySource.clear();boundarySource.addFeatures(polys);}
else{
polys.sort((a,b)=>b.getGeometry().getArea()-a.getGeometry().getArea());
boundarySource.clear();boundarySource.addFeature(polys[0]);
featureSource.addFeatures(polys.slice(1));
}
} else if(lines.length>0){
lines.forEach(f=>{f.set('road_type',2);f.set('road_id',`R${nextRoadId++}`);roadFeatureList.push({id:f.get('road_id'),type:2,feature:f});});
roadSource.addFeatures(lines);
updateRoadList();
} else {
boundarySource.clear();if(polys.length)boundarySource.addFeature(polys[0]);
featureSource.addFeatures(polys.slice(1));
lines.forEach(f=>{f.set('road_type',2);f.set('road_id',`R${nextRoadId++}`);roadFeatureList.push({id:f.get('road_id'),type:2,feature:f});});
roadSource.addFeatures(lines);updateRoadList();
}
} else if(target==='boundary'){
boundarySource.clear();
if(polys.length)boundarySource.addFeature(polys[0]);
else if(feats.length)boundarySource.addFeature(feats[0]);
} else if(target==='roads'){
const toAdd=lines.length?lines:feats;
toAdd.forEach(f=>{f.set('road_type',2);f.set('road_id',`R${nextRoadId++}`);roadFeatureList.push({id:f.get('road_id'),type:2,feature:f});});
roadSource.addFeatures(toAdd);updateRoadList();
} else if(target==='features'){
featureSource.addFeatures(polys.length?polys:feats);
}
// Zoom to imported data
const allSrc=new ol.source.Vector({features:[...boundarySource.getFeatures(),...roadSource.getFeatures(),...featureSource.getFeatures()]});
if(!allSrc.isEmpty())map.getView().fit(allSrc.getExtent(),{padding:[60,60,60,60],maxZoom:18,duration:600});
}
// ── KML parser ──
function kmlToGeoJSON(kmlText){
const parser=new DOMParser();
const doc=parser.parseFromString(kmlText,'text/xml');
const fmt=new ol.format.KML({extractStyles:false});
const feats=fmt.readFeatures(doc,{dataProjection:'EPSG:4326',featureProjection:'EPSG:4326'});
const gjFmt=new ol.format.GeoJSON();
return {type:'FeatureCollection',features:feats.map(f=>JSON.parse(gjFmt.writeFeature(f,{dataProjection:'EPSG:4326',featureProjection:'EPSG:4326'})))};
}
// ── GPX parser ──
function gpxToGeoJSON(gpxText){
const fmt=new ol.format.GPX();
const feats=fmt.readFeatures(gpxText,{dataProjection:'EPSG:4326',featureProjection:'EPSG:4326'});
const gjFmt=new ol.format.GeoJSON();
return {type:'FeatureCollection',features:feats.map(f=>JSON.parse(gjFmt.writeFeature(f,{dataProjection:'EPSG:4326',featureProjection:'EPSG:4326'})))};
}
// ── CSV parser (expects lat,lon or x,y columns) ──
function csvToGeoJSON(csvText){
const lines=csvText.trim().split('\n');
const headers=lines[0].split(',').map(h=>h.trim().replace(/"/g,'').toLowerCase());
const latIdx=headers.findIndex(h=>h==='lat'||h==='latitude'||h==='y');
const lonIdx=headers.findIndex(h=>h==='lon'||h==='lng'||h==='longitude'||h==='x');
if(latIdx<0||lonIdx<0)throw new Error('CSV must have lat/lon or x/y columns');
const features=lines.slice(1).map((line,i)=>{
const cols=line.split(',').map(c=>c.trim().replace(/"/g,''));
const lat=parseFloat(cols[latIdx]),lon=parseFloat(cols[lonIdx]);
if(isNaN(lat)||isNaN(lon))return null;
const props={};headers.forEach((h,j)=>{props[h]=cols[j]||'';});
return {type:'Feature',geometry:{type:'Point',coordinates:[lon,lat]},properties:props};
}).filter(Boolean);
return {type:'FeatureCollection',features};
}
// ── Basic DXF parser (LINE, LWPOLYLINE, POLYLINE entities) ──
function dxfToGeoJSON(dxfText){
const features=[];
const lines=dxfText.split('\n').map(l=>l.trim());
let i=0;
while(i<lines.length){
if(lines[i]==='LINE'&&lines[i-1]==='0'){
// Extract start/end points
const vals={};
for(let j=i+1;j<Math.min(i+30,lines.length)-1;j+=2){
vals[lines[j]]=parseFloat(lines[j+1]);
}
if(!isNaN(vals['10'])&&!isNaN(vals['20'])&&!isNaN(vals['11'])&&!isNaN(vals['21'])){
features.push({type:'Feature',geometry:{type:'LineString',coordinates:[[vals['10'],vals['20']],[vals['11'],vals['21']]]},properties:{type:'line'}});
}
}
if((lines[i]==='LWPOLYLINE'||lines[i]==='POLYLINE')&&lines[i-1]==='0'){
// Collect coordinates
const coords=[];
for(let j=i+1;j<Math.min(i+500,lines.length)-1;j+=2){
if(lines[j]==='10')coords.push([parseFloat(lines[j+1]),0]);
if(lines[j]==='20'&&coords.length)coords[coords.length-1][1]=parseFloat(lines[j+1]);
if(lines[j]==='0'&&j>i+1)break;
}
if(coords.length>=2){
const isClosedPoly=coords.length>=3&&Math.hypot(coords[0][0]-coords[coords.length-1][0],coords[0][1]-coords[coords.length-1][1])<1e-6;
features.push({type:'Feature',geometry:{type:isClosedPoly?'Polygon':'LineString',coordinates:isClosedPoly?[coords]:coords},properties:{type:'polyline'}});
}
}
i++;
}
if(!features.length)throw new Error('No LINE or LWPOLYLINE entities found in DXF');
return {type:'FeatureCollection',features};
}
// ═══════════════════════════════════════════════
// UI HELPERS
// ═══════════════════════════════════════════════
function switchTab(name){
const names=['digitize','config','layers','results','io'];
document.querySelectorAll('.tab').forEach((t,i)=>t.classList.toggle('active',names[i]===name));
document.querySelectorAll('.tab-panel').forEach(p=>p.classList.remove('active'));
document.getElementById('tab-'+name).classList.add('active');
}
function setStatus(msg,state='ok'){
document.getElementById('status-msg').textContent=msg;
document.getElementById('status-dot').className=state==='loading'?'loading':state==='error'?'error':'';
}
function showLoading(show){document.getElementById('loading').classList.toggle('show',show);}
let toastTimer;
function showToast(msg,type='info'){
const t=document.getElementById('toast');
t.textContent=msg;t.className=type;t.classList.add('show');
clearTimeout(toastTimer);toastTimer=setTimeout(()=>t.classList.remove('show'),4000);
}
// ═══════════════════════════════════════════════
// INIT
// ═══════════════════════════════════════════════
applyTheme(currentTheme);
renderBasemapGrids();
renderLayerLists();
initFieldHints();
buildRefTable();
// Sample boundary (~Kumasi)
(()=>{
const coords = [
[-1.6129, 6.6925],
[-1.6118, 6.6924 ],
[-1.61173 ,6.6920 ],
[-1.6124 , 6.6918 ],
[-1.6129 ,6.6925 ]
];
const geom=new ol.geom.Polygon([coords.map(c=>ol.proj.fromLonLat(c))]);
boundarySource.addFeature(new ol.Feature({geometry:geom}));
map.getView().fit(geom.getExtent(),{padding:[60,60,60,60],duration:800});
})();
// API health
(async()=>{
try{
const r=await fetch(`${API_URL}/health`,{signal:AbortSignal.timeout(3000)});
setStatus(r.ok?'API connected. Digitize a boundary to begin.':'⚠ API offline — check port 8000.',r.ok?'ok':'error');
}catch{setStatus('⚠ Backend not reachable on port 8000.','error');}
})();
</script>
</body>
</html>