2049 lines
108 KiB
HTML
2049 lines
108 KiB
HTML
<!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 & 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 & 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">Low–Med 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">m²</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">m²</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 & 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 id="stat-backend-warnings" style="display:none;margin-top:6px;background:rgba(245,166,35,0.1);border:1px solid rgba(245,166,35,0.3);border-radius:5px;padding:7px 9px">
|
||
<div style="font-size:9px;font-weight:700;color:#f5a623;margin-bottom:3px">⚠ Configuration Warnings</div>
|
||
<div id="stat-backend-warn-list" style="font-size:9px;color:#f5a623;line-height:1.7"></div>
|
||
</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')||'local';
|
||
const RCOLS={'primary':'#f78166','secondary':'#f97316','local':'#f5a623','lane':'#60a5fa'};
|
||
const c=RCOLS[rt]||'#f5a623';
|
||
return new ol.style.Style({
|
||
stroke:new ol.style.Stroke({color:c,width:3}),
|
||
image:new ol.style.Circle({radius:4,fill:new ol.style.Fill({color:c})})
|
||
});
|
||
}})},
|
||
{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.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.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;}
|
||
// Client-side 100 ha pre-check (server enforces exactly)
|
||
{
|
||
const bFeat=boundarySource.getFeatures()[0];
|
||
if(bFeat){
|
||
const bGeomLL=bFeat.getGeometry().clone().transform('EPSG:3857','EPSG:4326');
|
||
const approxHa=ol.sphere.getArea(bGeomLL)/10000;
|
||
if(approxHa>100){
|
||
showToast(`Site (~${Math.round(approxHa)} ha) exceeds the 100 ha limit. Digitize a smaller boundary.`,'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(s) respected`);
|
||
if(s.user_roads_drawn>0)extras.push(`${s.user_roads_drawn} user road(s)`);
|
||
if(s.dominant_axis_deg!==undefined)extras.push(`axis ${s.dominant_axis_deg}°`);
|
||
document.getElementById('stat-extra-info').textContent=extras.join(' · ');
|
||
|
||
// Show backend warnings
|
||
const backWarnEl=document.getElementById('stat-backend-warnings');
|
||
const bwListEl=document.getElementById('stat-backend-warn-list');
|
||
const bwarns=s.warnings||[];
|
||
if(backWarnEl&&bwListEl){
|
||
backWarnEl.style.display=bwarns.length?'block':'none';
|
||
bwListEl.innerHTML=bwarns.map(w=>`<div>• ${w}</div>`).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.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.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 (Nairobi)
|
||
(()=>{
|
||
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> |