1603 lines
75 KiB
HTML
1603 lines
75 KiB
HTML
<!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 & 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 & 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> |