ParcelToolv4/frontend/index.html
2026-03-03 11:33:31 +03:00

1603 lines
75 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

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

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>ParcelGen — Land Subdivision Tool</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Space+Mono:ital,wght@0,400;0,700;1,400&family=DM+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>
:root {
--bg: #0d1117;
--surface: #161b22;
--surface2: #21262d;
--border: #30363d;
--accent: #58a6ff;
--accent2: #3fb950;
--warn: #f78166;
--road: #f0c84a;
--text: #e6edf3;
--text2: #8b949e;
--culdesac: #bc8cff;
--panel-w: 330px;
--imperial: #f97316;
--metric: #58a6ff;
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; overflow: hidden; font-family: 'DM Sans', sans-serif; background: var(--bg); color: var(--text); }
#app { display: flex; height: 100vh; }
#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: 10;
}
#map-container { flex: 1; position: relative; overflow: hidden; }
#map { width: 100%; height: 100%; }
/* ── Header ── */
.sidebar-header { padding: 14px 18px 12px; border-bottom: 1px solid var(--border); background: var(--bg); }
.sidebar-header-top { display: flex; align-items: center; justify-content: space-between; }
.sidebar-header h1 { font-family: 'Space Mono', monospace; font-size: 16px; color: var(--accent); letter-spacing: -0.5px; }
.sidebar-header p { font-size: 11px; color: var(--text2); margin-top: 3px; }
/* ── Unit Switcher ── */
.unit-switcher {
display: flex; align-items: center;
background: var(--surface2); border: 1px solid var(--border);
border-radius: 20px; padding: 3px; gap: 2px; flex-shrink: 0;
}
.unit-btn {
padding: 4px 10px; border-radius: 16px; border: none;
font-size: 11px; font-weight: 700; letter-spacing: 0.5px;
cursor: pointer; transition: all 0.2s; font-family: 'Space Mono', monospace;
background: transparent; color: var(--text2);
}
.unit-btn.active-metric { background: var(--metric); color: #000; }
.unit-btn.active-imperial { background: var(--imperial); color: #fff; }
/* ── Unit badge ── */
.unit-badge {
display: inline-flex; align-items: center; justify-content: center;
padding: 2px 7px; border-radius: 10px; font-size: 10px; font-weight: 700;
font-family: 'Space Mono', monospace; flex-shrink: 0; transition: all 0.3s;
}
.unit-badge.metric { background: rgba(88,166,255,0.15); color: var(--metric); border: 1px solid rgba(88,166,255,0.3); }
.unit-badge.imperial { background: rgba(249,115,22,0.15); color: var(--imperial); border: 1px solid rgba(249,115,22,0.3); }
/* ── Unit hint ── */
.unit-hint { font-size: 10px; color: var(--text2); margin-top: 3px; font-style: italic; min-height: 14px; }
.unit-hint.has-hint { color: var(--imperial); }
/* ── Tabs ── */
.tabs { display: flex; border-bottom: 1px solid var(--border); }
.tab {
flex: 1; padding: 9px 4px; font-size: 10px; font-weight: 600;
text-transform: uppercase; letter-spacing: 0.5px; color: var(--text2);
cursor: pointer; text-align: center; border-bottom: 2px solid transparent; transition: all 0.2s;
}
.tab.active { color: var(--accent); border-bottom-color: var(--accent); }
.tab:hover:not(.active) { color: var(--text); background: var(--surface2); }
.tab-panel { display: none; flex: 1; overflow-y: auto; padding: 14px; flex-direction: column; gap: 10px; }
.tab-panel.active { display: flex; }
/* ── Tool Groups ── */
.tool-group { background: var(--surface2); border: 1px solid var(--border); border-radius: 8px; padding: 12px; }
.tool-group h3 {
font-size: 10px; font-weight: 700; text-transform: uppercase;
letter-spacing: 0.8px; color: var(--text2); margin-bottom: 10px;
display: flex; align-items: center; gap: 6px;
}
.tool-group h3::before { content:''; display:block; width:6px; height:6px; border-radius:50%; background:var(--accent); flex-shrink:0; }
/* ── Buttons ── */
.btn {
display: flex; align-items: center; gap: 8px; width: 100%;
padding: 8px 12px; margin-bottom: 5px;
background: var(--surface); border: 1px solid var(--border);
border-radius: 6px; color: var(--text); font-size: 12px;
cursor: pointer; transition: all 0.15s; font-family: inherit;
}
.btn:last-child { margin-bottom: 0; }
.btn:hover { background: var(--surface2); border-color: var(--accent); color: var(--accent); }
.btn.active { background: rgba(88,166,255,0.15); border-color: var(--accent); color: var(--accent); }
.btn.road-active { background: rgba(240,200,74,0.12); border-color: var(--road); color: var(--road); }
.btn.feature-active { background: rgba(188,140,255,0.12); border-color: var(--culdesac); color: var(--culdesac); }
.btn.danger { border-color: var(--warn); color: var(--warn); }
.btn.danger:hover { background: rgba(247,129,102,0.12); }
.btn.success { background: var(--accent2); border-color: var(--accent2); color: #000; font-weight: 600; }
.btn.success:hover { background: #52d65e; }
.btn.export { border-color: var(--culdesac); color: var(--culdesac); }
.btn.export:hover { background: rgba(188,140,255,0.12); }
.btn-icon { width: 16px; height: 16px; flex-shrink: 0; }
/* ── Fields ── */
.field { margin-bottom: 8px; }
.field:last-child { margin-bottom: 0; }
.field label { display: flex; align-items: center; justify-content: space-between; font-size: 11px; color: var(--text2); margin-bottom: 4px; font-weight: 500; }
.field-input-row { display: flex; align-items: center; gap: 6px; }
.field input {
flex: 1; padding: 7px 9px; background: var(--surface); border: 1px solid var(--border);
border-radius: 5px; color: var(--text); font-size: 12px; font-family: inherit;
transition: border-color 0.15s; min-width: 0;
}
.field input:focus { outline: none; border-color: var(--accent); }
/* ── Imperial banner ── */
.conversion-banner {
background: rgba(249,115,22,0.1); border: 1px solid rgba(249,115,22,0.3);
border-radius: 6px; padding: 8px 10px; font-size: 11px; color: var(--imperial);
display: none; align-items: flex-start; gap: 8px; margin-bottom: 4px;
}
.conversion-banner.visible { display: flex; }
/* ── Stats ── */
.stat-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; }
.stat-card { background: var(--surface); border: 1px solid var(--border); border-radius: 6px; padding: 9px; }
.stat-card .val { font-family: 'Space Mono', monospace; font-size: 18px; color: var(--accent); font-weight: 700; }
.stat-card .lbl { font-size: 10px; color: var(--text2); margin-top: 2px; text-transform: uppercase; letter-spacing: 0.4px; }
.dual-value .primary { font-family: 'Space Mono', monospace; color: var(--accent); font-weight: 700; font-size: 12px; }
.dual-value .secondary { color: var(--text2); font-size: 10px; font-style: italic; }
/* ── Toggle ── */
.toggle-row { display: flex; align-items: center; justify-content: space-between; padding: 4px 0; font-size: 12px; }
.toggle { position: relative; width: 34px; height: 19px; flex-shrink: 0; }
.toggle input { opacity: 0; width: 0; height: 0; }
.toggle-slider { position: absolute; cursor: pointer; inset: 0; background: var(--surface2); border: 1px solid var(--border); border-radius: 20px; transition: 0.25s; }
.toggle-slider:before { content:''; position:absolute; height:13px; width:13px; left:2px; bottom:2px; background:var(--text2); border-radius:50%; transition:0.25s; }
.toggle input:checked + .toggle-slider { background: rgba(88,166,255,0.25); border-color: var(--accent); }
.toggle input:checked + .toggle-slider:before { transform: translateX(15px); background: var(--accent); }
/* ── Parcel list ── */
.parcel-list { max-height: 280px; overflow-y: auto; }
.parcel-item {
display: flex; justify-content: space-between; align-items: center;
padding: 7px 9px; 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: 'Space Mono', monospace; color: var(--accent); font-size: 10px; }
.parcel-item .dims { color: var(--text2); font-size: 10px; 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: 2px 6px; border-radius: 8px; font-size: 9px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.3px; }
.badge.built { background: rgba(247,129,102,0.2); color: var(--warn); }
.badge.vacant { background: rgba(63,185,80,0.2); color: var(--accent2); }
/* ── Legend ── */
.legend-item { display: flex; align-items: center; gap: 8px; font-size: 11px; margin-bottom: 5px; }
.legend-item:last-child { margin-bottom: 0; }
.legend-swatch { width: 16px; height: 10px; border-radius: 2px; flex-shrink: 0; }
.legend-label { flex: 1; color: var(--text2); }
.legend-toggle-row { display: flex; align-items: center; gap: 8px; font-size: 11px; margin-bottom: 6px; }
.legend-toggle-row:last-child { margin-bottom: 0; }
.legend-toggle-row .leg-swatch { width: 14px; height: 14px; border-radius: 3px; flex-shrink: 0; }
.legend-toggle-row .leg-label { flex: 1; color: var(--text2); }
.legend-opacity { flex-shrink: 0; display: flex; align-items: center; gap: 6px; }
.legend-opacity input[type=range] { width: 50px; accent-color: var(--accent); cursor: pointer; }
.legend-opacity span { font-size: 10px; color: var(--text2); font-family: 'Space Mono', monospace; width: 26px; text-align: right; }
/* ══════════════════════════════════
BASEMAP & LAYER SWITCHER PANEL
══════════════════════════════════ */
#map-controls {
position: absolute; bottom: 38px; right: 12px;
z-index: 20; display: flex; flex-direction: column; align-items: flex-end; gap: 8px;
}
/* Floating panel */
.map-panel {
background: rgba(22, 27, 34, 0.97);
border: 1px solid var(--border); border-radius: 10px;
backdrop-filter: blur(16px);
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
overflow: hidden;
transition: all 0.25s cubic-bezier(0.4,0,0.2,1);
min-width: 230px;
}
.map-panel-header {
display: flex; align-items: center; justify-content: space-between;
padding: 9px 12px; border-bottom: 1px solid var(--border);
cursor: pointer; user-select: none;
}
.map-panel-header:hover { background: rgba(255,255,255,0.04); }
.map-panel-title { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.7px; color: var(--text2); display: flex; align-items: center; gap: 7px; }
.map-panel-title svg { color: var(--accent); }
.panel-chevron { color: var(--text2); font-size: 10px; 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(0.4,0,0.2,1); }
.map-panel-body.open { max-height: 600px; }
.map-panel-inner { padding: 10px 12px 12px; }
/* ── Basemap grid ── */
.basemap-section-label { font-size: 10px; color: var(--text2); font-weight: 600; text-transform: uppercase; letter-spacing: 0.6px; margin-bottom: 7px; margin-top: 4px; }
.basemap-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; margin-bottom: 10px; }
.basemap-card {
position: relative; border-radius: 7px; 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(88,166,255,0.5); transform: scale(1.02); }
.basemap-card.active { border-color: var(--accent); }
.basemap-card.active::after {
content: '✓'; position: absolute; top: 4px; right: 5px;
background: var(--accent); color: #000; font-size: 9px; font-weight: 700;
border-radius: 50%; width: 15px; height: 15px;
display: flex; align-items: center; justify-content: center;
line-height: 1;
}
.basemap-thumb {
width: 100%; height: 100%; object-fit: cover;
display: block; pointer-events: none;
}
.basemap-thumb-placeholder {
width: 100%; height: 100%; display: flex; align-items: center; justify-content: center;
font-size: 22px;
}
.basemap-label {
position: absolute; bottom: 0; left: 0; right: 0;
background: linear-gradient(transparent, rgba(0,0,0,0.8));
padding: 8px 5px 4px; font-size: 9px; font-weight: 600;
color: #fff; text-align: center; letter-spacing: 0.3px; text-transform: uppercase;
}
/* ── Divider ── */
.panel-divider { height: 1px; background: var(--border); margin: 8px 0; }
/* ── Layer toggle rows ── */
.layer-row {
display: flex; align-items: center; gap: 8px;
padding: 5px 0; font-size: 11px; color: var(--text2);
border-bottom: 1px solid rgba(255,255,255,0.04);
}
.layer-row:last-child { border-bottom: none; }
.layer-row .swatch { width: 12px; height: 12px; border-radius: 2px; flex-shrink: 0; }
.layer-row .lname { flex: 1; }
.layer-row .opacity-wrap { display: flex; align-items: center; gap: 4px; }
.layer-row .opacity-wrap input { width: 44px; accent-color: var(--accent); cursor: pointer; }
.layer-row .opacity-wrap span { font-size: 9px; font-family: 'Space Mono', monospace; color: var(--text2); width: 24px; text-align: right; }
.layer-row input[type=checkbox] { width: 14px; height: 14px; accent-color: var(--accent); cursor: pointer; flex-shrink: 0; }
/* ── Control buttons on map ── */
#ctrl-btns {
display: flex; flex-direction: column; gap: 5px;
}
.ctrl-btn {
background: rgba(22,27,34,0.97); border: 1px solid var(--border);
border-radius: 7px; width: 34px; height: 34px;
display: flex; align-items: center; justify-content: center;
cursor: pointer; color: var(--text2); font-size: 14px;
backdrop-filter: blur(8px); transition: all 0.15s;
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
}
.ctrl-btn:hover { background: var(--surface2); color: var(--accent); border-color: var(--accent); }
.ctrl-btn.active-ctrl { background: rgba(88,166,255,0.15); color: var(--accent); border-color: var(--accent); }
/* ── Status bar ── */
#statusbar {
position: absolute; bottom: 0; left: 0; right: 0;
background: rgba(13,17,23,0.92); border-top: 1px solid var(--border);
padding: 5px 14px; display: flex; align-items: center; gap: 10px;
font-size: 11px; color: var(--text2); backdrop-filter: blur(8px); z-index: 5;
}
#status-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--accent2); flex-shrink: 0; }
#status-dot.loading { background: var(--road); animation: pulse 0.8s infinite; }
#status-dot.error { background: var(--warn); }
#status-msg { flex: 1; }
#coord-display { font-family: 'Space Mono', monospace; font-size: 10px; }
/* ── Map toolbar ── */
#toolbar {
position: absolute; top: 12px; left: 54px;
display: flex; gap: 5px; z-index: 5; flex-wrap: wrap; max-width: 520px;
}
.tool-btn-map {
background: rgba(22,27,34,0.95); border: 1px solid var(--border);
border-radius: 6px; padding: 7px 12px; color: var(--text); font-size: 11px;
cursor: pointer; display: flex; align-items: center; gap: 5px;
transition: all 0.15s; font-family: 'DM Sans', sans-serif; font-weight: 500;
white-space: nowrap; backdrop-filter: blur(4px);
}
.tool-btn-map:hover, .tool-btn-map.active { background: var(--surface2); border-color: var(--accent); color: var(--accent); }
.tool-btn-map.road-active { border-color: var(--road); color: var(--road); background: rgba(240,200,74,0.1); }
.tool-btn-map.feat-active { border-color: var(--culdesac); color: var(--culdesac); background: rgba(188,140,255,0.1); }
/* ── Unit indicator ── */
#unit-indicator {
position: absolute; top: 12px; right: 12px;
background: rgba(22,27,34,0.95); border: 1px solid var(--border);
border-radius: 6px; padding: 5px 11px; z-index: 5;
font-family: 'Space Mono', monospace; font-size: 10px;
display: flex; align-items: center; gap: 7px; 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(--text2); }
/* ── Tooltip ── */
#tooltip {
position: absolute; background: rgba(22,27,34,0.97);
border: 1px solid var(--border); border-radius: 7px; padding: 10px 13px;
font-size: 11px; pointer-events: none; display: none; z-index: 1000;
max-width: 220px; backdrop-filter: blur(10px); box-shadow: 0 6px 24px rgba(0,0,0,0.5);
}
#tooltip .tip-title { font-weight: 700; font-size: 12px; color: var(--accent); margin-bottom: 6px; font-family: 'Space Mono', monospace; }
#tooltip .tip-row { display: flex; justify-content: space-between; gap: 14px; color: var(--text2); margin-top: 3px; }
#tooltip .tip-row span:last-child { color: var(--text); font-weight: 500; }
/* ── Loading ── */
#loading {
position: absolute; inset: 0; background: rgba(13,17,23,0.75);
display: none; align-items: center; justify-content: center;
z-index: 30; flex-direction: column; gap: 14px; backdrop-filter: blur(6px);
}
#loading.show { display: flex; }
.spinner { width: 38px; height: 38px; border: 3px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: spin 0.75s linear infinite; }
/* ── Toast ── */
#toast {
position: absolute; bottom: 44px; left: 50%; transform: translateX(-50%) translateY(10px);
background: var(--surface); border: 1px solid var(--border); border-radius: 8px;
padding: 10px 18px; font-size: 12px; z-index: 25; max-width: 340px;
display: none; opacity: 0; transition: all 0.3s; text-align: center;
box-shadow: 0 8px 24px rgba(0,0,0,0.4);
}
#toast.show { display: block; opacity: 1; transform: translateX(-50%) translateY(0); }
#toast.success { border-left: 3px solid var(--accent2); }
#toast.error { border-left: 3px solid var(--warn); }
#toast.info { border-left: 3px solid var(--accent); }
/* ── Measure tool ── */
.tool-btn-map.measure-active { border-color: #34d399; color: #34d399; background: rgba(52,211,153,0.1); }
#measure-label {
position: absolute; background: rgba(13,17,23,0.92); border: 1px solid #34d399;
color: #34d399; border-radius: 5px; padding: 3px 8px; font-size: 11px;
font-family: 'Space Mono', monospace; font-weight: 700;
pointer-events: none; display: none; z-index: 20; white-space: nowrap;
box-shadow: 0 2px 8px rgba(0,0,0,0.4);
}
/* ── Click-selected parcel panel ── */
#selected-panel {
position: absolute; bottom: 38px; left: 54px;
background: rgba(22,27,34,0.97); border: 1px solid var(--accent);
border-radius: 9px; padding: 12px 14px; z-index: 20;
font-size: 11px; min-width: 200px; max-width: 280px;
box-shadow: 0 6px 24px rgba(0,0,0,0.5); backdrop-filter: blur(12px);
display: none;
}
#selected-panel .sel-title {
font-family: 'Space Mono', monospace; font-size: 13px; font-weight: 700;
color: var(--accent); margin-bottom: 8px;
display: flex; align-items: center; justify-content: space-between;
}
#selected-panel .sel-close {
cursor: pointer; color: var(--text2); font-size: 14px; 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: 12px;
padding: 3px 0; border-bottom: 1px solid rgba(255,255,255,0.05);
color: var(--text2);
}
#selected-panel .sel-row:last-child { border-bottom: none; }
#selected-panel .sel-row span:last-child { color: var(--text); font-weight: 500; font-family: 'Space Mono', monospace; font-size: 10px; }
@keyframes spin { to { transform: rotate(360deg); } }
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.4} }
::-webkit-scrollbar { width: 4px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
body.imperial .field input { border-color: rgba(249,115,22,0.4); }
body.imperial .field input:focus { border-color: var(--imperial); }
</style>
</head>
<body>
<div id="app">
<!-- ══════════════ SIDEBAR ══════════════ -->
<div id="sidebar">
<div class="sidebar-header">
<div class="sidebar-header-top">
<h1>⬡ ParcelGen</h1>
<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>
<p>Land Subdivision &amp; Parcel Tool</p>
</div>
<div class="tabs">
<div class="tab active" onclick="switchTab('draw')">Draw</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>
<!-- ── DRAW TAB ── -->
<div id="tab-draw" class="tab-panel active">
<div class="tool-group">
<h3>Study Area Boundary</h3>
<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>
Draw Boundary Polygon
</button>
<button class="btn danger" onclick="clearLayer('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="M6 18L18 6M6 6l12 12"/></svg>
Clear Boundary
</button>
</div>
<div class="tool-group">
<h3>Road Centrelines</h3>
<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 12h14M12 5l7 7-7 7"/></svg>
Draw Road Line
</button>
<button class="btn danger" onclick="clearLayer('roads')">Clear Roads</button>
<p style="font-size:10px;color:var(--text2);margin-top:6px;line-height:1.5;">Click vertices · Double-click to finish · Leave empty for auto-grid</p>
</div>
<div class="tool-group">
<h3>Existing Features (Buildings)</h3>
<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>
Draw Building Footprint
</button>
<button class="btn danger" onclick="clearLayer('features')">Clear Features</button>
<p style="font-size:10px;color:var(--text2);margin-top:6px;line-height:1.5;">Each building will be contained within exactly one parcel.</p>
</div>
<div class="tool-group">
<h3>Actions</h3>
<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>
Run Subdivision
</button>
<button class="btn" onclick="clearResults()">Clear Results</button>
<button class="btn export" onclick="exportGeoJSON()">
<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 GeoJSON
</button>
</div>
</div>
<!-- ── CONFIG TAB ── -->
<div id="tab-config" class="tab-panel">
<div class="conversion-banner" id="imperial-banner">
<svg width="14" height="14" fill="none" stroke="currentColor" viewBox="0 0 24 24" style="flex-shrink:0;margin-top:1px;"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
<div>Entering values in <strong>feet</strong>. Auto-converted to metres for processing.</div>
</div>
<div class="tool-group">
<h3>Parcel Dimensions</h3>
<div class="field">
<label>Min Frontage <span class="unit-badge metric" id="badge-frontage">m</span></label>
<div class="field-input-row"><input type="number" id="cfg-frontage" value="12" min="1" step="0.5"/></div>
<div class="unit-hint" id="hint-frontage"></div>
</div>
<div class="field">
<label>Min Depth <span class="unit-badge metric" id="badge-depth">m</span></label>
<div class="field-input-row"><input type="number" id="cfg-depth" value="25" min="1" step="0.5"/></div>
<div class="unit-hint" id="hint-depth"></div>
</div>
</div>
<div class="tool-group">
<h3>Road &amp; Block Parameters</h3>
<div class="field">
<label>Road Width <span class="unit-badge metric" id="badge-road-width">m</span></label>
<div class="field-input-row"><input type="number" id="cfg-road-width" value="9" min="1" step="0.5"/></div>
<div class="unit-hint" id="hint-road-width"></div>
</div>
<div class="field">
<label>Max Block Length <span class="unit-badge metric" id="badge-block-len">m</span></label>
<div class="field-input-row"><input type="number" id="cfg-block-len" value="120" min="10" step="1"/></div>
<div class="unit-hint" id="hint-block-len"></div>
</div>
<div class="field">
<label>Corner Radius <span class="unit-badge metric" id="badge-corner">m</span></label>
<div class="field-input-row"><input type="number" id="cfg-corner" value="3" min="0" step="0.25"/></div>
<div class="unit-hint" id="hint-corner"></div>
</div>
</div>
<div class="tool-group">
<h3>Options</h3>
<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>
<div class="tool-group" id="conversion-ref">
<h3>Unit Reference</h3>
<div id="unit-ref-table"></div>
</div>
</div>
<!-- ── LAYERS TAB ── -->
<div id="tab-layers" class="tab-panel">
<div class="tool-group">
<h3>Basemap</h3>
<div id="sidebar-basemap-grid" class="basemap-grid"></div>
</div>
<div class="tool-group">
<h3>Drawing Layers</h3>
<div id="draw-layer-list"></div>
</div>
<div class="tool-group">
<h3>Result Layers</h3>
<div id="result-layer-list"></div>
</div>
<div class="tool-group">
<h3>Legend</h3>
<div class="legend-item"><div class="legend-swatch" style="background:rgba(63,185,80,0.3);border:1px solid #3fb950;"></div><span class="legend-label">Site Boundary</span></div>
<div class="legend-item"><div class="legend-swatch" style="background:rgba(240,200,74,0.5);"></div><span class="legend-label">Road Surface</span></div>
<div class="legend-item"><div class="legend-swatch" style="background:rgba(88,166,255,0.3);border:1px solid #58a6ff;"></div><span class="legend-label">Vacant Parcels</span></div>
<div class="legend-item"><div class="legend-swatch" style="background:rgba(247,129,102,0.3);border:1px solid #f78166;"></div><span class="legend-label">Built Parcels (existing bldg)</span></div>
<div class="legend-item"><div class="legend-swatch" style="background:rgba(247,129,102,0.4);border:2px dashed #ff4444;"></div><span class="legend-label">⚠ No Road Access</span></div>
<div class="legend-item"><div class="legend-swatch" style="background:rgba(188,140,255,0.3);border:2px dashed #bc8cff;"></div><span class="legend-label">Existing Buildings</span></div>
<div class="legend-item"><div class="legend-swatch" style="background:rgba(188,140,255,0.4);"></div><span class="legend-label">Cul-de-sacs</span></div>
<div class="legend-item"><div class="legend-swatch" style="background:transparent;border:2px dashed #bc8cff;"></div><span class="legend-label">Existing Buildings</span></div>
</div>
</div>
<!-- ── RESULTS TAB ── -->
<div id="tab-results" class="tab-panel">
<div id="no-results" style="text-align:center;padding:40px 16px;color:var(--text2);">
<div style="font-size:36px;margin-bottom:10px;">📐</div>
<div style="font-size:12px;">Run subdivision to see results here.</div>
</div>
<div id="results-content" style="display:none;flex-direction:column;gap:10px;">
<div class="stat-grid">
<div class="stat-card"><div class="val" id="stat-parcels"></div><div class="lbl">Parcels</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 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 Access ⚠</div>
</div>
</div>
<div class="tool-group">
<h3>Areas</h3>
<div style="font-size:12px;line-height:2.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 Parcel</span><span class="dual-value" id="stat-avg-area"></span></div>
</div>
<div id="stat-extra-info" style="font-size:10px;color:var(--accent2);margin-top:6px;font-style:italic;"></div>
</div>
<div class="tool-group">
<h3>Parcel List <span style="color:var(--text2);font-weight:400;text-transform:none;letter-spacing:0;" id="parcel-count-label"></span></h3>
<div class="parcel-list" id="parcel-list"></div>
</div>
</div>
</div>
</div>
<!-- ══════════════ MAP ══════════════ -->
<div id="map-container">
<div id="map"></div>
<!-- Drawing 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-measure" onclick="toggleMeasure()">📏 Measure</div>
<div class="tool-btn-map" onclick="stopAll()">✕ Stop</div>
</div>
<!-- Measure floating 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>
<!-- Map controls (bottom-right) -->
<div id="map-controls">
<!-- Floating basemap + layer panel -->
<div class="map-panel" id="panel-basemap">
<div class="map-panel-header" onclick="togglePanel('basemap')">
<div class="map-panel-title">
<svg width="13" height="13" 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="13" height="13" 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">Draw a boundary polygon to begin.</span>
<span id="coord-display" style="font-family:'Space Mono',monospace;font-size:10px;"></span>
</div>
<!-- Tooltip -->
<div id="tooltip">
<div class="tip-title" id="tip-title"></div>
<div id="tip-body"></div>
</div>
<!-- Loading -->
<div id="loading">
<div class="spinner"></div>
<div style="font-family:'Space Mono',monospace;color:var(--accent);font-size:12px;">Subdividing parcels…</div>
</div>
<!-- Toast -->
<div id="toast"></div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/ol@9.1.0/dist/ol.js"></script>
<script>
// ═══════════════════════════════════════════════════════
// UNIT SYSTEM
// ═══════════════════════════════════════════════════════
const FT_PER_M = 3.28084, M_PER_FT = 0.3048;
const SQFT_PER_SQM = FT_PER_M * FT_PER_M;
let currentUnitSystem = 'metric';
function toMetres(v, sys) { return (sys||currentUnitSystem)==='imperial' ? v*M_PER_FT : v; }
function fromMetres(m, sys) { return (sys||currentUnitSystem)==='imperial' ? m*FT_PER_M : m; }
function formatArea(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 formatAreaDual(m2) {
const p = formatArea(m2), s = formatArea(m2, currentUnitSystem==='metric'?'imperial':'metric');
return `<span class="primary">${p}</span> <span class="secondary">(${s})</span>`;
}
function formatLength(m, sys) {
sys=sys||currentUnitSystem;
return sys==='imperial' ? (m*FT_PER_M).toFixed(1)+' ft' : m.toFixed(1)+' m';
}
const CONFIG_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-road-width', badge:'badge-road-width', hint:'hint-road-width', mDefault:9, iDefault:30},
{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(system) {
const wasImp = currentUnitSystem==='imperial';
currentUnitSystem = system;
document.body.classList.toggle('imperial', system==='imperial');
document.getElementById('btn-metric').className = 'unit-btn'+(system==='metric' ? ' active-metric':'');
document.getElementById('btn-imperial').className = 'unit-btn'+(system==='imperial' ? ' active-imperial':'');
document.getElementById('imperial-banner').classList.toggle('visible', system==='imperial');
const ind = document.getElementById('unit-indicator');
ind.className = system==='metric' ? 'metric-mode' : 'imperial-mode';
document.getElementById('unit-indicator-text').textContent = system==='metric' ? 'METRIC (m)' : 'IMPERIAL (ft)';
CONFIG_FIELDS.forEach(f => {
const inp = document.getElementById(f.id), bdg = document.getElementById(f.badge);
const cur = parseFloat(inp.value)||0;
let nv;
if (system==='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, system);
});
buildConversionRefTable();
if (lastResult) { updateResultsDisplay(lastResult); rebuildParcelList(lastResult); }
showToast(system==='metric' ? '📏 Metric — metres' : '📐 Imperial — feet', '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() {
CONFIG_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 buildConversionRefTable() {
const t = document.getElementById('unit-ref-table');
const rows = CONFIG_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;
return `<tr><td style="padding:2px 4px;color:var(--text2);text-transform:capitalize;">${f.id.replace('cfg-','').replace('-',' ')}</td>
<td style="padding:2px 4px;text-align:right;font-family:'Space Mono',monospace;color:var(--metric);">${m.toFixed(2)}</td>
<td style="padding:2px 4px;text-align:right;font-family:'Space Mono',monospace;color:var(--imperial);">${ft.toFixed(1)}</td></tr>`;
}).join('');
t.innerHTML = `<table style="width:100%;border-collapse:collapse;">
<thead><tr style="color:var(--text2);font-size:10px;">
<td style="padding:2px 4px;">Parameter</td>
<td style="padding:2px 4px;text-align:right;color:var(--metric);">Metres</td>
<td style="padding:2px 4px;text-align:right;color:var(--imperial);">Feet</td></tr></thead>
<tbody>${rows}<tr style="border-top:1px solid var(--border);">
<td style="padding:4px 4px 2px;color:var(--text2);">1 ha</td>
<td style="padding:4px 4px 2px;text-align:right;font-family:'Space Mono',monospace;color:var(--metric);">10 000 m²</td>
<td style="padding:4px 4px 2px;text-align:right;font-family:'Space Mono',monospace;color:var(--imperial);">2.471 ac</td></tr></tbody></table>`;
}
function getConfig() {
const toM = v => currentUnitSystem==='imperial' ? v*M_PER_FT : v;
return {
min_frontage: toM(parseFloat(document.getElementById('cfg-frontage').value)||12),
min_depth: toM(parseFloat(document.getElementById('cfg-depth').value)||25),
road_width: toM(parseFloat(document.getElementById('cfg-road-width').value)||9),
max_block_length: toM(parseFloat(document.getElementById('cfg-block-len').value)||120),
corner_radius: toM(parseFloat(document.getElementById('cfg-corner').value)||3),
allow_culdesac: document.getElementById('cfg-culdesac').checked,
};
}
// ═══════════════════════════════════════════════════════
// BASEMAP DEFINITIONS
// ═══════════════════════════════════════════════════════
const BASEMAPS = [
{
id: 'dark',
label: 'Dark',
emoji: '🌑',
category: 'Style',
makeSource: () => new ol.source.XYZ({
url: 'https://{a-c}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png',
attributions: '© OpenStreetMap © CARTO'
})
},
{
id: 'light',
label: 'Light',
emoji: '☀️',
category: 'Style',
makeSource: () => new ol.source.XYZ({
url: 'https://{a-c}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png',
attributions: '© OpenStreetMap © CARTO'
})
},
{
id: 'osm',
label: 'Street',
emoji: '🗺️',
category: 'Street',
makeSource: () => new ol.source.OSM()
},
{
id: 'topo',
label: 'Topo OSM',
emoji: '🏔️',
category: 'Terrain',
makeSource: () => new ol.source.XYZ({
url: 'https://tile.opentopomap.org/{z}/{x}/{y}.png',
attributions: '© OpenStreetMap contributors, © OpenTopoMap (CC-BY-SA)',
maxZoom: 17
})
},
{
id: 'satellite',
label: 'Satellite',
emoji: '🛰️',
category: 'Imagery',
makeSource: () => new ol.source.XYZ({
url: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
attributions: 'Tiles © Esri — Source: Esri, USGS, NOAA'
})
},
{
id: 'sat-labels',
label: 'Satellite+',
emoji: '🛰️',
category: 'Imagery',
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: 'terrain',
label: 'Terrain',
emoji: '⛰️',
category: 'Terrain',
makeSource: () => new ol.source.XYZ({
url: 'https://stamen-tiles-{a-d}.a.ssl.fastly.net/terrain/{z}/{x}/{y}.jpg',
attributions: 'Map tiles by Stamen Design (CC BY 3.0) — Data © OpenStreetMap contributors'
})
},
{
id: 'watercolor',
label: 'Watercolor',
emoji: '🎨',
category: 'Artistic',
makeSource: () => new ol.source.XYZ({
url: 'https://stamen-tiles-{a-d}.a.ssl.fastly.net/watercolor/{z}/{x}/{y}.jpg',
attributions: 'Map tiles by Stamen Design (CC BY 3.0) — Data © OpenStreetMap contributors'
})
},
{
id: 'esri-topo',
label: 'ESRI Topo',
emoji: '🗻',
category: 'Terrain',
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 Relief',
emoji: '🌄',
category: 'Terrain',
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: '🌊',
category: 'Terrain',
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 Lights',
emoji: '🌃',
category: 'Style',
makeSource: () => new ol.source.XYZ({
url: 'https://{a-c}.basemaps.cartocdn.com/dark_nolabels/{z}/{x}/{y}.png',
attributions: '© OpenStreetMap © CARTO'
})
},
];
let currentBasemapId = 'dark';
let baseLayer;
// ═══════════════════════════════════════════════════════
// MAP SETUP
// ═══════════════════════════════════════════════════════
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();
showToast(`🗺 Basemap: ${bm.label}`, 'info');
}
// ─── Overlay 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(63,185,80,0.4)',
layer: new ol.layer.Vector({
source: boundarySource, zIndex: 10,
style: new ol.style.Style({
fill: new ol.style.Fill({ color: 'rgba(63,185,80,0.07)' }),
stroke: new ol.style.Stroke({ color: '#3fb950', width: 2, lineDash: [6,3] })
})
})
},
{
id: 'features', label: 'Buildings (drawn)', swatch: 'rgba(188,140,255,0.5)',
layer: new ol.layer.Vector({
source: featureSource, zIndex: 11,
style: new ol.style.Style({
fill: new ol.style.Fill({ color: 'rgba(188,140,255,0.2)' }),
stroke: new ol.style.Stroke({ color: '#bc8cff', width: 1.5, lineDash: [4,3] })
})
})
},
{
id: 'roads-drawn', label: 'Roads (drawn)', swatch: 'rgba(240,200,74,0.6)',
layer: new ol.layer.Vector({
source: roadSource, zIndex: 12,
style: new ol.style.Style({ stroke: new ol.style.Stroke({ color: '#f0c84a', width: 2 }) })
})
},
{
id: 'res-roads', label: 'Road surfaces', swatch: 'rgba(240,200,74,0.4)',
layer: new ol.layer.Vector({
source: new ol.source.Vector(), zIndex: 4, visible: true,
style: new ol.style.Style({
fill: new ol.style.Fill({ color: 'rgba(240,200,74,0.28)' }),
stroke: new ol.style.Stroke({ color: '#f0c84a', width: 1 })
})
})
},
{
id: 'res-culdesac', label: 'Cul-de-sacs', swatch: 'rgba(188,140,255,0.5)',
layer: new ol.layer.Vector({
source: new ol.source.Vector(), zIndex: 5, visible: true,
style: new ol.style.Style({
fill: new ol.style.Fill({ color: 'rgba(188,140,255,0.35)' }),
stroke: new ol.style.Stroke({ color: '#bc8cff', 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, visible: true,
style: new ol.style.Style({ stroke: new ol.style.Stroke({ color: 'rgba(52,211,153,0.3)', width: 1, lineDash: [8,4] }) })
})
},
{
id: 'res-parcels', label: 'Parcels', swatch: 'rgba(88,166,255,0.4)',
layer: new ol.layer.Vector({
source: resultSource, zIndex: 6, visible: true,
style: styleResult
})
},
];
function styleResult(feature) {
const type = feature.get('type');
if (type === 'road_surface') return null; // handled by dedicated layer
if (type === 'culdesac') return null;
if (type === 'block') return null;
const built = feature.get('status') === 'built';
const noAccess = feature.get('has_access') === false;
return [
new ol.style.Style({
fill: new ol.style.Fill({ color: noAccess ? 'rgba(247,129,102,0.40)' : built ? 'rgba(247,129,102,0.22)' : 'rgba(88,166,255,0.18)' }),
stroke: new ol.style.Stroke({ color: noAccess ? '#ff4444' : built ? '#f78166' : '#58a6ff', width: noAccess ? 2.5 : 1.5, lineDash: noAccess ? [5,3] : undefined })
}),
new ol.style.Style({
text: new ol.style.Text({
text: feature.get('parcel_id') || '',
font: '9px Space Mono, monospace',
fill: new ol.style.Fill({ color: '#e6edf3' }),
stroke: new ol.style.Stroke({ color: '#0d1117', width: 3 }),
overflow: true
})
})
];
}
// ─── Measure tool ─────────────────────────────────────────────────────────────
const measureSource = new ol.source.Vector();
const measureLayer = new ol.layer.Vector({
source: measureSource, zIndex: 50,
style: f => {
const geomType = f.getGeometry().getType();
if (geomType === 'LineString' || geomType === 'Polygon') {
return new ol.style.Style({
fill: new ol.style.Fill({ color: 'rgba(52,211,153,0.08)' }),
stroke: new ol.style.Stroke({ color: '#34d399', width: 2, lineDash: [6,4] })
});
}
return new ol.style.Style({
image: new ol.style.Circle({ radius: 4, fill: new ol.style.Fill({ color: '#34d399' }) })
});
}
});
map.addLayer(measureLayer);
// ─── Selection highlight 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.25)' }),
stroke: new ol.style.Stroke({ color: '#ffd700', width: 3 })
})
});
map.addLayer(selectLayer);
let selectedFeature = null;
// Helper refs for result sub-sources
// Helper refs for result sub-sources
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 RENDERING
// ═══════════════════════════════════════════════════════
function makeBasemapCard(bm, containerId) {
const card = document.createElement('div');
card.className = 'basemap-card' + (bm.id === currentBasemapId ? ' active' : '');
card.id = `bm-card-${containerId}-${bm.id}`;
card.onclick = () => switchBasemap(bm.id);
card.title = bm.label;
// Placeholder thumbnail with emoji + gradient
const gradients = {
dark: 'linear-gradient(135deg, #0d1117 0%, #21262d 100%)',
light: 'linear-gradient(135deg, #f0f4f8 0%, #dce3ea 100%)',
osm: 'linear-gradient(135deg, #e8f5e9 0%, #a5d6a7 100%)',
topo: 'linear-gradient(135deg, #3e2723 0%, #6d4c41 50%, #bcaaa4 100%)',
satellite: 'linear-gradient(135deg, #0a1628 0%, #1a3a5c 50%, #2d6a4f 100%)',
'sat-labels': 'linear-gradient(135deg, #1a2744 0%, #2d5a8e 50%, #3d8b40 100%)',
terrain: 'linear-gradient(135deg, #8d6e63 0%, #a5d6a7 50%, #c8e6c9 100%)',
watercolor: 'linear-gradient(135deg, #b3e5fc 0%, #e1bee7 50%, #f8bbd0 100%)',
'esri-topo': 'linear-gradient(135deg, #795548 0%, #9e9d24 50%, #558b2f 100%)',
'esri-shaded': 'linear-gradient(135deg, #37474f 0%, #78909c 50%, #b0bec5 100%)',
ocean: 'linear-gradient(135deg, #0d47a1 0%, #1976d2 50%, #4fc3f7 100%)',
night: 'linear-gradient(135deg, #000000 0%, #1a237e 50%, #0d1117 100%)',
};
card.innerHTML = `
<div class="basemap-thumb-placeholder" style="background:${gradients[bm.id]||'#21262d'};">
<span style="font-size:20px;">${bm.emoji}</span>
</div>
<div class="basemap-label">${bm.label}</div>
`;
return card;
}
function renderBasemapGrids() {
// Render both grids (sidebar + float panel)
['sidebar-basemap-grid','float-basemap-grid'].forEach(containerId => {
const grid = document.getElementById(containerId);
if (!grid) return;
grid.innerHTML = '';
BASEMAPS.forEach(bm => grid.appendChild(makeBasemapCard(bm, containerId)));
});
}
function renderLayerLists() {
const drawIds = ['boundary','features','roads-drawn'];
const resultIds = ['res-roads','res-culdesac','res-blocks','res-parcels'];
function makeLayerRow(ovl) {
return `
<div class="layer-row">
<div class="swatch" style="background:${ovl.swatch};border:1px solid ${ovl.swatch.replace(/[\d.]+\)$/,'1)')};"></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="setLayerOpacity('${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="toggleOvlLayer('${ovl.id}', this.checked)" title="Toggle visibility">
</div>`;
}
// Sidebar
document.getElementById('draw-layer-list').innerHTML = drawIds.map(id => {
const ovl = OVERLAY_LAYERS.find(l => l.id===id); return ovl ? makeLayerRow(ovl) : '';
}).join('');
document.getElementById('result-layer-list').innerHTML = resultIds.map(id => {
const ovl = OVERLAY_LAYERS.find(l => l.id===id); return ovl ? makeLayerRow(ovl) : '';
}).join('');
// Float panel - all layers combined
const allIds = [...drawIds, ...resultIds];
document.getElementById('float-layer-list').innerHTML = `
<div style="font-size:10px;color:var(--text2);font-weight:600;text-transform:uppercase;letter-spacing:0.5px;margin-bottom:6px;">Drawing</div>
${drawIds.map(id => { const o=OVERLAY_LAYERS.find(l=>l.id===id); return o?makeLayerRow(o):''; }).join('')}
<div style="font-size:10px;color:var(--text2);font-weight:600;text-transform:uppercase;letter-spacing:0.5px;margin:10px 0 6px;">Results</div>
${resultIds.map(id => { const o=OVERLAY_LAYERS.find(l=>l.id===id); return o?makeLayerRow(o):''; }).join('')}
`;
}
function toggleOvlLayer(id, visible) {
const ovl = OVERLAY_LAYERS.find(l => l.id===id);
if (ovl) { ovl.layer.setVisible(visible); renderLayerLists(); }
}
function setLayerOpacity(id, opacity) {
const ovl = OVERLAY_LAYERS.find(l => l.id===id);
if (ovl) ovl.layer.setOpacity(opacity);
}
// Panel open/close
function togglePanel(which) {
const body = document.getElementById(`body-${which}`);
const chev = document.getElementById(`chevron-${which}`);
const isOpen = body.classList.contains('open');
body.classList.toggle('open', !isOpen);
chev.classList.toggle('open', !isOpen);
}
// ═══════════════════════════════════════════════════════
// DRAW INTERACTIONS
// ═══════════════════════════════════════════════════════
let drawInteraction = null, drawMode = null;
let measureInteraction = null;
let measureMode = false;
function stopDraw() {
if (drawInteraction) { map.removeInteraction(drawInteraction); drawInteraction = null; }
drawMode = 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'));
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 stopAll() {
stopDraw();
stopMeasure();
setStatus('Ready.', 'ok');
}
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: '#34d399', width: 2, lineDash: [6,4] }),
image: new ol.style.Circle({ radius: 4, fill: new ol.style.Fill({ color: '#34d399' }) })
})
});
const label = document.getElementById('measure-label');
const mapEl = document.getElementById('map-container');
// Live sketch update
measureInteraction.on('drawstart', e => {
measureSource.clear(true);
e.feature.getGeometry().on('change', ge => {
const geom = ge.target;
const len = ol.sphere.getLength(geom);
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');
// Float the label near the last vertex
const coords = geom.getLastCoordinate();
const px = map.getPixelFromCoordinate(coords);
if (px) {
label.style.left = (px[0] + 10) + 'px';
label.style.top = (px[1] - 28) + 'px';
}
});
});
measureInteraction.on('drawend', e => {
const geom = e.feature.getGeometry();
const len = ol.sphere.getLength(geom);
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('📏 Measured: ' + disp + ' — click Measure again to restart', 'ok');
showToast('📏 ' + disp, 'info');
label.style.display = 'none';
// Keep sketch visible but stop interaction so user can start a new one
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 measure points · Double-click to finish', 'ok');
}
function setDrawMode(mode) {
stopDraw(); 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 vertices · Double-click to finish boundary', '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 vertices · Double-click to finish road · Repeat for more roads', '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('Draw existing building footprint · Double-click to finish', 'ok');
}
drawInteraction = new ol.interaction.Draw({ source, type: geomType });
drawInteraction.on('drawend', e => {
if (mode==='boundary') { boundarySource.clear(); boundarySource.addFeature(e.feature); }
if (mode!=='road') stopDraw();
setStatus(mode==='road' ? 'Road added. Draw another or click Stop.' : `${mode} drawn.`, 'ok');
});
map.addInteraction(drawInteraction);
}
function clearLayer(which) {
if (which==='boundary') boundarySource.clear();
else if (which==='roads') roadSource.clear();
else if (which==='features') featureSource.clear();
setStatus(`${which} cleared.`, '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');
}
// ═══════════════════════════════════════════════════════
// HOVER TOOLTIP
// ═══════════════════════════════════════════════════════
const tooltip = document.getElementById('tooltip');
map.on('pointermove', evt => {
if (evt.dragging) { tooltip.style.display='none'; return; }
const parcelLayer = OVERLAY_LAYERS.find(l=>l.id==='res-parcels').layer;
const feature = map.forEachFeatureAtPixel(evt.pixel, f=>f, {layerFilter: l => l===parcelLayer});
if (feature && feature.get('parcel_id')) {
const p = feature.getProperties();
document.getElementById('tip-title').textContent = p.parcel_id||'—';
document.getElementById('tip-body').innerHTML = `
<div class="tip-row"><span>Area</span><span>${formatArea(p.area_m2||0)}</span></div>
<div class="tip-row"><span>Alt. Area</span><span style="font-size:10px;color:var(--text2);">${formatArea(p.area_m2||0, currentUnitSystem==='metric'?'imperial':'metric')}</span></div>
<div class="tip-row"><span>Frontage</span><span>${formatLength(p.frontage_m||0)}</span></div>
<div class="tip-row"><span>Depth</span><span>${formatLength(p.depth_m||0)}</span></div>
<div class="tip-row"><span>Status</span><span style="color:${p.status==='built'?'var(--warn)':'var(--accent2)'}">${p.status||'—'}</span></div>
<div class="tip-row"><span>Access</span><span style="color:${p.has_access===false?'var(--warn)':'var(--accent2)'}">${p.has_access===false?'⚠ No access':'✓ Road access'}</span></div>
`;
tooltip.style.display='block';
// Position relative to #map-container, not the viewport
const mapRect = document.getElementById('map-container').getBoundingClientRect();
const cx = evt.originalEvent.clientX - mapRect.left;
const cy = evt.originalEvent.clientY - mapRect.top;
const tw = tooltip.offsetWidth || 220;
const th = tooltip.offsetHeight || 160;
const margin = 14;
// Flip horizontally if near right edge
const lx = (cx + margin + tw > mapRect.width) ? cx - tw - margin : cx + margin;
// Flip vertically if near bottom edge
const ly = (cy + margin + th > mapRect.height) ? cy - th - margin : cy + margin;
tooltip.style.left = Math.max(4, lx) + 'px';
tooltip.style.top = Math.max(4, ly) + 'px';
map.getTargetElement().style.cursor='pointer';
} else {
tooltip.style.display='none';
map.getTargetElement().style.cursor='';
}
});
// ═══════════════════════════════════════════════════════
// CLICK SELECTION
// ═══════════════════════════════════════════════════════
map.on('click', evt => {
if (measureMode) return; // clicks handled by draw interaction
const parcelLayer = OVERLAY_LAYERS.find(l => l.id === 'res-parcels').layer;
const feature = map.forEachFeatureAtPixel(evt.pixel, f => f, { layerFilter: l => l === parcelLayer });
if (feature && feature.get('parcel_id')) {
selectFeature(feature);
} else {
clearSelection();
}
});
function selectFeature(feature) {
// Highlight
selectSource.clear(true);
selectSource.addFeature(feature.clone());
selectedFeature = feature;
// Populate panel
const p = feature.getProperties();
document.getElementById('sel-title-text').textContent = p.parcel_id || '—';
document.getElementById('sel-body').innerHTML = `
<div class="sel-row"><span>Area</span><span>${formatArea(p.area_m2||0)}</span></div>
<div class="sel-row"><span>Alt area</span><span>${formatArea(p.area_m2||0, currentUnitSystem==='metric'?'imperial':'metric')}</span></div>
<div class="sel-row"><span>Frontage</span><span>${formatLength(p.frontage_m||0)}</span></div>
<div class="sel-row"><span>Depth</span><span>${formatLength(p.depth_m||0)}</span></div>
<div class="sel-row"><span>Block</span><span>${p.block_id||'—'}</span></div>
<div class="sel-row"><span>Status</span><span style="color:${p.status==='built'?'var(--warn)':'var(--accent2)'}">${p.status||'vacant'}</span></div>
<div class="sel-row"><span>Access</span><span style="color:${p.has_access===false?'var(--warn)':'var(--accent2)'}">${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
// ═══════════════════════════════════════════════════════
let lastResult = null;
function olFeatureToGeom(feat) {
return JSON.parse(new ol.format.GeoJSON().writeGeometry(
feat.getGeometry().clone().transform('EPSG:3857','EPSG:4326')));
}
async function runSubdivision() {
if (boundarySource.isEmpty()) { showToast('Draw a boundary polygon first.','error'); return; }
showLoading(true); setStatus('Sending to API…','loading');
try {
const resp = await fetch(`${API_URL}/api/subdivide`, {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({
boundary: olFeatureToGeom(boundarySource.getFeatures()[0]),
roads: roadSource.getFeatures().map(f => ({geometry:olFeatureToGeom(f),properties:{}})),
existing_features: featureSource.getFeatures().map(f => ({geometry:olFeatureToGeom(f),properties:{feature_type:'building'}})),
config: getConfig()
})
});
if (!resp.ok) throw new Error((await resp.json()).detail||'Server error');
const data = await resp.json();
lastResult = data;
renderResults(data);
switchTab('results');
showToast(`${data.stats.total_parcels} parcels in ${data.stats.total_blocks} blocks`, 'success');
} catch(e) {
showToast('Error: '+e.message,'error'); setStatus('Failed: '+e.message,'error');
} finally { showLoading(false); }
}
function readFeaturesUnique(geojsonArray, proj, extraProps) {
// Always produce brand-new OL Feature objects with guaranteed-unique IDs
// to avoid "feature already added to source" when subdivision is re-run.
const fmt = new ol.format.GeoJSON();
return geojsonArray.map((f, i) => {
// Deep-clone the GeoJSON so readFeature never sees a cached object
const clone = JSON.parse(JSON.stringify(f));
// Strip any id so OL generates a fresh internal id
delete clone.id;
const feat = fmt.readFeature(clone, proj);
feat.setId(undefined); // clear any id OL might have picked up
feat.setProperties({...clone.properties, ...(extraProps||{})});
return feat;
});
}
function renderResults(data) {
// Fully clear every source before adding fresh features
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,70,50], maxZoom:18, duration:600});
}
updateResultsDisplay(data);
rebuildParcelList(data);
renderLayerLists(); // refresh opacity sliders etc
document.getElementById('no-results').style.display='none';
document.getElementById('results-content').style.display='flex';
setStatus(`Done: ${data.stats.total_parcels} parcels, ${data.stats.total_blocks} blocks.`,'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;
// No-access warning card
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 = formatAreaDual(s.boundary_area_m2 || 0);
document.getElementById('stat-road-area').innerHTML = formatAreaDual(s.road_area_m2 || 0);
document.getElementById('stat-build-area').innerHTML = formatAreaDual(s.buildable_area_m2 || 0);
document.getElementById('stat-avg-area').innerHTML = formatAreaDual(s.avg_parcel_area_m2|| 0);
// Extra info line
const extras = [];
if (s.parcels_built > 0) extras.push(`${s.parcels_built} built parcel${s.parcels_built>1?'s':''}`);
if (s.existing_buildings > 0) extras.push(`${s.existing_buildings} building${s.existing_buildings>1?'s':''} respected`);
if (s.user_roads_drawn > 0) extras.push(`${s.user_roads_drawn} user road${s.user_roads_drawn>1?'s':''} used`);
const infoEl = document.getElementById('stat-extra-info');
if (infoEl) infoEl.textContent = extras.length ? extras.join(' · ') : '';
}
function rebuildParcelList(data) {
const list = document.getElementById('parcel-list');
list.innerHTML = '';
document.getElementById('parcel-count-label').textContent = `(${data.parcels.length})`;
data.parcels.forEach((f, idx) => {
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');
item.innerHTML = `
<div>
<div class="pid">${p.parcel_id}</div>
<div class="dims metric-dims">${(p.frontage_m||0).toFixed(1)}m × ${(p.depth_m||0).toFixed(1)}m</div>
<div class="dims imperial-dims">${((p.frontage_m||0)*FT_PER_M).toFixed(1)}ft × ${((p.depth_m||0)*FT_PER_M).toFixed(1)}ft</div>
${warn.length ? `<div style="font-size:9px;color:var(--warn);">⚠ ${warn.join(', ')}</div>` : ''}
</div>
<div style="text-align:right;">
<div style="font-size:11px;color:var(--accent);font-family:'Space Mono',monospace;">${formatArea(p.area_m2||0)}</div>
<div style="font-size:9px;color:var(--text2);font-style:italic;">${formatArea(p.area_m2||0, currentUnitSystem==='metric'?'imperial':'metric')}</div>
<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,80,60], maxZoom:20, duration:500});
};
list.appendChild(item);
});
}
// ═══════════════════════════════════════════════════════
// EXPORT
// ═══════════════════════════════════════════════════════
function exportGeoJSON() {
if (!lastResult) { showToast('No results to export.','error'); return; }
const fc = {
type:'FeatureCollection',
metadata: { generated: new Date().toISOString(), display_units: currentUnitSystem, note:'Attributes always in SI (metres, m²)' },
features: [...lastResult.parcels, ...lastResult.roads, ...lastResult.culdesacs]
};
const a = document.createElement('a');
a.href = URL.createObjectURL(new Blob([JSON.stringify(fc,null,2)], {type:'application/json'}));
a.download = `parcels_${currentUnitSystem}_${Date.now()}.geojson`;
a.click();
showToast('GeoJSON exported.','success');
}
// ═══════════════════════════════════════════════════════
// UI HELPERS
// ═══════════════════════════════════════════════════════
function switchTab(name) {
const names=['draw','config','layers','results'];
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;
const d = document.getElementById('status-dot');
d.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
// ═══════════════════════════════════════════════════════
renderBasemapGrids();
renderLayerLists();
initFieldHints();
buildConversionRefTable();
// Sample boundary (kumasi)
(function() {
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 check
(async function() {
try {
const r = await fetch(`${API_URL}/health`, {signal: AbortSignal.timeout(3000)});
setStatus(r.ok ? 'API connected. Draw 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>