2914 lines
143 KiB
HTML
2914 lines
143 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="de">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover">
|
|
<title>Drone Mission Control</title>
|
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
|
<style>
|
|
html,body{margin:0;padding:0;height:100%;overflow:hidden;background:#000;font-family:-apple-system,Segoe UI,Roboto,sans-serif;-webkit-user-select:none;user-select:none;}
|
|
#map{position:fixed;inset:0;background:#c9c6bd;touch-action:none;}
|
|
.leaflet-control-attribution{font-size:9px;background:rgba(255,255,255,0.6)!important;}
|
|
|
|
#topBarWrap{position:fixed;top:0;left:50%;transform:translateX(-50%);width:64%;z-index:1000;border-radius:0 0 26px 26px;padding:5px 10px;background:rgba(255,255,255,0.05);transition:background 0.25s ease;}
|
|
#topBarWrap.mode-edit{background:rgba(80,200,120,0.55);}
|
|
#topBarWrap.mode-fly{background:rgba(226,87,74,0.55);}
|
|
#topBar{display:flex;align-items:stretch;justify-content:space-between;gap:4px;}
|
|
#topBarMiddle{display:flex;align-items:center;gap:8px;min-width:0;flex:1;}
|
|
@media (orientation: landscape){
|
|
#searchWrap{flex:1 1 180px;max-width:none;}
|
|
}
|
|
.mode-btn{background:none;border:none;border-radius:0;font-size:20px;font-weight:800;padding:6px 24px;cursor:pointer;flex-shrink:0;display:flex;align-items:center;justify-content:center;transition:color 0.2s ease, opacity 0.2s ease, background 0.2s ease;color:rgb(20,20,20);}
|
|
#editModeBtn{border-radius:0 0 0 26px;}
|
|
#flyModeBtn{border-radius:0 0 26px 0;}
|
|
#editModeBtn{color:rgb(20,20,20);}
|
|
#flyModeBtn{color:rgb(20,20,20);}
|
|
#topBarWrap.mode-edit #editModeBtn{color:rgb(20,20,20);}
|
|
#topBarWrap.mode-edit #flyModeBtn{color:rgb(20,20,20);background:rgba(226,87,74,0.85);margin:-5px -10px -5px 0;}
|
|
#topBarWrap.mode-fly #flyModeBtn{color:rgb(20,20,20);}
|
|
#topBarWrap.mode-fly #editModeBtn{color:rgb(20,20,20);background:rgba(80,200,120,0.85);margin:-5px 0 -5px -10px;}
|
|
#flightModePill{display:flex;align-items:center;justify-content:center;gap:6px;background:rgba(20,20,20,0.68);border:1px solid rgba(255,255,255,0.4);border-radius:19px;height:38px;padding:0 14px;color:#fff;font-size:17px;font-weight:700;cursor:pointer;flex:1 1 auto;min-width:0;}
|
|
.dropdown-caret{font-size:10px;opacity:0.7;}
|
|
#flightModeDropdown{display:none;position:fixed;top:54px;left:50%;transform:translateX(-50%);z-index:1001;background:rgba(15,15,15,0.95);border:1px solid rgba(255,255,255,0.2);border-radius:14px;overflow:hidden;min-width:180px;}
|
|
.flight-mode-option{padding:12px 18px;color:#fff;font-size:14px;font-weight:500;cursor:pointer;border-bottom:1px solid rgba(255,255,255,0.1);}
|
|
.flight-mode-option:last-child{border-bottom:none;}
|
|
.flight-mode-option.active{background:rgba(226,87,74,0.25);font-weight:700;}
|
|
#searchWrap{flex:1 1 240px;max-width:none;min-width:0;display:flex;align-items:center;background:rgba(20,20,20,0.68);border:1px solid rgba(255,255,255,0.4);border-radius:19px;height:38px;padding:0 6px 0 12px;}
|
|
#searchInput{flex:1;min-width:0;background:transparent;border:none;outline:none;color:#fff;font-size:14px;padding:0 6px;}
|
|
#searchInput::placeholder{color:rgba(255,255,255,0.55);}
|
|
.icon-btn{width:30px;height:30px;border-radius:50%;background:rgba(255,255,255,0.10);border:none;color:#fff;display:flex;align-items:center;justify-content:center;flex-shrink:0;touch-action:none;padding:0;}
|
|
#homeBtn,#fitBtn{width:38px;height:38px;border-radius:50%;background:rgba(20,20,20,0.65);border:1px solid rgba(255,255,255,0.4);color:#fff;display:flex;align-items:center;justify-content:center;flex-shrink:0;touch-action:none;padding:0;}
|
|
#fsBtn{position:fixed;top:10px;right:calc(6px + env(safe-area-inset-right));width:38px;height:38px;border-radius:50%;background:rgba(20,20,20,0.65);border:1px solid rgba(255,255,255,0.4);color:#fff;display:flex;align-items:center;justify-content:center;z-index:1000;touch-action:none;padding:0;}
|
|
#headerWindPill{display:flex;align-items:center;gap:4px;background:rgba(20,20,20,0.68);border:1px solid rgba(255,255,255,0.4);border-radius:19px;height:38px;padding:0 10px;color:#fff;font-size:12px;font-weight:500;white-space:nowrap;flex-shrink:0;cursor:pointer;touch-action:none;}
|
|
#headerWindPill.active{background:rgba(77,157,255,0.32);border-color:#4d9dff;}
|
|
#headerWindArrow svg{display:block;}
|
|
|
|
#centerCluster{position:fixed;left:50%;top:50%;transform:translate(-50%,-50%);z-index:999;}
|
|
#haloSvg{position:absolute;left:50%;top:50%;transform:translate(-96px,-96px);width:192px;height:192px;display:none;}
|
|
#haloSvg path{fill:rgba(20,20,20,0.62);stroke:rgba(255,255,255,0.5);stroke-width:1;touch-action:none;cursor:pointer;}
|
|
#haloSvg path:active{fill:rgba(60,60,60,0.75);}
|
|
#haloSvg path.halo-active{fill:rgba(77,157,255,0.55);stroke:#4d9dff;stroke-width:2;}
|
|
#reticleBtn{position:relative;z-index:2;width:55px;height:55px;border-radius:50%;background:rgba(20,20,20,0.68);border:2px solid #fff;display:flex;align-items:center;justify-content:center;touch-action:none;padding:0;}
|
|
#reticleBtn svg{display:block;}
|
|
#pafCluster{position:fixed;left:50%;top:50%;transform:translate(-50%,-50%);z-index:999;}
|
|
#pafReticleBtn{position:relative;z-index:2;width:55px;height:55px;border-radius:50%;background:rgba(20,20,20,0.68);border:2px solid #f0c86e;display:flex;align-items:center;justify-content:center;touch-action:none;padding:0;cursor:pointer;}
|
|
#pafReticleBtn svg{display:block;}
|
|
|
|
.wheel{position:fixed;top:56px;bottom:max(6%, 124px);width:44px;border-radius:22px;overflow:hidden;z-index:998;touch-action:none;
|
|
background:linear-gradient(to right, rgba(5,5,5,0.9), rgba(51,51,51,0.9) 50%, rgba(5,5,5,0.9));
|
|
-webkit-mask-image:linear-gradient(to bottom, transparent 0%, black 10%, black 90%, transparent 100%);
|
|
mask-image:linear-gradient(to bottom, transparent 0%, black 10%, black 90%, transparent 100%);}
|
|
#altWheel{left:calc(14px + max(env(safe-area-inset-left), env(safe-area-inset-right)));}
|
|
#spdWheel{right:calc(14px + max(env(safe-area-inset-left), env(safe-area-inset-right)));}
|
|
.tape{position:absolute;left:0;right:0;top:0;}
|
|
.indicator{position:fixed;width:44px;height:2px;background:#4d9dff;z-index:1000;pointer-events:none;}
|
|
#altIndicator{left:calc(14px + max(env(safe-area-inset-left), env(safe-area-inset-right)));}
|
|
#spdIndicator{right:calc(14px + max(env(safe-area-inset-left), env(safe-area-inset-right)));}
|
|
.readout{position:fixed;top:50%;transform:translateY(-50%);background:rgba(20,20,20,0.7);color:#fff;padding:4px 8px;border-radius:6px;font-size:15px;font-weight:500;white-space:nowrap;z-index:1000;pointer-events:none;}
|
|
.readout .ro-line{display:block;line-height:1.25;}
|
|
.readout .ro-title{display:block;font-size:12px;font-weight:700;opacity:0.8;letter-spacing:0.04em;margin-bottom:1px;}
|
|
.readout .ro-set{color:#8fd0ff;font-weight:700;}
|
|
.readout .ro-live{color:#3ecf8e;font-weight:700;}
|
|
.readout .ro-tag{font-size:11px;font-weight:600;opacity:0.75;}
|
|
#altReadout{left:calc(68px + max(env(safe-area-inset-left), env(safe-area-inset-right)));}
|
|
#spdReadout{right:calc(68px + max(env(safe-area-inset-left), env(safe-area-inset-right)));text-align:right;}
|
|
|
|
#bottomBar{position:fixed;left:0;right:0;bottom:0;z-index:1000;background:rgba(15,15,15,0.72);color:#fff;padding-left:max(env(safe-area-inset-left), env(safe-area-inset-right));padding-right:max(env(safe-area-inset-left), env(safe-area-inset-right));padding-bottom:calc(6px + env(safe-area-inset-bottom));}
|
|
#altProfile{width:100%;height:56px;padding:6px 8px 0 8px;box-sizing:border-box;}
|
|
#statsRow{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:6px 10px;}
|
|
#missionDroneGroup{display:flex;flex-direction:row;gap:6px;max-width:38%;flex-shrink:1;min-width:0;}
|
|
#missionNameBtn{max-width:150px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:13px;font-weight:600;background:rgba(255,255,255,0.12);border:none;color:#fff;padding:7px 12px;border-radius:14px;cursor:pointer;text-align:left;min-width:0;flex-shrink:1;}
|
|
#droneNameBtn{max-width:130px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12px;font-weight:500;background:rgba(77,157,255,0.18);border:none;color:#cfe2ff;padding:7px 12px;border-radius:14px;cursor:pointer;text-align:left;min-width:0;flex-shrink:1;}
|
|
#warningsBtn{width:34px;height:34px;border-radius:50%;background:rgba(226,87,74,0.18);border:1px solid rgba(226,87,74,0.5);color:#ffb3a8;font-size:15px;display:flex;align-items:center;justify-content:center;flex-shrink:0;cursor:pointer;padding:0;}
|
|
#statsCenter{display:flex;align-items:center;gap:14px;font-size:14px;cursor:pointer;min-width:0;background:rgba(255,255,255,0.12);border:none;color:#fff;padding:9px 18px;border-radius:20px;flex:0 1 auto;}
|
|
.stat-label{font-weight:600;color:rgba(255,255,255,0.65);}
|
|
.stat-item{display:flex;align-items:center;gap:5px;white-space:nowrap;}
|
|
.stat-item svg{flex-shrink:0;opacity:0.85;}
|
|
#sendBtn{display:flex;align-items:center;gap:6px;background:#2f6fe0;border:none;color:#fff;padding:7px 14px;border-radius:16px;font-size:14px;font-weight:600;cursor:pointer;flex-shrink:0;}
|
|
#windFetchBtn{display:flex;align-items:center;gap:6px;background:rgba(77,157,255,0.18);border:1px solid rgba(77,157,255,0.5);color:#cfe2ff;padding:6px 12px;border-radius:16px;font-size:13px;font-weight:600;cursor:pointer;flex-shrink:0;white-space:nowrap;}
|
|
#windValidateBtn{display:flex;align-items:center;gap:6px;background:rgba(200,160,77,0.18);border:1px solid rgba(200,160,77,0.5);color:#f0dcb0;padding:6px 12px;border-radius:16px;font-size:13px;font-weight:600;cursor:pointer;flex-shrink:0;white-space:nowrap;}
|
|
#warnBanner{position:fixed;left:0;right:0;z-index:1000;background:#7c2d12;color:#fde3d0;font-size:12px;padding:4px 12px;text-align:center;display:none;}
|
|
#hint{position:fixed;left:50%;top:56px;transform:translateX(-50%);z-index:1000;background:rgba(20,20,20,0.6);color:#ddd;font-size:12px;padding:4px 10px;border-radius:6px;}
|
|
#toast{position:fixed;left:50%;bottom:100px;transform:translateX(-50%);background:rgba(20,20,20,0.82);color:#fff;padding:8px 16px;border-radius:20px;font-size:14px;z-index:1700;opacity:0;transition:opacity 0.3s;pointer-events:none;white-space:nowrap;}
|
|
|
|
.panel{position:fixed;inset:0;z-index:1500;background:rgba(8,8,8,0.92);display:none;flex-direction:column;color:#fff;}
|
|
.panel-header{display:flex;align-items:center;justify-content:space-between;padding:10px calc(12px + max(env(safe-area-inset-left), env(safe-area-inset-right))) 10px calc(12px + max(env(safe-area-inset-left), env(safe-area-inset-right)));padding-top:calc(10px + env(safe-area-inset-top));border-bottom:1px solid rgba(255,255,255,0.2);flex-shrink:0;}
|
|
.panel-header h3{margin:0;font-size:17px;font-weight:600;}
|
|
.panel-close{background:rgba(255,255,255,0.16);border:none;color:#fff;width:32px;height:32px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:16px;flex-shrink:0;}
|
|
.panel-tabs{display:flex;border-bottom:1px solid rgba(255,255,255,0.15);flex-shrink:0;}
|
|
.tab-btn,.mtab-btn{flex:1;background:none;border:none;color:rgba(255,255,255,0.6);padding:10px 0;font-size:14px;font-weight:600;cursor:pointer;border-bottom:2px solid transparent;}
|
|
.tab-btn.active,.mtab-btn.active{color:#fff;border-bottom-color:#4d9dff;}
|
|
.panel-body{flex:1;overflow:hidden;position:relative;}
|
|
.tab-content{display:none;height:100%;box-sizing:border-box;flex-direction:column;}
|
|
.tab-content.active{display:flex;}
|
|
#tabList{padding:0;overflow:hidden;}
|
|
#tabListHeader{padding:8px calc(12px + max(env(safe-area-inset-left), env(safe-area-inset-right))) 8px calc(12px + max(env(safe-area-inset-left), env(safe-area-inset-right)));flex-shrink:0;}
|
|
#wpRowsWrap{flex:1;overflow-y:auto;padding:4px calc(12px + max(env(safe-area-inset-left), env(safe-area-inset-right))) 4px calc(12px + max(env(safe-area-inset-left), env(safe-area-inset-right)));}
|
|
#tabAltitude,#tabSpeed{padding:10px calc(20px + max(env(safe-area-inset-left), env(safe-area-inset-right))) 16px calc(20px + max(env(safe-area-inset-left), env(safe-area-inset-right)));}
|
|
#wpListPanel .panel-header{padding-left:calc(12px + max(env(safe-area-inset-left), env(safe-area-inset-right)));padding-right:calc(12px + max(env(safe-area-inset-left), env(safe-area-inset-right)));}
|
|
.chart-caption{font-size:12px;color:rgba(255,255,255,0.55);margin-bottom:6px;flex-shrink:0;}
|
|
.full-chart{flex:1;min-height:0;}
|
|
.wp-col-header{display:flex;align-items:center;gap:10px;padding:8px 2px;border-bottom:1px solid rgba(255,255,255,0.25);font-size:13px;font-weight:600;text-transform:uppercase;letter-spacing:0.04em;color:rgba(255,255,255,0.65);}
|
|
.wp-num-header{width:26px;flex-shrink:0;}
|
|
.wp-del-header{width:40px;flex-shrink:0;font-size:10px;text-align:center;}
|
|
.wp-row{display:flex;align-items:center;gap:10px;padding:12px 2px;border-bottom:1px solid rgba(255,255,255,0.12);font-size:16px;cursor:pointer;border-left:3px solid transparent;}
|
|
.wp-row.wp-row-active{background:rgba(77,157,255,0.34);border-left-color:#4d9dff;}
|
|
#wpRowsWrap.row-edit-frozen{overflow-y:hidden;}
|
|
#wpRowsWrap.row-edit-frozen .wp-row{opacity:0.35;pointer-events:none;}
|
|
#wpRowsWrap.row-edit-frozen .wp-row.wp-row-editing{opacity:1;pointer-events:auto;background:rgba(77,157,255,0.28);border:1.5px solid #4d9dff;border-radius:10px;box-shadow:0 0 14px rgba(77,157,255,0.45);}
|
|
.value-chip{display:inline-flex;align-items:center;gap:5px;background:rgba(20,20,20,0.85);border:1.5px solid rgba(77,157,255,0.7);border-radius:12px;padding:6px 10px;font-weight:700;color:#fff;cursor:grab;touch-action:none;user-select:none;-webkit-user-select:none;white-space:nowrap;}
|
|
.value-chip.chip-flash{border-color:#8fd0ff;background:rgba(77,157,255,0.4);}
|
|
.chip-axis-icon{font-size:10px;color:rgba(255,255,255,0.55);line-height:1;}
|
|
.execute-select{background:rgba(20,20,20,0.85);border:1.5px solid rgba(77,157,255,0.7);border-radius:12px;color:#fff;font-size:13px;font-weight:600;padding:6px 8px;cursor:pointer;}
|
|
.field-group{margin-bottom:18px;}
|
|
.field-group-title{font-size:13px;font-weight:700;color:rgba(255,255,255,0.55);text-transform:uppercase;letter-spacing:0.06em;margin-bottom:10px;}
|
|
.field-group-grid{display:grid;grid-template-columns:repeat(auto-fit, minmax(150px, 1fr));gap:10px;}
|
|
.swipe-input{display:flex;flex-direction:column;gap:6px;}
|
|
.swipe-input-label{font-size:12px;color:rgba(255,255,255,0.7);}
|
|
.swipe-input .value-chip{justify-content:center;font-size:15px;padding:9px 10px;}
|
|
.value-chip.chip-modified{border-color:rgba(240,200,110,0.85);background:rgba(200,160,77,0.16);}
|
|
.swipe-input-label .modified-dot{display:inline-block;width:6px;height:6px;border-radius:50%;background:#f0c86e;margin-left:6px;vertical-align:middle;opacity:0;transition:opacity 0.25s;}
|
|
.swipe-input.is-modified .swipe-input-label .modified-dot{opacity:1;}
|
|
.wp-row.wp-row-bad{color:#ffb3ab;}
|
|
.wp-row.wp-row-bad .wp-num{background:rgba(226,87,74,0.4);}
|
|
.wp-row.wp-row-bad.wp-row-active{background:rgba(226,87,74,0.28);border-left-color:#e2574a;}
|
|
.wp-num{width:26px;height:26px;border-radius:50%;background:rgba(255,255,255,0.18);display:flex;align-items:center;justify-content:center;font-size:13px;flex-shrink:0;}
|
|
.wp-col{flex:1;}
|
|
.wp-del-btn{width:30px;height:30px;border-radius:50%;background:rgba(226,87,74,0.18);border:none;display:flex;align-items:center;justify-content:center;flex-shrink:0;}
|
|
.wp-edit-btn{background:rgba(255,255,255,0.14)!important;}
|
|
.wp-empty{padding:20px 4px;color:rgba(255,255,255,0.6);font-size:15px;text-align:center;}
|
|
.drone-row{flex-direction:column;align-items:flex-start;gap:6px;}
|
|
.drone-row-top{display:flex;align-items:center;width:100%;gap:10px;}
|
|
.drone-summary{font-size:12px;color:rgba(255,255,255,0.55);}
|
|
#droneToolbar{display:flex;gap:8px;margin-bottom:8px;flex-shrink:0;}
|
|
#newDroneBtn,#importDroneBtn{display:flex;align-items:center;justify-content:center;gap:6px;flex:1;background:rgba(77,157,255,0.2);color:#cfe2ff;border:1px solid rgba(77,157,255,0.5);border-radius:12px;padding:10px;font-size:14px;font-weight:600;cursor:pointer;}
|
|
#missionToolbar{display:flex;gap:8px;flex-shrink:0;}
|
|
#newMissionBtn,#importMissionBtn{display:flex;align-items:center;justify-content:center;gap:6px;flex:1;background:rgba(77,157,255,0.2);color:#cfe2ff;border:1px solid rgba(77,157,255,0.5);border-radius:12px;padding:10px;font-size:14px;font-weight:600;cursor:pointer;}
|
|
.row-actions{display:flex;align-items:center;gap:16px;flex-shrink:0;}
|
|
.sort-indicator{display:inline-block;margin-left:4px;font-size:10px;color:#4d9dff;min-width:8px;}
|
|
.wp-share-btn{background:rgba(77,157,255,0.18)!important;}
|
|
.form-row{display:flex;flex-direction:column;gap:5px;margin-bottom:14px;}
|
|
.form-row label{font-size:12px;color:rgba(255,255,255,0.6);}
|
|
.form-row input{background:rgba(255,255,255,0.08);border:1px solid rgba(255,255,255,0.25);border-radius:8px;color:#fff;padding:10px;font-size:15px;}
|
|
.form-actions{display:flex;gap:10px;margin-top:10px;}
|
|
.btn-secondary{flex:1;background:rgba(255,255,255,0.12);border:none;color:#fff;padding:12px;border-radius:10px;font-size:15px;font-weight:600;cursor:pointer;}
|
|
.btn-primary{flex:1;background:#2f6fe0;border:none;color:#fff;padding:12px;border-radius:10px;font-size:15px;font-weight:600;cursor:pointer;}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
|
|
<div id="map"></div>
|
|
|
|
|
|
<div id="hint">Karte verschieben und pinchen, um zu zoomen</div>
|
|
|
|
<div id="topBarWrap">
|
|
<div id="topBar">
|
|
<button id="editModeBtn" class="mode-btn">Plan</button>
|
|
<div id="topBarMiddle">
|
|
<div id="headerWindPill" aria-label="Main wind direction">
|
|
<span id="headerWindArrow"></span>
|
|
<span id="headerWindText">—</span>
|
|
</div>
|
|
<div id="searchWrap">
|
|
<input id="searchInput" type="text" placeholder="Search location">
|
|
<button id="gpsBtn" class="icon-btn" aria-label="Use current GPS position">
|
|
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="7"/><circle cx="12" cy="12" r="1.6" fill="white" stroke="none"/><line x1="12" y1="2" x2="12" y2="5"/><line x1="12" y1="19" x2="12" y2="22"/><line x1="2" y1="12" x2="5" y2="12"/><line x1="19" y1="12" x2="22" y2="12"/></svg>
|
|
</button>
|
|
</div>
|
|
<button id="flightModePill" style="display:none;" aria-haspopup="true" aria-label="Flight mode">
|
|
<span id="flightModeLabel">Mode: Waypoint</span>
|
|
<span class="dropdown-caret">▾</span>
|
|
</button>
|
|
<button id="homeBtn" aria-label="Zur Missionslage zentrieren">
|
|
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 11l9-8 9 8"/><path d="M5 10v10h14V10"/></svg>
|
|
</button>
|
|
<button id="fitBtn" aria-label="Gesamte Mission anzeigen">
|
|
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 3H5a2 2 0 0 0-2 2v3"/><path d="M16 3h3a2 2 0 0 1 2 2v3"/><path d="M8 21H5a2 2 0 0 1-2-2v-3"/><path d="M16 21h3a2 2 0 0 0 2-2v-3"/></svg>
|
|
</button>
|
|
</div>
|
|
<button id="flyModeBtn" class="mode-btn">Fly</button>
|
|
</div>
|
|
</div>
|
|
<div id="flightModeDropdown">
|
|
<div class="flight-mode-option" data-mode="Waypoint">Waypoint</div>
|
|
<div class="flight-mode-option" data-mode="Point and Fly">Point and Fly</div>
|
|
<div class="flight-mode-option" data-mode="Return to Home">Return to Home</div>
|
|
<div class="flight-mode-option" data-mode="Manual">Manual</div>
|
|
</div>
|
|
<button id="fsBtn" aria-label="Vollbild">
|
|
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/><line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/></svg>
|
|
</button>
|
|
|
|
<div id="centerCluster">
|
|
<svg id="haloSvg" viewBox="0 0 192 192">
|
|
<path id="haloLoiter"></path>
|
|
<path id="haloLanding"></path>
|
|
<path id="haloRemove"></path>
|
|
<g id="haloLoiterIcon"></g>
|
|
<g id="haloLandingIcon"></g>
|
|
<g id="haloRemoveIcon"></g>
|
|
</svg>
|
|
<button id="reticleBtn" aria-label="Waypoint hinzufuegen"></button>
|
|
</div>
|
|
|
|
<div id="pafCluster" style="display:none;">
|
|
<button id="pafReticleBtn" aria-label="Set point-and-fly target">
|
|
<svg viewBox="0 0 24 24" width="26" height="26" fill="none" stroke="#fff" stroke-width="2.5" stroke-linecap="round"><line x1="6" y1="6" x2="18" y2="18"/><line x1="18" y1="6" x2="6" y2="18"/></svg>
|
|
</button>
|
|
</div>
|
|
|
|
<div id="altWheel" class="wheel"><div id="altTape" class="tape"></div></div>
|
|
<div id="altIndicator" class="indicator"></div>
|
|
<div id="altReadout" class="readout">Alt: 60 m</div>
|
|
|
|
<div id="spdWheel" class="wheel"><div id="spdTape" class="tape"></div></div>
|
|
<div id="spdIndicator" class="indicator"></div>
|
|
<div id="spdReadout" class="readout">Speed: 15 m/s</div>
|
|
|
|
<div id="bottomBar">
|
|
<div id="altProfile"></div>
|
|
<div id="statsRow">
|
|
<div id="missionDroneGroup">
|
|
<button id="missionNameBtn">Mission: Amsterdam</button>
|
|
<button id="droneNameBtn">Drone: T1 Ranger</button>
|
|
<button id="warningsBtn" aria-label="Show warnings" style="display:none;">⚠</button>
|
|
</div>
|
|
<button id="statsCenter">
|
|
<span class="stat-label">Details:</span>
|
|
<span class="stat-item"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 21V4l14 4-14 4"/></svg><span id="statWp">0 WP</span></span>
|
|
<span class="stat-item"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="4" y1="12" x2="20" y2="12"/><line x1="4" y1="8" x2="4" y2="16"/><line x1="20" y1="8" x2="20" y2="16"/></svg><span id="statDist">0 m</span></span>
|
|
<span class="stat-item"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 3"/></svg><span id="statDur">0:00</span></span>
|
|
</button>
|
|
<button id="sendBtn">
|
|
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
|
|
Send
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div id="warnBanner">Route exceeds drone capabilities at highlighted point</div>
|
|
<div id="toast"></div>
|
|
|
|
<div id="wpListPanel" class="panel">
|
|
<div class="panel-header">
|
|
<h3>Waypoints</h3>
|
|
<div style="display:flex;align-items:center;gap:8px;">
|
|
<button id="windFetchBtn" aria-label="Fetch wind data">
|
|
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.6 4.6a2 2 0 1 1 1.4 3.4H2"/><path d="M14 4.5a2 2 0 1 1 1.4 3.4H4"/><path d="M17.6 15.6a2 2 0 1 0 1.4-3.4H2"/></svg>
|
|
<span>Fetch wind</span>
|
|
</button>
|
|
<button id="windValidateBtn" aria-label="Validate wind data">
|
|
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><line x1="12" y1="8" x2="12" y2="13"/><circle cx="12" cy="16" r="0.5" fill="white"/></svg>
|
|
<span>Validate</span>
|
|
</button>
|
|
<button id="wpListClose" class="panel-close" aria-label="Liste schliessen">✕</button>
|
|
</div>
|
|
</div>
|
|
<div class="panel-tabs">
|
|
<button class="tab-btn active" data-tab="list">List</button>
|
|
<button class="tab-btn" data-tab="altitude">Altitude</button>
|
|
<button class="tab-btn" data-tab="speed">Speed</button>
|
|
</div>
|
|
<div class="panel-body">
|
|
<div id="tabList" class="tab-content active">
|
|
<div class="wp-col-header" id="tabListHeader">
|
|
<span class="wp-num-header">#</span>
|
|
<span class="wp-col">Altitude</span>
|
|
<span class="wp-col">Speed</span>
|
|
<span class="wp-col">Execute</span>
|
|
<span class="wp-col">Catch radius</span>
|
|
<span class="wp-col">Wind</span>
|
|
<span class="wp-del-header" style="width:76px;">Action</span>
|
|
</div>
|
|
<div id="wpRowsWrap">
|
|
<div id="wpRows"></div>
|
|
</div>
|
|
</div>
|
|
<div id="tabAltitude" class="tab-content">
|
|
<div class="chart-caption">Drag a point up or down to adjust that waypoint's altitude. Brown area shows ground elevation sampled every 30 m along the route (loads automatically). Red bands mark less than 10m ground clearance.</div>
|
|
<div id="altFullChart" class="full-chart"></div>
|
|
</div>
|
|
<div id="tabSpeed" class="tab-content">
|
|
<div class="chart-caption">Drag a point up or down to adjust that waypoint's speed. Diamonds show head-/tailwind along each leg; the faint dashed line shows achievable ground speed (needs wind data fetched).</div>
|
|
<div id="spdFullChart" class="full-chart"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="missionsPanel" class="panel">
|
|
<div class="panel-header">
|
|
<h3>Missions & Drones</h3>
|
|
<button id="missionsClose" class="panel-close" aria-label="Close">✕</button>
|
|
</div>
|
|
<div class="panel-tabs">
|
|
<button class="mtab-btn active" data-mtab="missions">Missions</button>
|
|
<button class="mtab-btn" data-mtab="drones">Drones</button>
|
|
</div>
|
|
<div class="panel-body">
|
|
<div id="mtabMissions" class="tab-content active">
|
|
<div class="wp-col-header" style="padding:8px calc(12px + max(env(safe-area-inset-left), env(safe-area-inset-right)));flex-shrink:0;">
|
|
<span class="wp-col mission-sort-header" data-sort="name" style="flex:2;cursor:pointer;">Name<span class="sort-indicator" id="sortIndicatorName"></span></span>
|
|
<span class="wp-col">Waypoints</span>
|
|
<span class="wp-col mission-sort-header" data-sort="date" style="cursor:pointer;">Date<span class="sort-indicator" id="sortIndicatorDate"></span></span>
|
|
<span class="wp-del-header" style="width:112px;">Action</span>
|
|
</div>
|
|
<div style="padding:8px calc(12px + max(env(safe-area-inset-left), env(safe-area-inset-right))) 0;flex-shrink:0;">
|
|
<div id="missionToolbar">
|
|
<button id="newMissionBtn">+ New mission</button>
|
|
<button id="importMissionBtn">↑ Import</button>
|
|
<input type="file" id="importMissionInput" accept="application/json,.json" style="display:none;">
|
|
</div>
|
|
</div>
|
|
<div style="flex:1;overflow-y:auto;padding:4px calc(12px + max(env(safe-area-inset-left), env(safe-area-inset-right)));">
|
|
<div id="missionsRows"></div>
|
|
</div>
|
|
</div>
|
|
<div id="mtabDrones" class="tab-content" style="padding:10px calc(12px + max(env(safe-area-inset-left), env(safe-area-inset-right))) 16px calc(12px + max(env(safe-area-inset-left), env(safe-area-inset-right)));">
|
|
<div id="droneToolbar">
|
|
<button id="newDroneBtn">+ New profile</button>
|
|
<button id="importDroneBtn">↑ Import</button>
|
|
<input type="file" id="importDroneInput" accept="application/json,.json" style="display:none;">
|
|
</div>
|
|
<div id="dronesRows" style="flex:1;overflow-y:auto;"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="windValidatePanel" class="panel">
|
|
<div class="panel-header">
|
|
<h3>Wind Validation</h3>
|
|
<button id="windValidateClose" class="panel-close" aria-label="Close">✕</button>
|
|
</div>
|
|
<div class="panel-body" style="padding:12px calc(12px + max(env(safe-area-inset-left), env(safe-area-inset-right))) 16px calc(12px + max(env(safe-area-inset-left), env(safe-area-inset-right)));overflow-y:auto;box-sizing:border-box;">
|
|
<div id="windValidateRows" style="font-size:14px;line-height:1.6;"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="warningsPanel" class="panel">
|
|
<div class="panel-header">
|
|
<h3>Warnings</h3>
|
|
<button id="warningsClose" class="panel-close" aria-label="Close">✕</button>
|
|
</div>
|
|
<div class="panel-body" style="padding:8px calc(12px + max(env(safe-area-inset-left), env(safe-area-inset-right))) 16px calc(12px + max(env(safe-area-inset-left), env(safe-area-inset-right)));overflow-y:auto;box-sizing:border-box;">
|
|
<div id="warningsRows"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="droneEditorPanel" class="panel">
|
|
<div class="panel-header">
|
|
<h3 id="droneEditorTitle">Edit drone profile</h3>
|
|
<button id="droneEditorClose" class="panel-close" aria-label="Close">✕</button>
|
|
</div>
|
|
<div class="panel-body" style="padding:16px calc(12px + max(env(safe-area-inset-left), env(safe-area-inset-right))) 16px calc(12px + max(env(safe-area-inset-left), env(safe-area-inset-right)));overflow-y:auto;box-sizing:border-box;">
|
|
<div class="form-row"><label>Name</label><input id="deName" type="text"></div>
|
|
<div id="deFieldGroups"></div>
|
|
<div class="form-actions">
|
|
<button id="deCancelBtn" class="btn-secondary">Cancel</button>
|
|
<button id="deSaveBtn" class="btn-primary">Save</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
|
|
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
|
<script>
|
|
(function(){
|
|
var reticleBtn = document.getElementById('reticleBtn');
|
|
var haloSvg = document.getElementById('haloSvg');
|
|
var haloLoiter = document.getElementById('haloLoiter');
|
|
var haloLanding = document.getElementById('haloLanding');
|
|
var haloRemove = document.getElementById('haloRemove');
|
|
var haloLoiterIcon = document.getElementById('haloLoiterIcon');
|
|
var haloLandingIcon = document.getElementById('haloLandingIcon');
|
|
var haloRemoveIcon = document.getElementById('haloRemoveIcon');
|
|
var altWheel = document.getElementById('altWheel');
|
|
var altTape = document.getElementById('altTape');
|
|
var altReadout = document.getElementById('altReadout');
|
|
var altIndicator = document.getElementById('altIndicator');
|
|
var spdWheel = document.getElementById('spdWheel');
|
|
var spdTape = document.getElementById('spdTape');
|
|
var spdReadout = document.getElementById('spdReadout');
|
|
var spdIndicator = document.getElementById('spdIndicator');
|
|
var statWp = document.getElementById('statWp');
|
|
var statDist = document.getElementById('statDist');
|
|
var statDur = document.getElementById('statDur');
|
|
var warnBanner = document.getElementById('warnBanner');
|
|
var bottomBar = document.getElementById('bottomBar');
|
|
var fsBtn = document.getElementById('fsBtn');
|
|
var topBarWrap = document.getElementById('topBarWrap');
|
|
var editModeBtn = document.getElementById('editModeBtn');
|
|
var flyModeBtn = document.getElementById('flyModeBtn');
|
|
var flightModePill = document.getElementById('flightModePill');
|
|
var flightModeLabel = document.getElementById('flightModeLabel');
|
|
var flightModeDropdown = document.getElementById('flightModeDropdown');
|
|
var centerCluster = document.getElementById('centerCluster');
|
|
var homeBtn = document.getElementById('homeBtn');
|
|
var fitBtn = document.getElementById('fitBtn');
|
|
var hint = document.getElementById('hint');
|
|
var statsCenter = document.getElementById('statsCenter');
|
|
var altProfile = document.getElementById('altProfile');
|
|
var wpListPanel = document.getElementById('wpListPanel');
|
|
var tabListEl = document.getElementById('tabList');
|
|
var wpRows = document.getElementById('wpRows');
|
|
var wpRowsWrap = document.getElementById('wpRowsWrap');
|
|
var wpListClose = document.getElementById('wpListClose');
|
|
var searchInput = document.getElementById('searchInput');
|
|
var gpsBtn = document.getElementById('gpsBtn');
|
|
var missionNameBtn = document.getElementById('missionNameBtn');
|
|
var droneNameBtn = document.getElementById('droneNameBtn');
|
|
var missionsPanel = document.getElementById('missionsPanel');
|
|
var missionsRows = document.getElementById('missionsRows');
|
|
var missionsClose = document.getElementById('missionsClose');
|
|
var mtabButtons = document.querySelectorAll('.mtab-btn');
|
|
var dronesRows = document.getElementById('dronesRows');
|
|
var newDroneBtn = document.getElementById('newDroneBtn');
|
|
var newMissionBtn = document.getElementById('newMissionBtn');
|
|
var importMissionBtn = document.getElementById('importMissionBtn');
|
|
var importDroneBtn = document.getElementById('importDroneBtn');
|
|
var importDroneInput = document.getElementById('importDroneInput');
|
|
var importMissionInput = document.getElementById('importMissionInput');
|
|
var droneEditorPanel = document.getElementById('droneEditorPanel');
|
|
var droneEditorClose = document.getElementById('droneEditorClose');
|
|
var warningsBtn = document.getElementById('warningsBtn');
|
|
var warningsPanel = document.getElementById('warningsPanel');
|
|
var warningsClose = document.getElementById('warningsClose');
|
|
var warningsRows = document.getElementById('warningsRows');
|
|
var windValidateBtn = document.getElementById('windValidateBtn');
|
|
var windValidatePanel = document.getElementById('windValidatePanel');
|
|
var windValidateClose = document.getElementById('windValidateClose');
|
|
var windValidateRows = document.getElementById('windValidateRows');
|
|
var sendBtn = document.getElementById('sendBtn');
|
|
var toast = document.getElementById('toast');
|
|
var headerWindArrow = document.getElementById('headerWindArrow');
|
|
var headerWindText = document.getElementById('headerWindText');
|
|
var headerWindPill = document.getElementById('headerWindPill');
|
|
var tabButtons = document.querySelectorAll('.tab-btn');
|
|
var altFullChart = document.getElementById('altFullChart');
|
|
var spdFullChart = document.getElementById('spdFullChart');
|
|
|
|
function positionIndicators(){
|
|
var r = altWheel.getBoundingClientRect();
|
|
altIndicator.style.top = (r.top + r.height/2 - 1) + 'px';
|
|
var r2 = spdWheel.getBoundingClientRect();
|
|
spdIndicator.style.top = (r2.top + r2.height/2 - 1) + 'px';
|
|
}
|
|
function positionWarnBanner(){
|
|
var h = bottomBar.getBoundingClientRect().height;
|
|
warnBanner.style.bottom = h + 'px';
|
|
}
|
|
|
|
var toastTimer = null;
|
|
function showToast(msg){
|
|
toast.textContent = msg;
|
|
toast.style.opacity = '1';
|
|
clearTimeout(toastTimer);
|
|
toastTimer = setTimeout(function(){ toast.style.opacity = '0'; }, 2600);
|
|
}
|
|
|
|
var HALO_SCALE = 1.2;
|
|
var icons = {
|
|
plus: '<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="white" stroke-width="2.2" stroke-linecap="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>',
|
|
pencil: '<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg>',
|
|
check: '<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="white" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>'
|
|
};
|
|
|
|
function haloPoint(angleDeg, r){
|
|
var rad = angleDeg * Math.PI/180;
|
|
var c = 80 * HALO_SCALE;
|
|
return {x: c + r*Math.sin(rad), y: c - r*Math.cos(rad)};
|
|
}
|
|
function buildRingWedgePath(rIn, rOut, aStart, aEnd, steps){
|
|
var pts = [];
|
|
var i;
|
|
for (i=0;i<=steps;i++){
|
|
var a = aStart + (aEnd-aStart)*i/steps;
|
|
pts.push(haloPoint(a, rOut));
|
|
}
|
|
for (i=steps;i>=0;i--){
|
|
var a2 = aStart + (aEnd-aStart)*i/steps;
|
|
pts.push(haloPoint(a2, rIn));
|
|
}
|
|
var d = 'M ' + pts[0].x.toFixed(1) + ' ' + pts[0].y.toFixed(1) + ' ';
|
|
for (i=1;i<pts.length;i++){ d += 'L ' + pts[i].x.toFixed(1) + ' ' + pts[i].y.toFixed(1) + ' '; }
|
|
d += 'Z';
|
|
return d;
|
|
}
|
|
|
|
var HALO_RIN = 27 * HALO_SCALE, HALO_ROUT = 60 * HALO_SCALE;
|
|
haloLoiter.setAttribute('d', buildRingWedgePath(HALO_RIN, HALO_ROUT, -60, 60, 18));
|
|
haloLanding.setAttribute('d', buildRingWedgePath(HALO_RIN, HALO_ROUT, 60, 180, 18));
|
|
haloRemove.setAttribute('d', buildRingWedgePath(HALO_RIN, HALO_ROUT, 180, 300, 18));
|
|
|
|
var midR = (HALO_RIN+HALO_ROUT)/2;
|
|
var loiterMid = haloPoint(0, midR);
|
|
var landingMid = haloPoint(120, midR);
|
|
var removeMid = haloPoint(240, midR);
|
|
var s = HALO_SCALE;
|
|
|
|
haloLoiterIcon.innerHTML = '<circle cx="'+loiterMid.x+'" cy="'+loiterMid.y+'" r="'+(7*s)+'" fill="none" stroke="white" stroke-width="2"/><circle cx="'+(loiterMid.x+7*s)+'" cy="'+loiterMid.y+'" r="'+(2*s)+'" fill="white"/>';
|
|
haloLoiterIcon.setAttribute('pointer-events','none');
|
|
|
|
haloLandingIcon.innerHTML =
|
|
'<line x1="'+landingMid.x+'" y1="'+(landingMid.y-8*s)+'" x2="'+landingMid.x+'" y2="'+(landingMid.y+2*s)+'" stroke="white" stroke-width="2" stroke-linecap="round"/>' +
|
|
'<polyline points="'+(landingMid.x-5*s)+','+(landingMid.y-3*s)+' '+landingMid.x+','+(landingMid.y+2*s)+' '+(landingMid.x+5*s)+','+(landingMid.y-3*s)+'" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>' +
|
|
'<line x1="'+(landingMid.x-8*s)+'" y1="'+(landingMid.y+8*s)+'" x2="'+(landingMid.x+8*s)+'" y2="'+(landingMid.y+8*s)+'" stroke="white" stroke-width="2" stroke-linecap="round"/>';
|
|
haloLandingIcon.setAttribute('pointer-events','none');
|
|
|
|
haloRemoveIcon.innerHTML = '<line x1="'+(removeMid.x-8*s)+'" y1="'+removeMid.y+'" x2="'+(removeMid.x+8*s)+'" y2="'+removeMid.y+'" stroke="white" stroke-width="3" stroke-linecap="round"/>';
|
|
haloRemoveIcon.setAttribute('pointer-events','none');
|
|
|
|
function bindHaloAction(el, handler){
|
|
var pressed = false;
|
|
el.addEventListener('pointerdown', function(e){ e.stopPropagation(); e.preventDefault(); pressed = true; });
|
|
el.addEventListener('pointerup', function(e){ e.stopPropagation(); if (pressed) handler(); pressed = false; });
|
|
el.addEventListener('pointercancel', function(){ pressed = false; });
|
|
}
|
|
|
|
var map = L.map('map', {zoomControl:false, attributionControl:true, tap:true}).setView([52.3676, 4.9041], 16);
|
|
L.control.attribution({position:'bottomleft', prefix:false}).addTo(map);
|
|
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
|
maxZoom: 19,
|
|
attribution: '© OpenStreetMap contributors'
|
|
}).addTo(map);
|
|
|
|
var missionCenter = map.getCenter();
|
|
var missionZoom = map.getZoom();
|
|
|
|
function fitToWaypoints(){
|
|
if (!waypoints.length) return false;
|
|
var latlngs = waypoints.map(function(w){ return [w.lat, w.lng]; });
|
|
map.flyToBounds(latlngs, {paddingTopLeft:[50,50], paddingBottomRight:[50,140], duration:0.6});
|
|
map.once('moveend', function(){ missionCenter = map.getCenter(); missionZoom = map.getZoom(); });
|
|
return true;
|
|
}
|
|
|
|
var HOME_FOCUS_ZOOM = 16;
|
|
homeBtn.addEventListener('click', function(){
|
|
if (waypoints.length){
|
|
map.flyTo([waypoints[0].lat, waypoints[0].lng], HOME_FOCUS_ZOOM, {duration:0.6});
|
|
} else {
|
|
map.flyTo(missionCenter, missionZoom, {duration:0.6});
|
|
}
|
|
});
|
|
fitBtn.addEventListener('click', function(){
|
|
if (!fitToWaypoints()) showToast('No waypoints to fit');
|
|
});
|
|
|
|
fsBtn.addEventListener('click', function(){
|
|
if (!document.fullscreenElement){
|
|
document.documentElement.requestFullscreen().then(function(){
|
|
if (screen.orientation && screen.orientation.lock){
|
|
screen.orientation.lock('landscape').catch(function(){});
|
|
}
|
|
hint.style.display = 'none';
|
|
}).catch(function(){});
|
|
} else {
|
|
document.exitFullscreen();
|
|
}
|
|
});
|
|
|
|
// --- Plan / Fly mode ---
|
|
// Fly mode is a UI scaffold for now: this app has no live telemetry link
|
|
// (no DroneBridge/MAVLink connection yet), so the "live" readouts below
|
|
// intentionally show a placeholder rather than fabricated numbers.
|
|
var appMode = 'edit';
|
|
var currentFlightMode = 'Waypoint';
|
|
// Point-and-Fly target: a single transient waypoint, deliberately NOT part of
|
|
// the mission waypoint list — it never touches `waypoints` or autosave.
|
|
var pafTarget = null;
|
|
var pafLayerGroup = null;
|
|
var pafCluster = document.getElementById('pafCluster');
|
|
var pafReticleBtn = document.getElementById('pafReticleBtn');
|
|
|
|
function updatePafVisibility(){
|
|
pafCluster.style.display = isPointAndFly() ? 'block' : 'none';
|
|
}
|
|
function redrawPafLayer(){
|
|
if (!pafLayerGroup){ pafLayerGroup = L.layerGroup().addTo(map); }
|
|
pafLayerGroup.clearLayers();
|
|
if (!pafTarget || appMode !== 'fly') return;
|
|
L.circle([pafTarget.lat, pafTarget.lng], {
|
|
radius: activeDrone.loiterRadius,
|
|
color:'#f0c86e', weight:2, opacity:0.9, fillColor:'#f0c86e', fillOpacity:0.12, dashArray:'6 5'
|
|
}).addTo(pafLayerGroup);
|
|
L.circleMarker([pafTarget.lat, pafTarget.lng], {
|
|
radius:7, color:'#f0c86e', weight:3, fillColor:'#1a1a1a', fillOpacity:1
|
|
}).addTo(pafLayerGroup);
|
|
L.marker([pafTarget.lat, pafTarget.lng], {
|
|
icon: L.divIcon({
|
|
html:'<div style="background:rgba(15,15,15,0.85);border:1px solid rgba(240,200,110,0.8);border-radius:10px;padding:2px 8px;white-space:nowrap;font-size:11px;font-weight:700;color:#f0c86e;">Alt: '+pafTarget.alt+' m'+(pafTarget.terrainPending ? '\u2026' : '')+'</div>',
|
|
className:'', iconSize:[76,22], iconAnchor:[-10,30]
|
|
}), interactive:false
|
|
}).addTo(pafLayerGroup);
|
|
}
|
|
var PAF_MIN_TERRAIN_CLEARANCE = 60;
|
|
pafReticleBtn.addEventListener('click', function(e){
|
|
e.stopPropagation();
|
|
var c = map.getCenter();
|
|
// Set immediately at the requested altitude, then raise it once terrain is
|
|
// known — the target must never sit on screen unverified.
|
|
pafTarget = {lat:c.lat, lng:c.lng, alt:curAlt, speed:curSpeed, action:'loiter', terrainPending:true};
|
|
redrawPafLayer();
|
|
var ref = waypoints.length ? waypoints[0] : null;
|
|
fetchElevationAt(c.lat, c.lng, function(targetElev){
|
|
if (!pafTarget || pafTarget.lat !== c.lat || pafTarget.lng !== c.lng) return; // superseded
|
|
if (typeof targetElev !== 'number'){
|
|
pafTarget.terrainPending = false;
|
|
redrawPafLayer();
|
|
showToast('Target set \u2014 terrain data unavailable, altitude unverified');
|
|
return;
|
|
}
|
|
function apply(refElev){
|
|
if (!pafTarget || pafTarget.lat !== c.lat || pafTarget.lng !== c.lng) return;
|
|
var minAlt = Math.round((targetElev - refElev) + PAF_MIN_TERRAIN_CLEARANCE);
|
|
pafTarget.terrainPending = false;
|
|
if (pafTarget.alt < minAlt){
|
|
pafTarget.alt = minAlt;
|
|
redrawPafLayer();
|
|
showToast('Altitude raised to ' + minAlt + ' m for ' + PAF_MIN_TERRAIN_CLEARANCE + ' m terrain clearance');
|
|
} else {
|
|
redrawPafLayer();
|
|
showToast('Target set \u2014 Alt: ' + pafTarget.alt + ' m');
|
|
}
|
|
}
|
|
if (ref){
|
|
fetchElevationAt(ref.lat, ref.lng, function(refElev){
|
|
apply(typeof refElev === 'number' ? refElev : targetElev);
|
|
});
|
|
} else {
|
|
// No mission waypoints: altitude is simply AGL at the target itself.
|
|
apply(targetElev);
|
|
}
|
|
});
|
|
});
|
|
|
|
// Single source of truth for wheel visibility and readout content across modes.
|
|
function isPointAndFly(){ return appMode === 'fly' && currentFlightMode === 'Point and Fly'; }
|
|
function updateReadouts(){
|
|
var showWheels = (appMode === 'edit') || isPointAndFly();
|
|
altWheel.style.display = showWheels ? 'block' : 'none';
|
|
spdWheel.style.display = showWheels ? 'block' : 'none';
|
|
altIndicator.style.display = showWheels ? 'block' : 'none';
|
|
spdIndicator.style.display = showWheels ? 'block' : 'none';
|
|
if (appMode === 'fly'){
|
|
// Live telemetry is not wired up yet; placeholder until DroneBridge/MSP lands.
|
|
altReadout.innerHTML = '<span class="ro-title">Alt</span>' +
|
|
'<span class="ro-line"><span class="ro-tag">SET</span> <span class="ro-set">' + curAlt + ' m</span></span>' +
|
|
'<span class="ro-line"><span class="ro-tag">LIVE</span> <span class="ro-live">\u2014</span></span>';
|
|
spdReadout.innerHTML = '<span class="ro-title">Speed</span>' +
|
|
'<span class="ro-line"><span class="ro-tag">SET</span> <span class="ro-set">' + curSpeed + ' m/s</span></span>' +
|
|
'<span class="ro-line"><span class="ro-tag">LIVE</span> <span class="ro-live">\u2014</span></span>';
|
|
} else {
|
|
altReadout.textContent = 'Alt: ' + curAlt + ' m';
|
|
spdReadout.textContent = 'Speed: ' + curSpeed + ' m/s';
|
|
}
|
|
}
|
|
|
|
function setAppMode(newMode){
|
|
appMode = newMode;
|
|
topBarWrap.classList.toggle('mode-edit', appMode === 'edit');
|
|
topBarWrap.classList.toggle('mode-fly', appMode === 'fly');
|
|
searchWrap.style.display = (appMode === 'fly') ? 'none' : 'flex';
|
|
flightModePill.style.display = (appMode === 'fly') ? 'flex' : 'none';
|
|
centerCluster.style.display = (appMode === 'fly') ? 'none' : 'block';
|
|
// Either direction: collapse the dropdown and drop the transient target,
|
|
// and entering fly always starts from the default Waypoint mode.
|
|
flightModeDropdown.style.display = 'none';
|
|
pafTarget = null;
|
|
if (appMode === 'fly'){
|
|
setFlightMode('Waypoint');
|
|
}
|
|
updateReadouts();
|
|
updatePafVisibility();
|
|
redrawPafLayer();
|
|
}
|
|
function setFlightMode(name){
|
|
currentFlightMode = name;
|
|
flightModeLabel.textContent = 'Mode: ' + currentFlightMode;
|
|
document.querySelectorAll('.flight-mode-option').forEach(function(o){
|
|
o.classList.toggle('active', o.getAttribute('data-mode') === name);
|
|
});
|
|
}
|
|
editModeBtn.addEventListener('click', function(e){ e.stopPropagation(); setAppMode('edit'); });
|
|
flyModeBtn.addEventListener('click', function(e){ e.stopPropagation(); setAppMode('fly'); });
|
|
|
|
flightModePill.addEventListener('click', function(e){
|
|
e.stopPropagation();
|
|
var open = flightModeDropdown.style.display === 'block';
|
|
flightModeDropdown.style.display = open ? 'none' : 'block';
|
|
});
|
|
document.querySelectorAll('.flight-mode-option').forEach(function(opt){
|
|
opt.classList.toggle('active', opt.getAttribute('data-mode') === currentFlightMode);
|
|
opt.addEventListener('click', function(e){
|
|
e.stopPropagation();
|
|
setFlightMode(opt.getAttribute('data-mode'));
|
|
flightModeDropdown.style.display = 'none';
|
|
// Switching flight mode discards the transient target.
|
|
pafTarget = null;
|
|
updatePafVisibility();
|
|
updateReadouts();
|
|
redrawPafLayer();
|
|
});
|
|
});
|
|
document.addEventListener('click', function(){ flightModeDropdown.style.display = 'none'; });
|
|
|
|
function extractPlaceLabel(nomResult){
|
|
var addr = nomResult.address || {};
|
|
var label = addr.city || addr.town || addr.village || addr.municipality || addr.suburb || addr.county;
|
|
if (!label && nomResult.display_name) label = nomResult.display_name.split(',')[0];
|
|
return label || 'Unknown location';
|
|
}
|
|
var missionNeedsPlaceName = false;
|
|
function nameMissionFromLocation(lat, lon){
|
|
fetch('https://nominatim.openstreetmap.org/reverse?format=json&addressdetails=1&accept-language=en&lat=' + lat + '&lon=' + lon)
|
|
.then(function(r){ return r.json(); })
|
|
.then(function(res){
|
|
var label = extractPlaceLabel(res);
|
|
currentMission.name = label;
|
|
currentMission.placeName = label;
|
|
missionNameBtn.textContent = 'Mission: ' + currentMission.name;
|
|
missionNameBtn.title = missionNameBtn.textContent;
|
|
scheduleAutosave();
|
|
})
|
|
.catch(function(){ /* keep placeholder name, not critical */ });
|
|
}
|
|
function startNewMission(){
|
|
flushPendingAutosave();
|
|
currentMission = new Mission({name:'New mission', placeName:null}, onMissionChange);
|
|
waypoints = currentMission.waypoints;
|
|
missionNeedsPlaceName = true;
|
|
missionNameBtn.textContent = 'Mission: ' + currentMission.name;
|
|
missionNameBtn.title = missionNameBtn.textContent;
|
|
mode = 'idle';
|
|
targetIdx = -1;
|
|
missionsOpen = false;
|
|
missionsPanel.style.display = 'none';
|
|
redraw();
|
|
}
|
|
newMissionBtn.addEventListener('click', function(e){ e.stopPropagation(); startNewMission(); });
|
|
|
|
function setMissionFromPlace(label, lat, lon, zoom){
|
|
flushPendingAutosave();
|
|
missionNeedsPlaceName = false;
|
|
currentMission = new Mission({name:label, placeName:label}, onMissionChange);
|
|
waypoints = currentMission.waypoints;
|
|
missionNameBtn.textContent = 'Mission: ' + currentMission.name;
|
|
missionNameBtn.title = 'Mission: ' + currentMission.name;
|
|
missionCenter = [lat, lon];
|
|
missionZoom = zoom;
|
|
mode = 'idle';
|
|
targetIdx = -1;
|
|
redraw();
|
|
refreshHeaderWind();
|
|
}
|
|
function doSearch(){
|
|
var q = searchInput.value.trim();
|
|
if (!q) return;
|
|
fetch('https://nominatim.openstreetmap.org/search?format=json&addressdetails=1&accept-language=en&limit=1&q=' + encodeURIComponent(q))
|
|
.then(function(r){ return r.json(); })
|
|
.then(function(results){
|
|
if (!results.length){ showToast('Location not found'); return; }
|
|
var res = results[0];
|
|
var lat = parseFloat(res.lat), lon = parseFloat(res.lon);
|
|
map.flyTo([lat, lon], 15, {duration:0.8});
|
|
setMissionFromPlace(extractPlaceLabel(res), lat, lon, 15);
|
|
})
|
|
.catch(function(){ showToast('Search failed'); });
|
|
}
|
|
searchInput.addEventListener('keydown', function(e){
|
|
if (e.key === 'Enter'){ e.preventDefault(); doSearch(); }
|
|
});
|
|
gpsBtn.addEventListener('click', function(){
|
|
if (!window.isSecureContext){ showToast('GPS needs HTTPS \u2013 open via a hosted link, not a local file'); return; }
|
|
if (!navigator.geolocation){ showToast('Geolocation not supported by this browser'); return; }
|
|
showToast('Locating\u2026');
|
|
navigator.geolocation.getCurrentPosition(function(pos){
|
|
var lat = pos.coords.latitude, lon = pos.coords.longitude;
|
|
map.flyTo([lat, lon], 16, {duration:0.8});
|
|
fetch('https://nominatim.openstreetmap.org/reverse?format=json&addressdetails=1&accept-language=en&lat=' + lat + '&lon=' + lon)
|
|
.then(function(r){ return r.json(); })
|
|
.then(function(res){ setMissionFromPlace(extractPlaceLabel(res), lat, lon, 16); })
|
|
.catch(function(){ setMissionFromPlace('Current location', lat, lon, 16); });
|
|
}, function(err){
|
|
var msg = 'Location unavailable';
|
|
if (err){
|
|
if (err.code === 1) msg = 'Location permission denied';
|
|
else if (err.code === 2) msg = 'Position unavailable \u2013 check device GPS/network';
|
|
else if (err.code === 3) msg = 'Location request timed out';
|
|
}
|
|
showToast(msg);
|
|
}, {enableHighAccuracy:true, timeout:8000});
|
|
});
|
|
|
|
function generateId(){
|
|
return 'm_' + Date.now().toString(36) + '_' + Math.random().toString(36).slice(2,8);
|
|
}
|
|
var APP_VERSION = '1.0.0';
|
|
|
|
// A Mission owns its metadata and a reactive waypoints array: mutating a
|
|
// waypoint (wp.alt = x), or the array itself (push/splice/index-assign),
|
|
// transparently notifies onChange - no matter which code path did it.
|
|
class Mission {
|
|
constructor(data, onChange){
|
|
data = data || {};
|
|
this.id = data.id || generateId();
|
|
this.name = data.name || 'Untitled';
|
|
this.placeName = data.placeName || this.name;
|
|
this.createdAt = data.createdAt || Date.now();
|
|
this.savedAt = data.savedAt || Date.now();
|
|
this.terrain = data.terrain || null;
|
|
this._onChange = onChange || null;
|
|
this.waypoints = this._reactiveWaypoints(data.waypoints || []);
|
|
}
|
|
_notify(){
|
|
this.savedAt = Date.now();
|
|
if (this._onChange) this._onChange(this);
|
|
}
|
|
_reactivePoint(pt){
|
|
var self = this;
|
|
return new Proxy(pt, {
|
|
set: function(target, prop, value){ target[prop] = value; self._notify(); return true; }
|
|
});
|
|
}
|
|
_reactiveWaypoints(rawArray){
|
|
var self = this;
|
|
var arr = rawArray.map(function(pt){ return self._reactivePoint(pt); });
|
|
return new Proxy(arr, {
|
|
set: function(target, prop, value){
|
|
if (prop !== 'length' && !isNaN(prop)) value = self._reactivePoint(value);
|
|
target[prop] = value;
|
|
self._notify();
|
|
return true;
|
|
},
|
|
deleteProperty: function(target, prop){ delete target[prop]; self._notify(); return true; }
|
|
});
|
|
}
|
|
setTerrain(samples){ this.terrain = samples; this._notify(); }
|
|
rename(newName){ this.name = newName; this._notify(); }
|
|
toRecord(){
|
|
return {
|
|
id: this.id, name: this.name, placeName: this.placeName,
|
|
createdAt: this.createdAt, savedAt: this.savedAt,
|
|
waypoints: JSON.parse(JSON.stringify(Array.prototype.slice.call(this.waypoints))),
|
|
terrain: this.terrain
|
|
};
|
|
}
|
|
}
|
|
|
|
function onMissionChange(){ scheduleAutosave(); }
|
|
var currentMission = new Mission({name:'Amsterdam', placeName:'Amsterdam'}, onMissionChange);
|
|
var waypoints = currentMission.waypoints;
|
|
|
|
function loadMissions(){
|
|
var list;
|
|
try { list = JSON.parse(localStorage.getItem('taf_missions') || '[]'); }
|
|
catch(e){ list = []; }
|
|
var migrated = false;
|
|
list.forEach(function(m){
|
|
if (!m.id){ m.id = generateId(); migrated = true; }
|
|
if (!m.placeName){ m.placeName = m.name.replace(/^Mission:\s*/i, ''); migrated = true; }
|
|
if (!m.createdAt){ m.createdAt = m.savedAt || Date.now(); migrated = true; }
|
|
if (m.terrain === undefined){ m.terrain = null; migrated = true; }
|
|
});
|
|
if (migrated) saveMissionsList(list);
|
|
return list;
|
|
}
|
|
function saveMissionsList(list){ localStorage.setItem('taf_missions', JSON.stringify(list)); }
|
|
function saveCurrentMission(silent){
|
|
if (!waypoints.length){
|
|
if (!silent) showToast('Add at least one waypoint before saving');
|
|
return;
|
|
}
|
|
var missions = loadMissions();
|
|
var record = currentMission.toRecord();
|
|
var idx = missions.findIndex(function(m){ return m.id === currentMission.id; });
|
|
if (idx >= 0 && missions[idx].createdAt) record.createdAt = missions[idx].createdAt;
|
|
if (idx >= 0) missions[idx] = record; else missions.push(record);
|
|
saveMissionsList(missions);
|
|
if (!silent) showToast('Mission saved (not sent)');
|
|
}
|
|
sendBtn.addEventListener('click', function(e){ e.stopPropagation(); saveCurrentMission(false); });
|
|
|
|
var autosaveTimer = null;
|
|
function scheduleAutosave(){
|
|
if (!waypoints.length) return;
|
|
clearTimeout(autosaveTimer);
|
|
autosaveTimer = setTimeout(function(){ saveCurrentMission(true); }, 800);
|
|
}
|
|
function flushPendingAutosave(){
|
|
if (autosaveTimer){
|
|
clearTimeout(autosaveTimer);
|
|
autosaveTimer = null;
|
|
saveCurrentMission(true);
|
|
}
|
|
}
|
|
window.addEventListener('beforeunload', flushPendingAutosave);
|
|
window.addEventListener('pagehide', flushPendingAutosave);
|
|
document.addEventListener('visibilitychange', function(){
|
|
if (document.visibilityState === 'hidden') flushPendingAutosave();
|
|
});
|
|
|
|
var missionsOpen = false;
|
|
function trashIconSvg(){
|
|
return '<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="#e2574a" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg>';
|
|
}
|
|
|
|
// --- Wind data (Open-Meteo, interpolated between pressure levels to the exact waypoint altitude) ---
|
|
// Pressure level "altitude" (geopotential height) is above mean sea level, not above ground,
|
|
// and isn't fixed - it shifts with weather. We fetch geopotential_height alongside wind for
|
|
// each level, then linearly interpolate wind speed/direction to the target height.
|
|
var PRESSURE_LEVELS = [1000, 975, 950, 925, 900, 850, 800, 750, 700];
|
|
|
|
function windArrowSvg(dirFrom, size, color){
|
|
if (dirFrom === undefined || dirFrom === null) return '';
|
|
var toward = (dirFrom + 180) % 360;
|
|
var s = size || 14;
|
|
return '<svg viewBox="0 0 24 24" width="'+s+'" height="'+s+'" style="transform:rotate('+toward+'deg);flex-shrink:0;display:inline-block;vertical-align:middle;"><polygon points="12,1 16.5,22 12,17.5 7.5,22" fill="'+(color||'#ffffff')+'"/></svg>';
|
|
}
|
|
|
|
function nearestHourIndex(timeArr){
|
|
var now = Date.now();
|
|
var bestIdx = 0, bestDiff = Infinity;
|
|
for (var i=0;i<timeArr.length;i++){
|
|
var t = new Date(timeArr[i] + ':00Z').getTime();
|
|
var diff = Math.abs(t - now);
|
|
if (diff < bestDiff){ bestDiff = diff; bestIdx = i; }
|
|
}
|
|
return bestIdx;
|
|
}
|
|
|
|
function interpolateAngle(a, b, frac){
|
|
var diff = ((b - a + 540) % 360) - 180;
|
|
return (a + diff*frac + 360) % 360;
|
|
}
|
|
|
|
function interpolateWindAtHeight(levels, targetHeight){
|
|
if (!levels.length) return null;
|
|
var sorted = levels.slice().sort(function(a,b){ return a.height - b.height; });
|
|
if (targetHeight <= sorted[0].height) return {speed:sorted[0].speed, dir:sorted[0].dir};
|
|
var last = sorted[sorted.length-1];
|
|
if (targetHeight >= last.height) return {speed:last.speed, dir:last.dir};
|
|
for (var i=0;i<sorted.length-1;i++){
|
|
var a = sorted[i], b = sorted[i+1];
|
|
if (targetHeight >= a.height && targetHeight <= b.height){
|
|
var frac = (targetHeight - a.height) / (b.height - a.height);
|
|
return {speed: a.speed + (b.speed-a.speed)*frac, dir: interpolateAngle(a.dir, b.dir, frac)};
|
|
}
|
|
}
|
|
return {speed:last.speed, dir:last.dir};
|
|
}
|
|
|
|
var showPerWaypointWind = false;
|
|
headerWindPill.addEventListener('click', function(e){
|
|
e.stopPropagation();
|
|
showPerWaypointWind = !showPerWaypointWind;
|
|
headerWindPill.classList.toggle('active', showPerWaypointWind);
|
|
redraw();
|
|
});
|
|
|
|
function updateHeaderWindPill(speed, dir){
|
|
headerWindArrow.innerHTML = windArrowSvg(dir, 15);
|
|
headerWindText.textContent = Math.round(speed) + ' m/s';
|
|
}
|
|
|
|
function headerWindLocation(){
|
|
if (waypoints.length) return {lat: waypoints[0].lat, lon: waypoints[0].lng};
|
|
if (!missionCenter) return null;
|
|
if (missionCenter.lat !== undefined) return {lat: missionCenter.lat, lon: missionCenter.lng};
|
|
if (Array.isArray(missionCenter)) return {lat: missionCenter[0], lon: missionCenter[1]};
|
|
return null;
|
|
}
|
|
function runWindValidation(){
|
|
var loc = headerWindLocation();
|
|
if (!loc){ showToast('No location available yet'); return; }
|
|
windValidateRows.innerHTML = '<div class="wp-empty">Loading\u2026</div>';
|
|
sizePanels();
|
|
windValidatePanel.style.display = 'flex';
|
|
var fixedHeights = [10,80,120,180];
|
|
var fixedVars = fixedHeights.map(function(h){ return 'wind_speed_'+h+'m,wind_direction_'+h+'m'; }).join(',') + ',wind_gusts_10m';
|
|
var pressureVars = PRESSURE_LEVELS.map(function(p){
|
|
return 'wind_speed_'+p+'hPa,wind_direction_'+p+'hPa,geopotential_height_'+p+'hPa';
|
|
}).join(',');
|
|
var fixedUrl = 'https://api.open-meteo.com/v1/forecast?latitude='+loc.lat.toFixed(5)+'&longitude='+loc.lon.toFixed(5)+
|
|
'&hourly='+fixedVars+'&wind_speed_unit=ms&timezone=UTC&forecast_days=1';
|
|
var pressureUrl = 'https://api.open-meteo.com/v1/forecast?latitude='+loc.lat.toFixed(5)+'&longitude='+loc.lon.toFixed(5)+
|
|
'&hourly='+pressureVars+'&wind_speed_unit=ms&timezone=UTC&forecast_days=1';
|
|
|
|
function fetchWithTimeout(url, ms){
|
|
var controller = new AbortController();
|
|
var timer = setTimeout(function(){ controller.abort(); }, ms);
|
|
return fetch(url, {signal: controller.signal}).then(function(r){
|
|
clearTimeout(timer);
|
|
return r.json();
|
|
}).catch(function(){
|
|
clearTimeout(timer);
|
|
return null;
|
|
});
|
|
}
|
|
|
|
Promise.all([
|
|
fetchWithTimeout(fixedUrl, 8000),
|
|
fetchWithTimeout(pressureUrl, 8000)
|
|
]).then(function(results){
|
|
var fixedRes = results[0], pressureRes = results[1];
|
|
var elevation = (fixedRes && fixedRes.elevation) || (pressureRes && pressureRes.elevation) || 0;
|
|
var html = '';
|
|
html += '<div style="margin-bottom:12px;"><b>Location:</b> '+loc.lat.toFixed(5)+', '+loc.lon.toFixed(5)+' · ground elevation '+Math.round(elevation)+' m ASL</div>';
|
|
|
|
if (fixedRes && fixedRes.hourly && fixedRes.hourly.time){
|
|
var fHourIdx = nearestHourIndex(fixedRes.hourly.time);
|
|
html += '<div style="margin-bottom:6px;"><b>Fixed hub heights</b> — measured '+fixedRes.hourly.time[fHourIdx]+' UTC</div>';
|
|
fixedHeights.forEach(function(h){
|
|
var spdArr = fixedRes.hourly['wind_speed_'+h+'m'];
|
|
var dirArr = fixedRes.hourly['wind_direction_'+h+'m'];
|
|
var spd = (spdArr && typeof spdArr[fHourIdx] === 'number') ? spdArr[fHourIdx] : null;
|
|
var dir = (dirArr && typeof dirArr[fHourIdx] === 'number') ? dirArr[fHourIdx] : null;
|
|
html += '<div>'+h+' m AGL: '+(spd!==null?spd.toFixed(1)+' m/s':'n/a')+(dir!==null?(', '+Math.round(dir)+'\u00b0'):'')+'</div>';
|
|
});
|
|
var gustArr = fixedRes.hourly['wind_gusts_10m'];
|
|
var gustVal = (gustArr && typeof gustArr[fHourIdx] === 'number') ? gustArr[fHourIdx] : null;
|
|
html += '<div style="margin-top:4px;">Gusts (10 m AGL): '+(gustVal!==null?gustVal.toFixed(1)+' m/s':'n/a')+'</div>';
|
|
} else {
|
|
html += '<div style="margin-bottom:6px;color:#ff8a7a;"><b>Fixed hub heights:</b> request failed</div>';
|
|
}
|
|
|
|
if (pressureRes && pressureRes.hourly && pressureRes.hourly.time){
|
|
var pHourIdx = nearestHourIndex(pressureRes.hourly.time);
|
|
var levels = PRESSURE_LEVELS.map(function(p){
|
|
var hArr = pressureRes.hourly['geopotential_height_'+p+'hPa'];
|
|
var sArr = pressureRes.hourly['wind_speed_'+p+'hPa'];
|
|
var dArr = pressureRes.hourly['wind_direction_'+p+'hPa'];
|
|
return {
|
|
p:p,
|
|
height: (hArr && typeof hArr[pHourIdx] === 'number') ? hArr[pHourIdx] : null,
|
|
speed: (sArr && typeof sArr[pHourIdx] === 'number') ? sArr[pHourIdx] : null,
|
|
dir: (dArr && typeof dArr[pHourIdx] === 'number') ? dArr[pHourIdx] : null
|
|
};
|
|
});
|
|
html += '<div style="margin:12px 0 6px;"><b>Pressure levels</b> — measured '+pressureRes.hourly.time[pHourIdx]+' UTC (discarded if below ground or incomplete)</div>';
|
|
levels.forEach(function(lv){
|
|
var excluded = (lv.height === null || lv.speed === null || lv.dir === null || lv.height < elevation);
|
|
var reason = lv.height === null || lv.speed === null || lv.dir === null ? ' (incomplete, excluded)' : (lv.height < elevation ? ' (below ground, excluded)' : '');
|
|
html += '<div'+(excluded?' style="opacity:0.4;text-decoration:line-through;"':'')+'>'+lv.p+' hPa: '+(lv.height!==null?Math.round(lv.height):'?')+' m ASL, '+(lv.speed!==null?lv.speed.toFixed(1)+' m/s':'n/a')+reason+'</div>';
|
|
});
|
|
var combinedForValidation = buildCombinedWindLevels(pressureRes, fixedRes);
|
|
var result120 = interpolateWindAtHeight(combinedForValidation.levels, elevation + 120);
|
|
var result120Valid = result120 && typeof result120.speed === 'number' && !isNaN(result120.speed) && typeof result120.dir === 'number' && !isNaN(result120.dir);
|
|
html += '<div style="margin-top:12px;padding-top:12px;border-top:1px solid rgba(255,255,255,0.2);"><b>Interpolated & referenced at 120 m AGL</b> (= '+Math.round(elevation+120)+' m ASL, using pressure levels + fixed heights combined): '+
|
|
(result120Valid ? (result120.speed.toFixed(1)+' m/s, '+Math.round(result120.dir)+'\u00b0') : 'n/a (missing data at boundary level)') + '</div>';
|
|
} else {
|
|
html += '<div style="color:#ff8a7a;"><b>Pressure levels:</b> request failed</div>';
|
|
}
|
|
|
|
windValidateRows.innerHTML = html;
|
|
}).catch(function(){
|
|
windValidateRows.innerHTML = '<div class="wp-empty">Something went wrong while rendering results</div>';
|
|
});
|
|
}
|
|
windValidateBtn.addEventListener('click', function(e){ e.stopPropagation(); runWindValidation(); });
|
|
windValidateClose.addEventListener('click', function(e){ e.stopPropagation(); windValidatePanel.style.display = 'none'; });
|
|
|
|
var FIXED_WIND_HEIGHTS = [10, 80, 120, 180];
|
|
|
|
// Combines pressure-level data and fixed hub-height data (converted AGL->ASL)
|
|
// into one unified support-point list for interpolation. Either source may be
|
|
// null; whatever is available is used.
|
|
function buildCombinedWindLevels(pRes, fRes){
|
|
var levels = [];
|
|
var elevation = 0;
|
|
if (pRes && pRes.hourly && pRes.hourly.time){
|
|
var pHourIdx = nearestHourIndex(pRes.hourly.time);
|
|
elevation = pRes.elevation || 0;
|
|
PRESSURE_LEVELS.forEach(function(p){
|
|
var hArr = pRes.hourly['geopotential_height_'+p+'hPa'];
|
|
var sArr = pRes.hourly['wind_speed_'+p+'hPa'];
|
|
var dArr = pRes.hourly['wind_direction_'+p+'hPa'];
|
|
var h = hArr ? hArr[pHourIdx] : null;
|
|
var s = sArr ? sArr[pHourIdx] : null;
|
|
var d = dArr ? dArr[pHourIdx] : null;
|
|
if (typeof h === 'number' && typeof s === 'number' && typeof d === 'number' && h >= elevation){
|
|
levels.push({height:h, speed:s, dir:d});
|
|
}
|
|
});
|
|
}
|
|
if (fRes && fRes.hourly && fRes.hourly.time){
|
|
var fHourIdx = nearestHourIndex(fRes.hourly.time);
|
|
if (!elevation) elevation = fRes.elevation || 0;
|
|
FIXED_WIND_HEIGHTS.forEach(function(h){
|
|
var sArr = fRes.hourly['wind_speed_'+h+'m'];
|
|
var dArr = fRes.hourly['wind_direction_'+h+'m'];
|
|
var s = sArr ? sArr[fHourIdx] : null;
|
|
var d = dArr ? dArr[fHourIdx] : null;
|
|
if (typeof s === 'number' && typeof d === 'number'){
|
|
levels.push({height: elevation + h, speed: s, dir: d});
|
|
}
|
|
});
|
|
}
|
|
return {levels: levels, elevation: elevation};
|
|
}
|
|
function windUrlsFor(lats, lons){
|
|
var pressureVars = PRESSURE_LEVELS.map(function(p){
|
|
return 'wind_speed_'+p+'hPa,wind_direction_'+p+'hPa,geopotential_height_'+p+'hPa';
|
|
}).join(',');
|
|
var fixedVars = FIXED_WIND_HEIGHTS.map(function(h){ return 'wind_speed_'+h+'m,wind_direction_'+h+'m'; }).join(',') + ',wind_gusts_10m';
|
|
return {
|
|
pressure: 'https://api.open-meteo.com/v1/forecast?latitude='+lats+'&longitude='+lons+'&hourly='+pressureVars+'&wind_speed_unit=ms&timezone=UTC&forecast_days=1',
|
|
fixed: 'https://api.open-meteo.com/v1/forecast?latitude='+lats+'&longitude='+lons+'&hourly='+fixedVars+'&wind_speed_unit=ms&timezone=UTC&forecast_days=1'
|
|
};
|
|
}
|
|
|
|
function refreshHeaderWind(){
|
|
var loc = headerWindLocation();
|
|
if (!loc) return;
|
|
var urls = windUrlsFor(loc.lat.toFixed(5), loc.lon.toFixed(5));
|
|
Promise.all([
|
|
fetch(urls.pressure).then(function(r){ return r.json(); }).catch(function(){ return null; }),
|
|
fetch(urls.fixed).then(function(r){ return r.json(); }).catch(function(){ return null; })
|
|
]).then(function(both){
|
|
var combined = buildCombinedWindLevels(both[0], both[1]);
|
|
if (!combined.levels.length) return;
|
|
var result = interpolateWindAtHeight(combined.levels, combined.elevation + 120);
|
|
if (result && typeof result.speed === 'number' && typeof result.dir === 'number'){
|
|
updateHeaderWindPill(result.speed, result.dir);
|
|
}
|
|
});
|
|
}
|
|
|
|
function fetchWindForWaypoints(){
|
|
if (!waypoints.length){ showToast('No waypoints to fetch wind for'); return; }
|
|
var lats = waypoints.map(function(w){ return w.lat.toFixed(5); }).join(',');
|
|
var lons = waypoints.map(function(w){ return w.lng.toFixed(5); }).join(',');
|
|
var urls = windUrlsFor(lats, lons);
|
|
showToast('Fetching wind\u2026');
|
|
Promise.all([
|
|
fetch(urls.pressure).then(function(r){ return r.json(); }).catch(function(){ return null; }),
|
|
fetch(urls.fixed).then(function(r){ return r.json(); }).catch(function(){ return null; })
|
|
]).then(function(both){
|
|
var pressureData = both[0], fixedData = both[1];
|
|
if (!pressureData && !fixedData){ showToast('Wind fetch failed'); return; }
|
|
var pressureResults = pressureData ? (Array.isArray(pressureData) ? pressureData : [pressureData]) : [];
|
|
var fixedResults = fixedData ? (Array.isArray(fixedData) ? fixedData : [fixedData]) : [];
|
|
waypoints.forEach(function(wp, idx){
|
|
var combined = buildCombinedWindLevels(pressureResults[idx], fixedResults[idx]);
|
|
if (!combined.levels.length) return;
|
|
var targetAmsl = combined.elevation + wp.alt;
|
|
var result = interpolateWindAtHeight(combined.levels, targetAmsl);
|
|
if (result && typeof result.speed === 'number' && typeof result.dir === 'number'){
|
|
wp.windSpeed = result.speed;
|
|
wp.windDir = result.dir;
|
|
wp.windElevation = combined.elevation;
|
|
}
|
|
});
|
|
showToast('Wind data updated');
|
|
redraw();
|
|
});
|
|
}
|
|
document.getElementById('windFetchBtn').addEventListener('click', function(e){
|
|
e.stopPropagation();
|
|
fetchWindForWaypoints();
|
|
});
|
|
|
|
|
|
function shareIconSvg(){
|
|
return '<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/></svg>';
|
|
}
|
|
var missionsSortField = null;
|
|
var missionsSortDir = 1;
|
|
function renderMissionsList(){
|
|
var missions = loadMissions();
|
|
if (!missions.length){ missionsRows.innerHTML = '<div class="wp-empty">No saved missions yet</div>'; return; }
|
|
var sorted = missions.slice();
|
|
if (missionsSortField === 'name'){
|
|
sorted.sort(function(a,b){
|
|
var an = a.name.replace(/^Mission:\s*/i, '').toLowerCase();
|
|
var bn = b.name.replace(/^Mission:\s*/i, '').toLowerCase();
|
|
return an < bn ? -missionsSortDir : (an > bn ? missionsSortDir : 0);
|
|
});
|
|
} else if (missionsSortField === 'date'){
|
|
sorted.sort(function(a,b){ return (a.savedAt - b.savedAt) * missionsSortDir; });
|
|
} else {
|
|
sorted.sort(function(a,b){ return b.savedAt - a.savedAt; });
|
|
}
|
|
document.getElementById('sortIndicatorName').textContent = missionsSortField === 'name' ? (missionsSortDir > 0 ? '\u25b2' : '\u25bc') : '';
|
|
document.getElementById('sortIndicatorDate').textContent = missionsSortField === 'date' ? (missionsSortDir > 0 ? '\u25b2' : '\u25bc') : '';
|
|
var rows = sorted.map(function(m){
|
|
var date = new Date(m.savedAt).toLocaleDateString();
|
|
var activeClass = (m.id === currentMission.id) ? ' wp-row-active' : '';
|
|
return '<div class="wp-row'+activeClass+'" data-id="'+m.id+'">' +
|
|
'<span class="wp-col" style="flex:2;">'+m.name.replace(/^Mission:\s*/i, '')+'</span>' +
|
|
'<span class="wp-col">'+m.waypoints.length+' wp</span>' +
|
|
'<span class="wp-col">'+date+'</span>' +
|
|
'<div class="row-actions">' +
|
|
'<button class="wp-del-btn wp-edit-btn mission-rename-btn" data-id="'+m.id+'" aria-label="Rename mission">' + editIconSvg() + '</button>' +
|
|
'<button class="wp-del-btn wp-share-btn mission-share-btn" data-id="'+m.id+'" aria-label="Share mission">' + shareIconSvg() + '</button>' +
|
|
'<button class="wp-del-btn mission-del-btn" data-id="'+m.id+'" aria-label="Delete mission">' + trashIconSvg() + '</button>' +
|
|
'</div>' +
|
|
'</div>';
|
|
});
|
|
missionsRows.innerHTML = rows.join('');
|
|
}
|
|
|
|
function buildMissionExportObject(m){
|
|
var terrain = null;
|
|
if (terrainSamples){
|
|
var routeMatches = m.waypoints.map(function(w){ return w.lat.toFixed(5)+','+w.lng.toFixed(5); }).join('|') === terrainSamples.routeKey;
|
|
if (routeMatches) terrain = terrainSamples;
|
|
}
|
|
return {
|
|
appVersion: APP_VERSION,
|
|
exportedAt: new Date().toISOString(),
|
|
mission: {
|
|
id: m.id,
|
|
name: m.name.replace(/^Mission:\s*/i, ''),
|
|
placeName: m.placeName || m.name.replace(/^Mission:\s*/i, ''),
|
|
createdAt: m.createdAt || m.savedAt,
|
|
savedAt: m.savedAt,
|
|
waypoints: m.waypoints,
|
|
terrain: terrain
|
|
}
|
|
};
|
|
}
|
|
function shareMission(m){
|
|
var data = buildMissionExportObject(m);
|
|
var json = JSON.stringify(data, null, 2);
|
|
var safeName = (m.placeName || m.name).replace(/[^a-z0-9]/gi, '_');
|
|
var filename = 'mission-' + safeName + '.json';
|
|
|
|
function fallbackDownload(){
|
|
try {
|
|
var blob = new Blob([json], {type:'application/json'});
|
|
var url = URL.createObjectURL(blob);
|
|
var a = document.createElement('a');
|
|
a.href = url; a.download = filename;
|
|
document.body.appendChild(a); a.click(); document.body.removeChild(a);
|
|
setTimeout(function(){ URL.revokeObjectURL(url); }, 2000);
|
|
showToast('Mission file downloaded');
|
|
} catch(e2){
|
|
showToast('Share failed \u2013 please try again');
|
|
}
|
|
}
|
|
|
|
var attemptedNativeShare = false;
|
|
try {
|
|
if (navigator.share && navigator.canShare){
|
|
var file = new File([json], filename, {type:'application/json'});
|
|
if (navigator.canShare({files:[file]})){
|
|
attemptedNativeShare = true;
|
|
navigator.share({files:[file], title:'Mission: ' + (m.placeName || m.name)}).catch(function(err){
|
|
// AbortError means the user closed the share sheet themselves - respect that, don't force a download.
|
|
if (err && err.name === 'AbortError') return;
|
|
fallbackDownload();
|
|
});
|
|
}
|
|
}
|
|
} catch(e){ attemptedNativeShare = false; }
|
|
|
|
if (!attemptedNativeShare) fallbackDownload();
|
|
}
|
|
|
|
importMissionBtn.addEventListener('click', function(e){ e.stopPropagation(); importMissionInput.click(); });
|
|
importMissionInput.addEventListener('change', function(e){
|
|
var f = e.target.files && e.target.files[0];
|
|
if (!f) return;
|
|
var reader = new FileReader();
|
|
reader.onload = function(){
|
|
var parsed;
|
|
try { parsed = JSON.parse(reader.result); } catch(err){ showToast('Invalid mission file'); return; }
|
|
var missionData = parsed.mission || parsed;
|
|
if (!missionData || !missionData.waypoints || !missionData.waypoints.length){
|
|
showToast('File has no waypoints'); return;
|
|
}
|
|
if (parsed.appVersion && parsed.appVersion.split('.')[0] !== APP_VERSION.split('.')[0]){
|
|
showToast('Imported from a different app version (' + parsed.appVersion + ') \u2013 check carefully');
|
|
}
|
|
var missions = loadMissions();
|
|
var record = {
|
|
id: generateId(),
|
|
name: missionData.name || missionData.placeName || 'Imported mission',
|
|
placeName: missionData.placeName || missionData.name || 'Unknown',
|
|
createdAt: missionData.createdAt || Date.now(),
|
|
savedAt: Date.now(),
|
|
terrain: missionData.terrain || null,
|
|
waypoints: missionData.waypoints
|
|
};
|
|
missions.push(record);
|
|
saveMissionsList(missions);
|
|
renderMissionsList();
|
|
showToast('Mission imported');
|
|
importMissionInput.value = '';
|
|
};
|
|
reader.readAsText(f);
|
|
});
|
|
|
|
missionsRows.addEventListener('click', function(e){
|
|
var renameBtn = e.target.closest('.mission-rename-btn');
|
|
if (renameBtn){
|
|
var rid = renameBtn.getAttribute('data-id');
|
|
var missionsForRename = loadMissions();
|
|
var rm = missionsForRename.find(function(mm){ return mm.id === rid; });
|
|
if (!rm) return;
|
|
var newName = prompt('Rename mission', rm.name.replace(/^Mission:\s*/i, ''));
|
|
if (newName === null) return;
|
|
newName = newName.trim();
|
|
if (!newName) return;
|
|
rm.name = newName;
|
|
saveMissionsList(missionsForRename);
|
|
if (rm.id === currentMission.id){
|
|
currentMission.name = newName;
|
|
missionNameBtn.textContent = 'Mission: ' + currentMission.name;
|
|
missionNameBtn.title = missionNameBtn.textContent;
|
|
}
|
|
renderMissionsList();
|
|
return;
|
|
}
|
|
var shareBtn = e.target.closest('.mission-share-btn');
|
|
if (shareBtn){
|
|
var sid = shareBtn.getAttribute('data-id');
|
|
var missionsForShare = loadMissions();
|
|
var sm = missionsForShare.find(function(mm){ return mm.id === sid; });
|
|
if (sm) shareMission(sm);
|
|
return;
|
|
}
|
|
var delBtn = e.target.closest('.mission-del-btn');
|
|
if (delBtn){
|
|
var did = delBtn.getAttribute('data-id');
|
|
var missions = loadMissions();
|
|
var didx = missions.findIndex(function(mm){ return mm.id === did; });
|
|
if (didx >= 0) missions.splice(didx, 1);
|
|
saveMissionsList(missions);
|
|
renderMissionsList();
|
|
return;
|
|
}
|
|
var row = e.target.closest('.wp-row');
|
|
if (!row) return;
|
|
var rowId = row.getAttribute('data-id');
|
|
var missions2 = loadMissions();
|
|
var m = missions2.find(function(mm){ return mm.id === rowId; });
|
|
if (!m) return;
|
|
flushPendingAutosave();
|
|
currentMission = new Mission({
|
|
id: m.id,
|
|
name: m.name.replace(/^Mission:\s*/i, ''),
|
|
placeName: m.placeName,
|
|
createdAt: m.createdAt,
|
|
savedAt: m.savedAt,
|
|
terrain: m.terrain || null,
|
|
waypoints: JSON.parse(JSON.stringify(m.waypoints))
|
|
}, onMissionChange);
|
|
waypoints = currentMission.waypoints;
|
|
terrainSamples = currentMission.terrain;
|
|
missionNeedsPlaceName = false;
|
|
missionNameBtn.textContent = 'Mission: ' + currentMission.name;
|
|
mode = 'idle'; targetIdx = -1;
|
|
missionsOpen = false;
|
|
missionsPanel.style.display = 'none';
|
|
redraw();
|
|
fitToWaypoints();
|
|
refreshHeaderWind();
|
|
});
|
|
document.querySelectorAll('.mission-sort-header').forEach(function(h){
|
|
h.addEventListener('click', function(e){
|
|
e.stopPropagation();
|
|
var field = h.getAttribute('data-sort');
|
|
if (missionsSortField === field) missionsSortDir = -missionsSortDir;
|
|
else { missionsSortField = field; missionsSortDir = 1; }
|
|
renderMissionsList();
|
|
});
|
|
});
|
|
function openMissionsPanel(tab){
|
|
missionsOpen = true;
|
|
sizePanels();
|
|
missionsPanel.style.display = 'flex';
|
|
setActiveMTab(tab);
|
|
}
|
|
function closeMissionsPanel(){
|
|
missionsOpen = false;
|
|
missionsPanel.style.display = 'none';
|
|
}
|
|
missionNameBtn.addEventListener('click', function(e){ e.stopPropagation(); openMissionsPanel('missions'); });
|
|
droneNameBtn.addEventListener('click', function(e){ e.stopPropagation(); openMissionsPanel('drones'); });
|
|
missionsClose.addEventListener('click', function(e){ e.stopPropagation(); closeMissionsPanel(); });
|
|
|
|
function setActiveMTab(tab){
|
|
mtabButtons.forEach(function(b){ b.classList.toggle('active', b.getAttribute('data-mtab') === tab); });
|
|
document.getElementById('mtabMissions').classList.toggle('active', tab === 'missions');
|
|
document.getElementById('mtabDrones').classList.toggle('active', tab === 'drones');
|
|
if (tab === 'missions') renderMissionsList(); else renderDronesList();
|
|
}
|
|
mtabButtons.forEach(function(b){
|
|
b.addEventListener('click', function(e){ e.stopPropagation(); setActiveMTab(b.getAttribute('data-mtab')); });
|
|
});
|
|
|
|
// --- Drone profiles ---
|
|
var DEFAULT_DRONE = {name:'T1 Ranger', minSpeed:13, cruiseSpeed:15, maxSpeed:25, minTurnRadius:35, maxClimbRate:5, maxDescentRate:6, maxRange:15000, maxEndurance:45, loiterRadius:100, maxAltitude:2000, catchRadius:60};
|
|
function loadDrones(){
|
|
try {
|
|
var d = JSON.parse(localStorage.getItem('taf_drones') || 'null');
|
|
if (!d || !d.length) return null;
|
|
return d;
|
|
} catch(e){ return null; }
|
|
}
|
|
function saveDronesList(list){ localStorage.setItem('taf_drones', JSON.stringify(list)); }
|
|
var drones = loadDrones();
|
|
if (!drones){ drones = [JSON.parse(JSON.stringify(DEFAULT_DRONE))]; saveDronesList(drones); }
|
|
var driftedDefaults = false;
|
|
drones.forEach(function(d){
|
|
Object.keys(DEFAULT_DRONE).forEach(function(k){
|
|
if (d[k] === undefined){ d[k] = DEFAULT_DRONE[k]; driftedDefaults = true; }
|
|
});
|
|
});
|
|
if (driftedDefaults) saveDronesList(drones);
|
|
var activeDrone = drones[0];
|
|
|
|
function applyDroneProfile(drone){
|
|
activeDrone = drone;
|
|
SPD_MIN = drone.minSpeed;
|
|
SPD_MAX = drone.maxSpeed;
|
|
buildTape(spdTape, SPD_MIN, SPD_MAX, SPD_STEP, SPD_PX, SPD_MAJOR);
|
|
if (typeof drone.cruiseSpeed === 'number'){
|
|
curSpeed = drone.cruiseSpeed;
|
|
}
|
|
if (curSpeed < SPD_MIN) curSpeed = SPD_MIN;
|
|
if (curSpeed > SPD_MAX) curSpeed = SPD_MAX;
|
|
ALT_MAX = drone.maxAltitude;
|
|
buildTape(altTape, ALT_MIN, ALT_MAX, ALT_STEP, ALT_PX, ALT_MAJOR);
|
|
if (curAlt > ALT_MAX) curAlt = ALT_MAX;
|
|
if (curAlt < ALT_MIN) curAlt = ALT_MIN;
|
|
droneNameBtn.textContent = 'Drone: ' + drone.name;
|
|
droneNameBtn.title = 'Drone: ' + drone.name;
|
|
}
|
|
|
|
function droneSummary(d){
|
|
var rangeKm = Math.round(d.maxRange/100)/10;
|
|
return d.minSpeed+'\u2013'+(d.cruiseSpeed !== undefined ? d.cruiseSpeed+'\u2013' : '')+d.maxSpeed+' m/s \u00b7 R'+d.minTurnRadius+'m \u00b7 \u2191'+d.maxClimbRate+'/\u2193'+d.maxDescentRate+' m/s \u00b7 '+rangeKm+'km \u00b7 '+d.maxEndurance+'min \u00b7 ceil '+d.maxAltitude+'m \u00b7 loiter R'+d.loiterRadius+'m \u00b7 catch '+d.catchRadius+'m';
|
|
}
|
|
function editIconSvg(){
|
|
return '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg>';
|
|
}
|
|
function renderDronesList(){
|
|
var rows = drones.map(function(d, idx){
|
|
var activeClass = (d === activeDrone) ? ' wp-row-active' : '';
|
|
return '<div class="wp-row drone-row'+activeClass+'" data-idx="'+idx+'">' +
|
|
'<div class="drone-row-top">' +
|
|
'<span class="wp-col" style="font-weight:600;">'+d.name+'</span>' +
|
|
'<div class="row-actions">' +
|
|
'<button class="wp-del-btn wp-edit-btn drone-edit-btn" data-idx="'+idx+'" aria-label="Edit profile">'+editIconSvg()+'</button>' +
|
|
'<button class="wp-del-btn wp-share-btn drone-share-btn" data-idx="'+idx+'" aria-label="Share profile">'+shareIconSvg()+'</button>' +
|
|
'<button class="wp-del-btn drone-del-btn" data-idx="'+idx+'" aria-label="Delete profile">'+trashIconSvg()+'</button>' +
|
|
'</div>' +
|
|
'</div>' +
|
|
'<div class="drone-summary">'+droneSummary(d)+'</div>' +
|
|
'</div>';
|
|
});
|
|
dronesRows.innerHTML = rows.join('');
|
|
}
|
|
|
|
function buildDroneExportObject(d){
|
|
return {
|
|
appVersion: APP_VERSION,
|
|
exportedAt: new Date().toISOString(),
|
|
drone: d
|
|
};
|
|
}
|
|
function shareDrone(d){
|
|
var data = buildDroneExportObject(d);
|
|
var json = JSON.stringify(data, null, 2);
|
|
var safeName = d.name.replace(/[^a-z0-9]/gi, '_');
|
|
var filename = 'drone-' + safeName + '.json';
|
|
|
|
function fallbackDownload(){
|
|
try {
|
|
var blob = new Blob([json], {type:'application/json'});
|
|
var url = URL.createObjectURL(blob);
|
|
var a = document.createElement('a');
|
|
a.href = url; a.download = filename;
|
|
document.body.appendChild(a); a.click(); document.body.removeChild(a);
|
|
setTimeout(function(){ URL.revokeObjectURL(url); }, 2000);
|
|
showToast('Drone profile downloaded');
|
|
} catch(e2){
|
|
showToast('Share failed \u2013 please try again');
|
|
}
|
|
}
|
|
|
|
var attemptedNativeShare = false;
|
|
try {
|
|
if (navigator.share && navigator.canShare){
|
|
var file = new File([json], filename, {type:'application/json'});
|
|
if (navigator.canShare({files:[file]})){
|
|
attemptedNativeShare = true;
|
|
navigator.share({files:[file], title:'Drone profile: ' + d.name}).catch(function(err){
|
|
if (err && err.name === 'AbortError') return;
|
|
fallbackDownload();
|
|
});
|
|
}
|
|
}
|
|
} catch(e){ attemptedNativeShare = false; }
|
|
|
|
if (!attemptedNativeShare) fallbackDownload();
|
|
}
|
|
|
|
importDroneBtn.addEventListener('click', function(e){ e.stopPropagation(); importDroneInput.click(); });
|
|
importDroneInput.addEventListener('change', function(e){
|
|
var f = e.target.files && e.target.files[0];
|
|
if (!f) return;
|
|
var reader = new FileReader();
|
|
reader.onload = function(){
|
|
var parsed;
|
|
try { parsed = JSON.parse(reader.result); } catch(err){ showToast('Invalid drone profile file'); return; }
|
|
var droneData = parsed.drone || parsed;
|
|
if (!droneData || !droneData.name || droneData.maxSpeed === undefined){
|
|
showToast('File is not a valid drone profile'); return;
|
|
}
|
|
if (parsed.appVersion && parsed.appVersion.split('.')[0] !== APP_VERSION.split('.')[0]){
|
|
showToast('Imported from a different app version (' + parsed.appVersion + ') \u2013 check carefully');
|
|
}
|
|
drones.push(droneData);
|
|
saveDronesList(drones);
|
|
renderDronesList();
|
|
showToast('Drone profile imported');
|
|
importDroneInput.value = '';
|
|
};
|
|
reader.readAsText(f);
|
|
});
|
|
|
|
dronesRows.addEventListener('click', function(e){
|
|
var shareBtn = e.target.closest('.drone-share-btn');
|
|
if (shareBtn){ shareDrone(drones[parseInt(shareBtn.getAttribute('data-idx'), 10)]); return; }
|
|
var editBtn = e.target.closest('.drone-edit-btn');
|
|
if (editBtn){ openDroneEditor(parseInt(editBtn.getAttribute('data-idx'), 10)); return; }
|
|
var delBtn = e.target.closest('.drone-del-btn');
|
|
if (delBtn){
|
|
if (drones.length <= 1){ showToast('At least one drone profile is required'); return; }
|
|
var didx = parseInt(delBtn.getAttribute('data-idx'), 10);
|
|
var wasActive = drones[didx] === activeDrone;
|
|
drones.splice(didx, 1);
|
|
saveDronesList(drones);
|
|
if (wasActive) applyDroneProfile(drones[0]);
|
|
renderDronesList();
|
|
redraw();
|
|
return;
|
|
}
|
|
var row = e.target.closest('.drone-row');
|
|
if (!row) return;
|
|
var idx = parseInt(row.getAttribute('data-idx'), 10);
|
|
applyDroneProfile(drones[idx]);
|
|
renderDronesList();
|
|
closeMissionsPanel();
|
|
redraw();
|
|
});
|
|
|
|
var editingDroneIdx = -1;
|
|
// Reusable keyboard-free numeric input: swipe vertically for big steps,
|
|
// horizontally for small steps. Same gesture model as the waypoint-list chips.
|
|
// Usage: new SwipeValueInput({label, value, min, max, unit, stepBig, stepSmall, decimals})
|
|
// .el -> DOM node to insert; .getValue() -> current value
|
|
function SwipeValueInput(opts){
|
|
this.opts = opts;
|
|
this.value = opts.value;
|
|
this.initialValue = opts.value;
|
|
this.el = document.createElement('div');
|
|
this.el.className = 'swipe-input';
|
|
var label = document.createElement('div');
|
|
label.className = 'swipe-input-label';
|
|
label.textContent = opts.label;
|
|
var dot = document.createElement('span');
|
|
dot.className = 'modified-dot';
|
|
label.appendChild(dot);
|
|
this.chip = document.createElement('div');
|
|
this.chip.className = 'value-chip';
|
|
this.chip.innerHTML = '<span class="chip-axis-icon">\u2195\u2194</span><span class="chip-value"></span>';
|
|
this.valEl = this.chip.querySelector('.chip-value');
|
|
this.el.appendChild(label);
|
|
this.el.appendChild(this.chip);
|
|
this._renderValue();
|
|
this._drag = null;
|
|
var self = this;
|
|
this.chip.addEventListener('pointerdown', function(e){
|
|
e.preventDefault();
|
|
self._drag = {startX:e.clientX, startY:e.clientY, startVal:self.value, axis:null};
|
|
self.chip.setPointerCapture(e.pointerId);
|
|
});
|
|
this.chip.addEventListener('pointermove', function(e){
|
|
if (!self._drag) return;
|
|
e.preventDefault();
|
|
var dx = e.clientX - self._drag.startX;
|
|
var dy = e.clientY - self._drag.startY;
|
|
if (!self._drag.axis){
|
|
if (Math.abs(dx) < 8 && Math.abs(dy) < 8) return;
|
|
self._drag.axis = Math.abs(dy) >= Math.abs(dx) ? 'v' : 'h';
|
|
}
|
|
var stepSize = self._drag.axis === 'v' ? (self.opts.stepBig !== undefined ? self.opts.stepBig : 10) : (self.opts.stepSmall !== undefined ? self.opts.stepSmall : 1);
|
|
var travel = self._drag.axis === 'v' ? -dy : dx;
|
|
var steps = Math.round(travel / 14);
|
|
var val = self._drag.startVal + steps * stepSize;
|
|
var dec = self.opts.decimals || 0;
|
|
val = parseFloat(val.toFixed(dec));
|
|
if (self.opts.min !== undefined && val < self.opts.min) val = self.opts.min;
|
|
if (self.opts.max !== undefined && val > self.opts.max) val = self.opts.max;
|
|
if (val !== self.value){
|
|
self.value = val;
|
|
self._renderValue();
|
|
self.chip.classList.add('chip-flash');
|
|
var chip = self.chip;
|
|
setTimeout(function(){ chip.classList.remove('chip-flash'); }, 120);
|
|
if (self.opts.onChange) self.opts.onChange(val);
|
|
}
|
|
});
|
|
var endDrag = function(){ self._drag = null; };
|
|
this.chip.addEventListener('pointerup', endDrag);
|
|
this.chip.addEventListener('pointercancel', endDrag);
|
|
}
|
|
SwipeValueInput.prototype._renderValue = function(){
|
|
var dec = this.opts.decimals || 0;
|
|
this.valEl.textContent = this.value.toFixed(dec) + (this.opts.unit ? ' ' + this.opts.unit : '');
|
|
var modified = (this.value !== this.initialValue);
|
|
this.chip.classList.toggle('chip-modified', modified);
|
|
this.el.classList.toggle('is-modified', modified);
|
|
};
|
|
SwipeValueInput.prototype.getValue = function(){ return this.value; };
|
|
|
|
// Field layout of the drone editor: grouped, multiple fields per row.
|
|
var DRONE_EDITOR_GROUPS = [
|
|
{title: 'Speed (m/s)', fields: [
|
|
{key:'minSpeed', label:'Min (stall)', min:3, max:40, stepBig:10, stepSmall:1, unit:'m/s'},
|
|
{key:'cruiseSpeed', label:'Cruise', min:3, max:50, stepBig:10, stepSmall:1, unit:'m/s'},
|
|
{key:'maxSpeed', label:'Max', min:5, max:80, stepBig:10, stepSmall:1, unit:'m/s'}
|
|
]},
|
|
{title: 'Radii (m)', fields: [
|
|
{key:'minTurnRadius', label:'Min turn', min:5, max:500, stepBig:10, stepSmall:1, unit:'m'},
|
|
{key:'loiterRadius', label:'Loiter', min:20, max:1000, stepBig:10, stepSmall:5, unit:'m'},
|
|
{key:'catchRadius', label:'Waypoint catch', min:5, max:500, stepBig:10, stepSmall:1, unit:'m'}
|
|
]},
|
|
{title: 'Climb / Descent (m/s)', fields: [
|
|
{key:'maxClimbRate', label:'Max climb', min:0.5, max:20, stepBig:1, stepSmall:0.5, decimals:1, unit:'m/s'},
|
|
{key:'maxDescentRate', label:'Max descent', min:0.5, max:20, stepBig:1, stepSmall:0.5, decimals:1, unit:'m/s'}
|
|
]},
|
|
{title: 'Limits', fields: [
|
|
{key:'maxRange', label:'Range', min:500, max:200000, stepBig:1000, stepSmall:100, unit:'m'},
|
|
{key:'maxEndurance', label:'Endurance', min:5, max:600, stepBig:10, stepSmall:1, unit:'min'},
|
|
{key:'maxAltitude', label:'Ceiling', min:50, max:10000, stepBig:100, stepSmall:10, unit:'m'}
|
|
]}
|
|
];
|
|
var droneEditorInputs = {};
|
|
function openDroneEditor(idx){
|
|
editingDroneIdx = idx;
|
|
var d = idx >= 0 ? drones[idx] : Object.assign({}, DEFAULT_DRONE, {name:'New profile'});
|
|
document.getElementById('droneEditorTitle').textContent = idx >= 0 ? 'Edit drone profile' : 'New drone profile';
|
|
document.getElementById('deName').value = d.name;
|
|
var wrap = document.getElementById('deFieldGroups');
|
|
wrap.innerHTML = '';
|
|
droneEditorInputs = {};
|
|
DRONE_EDITOR_GROUPS.forEach(function(group){
|
|
var g = document.createElement('div');
|
|
g.className = 'field-group';
|
|
var title = document.createElement('div');
|
|
title.className = 'field-group-title';
|
|
title.textContent = group.title;
|
|
g.appendChild(title);
|
|
var grid = document.createElement('div');
|
|
grid.className = 'field-group-grid';
|
|
group.fields.forEach(function(f){
|
|
var input = new SwipeValueInput({
|
|
label: f.label,
|
|
value: (typeof d[f.key] === 'number') ? d[f.key] : DEFAULT_DRONE[f.key],
|
|
min: f.min, max: f.max,
|
|
stepBig: f.stepBig, stepSmall: f.stepSmall,
|
|
decimals: f.decimals || 0,
|
|
unit: f.unit
|
|
});
|
|
droneEditorInputs[f.key] = input;
|
|
grid.appendChild(input.el);
|
|
});
|
|
g.appendChild(grid);
|
|
wrap.appendChild(g);
|
|
});
|
|
sizePanels();
|
|
droneEditorPanel.style.display = 'flex';
|
|
}
|
|
function closeDroneEditor(){ droneEditorPanel.style.display = 'none'; }
|
|
droneEditorClose.addEventListener('click', closeDroneEditor);
|
|
document.getElementById('deCancelBtn').addEventListener('click', closeDroneEditor);
|
|
newDroneBtn.addEventListener('click', function(e){ e.stopPropagation(); openDroneEditor(-1); });
|
|
document.getElementById('deSaveBtn').addEventListener('click', function(){
|
|
var d = { name: document.getElementById('deName').value.trim() || 'Unnamed profile' };
|
|
Object.keys(droneEditorInputs).forEach(function(k){
|
|
d[k] = droneEditorInputs[k].getValue();
|
|
});
|
|
if (editingDroneIdx >= 0){
|
|
var wasActive = drones[editingDroneIdx] === activeDrone;
|
|
drones[editingDroneIdx] = d;
|
|
if (wasActive) applyDroneProfile(d);
|
|
} else {
|
|
drones.push(d);
|
|
}
|
|
saveDronesList(drones);
|
|
closeDroneEditor();
|
|
renderDronesList();
|
|
redraw();
|
|
});
|
|
|
|
var mode = 'idle';
|
|
var targetIdx = -1;
|
|
var listOpen = false;
|
|
var wpPanelTab = 'list';
|
|
var lastRouteRowBad = [];
|
|
var lastWarnItems = [];
|
|
var LANDING_RADIUS = 30;
|
|
var autoPanning = false;
|
|
|
|
var ALT_MIN=-500, ALT_MAX=activeDrone.maxAltitude, ALT_STEP=10, curAlt=60, ALT_PX=14, ALT_MAJOR=5;
|
|
var SPD_MIN=activeDrone.minSpeed, SPD_MAX=activeDrone.maxSpeed, SPD_STEP=1, curSpeed=Math.min(Math.max((typeof activeDrone.cruiseSpeed === 'number' ? activeDrone.cruiseSpeed : 15), activeDrone.minSpeed), activeDrone.maxSpeed), SPD_PX=14, SPD_MAJOR=5;
|
|
|
|
function isClimbBad(altA, altB, horizDist, speed){
|
|
var vert = altB - altA;
|
|
if (horizDist < 0.001) return Math.abs(vert) > 0.001;
|
|
var rate = vert / (horizDist/speed);
|
|
if (rate > 0) return rate > activeDrone.maxClimbRate;
|
|
return Math.abs(rate) > activeDrone.maxDescentRate;
|
|
}
|
|
|
|
function buildTape(tapeEl, min, max, step, pxPerStep, majorEvery){
|
|
var n = Math.round((max-min)/step);
|
|
var parts = [];
|
|
for (var i=0;i<=n;i++){
|
|
var y = i*pxPerStep;
|
|
var isMajor = (i % majorEvery === 0);
|
|
var w = isMajor ? '70%' : '38%';
|
|
var h = isMajor ? '2px' : '1px';
|
|
var bg = isMajor ? 'rgba(255,255,255,0.85)' : 'rgba(255,255,255,0.32)';
|
|
parts.push('<div style="position:absolute;left:50%;top:'+y+'px;width:'+w+';height:'+h+';background:'+bg+';transform:translate(-50%,0);"></div>');
|
|
}
|
|
tapeEl.style.height = (n*pxPerStep)+'px';
|
|
tapeEl.innerHTML = parts.join('');
|
|
}
|
|
buildTape(altTape, ALT_MIN, ALT_MAX, ALT_STEP, ALT_PX, ALT_MAJOR);
|
|
applyDroneProfile(activeDrone);
|
|
|
|
function toMeters(ll, ref){
|
|
var dLat = (ll.lat-ref.lat)*110540;
|
|
var dLng = (ll.lng-ref.lng)*111320*Math.cos(ref.lat*Math.PI/180);
|
|
return {x:dLng, y:dLat};
|
|
}
|
|
function fromMeters(m, ref){
|
|
return { lat: ref.lat + m.y/110540, lng: ref.lng + m.x/(111320*Math.cos(ref.lat*Math.PI/180)) };
|
|
}
|
|
|
|
function buildRoute(){
|
|
var n = waypoints.length;
|
|
if (n===0) return {vertexBad:[], segments:[], ref:null, legClimbBad:[]};
|
|
var ref = {lat: waypoints[0].lat, lng: waypoints[0].lng};
|
|
var pts = waypoints.map(function(w){ var m = toMeters(w, ref); return {x:m.x, y:m.y, alt:w.alt, speed:w.speed}; });
|
|
var vertexBad = new Array(n).fill(false);
|
|
var vdata = new Array(n);
|
|
|
|
for (var i=1;i<n-1;i++){
|
|
var pPrev=pts[i-1], pCur=pts[i], pNext=pts[i+1];
|
|
var inVec={x:pCur.x-pPrev.x,y:pCur.y-pPrev.y};
|
|
var inLen=Math.sqrt(inVec.x*inVec.x+inVec.y*inVec.y);
|
|
var outVec={x:pNext.x-pCur.x,y:pNext.y-pCur.y};
|
|
var outLen=Math.sqrt(outVec.x*outVec.x+outVec.y*outVec.y);
|
|
var vIn = inLen>1e-6 ? {x:inVec.x/inLen,y:inVec.y/inLen} : {x:1,y:0};
|
|
var vOut = outLen>1e-6 ? {x:outVec.x/outLen,y:outVec.y/outLen} : {x:1,y:0};
|
|
var dot = Math.max(-1, Math.min(1, vIn.x*vOut.x+vIn.y*vOut.y));
|
|
var turnAngle = Math.acos(dot);
|
|
var windIn = computeLegWindComponent(waypoints[i-1], waypoints[i]);
|
|
var windOut = computeLegWindComponent(waypoints[i], waypoints[i+1]);
|
|
var vertexWind = null;
|
|
if (windIn !== null && windOut !== null) vertexWind = (windIn + windOut) / 2;
|
|
else if (windIn !== null) vertexWind = windIn;
|
|
else if (windOut !== null) vertexWind = windOut;
|
|
var R = activeDrone.minTurnRadius;
|
|
if (vertexWind !== null && pCur.speed > 0.1){
|
|
var vertexGroundSpeed = Math.max(pCur.speed + vertexWind, 0.5);
|
|
R = activeDrone.minTurnRadius * (vertexGroundSpeed / pCur.speed);
|
|
}
|
|
var hasArc = turnAngle > 0.02;
|
|
var bad = false, t=0, T1=pCur, T2=pCur, center=null, angleStart=0, angleEnd=0, turnLeft=true;
|
|
if (hasArc){
|
|
var cappedAngle = Math.min(turnAngle, 160*Math.PI/180);
|
|
t = R*Math.tan(cappedAngle/2);
|
|
if (turnAngle >= 160*Math.PI/180) bad = true;
|
|
if (t > inLen*0.9 || t > outLen*0.9) bad = true;
|
|
var vertexCatchRadius = (waypoints[i].catchRadius !== undefined && waypoints[i].catchRadius !== null) ? waypoints[i].catchRadius : activeDrone.catchRadius;
|
|
if (t > vertexCatchRadius) bad = true;
|
|
var cross = vIn.x*vOut.y - vIn.y*vOut.x;
|
|
turnLeft = cross > 0;
|
|
var normalIn = turnLeft ? {x:-vIn.y,y:vIn.x} : {x:vIn.y,y:-vIn.x};
|
|
T1 = {x:pCur.x - vIn.x*t, y:pCur.y - vIn.y*t};
|
|
T2 = {x:pCur.x + vOut.x*t, y:pCur.y + vOut.y*t};
|
|
center = {x:T1.x+normalIn.x*R, y:T1.y+normalIn.y*R};
|
|
angleStart = Math.atan2(T1.y-center.y, T1.x-center.x);
|
|
angleEnd = angleStart + (turnLeft ? cappedAngle : -cappedAngle);
|
|
}
|
|
vertexBad[i] = bad;
|
|
vdata[i] = {T1:T1,T2:T2,center:center,R:R,angleStart:angleStart,angleEnd:angleEnd,hasArc:hasArc,bad:bad};
|
|
}
|
|
|
|
var legClimbBad = new Array(Math.max(n-1,0)).fill(false);
|
|
var legWindBad = new Array(Math.max(n-1,0)).fill(false);
|
|
var legGroundSpeed = new Array(Math.max(n-1,0)).fill(null);
|
|
for (var sIdx=0;sIdx<n-1;sIdx++){
|
|
var a=pts[sIdx], b=pts[sIdx+1];
|
|
var horiz = Math.sqrt((b.x-a.x)*(b.x-a.x)+(b.y-a.y)*(b.y-a.y));
|
|
var legWindComp = computeLegWindComponent(waypoints[sIdx], waypoints[sIdx+1]);
|
|
var effSpeed = (legWindComp !== null) ? Math.max(b.speed + legWindComp, 0.5) : b.speed;
|
|
legClimbBad[sIdx] = isClimbBad(a.alt, b.alt, horiz, effSpeed);
|
|
if (legWindComp !== null){
|
|
var groundSpeed = b.speed + legWindComp;
|
|
legGroundSpeed[sIdx] = groundSpeed;
|
|
var rawWindSpeed = waypoints[sIdx+1].windSpeed;
|
|
if (groundSpeed <= 1) legWindBad[sIdx] = true;
|
|
if (rawWindSpeed !== undefined && rawWindSpeed > activeDrone.maxSpeed) legWindBad[sIdx] = true;
|
|
}
|
|
}
|
|
|
|
var segments = [];
|
|
for (var leg=0; leg<n-1; leg++){
|
|
var startPt = (leg===0) ? pts[0] : ((vdata[leg] && vdata[leg].hasArc) ? vdata[leg].T2 : pts[leg]);
|
|
var endPt = (leg===n-2) ? pts[n-1] : ((vdata[leg+1] && vdata[leg+1].hasArc) ? vdata[leg+1].T1 : pts[leg+1]);
|
|
segments.push({type:'line', a:startPt, b:endPt, bad: legClimbBad[leg] || legWindBad[leg]});
|
|
if (leg+1 <= n-2 && vdata[leg+1] && vdata[leg+1].hasArc){
|
|
var vd = vdata[leg+1];
|
|
var steps=14, arcPts=[];
|
|
for (var k=0;k<=steps;k++){
|
|
var ang = vd.angleStart + (vd.angleEnd-vd.angleStart)*k/steps;
|
|
arcPts.push({x:vd.center.x+vd.R*Math.cos(ang), y:vd.center.y+vd.R*Math.sin(ang)});
|
|
}
|
|
segments.push({type:'arc', points:arcPts, bad: vd.bad});
|
|
}
|
|
}
|
|
return {vertexBad:vertexBad, segments:segments, ref:ref, legClimbBad:legClimbBad, legWindBad:legWindBad, legGroundSpeed:legGroundSpeed};
|
|
}
|
|
|
|
var pathLayerGroup = L.layerGroup().addTo(map);
|
|
var rubberBandLine = null;
|
|
|
|
function redrawMapLayers(){
|
|
pathLayerGroup.clearLayers();
|
|
var route = buildRoute();
|
|
var n = waypoints.length;
|
|
|
|
route.segments.forEach(function(seg){
|
|
var color = seg.bad ? '#e2574a' : '#ffffff';
|
|
if (seg.type === 'line'){
|
|
var p1 = fromMeters(seg.a, route.ref), p2 = fromMeters(seg.b, route.ref);
|
|
L.polyline([[p1.lat,p1.lng],[p2.lat,p2.lng]], {color:color, weight:3, opacity:0.95}).addTo(pathLayerGroup);
|
|
} else {
|
|
var latlngs = seg.points.map(function(p){ var ll = fromMeters(p, route.ref); return [ll.lat, ll.lng]; });
|
|
L.polyline(latlngs, {color:color, weight:3, opacity:0.95}).addTo(pathLayerGroup);
|
|
}
|
|
});
|
|
|
|
if (appMode === 'edit' && mode === 'idle' && n > 0){
|
|
var lastWp = waypoints[n-1];
|
|
var center = map.getCenter();
|
|
var bandLatLngs = [[lastWp.lat,lastWp.lng],[center.lat,center.lng]];
|
|
if (rubberBandLine){
|
|
rubberBandLine.setLatLngs(bandLatLngs);
|
|
} else {
|
|
rubberBandLine = L.polyline(bandLatLngs, {color:'#ffffff', weight:2, opacity:0.55, dashArray:'6,6', interactive:false}).addTo(map);
|
|
}
|
|
} else if (rubberBandLine){
|
|
map.removeLayer(rubberBandLine);
|
|
rubberBandLine = null;
|
|
}
|
|
|
|
for (var j=0;j<n;j++){
|
|
var wp = waypoints[j];
|
|
var isTarget = (j===targetIdx) && (mode==='snapped' || mode==='editing');
|
|
var bad = route.vertexBad[j];
|
|
var fill = bad ? '#e2574a' : '#2b2b28';
|
|
if (wp.action === 'loiter'){
|
|
L.circle([wp.lat,wp.lng], {radius:activeDrone.loiterRadius, color:'#4d9dff', weight:1.5, dashArray:'4,4', fillOpacity:0}).addTo(pathLayerGroup);
|
|
} else if (wp.action === 'landing'){
|
|
L.circle([wp.lat,wp.lng], {radius:LANDING_RADIUS, color:'#f2a93b', weight:2.5, dashArray:'3,5', fillOpacity:0}).addTo(pathLayerGroup);
|
|
}
|
|
L.circleMarker([wp.lat,wp.lng], {radius: isTarget?9:6, color:'#ffffff', weight: isTarget?3:1.5, fillColor: fill, fillOpacity:1}).addTo(pathLayerGroup);
|
|
if (showPerWaypointWind && wp.windSpeed !== undefined){
|
|
var arrow = windArrowSvg(wp.windDir, 15);
|
|
var windHtml = '<div style="display:flex;align-items:center;gap:4px;background:rgba(15,15,15,0.82);border:1px solid rgba(255,255,255,0.4);border-radius:10px;padding:2px 7px;white-space:nowrap;font-size:11px;color:#fff;">' +
|
|
arrow+'<span>'+Math.round(wp.windSpeed)+' m/s</span></div>';
|
|
L.marker([wp.lat, wp.lng], {
|
|
icon: L.divIcon({html: windHtml, className:'', iconSize:[70,22], iconAnchor:[-8,31]}),
|
|
interactive:false
|
|
}).addTo(pathLayerGroup);
|
|
}
|
|
}
|
|
return route;
|
|
}
|
|
|
|
function fmtDuration(sec){
|
|
sec = Math.round(sec);
|
|
var m = Math.floor(sec/60);
|
|
var s2 = sec%60;
|
|
return m+':' + (s2<10?'0':'') + s2;
|
|
}
|
|
|
|
function updateWheel(wheelEl, tapeEl, value, min, step, pxPerStep){
|
|
var h = wheelEl.clientHeight;
|
|
var y = (value-min)/step*pxPerStep;
|
|
tapeEl.style.transform = 'translateY('+(h/2 - y)+'px)';
|
|
}
|
|
|
|
function setReticleIcon(){
|
|
if (mode === 'editing') reticleBtn.innerHTML = icons.check;
|
|
else if (mode === 'snapped') reticleBtn.innerHTML = icons.pencil;
|
|
else reticleBtn.innerHTML = icons.plus;
|
|
}
|
|
|
|
function updateHaloActiveState(){
|
|
if (mode !== 'editing' || targetIdx < 0 || !waypoints[targetIdx]){
|
|
haloLoiter.classList.remove('halo-active');
|
|
haloLanding.classList.remove('halo-active');
|
|
return;
|
|
}
|
|
var act = waypoints[targetIdx].action;
|
|
haloLoiter.classList.toggle('halo-active', act === 'loiter');
|
|
haloLanding.classList.toggle('halo-active', act === 'landing');
|
|
}
|
|
|
|
function buildProfileSvg(legClimbBad){
|
|
var n = waypoints.length;
|
|
var pad = 4;
|
|
var boxW = Math.max(altProfile.clientWidth - 16, 40);
|
|
var boxH = Math.max(altProfile.clientHeight - 4, 20);
|
|
if (n < 2){
|
|
return '<svg viewBox="0 0 '+boxW+' '+boxH+'" style="width:100%;height:100%;display:block;">' +
|
|
'<text x="'+(boxW/2)+'" y="'+(boxH/2+4)+'" fill="rgba(255,255,255,0.45)" font-size="11" text-anchor="middle">Altitude profile</text></svg>';
|
|
}
|
|
var cum = [0];
|
|
for (var i=1;i<n;i++){ cum.push(cum[i-1] + L.latLng(waypoints[i-1]).distanceTo(L.latLng(waypoints[i]))); }
|
|
var totalD = cum[n-1] || 1;
|
|
var alts = waypoints.map(function(w){ return w.alt; });
|
|
var altMin = Math.min.apply(null, alts), altMax = Math.max.apply(null, alts);
|
|
if (altMax - altMin < 10){ altMax += 5; altMin -= 5; }
|
|
function xAt(idx){ return pad + (cum[idx]/totalD)*(boxW-2*pad); }
|
|
function yAt(alt){ return boxH-pad - ((alt-altMin)/(altMax-altMin))*(boxH-2*pad); }
|
|
var isHighlighted = (mode === 'snapped' || mode === 'editing');
|
|
var segsHtml = '';
|
|
for (i=0;i<n-1;i++){
|
|
var bad = legClimbBad ? legClimbBad[i] : false;
|
|
segsHtml += '<line x1="'+xAt(i)+'" y1="'+yAt(alts[i])+'" x2="'+xAt(i+1)+'" y2="'+yAt(alts[i+1])+'" stroke="'+(bad?'#e2574a':'#ffffff')+'" stroke-width="2"/>';
|
|
}
|
|
var dotsHtml = alts.map(function(alt,idx){
|
|
var active = isHighlighted && idx === targetIdx;
|
|
var bad = lastRouteRowBad[idx];
|
|
var fill = active ? '#4d9dff' : (bad ? '#e2574a' : '#ffffff');
|
|
return '<circle cx="'+xAt(idx)+'" cy="'+yAt(alt)+'" r="'+(active?4.5:2.4)+'" fill="'+fill+'" stroke="'+(active?'#ffffff':'none')+'" stroke-width="'+(active?1.5:0)+'"/>';
|
|
}).join('');
|
|
var areaD = 'M '+xAt(0)+' '+(boxH-pad);
|
|
for (i=0;i<n;i++){ areaD += ' L '+xAt(i)+' '+yAt(alts[i]); }
|
|
areaD += ' L '+xAt(n-1)+' '+(boxH-pad)+' Z';
|
|
return '<svg viewBox="0 0 '+boxW+' '+boxH+'" style="width:100%;height:100%;display:block;">' +
|
|
'<path d="'+areaD+'" fill="rgba(77,157,255,0.18)" stroke="none"/>' + segsHtml + dotsHtml + '</svg>';
|
|
}
|
|
|
|
// --- Full-screen adjustable Altitude / Speed charts ---
|
|
var chartDrag = {kind:null, idx:-1};
|
|
var altChartRange = {lo:ALT_MIN, hi:ALT_MAX};
|
|
var spdChartRange = {lo:SPD_MIN, hi:SPD_MAX};
|
|
var CHART_PAD_X = 70;
|
|
var CHART_PAD_Y = 20;
|
|
|
|
function computeChartRange(kind, vals){
|
|
var lo = kind === 'alt' ? ALT_MIN : SPD_MIN;
|
|
var hi = kind === 'alt' ? ALT_MAX : SPD_MAX;
|
|
if (!vals.length) return {lo:lo, hi:hi};
|
|
var dataMin = Math.min.apply(null, vals), dataMax = Math.max.apply(null, vals);
|
|
var span = dataMax - dataMin;
|
|
var minSpan = kind === 'alt' ? 40 : 10;
|
|
var pad = Math.max(span*0.35, kind === 'alt' ? 20 : 5);
|
|
var rLo = dataMin - pad, rHi = dataMax + pad;
|
|
if (rHi - rLo < minSpan){
|
|
var mid = (rLo+rHi)/2;
|
|
rLo = mid - minSpan/2; rHi = mid + minSpan/2;
|
|
}
|
|
if (rLo < lo) rLo = lo;
|
|
if (rHi > hi) rHi = hi;
|
|
if (rHi <= rLo){ rLo = lo; rHi = hi; }
|
|
return {lo:rLo, hi:rHi};
|
|
}
|
|
|
|
function computeAltChartRange(vals, terrainVals){
|
|
var allVals = vals.slice();
|
|
if (terrainVals) terrainVals.forEach(function(v){ if (v !== null && v !== undefined) allVals.push(v); });
|
|
if (!allVals.length) return {lo:ALT_MIN, hi:ALT_MAX};
|
|
var dataMin = Math.min.apply(null, allVals), dataMax = Math.max.apply(null, allVals);
|
|
var span = dataMax - dataMin;
|
|
// 0m is the default baseline (ground/home level) so the plot uses the full
|
|
// available height instead of symmetric padding wasting space below 0
|
|
// whenever one waypoint sits much higher than the rest.
|
|
var rLo = dataMin >= 0 ? 0 : dataMin - Math.max(span*0.15, 10);
|
|
var rHi = dataMax + Math.max(span*0.2, 20);
|
|
var minSpan = 60;
|
|
if (rHi - rLo < minSpan) rHi = rLo + minSpan;
|
|
if (rLo < ALT_MIN) rLo = ALT_MIN;
|
|
if (rHi > ALT_MAX) rHi = ALT_MAX;
|
|
if (rHi <= rLo){ rLo = ALT_MIN; rHi = ALT_MAX; }
|
|
return {lo:rLo, hi:rHi};
|
|
}
|
|
|
|
function niceStep(range, targetTicks){
|
|
if (range <= 0) return 1;
|
|
var rough = range/targetTicks;
|
|
var mag = Math.pow(10, Math.floor(Math.log10(rough)));
|
|
var norm = rough/mag;
|
|
var step;
|
|
if (norm < 1.5) step = mag;
|
|
else if (norm < 3) step = 2*mag;
|
|
else if (norm < 7) step = 5*mag;
|
|
else step = 10*mag;
|
|
return step;
|
|
}
|
|
|
|
function buildGridlines(lo, hi, boxW, boxH, padX, padY, unit){
|
|
var step = niceStep(hi-lo, 5);
|
|
var html = '<line x1="'+padX+'" y1="0" x2="'+padX+'" y2="'+boxH+'" stroke="rgba(255,255,255,0.35)" stroke-width="1.5"/>';
|
|
var startVal = Math.ceil(lo/step)*step;
|
|
for (var v = startVal; v <= hi + 1e-6; v += step){
|
|
var y = boxH-padY - ((v-lo)/(hi-lo))*(boxH-2*padY);
|
|
html += '<line x1="'+padX+'" y1="'+y+'" x2="'+(boxW-padX)+'" y2="'+y+'" stroke="rgba(255,255,255,0.14)" stroke-width="1"/>';
|
|
html += '<text x="'+(padX-18)+'" y="'+(y+4)+'" fill="rgba(255,255,255,0.45)" font-size="10" text-anchor="end">'+Math.round(v)+unit+'</text>';
|
|
}
|
|
return html;
|
|
}
|
|
|
|
function buildValueGridlines(vals, lo, hi, boxW, boxH, padX, padY, unit){
|
|
var counts = {};
|
|
vals.forEach(function(v){ counts[v] = (counts[v]||0) + 1; });
|
|
var seen = {};
|
|
var distinct = [];
|
|
vals.forEach(function(v){
|
|
if (!seen[v] && counts[v] >= 2){ seen[v] = true; distinct.push(v); }
|
|
});
|
|
var html = '';
|
|
distinct.forEach(function(v){
|
|
var y = boxH-padY - ((v-lo)/(hi-lo))*(boxH-2*padY);
|
|
html += '<line x1="'+padX+'" y1="'+y+'" x2="'+(boxW-padX)+'" y2="'+y+'" stroke="rgba(77,157,255,0.4)" stroke-width="1.5" stroke-dasharray="2,3"/>';
|
|
html += '<text x="'+(boxW-padX+18)+'" y="'+(y+4)+'" fill="rgba(255,255,255,0.9)" font-size="11" font-weight="600" text-anchor="start">'+v+unit+'</text>';
|
|
});
|
|
return {html:html, counts:counts};
|
|
}
|
|
|
|
function bearingBetween(a, b){
|
|
var lat1 = a.lat*Math.PI/180, lat2 = b.lat*Math.PI/180;
|
|
var dLon = (b.lng-a.lng)*Math.PI/180;
|
|
var y = Math.sin(dLon)*Math.cos(lat2);
|
|
var x = Math.cos(lat1)*Math.sin(lat2) - Math.sin(lat1)*Math.cos(lat2)*Math.cos(dLon);
|
|
return (Math.atan2(y,x)*180/Math.PI + 360) % 360;
|
|
}
|
|
function headTailwindComponent(bearingDeg, windSpeed, windDirFrom){
|
|
var windToward = (windDirFrom + 180) % 360;
|
|
var diff = (windToward - bearingDeg) * Math.PI/180;
|
|
return windSpeed * Math.cos(diff);
|
|
}
|
|
function computeLegWindComponent(a, b){
|
|
if (!a || !b || b.windSpeed === undefined) return null;
|
|
var brng = bearingBetween(a, b);
|
|
return headTailwindComponent(brng, b.windSpeed, b.windDir);
|
|
}
|
|
function computeLegWind(){
|
|
var legWind = [];
|
|
for (var i=0;i<waypoints.length-1;i++){
|
|
legWind.push(computeLegWindComponent(waypoints[i], waypoints[i+1]));
|
|
}
|
|
return legWind;
|
|
}
|
|
|
|
// --- Terrain elevation (Terrarium-encoded DEM tiles) ---
|
|
// Free, no-key AWS Open Data mirror. Same z/x/y tile scheme as the OSM
|
|
// basemap; swap this base URL for a self-hosted mirror later if desired.
|
|
var TERRAIN_TILE_URL = 'https://s3.amazonaws.com/elevation-tiles-prod/terrarium/{z}/{x}/{y}.png';
|
|
var TERRAIN_ZOOM = 13;
|
|
var terrainSamples = null;
|
|
var terrainLoading = false;
|
|
|
|
function routeKeyForTerrain(){
|
|
return waypoints.map(function(w){ return w.lat.toFixed(5)+','+w.lng.toFixed(5); }).join('|');
|
|
}
|
|
function sampleRouteLatLng(numSamples){
|
|
var n = waypoints.length;
|
|
if (n < 2) return [];
|
|
var cum = [0];
|
|
for (var i=1;i<n;i++){ cum.push(cum[i-1] + L.latLng(waypoints[i-1]).distanceTo(L.latLng(waypoints[i]))); }
|
|
var total = cum[n-1] || 1;
|
|
var pts = [];
|
|
for (var s=0;s<=numSamples;s++){
|
|
var target = (s/numSamples)*total;
|
|
var legIdx = 0;
|
|
while (legIdx < n-2 && cum[legIdx+1] < target) legIdx++;
|
|
var legStart = cum[legIdx], legEnd = cum[legIdx+1];
|
|
var frac = (legEnd > legStart) ? (target-legStart)/(legEnd-legStart) : 0;
|
|
var a = waypoints[legIdx], b = waypoints[legIdx+1];
|
|
pts.push({dist:target, lat: a.lat+(b.lat-a.lat)*frac, lng: a.lng+(b.lng-a.lng)*frac});
|
|
}
|
|
return pts;
|
|
}
|
|
function lngLatToTilePixel(lat, lng, z){
|
|
var n = Math.pow(2, z);
|
|
var x = (lng+180)/360*n;
|
|
var latRad = lat*Math.PI/180;
|
|
var y = (1 - Math.log(Math.tan(latRad) + 1/Math.cos(latRad))/Math.PI)/2*n;
|
|
var tx = Math.floor(x), ty = Math.floor(y);
|
|
var px = Math.min(255, Math.max(0, Math.floor((x-tx)*256)));
|
|
var py = Math.min(255, Math.max(0, Math.floor((y-ty)*256)));
|
|
return {tx:tx, ty:ty, px:px, py:py};
|
|
}
|
|
var TERRAIN_SAMPLE_SPACING = 30; // meters between samples, matching DEM resolution
|
|
var TERRAIN_MAX_SAMPLES = 2000; // hard cap (a 60km+ mission degrades gracefully to coarser spacing)
|
|
function fetchTerrainProfile(onDone){
|
|
var key = routeKeyForTerrain();
|
|
if (terrainSamples && terrainSamples.routeKey === key) { onDone(terrainSamples); return; }
|
|
if (waypoints.length < 2 || terrainLoading){ onDone(null); return; }
|
|
terrainLoading = true;
|
|
var totalRouteDist = 0;
|
|
for (var di=1; di<waypoints.length; di++){
|
|
totalRouteDist += L.latLng(waypoints[di-1]).distanceTo(L.latLng(waypoints[di]));
|
|
}
|
|
var numSamples = Math.min(Math.max(Math.ceil(totalRouteDist / TERRAIN_SAMPLE_SPACING), 10), TERRAIN_MAX_SAMPLES);
|
|
var samplePts = sampleRouteLatLng(numSamples);
|
|
var tileMap = {};
|
|
samplePts.forEach(function(p, idx){
|
|
var t = lngLatToTilePixel(p.lat, p.lng, TERRAIN_ZOOM);
|
|
var tkey = t.tx+'_'+t.ty;
|
|
if (!tileMap[tkey]) tileMap[tkey] = {tx:t.tx, ty:t.ty, items:[]};
|
|
tileMap[tkey].items.push({idx:idx, px:t.px, py:t.py});
|
|
});
|
|
var tileKeys = Object.keys(tileMap);
|
|
var elevations = new Array(samplePts.length).fill(null);
|
|
var remaining = tileKeys.length;
|
|
function finalize(){
|
|
terrainLoading = false;
|
|
var validIdx = -1;
|
|
for (var i=0;i<elevations.length;i++){ if (elevations[i] !== null){ validIdx = i; break; } }
|
|
if (validIdx === -1){ onDone(null); return; }
|
|
var home = elevations[validIdx];
|
|
var points = samplePts.map(function(p, idx){
|
|
return {dist:p.dist, elevRel: elevations[idx] !== null ? (elevations[idx]-home) : null};
|
|
});
|
|
terrainSamples = {routeKey:key, points:points};
|
|
currentMission.setTerrain(terrainSamples);
|
|
onDone(terrainSamples);
|
|
}
|
|
if (remaining === 0){ terrainLoading = false; onDone(null); return; }
|
|
tileKeys.forEach(function(tkey){
|
|
var tile = tileMap[tkey];
|
|
var img = new Image();
|
|
img.crossOrigin = 'anonymous';
|
|
img.onload = function(){
|
|
try {
|
|
var canvas = document.createElement('canvas');
|
|
canvas.width = 256; canvas.height = 256;
|
|
var ctx = canvas.getContext('2d');
|
|
ctx.drawImage(img, 0, 0);
|
|
var data = ctx.getImageData(0, 0, 256, 256).data;
|
|
tile.items.forEach(function(it){
|
|
var off = (it.py*256 + it.px)*4;
|
|
var elev = (data[off]*256 + data[off+1] + data[off+2]/256) - 32768;
|
|
elevations[it.idx] = elev;
|
|
});
|
|
} catch(e){ /* CORS or decode issue: leave as null for these points */ }
|
|
remaining--;
|
|
if (remaining === 0) finalize();
|
|
};
|
|
img.onerror = function(){ remaining--; if (remaining === 0) finalize(); };
|
|
img.src = TERRAIN_TILE_URL.replace('{z}', TERRAIN_ZOOM).replace('{x}', tile.tx).replace('{y}', tile.ty);
|
|
});
|
|
}
|
|
|
|
var MIN_TERRAIN_CLEARANCE = 10;
|
|
// Absolute (ASL) ground elevation at one point, from the same terrarium tiles
|
|
// used for the route profile. cb(null) on any tile/CORS failure.
|
|
function fetchElevationAt(lat, lng, cb){
|
|
var t = lngLatToTilePixel(lat, lng, TERRAIN_ZOOM);
|
|
var img = new Image();
|
|
img.crossOrigin = 'anonymous';
|
|
img.onload = function(){
|
|
try {
|
|
var canvas = document.createElement('canvas');
|
|
canvas.width = 256; canvas.height = 256;
|
|
var ctx = canvas.getContext('2d');
|
|
ctx.drawImage(img, 0, 0);
|
|
var data = ctx.getImageData(0, 0, 256, 256).data;
|
|
var off = (t.py*256 + t.px)*4;
|
|
cb((data[off]*256 + data[off+1] + data[off+2]/256) - 32768);
|
|
} catch(e){ cb(null); }
|
|
};
|
|
img.onerror = function(){ cb(null); };
|
|
img.src = TERRAIN_TILE_URL.replace('{z}', TERRAIN_ZOOM).replace('{x}', t.tx).replace('{y}', t.ty);
|
|
}
|
|
function checkTerrainClearance(){
|
|
var n = waypoints.length;
|
|
if (n < 2) return {bad:false, badRanges:[]};
|
|
var key = routeKeyForTerrain();
|
|
if (!terrainSamples || terrainSamples.routeKey !== key) return {bad:false, badRanges:[]};
|
|
var cum = [0];
|
|
for (var i=1;i<n;i++){ cum.push(cum[i-1] + L.latLng(waypoints[i-1]).distanceTo(L.latLng(waypoints[i]))); }
|
|
function plannedAltAt(d){
|
|
if (d <= cum[0]) return waypoints[0].alt;
|
|
if (d >= cum[n-1]) return waypoints[n-1].alt;
|
|
for (var k=0;k<n-1;k++){
|
|
if (d >= cum[k] && d <= cum[k+1]){
|
|
var frac = (cum[k+1] > cum[k]) ? (d-cum[k])/(cum[k+1]-cum[k]) : 0;
|
|
return waypoints[k].alt + (waypoints[k+1].alt - waypoints[k].alt)*frac;
|
|
}
|
|
}
|
|
return waypoints[n-1].alt;
|
|
}
|
|
var bad = false;
|
|
var badRanges = [];
|
|
var rangeStart = null;
|
|
terrainSamples.points.forEach(function(p){
|
|
if (p.elevRel === null) return;
|
|
var clearance = plannedAltAt(p.dist) - p.elevRel;
|
|
var pointBad = clearance < MIN_TERRAIN_CLEARANCE;
|
|
if (pointBad){
|
|
bad = true;
|
|
if (rangeStart === null) rangeStart = p.dist;
|
|
} else if (rangeStart !== null){
|
|
badRanges.push([rangeStart, p.dist]);
|
|
rangeStart = null;
|
|
}
|
|
});
|
|
if (rangeStart !== null) badRanges.push([rangeStart, terrainSamples.points[terrainSamples.points.length-1].dist]);
|
|
return {bad:bad, badRanges:badRanges};
|
|
}
|
|
function computeSpeedChartRange(vals, legWind){
|
|
var allVals = vals.slice();
|
|
legWind.forEach(function(v){ if (v !== null && v !== undefined) allVals.push(v); });
|
|
if (!allVals.length) return {lo:SPD_MIN, hi:SPD_MAX};
|
|
var dataMin = Math.min.apply(null, allVals), dataMax = Math.max.apply(null, allVals);
|
|
var span = dataMax - dataMin;
|
|
var minSpan = 10;
|
|
var pad = Math.max(span*0.12, 3);
|
|
var rLo = dataMin - pad, rHi = dataMax + pad;
|
|
if (rHi - rLo < minSpan){ var mid = (rLo+rHi)/2; rLo = mid - minSpan/2; rHi = mid + minSpan/2; }
|
|
return {lo:rLo, hi:rHi};
|
|
}
|
|
|
|
function buildFullChart(kind, useExistingRange){
|
|
var containerEl = kind === 'alt' ? altFullChart : spdFullChart;
|
|
var n = waypoints.length;
|
|
if (n === 0){ containerEl.innerHTML = '<div class="wp-empty">No waypoints yet</div>'; return; }
|
|
var boxW = Math.max(containerEl.clientWidth - 4, 100);
|
|
var boxH = Math.max(containerEl.clientHeight - 4, 100);
|
|
var padX = CHART_PAD_X, padY = CHART_PAD_Y;
|
|
var vals = waypoints.map(function(w){ return kind === 'alt' ? w.alt : w.speed; });
|
|
var legWind = kind === 'speed' ? computeLegWind() : [];
|
|
var groundSpeeds = [];
|
|
if (kind === 'speed'){
|
|
groundSpeeds = [];
|
|
for (var gi=0; gi<waypoints.length-1; gi++){
|
|
var gwc = computeLegWindComponent(waypoints[gi], waypoints[gi+1]);
|
|
groundSpeeds.push(gwc !== null ? (waypoints[gi+1].speed + gwc) : null);
|
|
}
|
|
}
|
|
var terrainForRoute = null;
|
|
if (kind === 'alt'){
|
|
var rk = routeKeyForTerrain();
|
|
if (terrainSamples && terrainSamples.routeKey === rk) terrainForRoute = terrainSamples;
|
|
else fetchTerrainProfile(function(){ redraw(); });
|
|
}
|
|
var terrainVals = terrainForRoute ? terrainForRoute.points.map(function(p){ return p.elevRel; }) : null;
|
|
var range;
|
|
if (useExistingRange && (kind === 'alt' ? altChartRange : spdChartRange)){
|
|
range = kind === 'alt' ? altChartRange : spdChartRange;
|
|
} else {
|
|
range = kind === 'speed' ? computeSpeedChartRange(vals, legWind.concat(groundSpeeds)) : computeAltChartRange(vals, terrainVals);
|
|
if (kind === 'alt') altChartRange = range; else spdChartRange = range;
|
|
}
|
|
var lo = range.lo, hi = range.hi;
|
|
var unit = kind === 'alt' ? ' m' : ' m/s';
|
|
|
|
var cum = [0];
|
|
for (var i=1;i<n;i++){ cum.push(cum[i-1] + L.latLng(waypoints[i-1]).distanceTo(L.latLng(waypoints[i]))); }
|
|
var totalD = cum[n-1] || 1;
|
|
function xAt(idx){ return n > 1 ? padX + (cum[idx]/totalD)*(boxW-2*padX) : boxW/2; }
|
|
function xAtDist(d){ return padX + (d/totalD)*(boxW-2*padX); }
|
|
function yAt(val){ return boxH-padY - ((val-lo)/(hi-lo))*(boxH-2*padY); }
|
|
|
|
var legBad = [];
|
|
if (kind === 'alt'){
|
|
for (i=0;i<n-1;i++){
|
|
var horiz = L.latLng(waypoints[i]).distanceTo(L.latLng(waypoints[i+1]));
|
|
legBad.push(isClimbBad(waypoints[i].alt, waypoints[i+1].alt, horiz, waypoints[i+1].speed));
|
|
}
|
|
}
|
|
|
|
var grid = buildGridlines(lo, hi, boxW, boxH, padX, padY, unit);
|
|
var valueGridResult = buildValueGridlines(vals, lo, hi, boxW, boxH, padX, padY, unit);
|
|
var valueGrid = valueGridResult.html;
|
|
var valCounts = valueGridResult.counts;
|
|
|
|
var segs = '';
|
|
if (kind === 'speed'){
|
|
for (i=0;i<n-1;i++){
|
|
var lvl = vals[i+1];
|
|
var yLvl = yAt(lvl);
|
|
segs += '<line x1="'+xAt(i)+'" y1="'+yLvl+'" x2="'+xAt(i+1)+'" y2="'+yLvl+'" stroke="#ffffff" stroke-width="2.5"/>';
|
|
if (i > 0){
|
|
var yPrevLvl = yAt(vals[i]);
|
|
segs += '<line x1="'+xAt(i)+'" y1="'+yPrevLvl+'" x2="'+xAt(i)+'" y2="'+yLvl+'" stroke="#ffffff" stroke-width="2.5"/>';
|
|
}
|
|
}
|
|
} else {
|
|
for (i=0;i<n-1;i++){
|
|
var bad = legBad[i];
|
|
segs += '<line x1="'+xAt(i)+'" y1="'+yAt(vals[i])+'" x2="'+xAt(i+1)+'" y2="'+yAt(vals[i+1])+'" stroke="'+(bad?'#e2574a':'#ffffff')+'" stroke-width="2.5"/>';
|
|
}
|
|
}
|
|
var pts = '';
|
|
var isEditingHere = (mode === 'snapped' || mode === 'editing');
|
|
for (i=0;i<n;i++){
|
|
var px = xAt(i), py = yAt(vals[i]);
|
|
var active = isEditingHere && i === targetIdx;
|
|
var ptFill = active ? '#4d9dff' : (lastRouteRowBad[i] ? '#e2574a' : '#ffffff');
|
|
var ptR = active ? 9 : 7;
|
|
var labelHtml = '';
|
|
if (valCounts[vals[i]] === 1){
|
|
var labelY = (i % 2 === 0) ? (py - 16) : (py + 22);
|
|
labelHtml = '<text x="'+px+'" y="'+labelY+'" fill="#fff" font-size="12" text-anchor="middle">'+vals[i]+unit+'</text>';
|
|
}
|
|
pts += '<g class="profile-point" data-idx="'+i+'" style="cursor:ns-resize;">' +
|
|
'<circle cx="'+px+'" cy="'+py+'" r="16" fill="rgba(255,255,255,0.001)"/>' +
|
|
'<circle cx="'+px+'" cy="'+py+'" r="'+ptR+'" fill="'+ptFill+'" stroke="#2f6fe0" stroke-width="2"/>' +
|
|
labelHtml +
|
|
'</g>';
|
|
}
|
|
var baseline = '';
|
|
if (kind === 'alt' && lo < 0 && hi > 0){
|
|
var zeroY = yAt(0);
|
|
baseline = '<line x1="'+padX+'" y1="'+zeroY+'" x2="'+(boxW-padX)+'" y2="'+zeroY+'" stroke="rgba(255,255,255,0.5)" stroke-dasharray="4,4" stroke-width="1.5"/>' +
|
|
'<text x="'+padX+'" y="'+(zeroY-5)+'" fill="rgba(255,255,255,0.6)" font-size="10">0 m (first waypoint / home reference)</text>';
|
|
}
|
|
|
|
var windHtml = '';
|
|
if (kind === 'speed' && legWind.some(function(v){ return v !== null; })){
|
|
var wZeroY = yAt(0);
|
|
windHtml += '<line x1="'+padX+'" y1="'+wZeroY+'" x2="'+(boxW-padX)+'" y2="'+wZeroY+'" stroke="rgba(255,255,255,0.4)" stroke-dasharray="3,4" stroke-width="1.5"/>' +
|
|
'<text x="'+padX+'" y="'+(wZeroY-5)+'" fill="rgba(255,255,255,0.55)" font-size="10">0 (no wind effect on track)</text>';
|
|
var windPts = [];
|
|
for (i=0;i<legWind.length;i++){
|
|
if (legWind[i] === null) continue;
|
|
var mx = (xAt(i) + xAt(i+1)) / 2;
|
|
var my = yAt(legWind[i]);
|
|
windPts.push({x:mx, y:my});
|
|
var isTail = legWind[i] >= 0;
|
|
var col = isTail ? '#3ecf8e' : '#e2574a';
|
|
var sign = isTail ? '+' : '';
|
|
windHtml += '<polygon points="'+mx+','+(my-7)+' '+(mx+7)+','+my+' '+mx+','+(my+7)+' '+(mx-7)+','+my+'" fill="'+col+'" stroke="#0c0c0c" stroke-width="1"/>' +
|
|
'<text x="'+mx+'" y="'+(my - 12)+'" fill="'+col+'" font-size="11" font-weight="600" text-anchor="middle">'+sign+legWind[i].toFixed(1)+'</text>';
|
|
}
|
|
for (i=0;i<windPts.length-1;i++){
|
|
windHtml += '<line x1="'+windPts[i].x+'" y1="'+windPts[i].y+'" x2="'+windPts[i+1].x+'" y2="'+windPts[i+1].y+'" stroke="rgba(255,255,255,0.25)" stroke-width="1" stroke-dasharray="2,3"/>';
|
|
}
|
|
}
|
|
|
|
var groundSpeedHtml = '';
|
|
if (kind === 'speed'){
|
|
var gsPrevLevel = null;
|
|
for (i=0;i<groundSpeeds.length;i++){
|
|
if (groundSpeeds[i] === null || groundSpeeds[i] === undefined){ gsPrevLevel = null; continue; }
|
|
var gy = yAt(groundSpeeds[i]);
|
|
var gx0 = xAt(i), gx1 = xAt(i+1);
|
|
groundSpeedHtml += '<line x1="'+gx0+'" y1="'+gy+'" x2="'+gx1+'" y2="'+gy+'" stroke="rgba(148,187,233,0.55)" stroke-width="1.5" stroke-dasharray="5,3"/>';
|
|
if (gsPrevLevel !== null){
|
|
groundSpeedHtml += '<line x1="'+gx0+'" y1="'+gsPrevLevel+'" x2="'+gx0+'" y2="'+gy+'" stroke="rgba(148,187,233,0.55)" stroke-width="1.5" stroke-dasharray="5,3"/>';
|
|
}
|
|
gsPrevLevel = gy;
|
|
}
|
|
}
|
|
|
|
var terrainHtml = '';
|
|
var dangerHtml = '';
|
|
if (kind === 'alt' && terrainForRoute){
|
|
var tpts = terrainForRoute.points.filter(function(p){ return p.elevRel !== null; });
|
|
if (tpts.length > 1){
|
|
var td = 'M '+xAtDist(tpts[0].dist)+' '+(boxH-padY);
|
|
tpts.forEach(function(p){ td += ' L '+xAtDist(p.dist)+' '+yAt(p.elevRel); });
|
|
td += ' L '+xAtDist(tpts[tpts.length-1].dist)+' '+(boxH-padY)+' Z';
|
|
terrainHtml = '<path d="'+td+'" fill="rgba(120,100,80,0.45)" stroke="rgba(170,150,120,0.8)" stroke-width="1.5"/>';
|
|
}
|
|
var terrainCheck = checkTerrainClearance();
|
|
terrainCheck.badRanges.forEach(function(r){
|
|
var x0 = xAtDist(r[0]), x1 = xAtDist(r[1]);
|
|
dangerHtml += '<rect x="'+x0+'" y="0" width="'+Math.max(x1-x0,2)+'" height="'+boxH+'" fill="rgba(226,87,74,0.22)"/>';
|
|
});
|
|
} else if (kind === 'alt' && terrainLoading){
|
|
terrainHtml = '<text x="'+padX+'" y="'+(boxH-padY+16)+'" fill="rgba(255,255,255,0.4)" font-size="10">Loading terrain\u2026</text>';
|
|
}
|
|
|
|
containerEl.innerHTML = '<svg viewBox="0 0 '+boxW+' '+boxH+'" style="width:100%;height:100%;display:block;touch-action:none;">' +
|
|
dangerHtml + grid + valueGrid + terrainHtml + baseline + windHtml + groundSpeedHtml + segs + pts + '</svg>';
|
|
}
|
|
|
|
var CHART_ALT_PX_PER_M = 5;
|
|
var CHART_SPD_PX_PER_MS = 22;
|
|
|
|
function attachChartDrag(containerEl, kind){
|
|
containerEl.addEventListener('pointerdown', function(e){
|
|
var pt = e.target.closest('.profile-point');
|
|
if (!pt) return;
|
|
e.preventDefault();
|
|
chartDrag.kind = kind;
|
|
chartDrag.idx = parseInt(pt.getAttribute('data-idx'), 10);
|
|
chartDrag.startY = e.clientY;
|
|
var wp0 = waypoints[chartDrag.idx];
|
|
chartDrag.startVal = wp0 ? (kind === 'alt' ? wp0.alt : wp0.speed) : 0;
|
|
containerEl.setPointerCapture(e.pointerId);
|
|
});
|
|
containerEl.addEventListener('pointermove', function(e){
|
|
if (chartDrag.kind !== kind || chartDrag.idx < 0) return;
|
|
e.preventDefault();
|
|
applyChartDrag(e, containerEl, kind);
|
|
});
|
|
containerEl.addEventListener('pointerup', function(){
|
|
chartDrag.kind = null; chartDrag.idx = -1;
|
|
buildFullChart(kind);
|
|
});
|
|
containerEl.addEventListener('pointercancel', function(){
|
|
chartDrag.kind = null; chartDrag.idx = -1;
|
|
buildFullChart(kind);
|
|
});
|
|
}
|
|
function applyChartDrag(e, containerEl, kind){
|
|
var wp = waypoints[chartDrag.idx];
|
|
if (!wp) return;
|
|
var lo = kind === 'alt' ? ALT_MIN : SPD_MIN;
|
|
var hi = kind === 'alt' ? ALT_MAX : SPD_MAX;
|
|
var step = kind === 'alt' ? ALT_STEP : SPD_STEP;
|
|
var pxPerUnit = kind === 'alt' ? CHART_ALT_PX_PER_M : CHART_SPD_PX_PER_MS;
|
|
|
|
var deltaY = chartDrag.startY - e.clientY;
|
|
var deltaVal = deltaY / pxPerUnit;
|
|
var val = chartDrag.startVal + deltaVal;
|
|
val = Math.round(val/step)*step;
|
|
if (val < lo) val = lo;
|
|
if (val > hi) val = hi;
|
|
|
|
if (kind === 'alt'){
|
|
wp.alt = val;
|
|
if (mode === 'editing' && targetIdx === chartDrag.idx) curAlt = val;
|
|
} else {
|
|
wp.speed = val;
|
|
if (mode === 'editing' && targetIdx === chartDrag.idx) curSpeed = val;
|
|
}
|
|
buildFullChart(kind, true);
|
|
redraw();
|
|
}
|
|
attachChartDrag(altFullChart, 'alt');
|
|
attachChartDrag(spdFullChart, 'speed');
|
|
|
|
function setActiveTab(tab){
|
|
wpPanelTab = tab;
|
|
tabButtons.forEach(function(b){ b.classList.toggle('active', b.getAttribute('data-tab') === tab); });
|
|
document.getElementById('tabList').classList.toggle('active', tab === 'list');
|
|
document.getElementById('tabAltitude').classList.toggle('active', tab === 'altitude');
|
|
document.getElementById('tabSpeed').classList.toggle('active', tab === 'speed');
|
|
if (tab === 'list'){
|
|
updateWpList();
|
|
scrollToActiveRow();
|
|
}
|
|
else if (tab === 'altitude') buildFullChart('alt');
|
|
else if (tab === 'speed') buildFullChart('speed');
|
|
}
|
|
tabButtons.forEach(function(b){
|
|
b.addEventListener('click', function(e){ e.stopPropagation(); setActiveTab(b.getAttribute('data-tab')); });
|
|
});
|
|
|
|
function scrollToActiveRow(){
|
|
var active = tabListEl.querySelector('.wp-row-active');
|
|
if (active) active.scrollIntoView({block:'center', behavior:'smooth'});
|
|
}
|
|
|
|
var rowEditIdx = -1;
|
|
function checkIconSvg(){
|
|
return '<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="#3ecf8e" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>';
|
|
}
|
|
function valueChip(field, idx, text){
|
|
return '<span class="value-chip" data-field="'+field+'" data-idx="'+idx+'">'+
|
|
'<span class="chip-axis-icon">\u2195\u2194</span><span class="chip-value">'+text+'</span></span>';
|
|
}
|
|
function updateWpList(){
|
|
if (waypoints.length === 0){ wpRows.innerHTML = '<div class="wp-empty">No waypoints yet</div>'; rowEditIdx = -1; wpRowsWrap.classList.remove('row-edit-frozen'); return; }
|
|
if (rowEditIdx >= waypoints.length) rowEditIdx = -1;
|
|
var isEditingHere = (mode === 'snapped' || mode === 'editing');
|
|
var rows = waypoints.map(function(wp, idx){
|
|
var actionLabel = wp.action === 'loiter' ? 'Loiter' : (wp.action === 'landing' ? 'Landing' : '\u2014');
|
|
var activeClass = (isEditingHere && idx === targetIdx) ? ' wp-row-active' : '';
|
|
var badClass = lastRouteRowBad[idx] ? ' wp-row-bad' : '';
|
|
var windLabel = '\u2014';
|
|
if (wp.windSpeed !== undefined){
|
|
windLabel = windArrowSvg(wp.windDir, 13) + ' ' + Math.round(wp.windSpeed) + ' m/s';
|
|
}
|
|
var windTitle = (wp.windSpeed !== undefined) ? (' title="Interpolated at '+Math.round(wp.windElevation + wp.alt)+' m AMSL (ground '+Math.round(wp.windElevation)+' m + '+wp.alt+' m alt)"') : '';
|
|
var catchR = (wp.catchRadius !== undefined && wp.catchRadius !== null) ? wp.catchRadius : activeDrone.catchRadius;
|
|
var isRowEditing = (idx === rowEditIdx);
|
|
|
|
var altCell, spdCell, execCell, catchCell;
|
|
if (isRowEditing){
|
|
altCell = '<span class="wp-col">'+valueChip('alt', idx, wp.alt+' m')+'</span>';
|
|
spdCell = '<span class="wp-col">'+valueChip('speed', idx, wp.speed+' m/s')+'</span>';
|
|
execCell = '<span class="wp-col"><select class="execute-select" data-idx="'+idx+'">' +
|
|
'<option value=""'+(!wp.action?' selected':'')+'>\u2014</option>' +
|
|
'<option value="loiter"'+(wp.action==='loiter'?' selected':'')+'>Loiter</option>' +
|
|
'<option value="landing"'+(wp.action==='landing'?' selected':'')+'>Landing</option>' +
|
|
'</select></span>';
|
|
catchCell = '<span class="wp-col">'+valueChip('catch', idx, catchR+' m')+'</span>';
|
|
} else {
|
|
altCell = '<span class="wp-col">'+wp.alt+' m</span>';
|
|
spdCell = '<span class="wp-col">'+wp.speed+' m/s</span>';
|
|
execCell = '<span class="wp-col">'+actionLabel+'</span>';
|
|
catchCell = '<span class="wp-col">'+catchR+' m</span>';
|
|
}
|
|
|
|
return '<div class="wp-row'+activeClass+badClass+(isRowEditing?' wp-row-editing':'')+'" data-idx="'+idx+'">' +
|
|
'<span class="wp-num">'+(idx+1)+'</span>' +
|
|
altCell + spdCell + execCell + catchCell +
|
|
'<span class="wp-col"'+windTitle+'>'+windLabel+'</span>' +
|
|
'<div class="row-actions">' +
|
|
'<button class="wp-del-btn wp-edit-btn wp-row-edit-btn" data-idx="'+idx+'" aria-label="'+(isRowEditing?'Done editing':'Edit waypoint values')+'">' + (isRowEditing ? checkIconSvg() : editIconSvg()) + '</button>' +
|
|
'<button class="wp-del-btn" data-idx="'+idx+'" aria-label="Delete waypoint">' + trashIconSvg() + '</button>' +
|
|
'</div>' +
|
|
'</div>';
|
|
});
|
|
wpRows.innerHTML = rows.join('');
|
|
wpRowsWrap.classList.toggle('row-edit-frozen', rowEditIdx >= 0);
|
|
}
|
|
wpRows.addEventListener('click', function(e){
|
|
var editBtn = e.target.closest('.wp-row-edit-btn');
|
|
if (editBtn){
|
|
var eidx = parseInt(editBtn.getAttribute('data-idx'), 10);
|
|
if (rowEditIdx === eidx){
|
|
rowEditIdx = -1;
|
|
} else {
|
|
rowEditIdx = eidx;
|
|
}
|
|
updateWpList();
|
|
redraw();
|
|
return;
|
|
}
|
|
var delBtn = e.target.closest('.wp-del-btn');
|
|
if (delBtn){
|
|
var didx = parseInt(delBtn.getAttribute('data-idx'), 10);
|
|
waypoints.splice(didx, 1);
|
|
if (rowEditIdx === didx){ rowEditIdx = -1; }
|
|
else if (rowEditIdx > didx){ rowEditIdx -= 1; }
|
|
if (targetIdx === didx){ mode = 'idle'; targetIdx = -1; }
|
|
else if (targetIdx > didx){ targetIdx -= 1; }
|
|
redraw();
|
|
return;
|
|
}
|
|
if (rowEditIdx >= 0) return;
|
|
var row = e.target.closest('.wp-row');
|
|
if (!row) return;
|
|
var idx = parseInt(row.getAttribute('data-idx'), 10);
|
|
var wp = waypoints[idx];
|
|
if (wp){
|
|
listOpen = false;
|
|
wpListPanel.style.display = 'none';
|
|
map.panTo([wp.lat, wp.lng], {animate:true, duration:0.4});
|
|
}
|
|
});
|
|
|
|
wpRows.addEventListener('change', function(e){
|
|
var sel = e.target.closest('.execute-select');
|
|
if (!sel) return;
|
|
var sidx = parseInt(sel.getAttribute('data-idx'), 10);
|
|
var wp = waypoints[sidx];
|
|
if (!wp) return;
|
|
wp.action = sel.value || null;
|
|
redraw();
|
|
});
|
|
|
|
// Swipe-to-adjust gesture on value chips: axis-locked after ~8px;
|
|
// vertical = steps of 10 (up increases), horizontal = steps of 1 (right increases).
|
|
var chipDrag = {el:null, field:null, idx:-1, startX:0, startY:0, startVal:0, axis:null, applied:0};
|
|
var CHIP_PX_PER_STEP = 14;
|
|
function chipFieldConfig(field, wp){
|
|
if (field === 'alt') return {get:function(){return wp.alt;}, set:function(v){wp.alt=v;}, min:ALT_MIN, max:ALT_MAX, unit:' m'};
|
|
if (field === 'speed') return {get:function(){return wp.speed;}, set:function(v){wp.speed=v;}, min:SPD_MIN, max:SPD_MAX, unit:' m/s'};
|
|
return {get:function(){return (wp.catchRadius !== undefined && wp.catchRadius !== null) ? wp.catchRadius : activeDrone.catchRadius;}, set:function(v){wp.catchRadius=v;}, min:5, max:500, unit:' m'};
|
|
}
|
|
wpRows.addEventListener('pointerdown', function(e){
|
|
var chip = e.target.closest('.value-chip');
|
|
if (!chip) return;
|
|
e.preventDefault();
|
|
var idx = parseInt(chip.getAttribute('data-idx'), 10);
|
|
var wp = waypoints[idx];
|
|
if (!wp) return;
|
|
var field = chip.getAttribute('data-field');
|
|
var cfg = chipFieldConfig(field, wp);
|
|
chipDrag = {el:chip, field:field, idx:idx, startX:e.clientX, startY:e.clientY, startVal:cfg.get(), axis:null, applied:0};
|
|
chip.setPointerCapture(e.pointerId);
|
|
});
|
|
wpRows.addEventListener('pointermove', function(e){
|
|
if (!chipDrag.el) return;
|
|
e.preventDefault();
|
|
var dx = e.clientX - chipDrag.startX;
|
|
var dy = e.clientY - chipDrag.startY;
|
|
if (!chipDrag.axis){
|
|
if (Math.abs(dx) < 8 && Math.abs(dy) < 8) return;
|
|
chipDrag.axis = Math.abs(dy) >= Math.abs(dx) ? 'v' : 'h';
|
|
}
|
|
var wp = waypoints[chipDrag.idx];
|
|
if (!wp) return;
|
|
var cfg = chipFieldConfig(chipDrag.field, wp);
|
|
var stepSize = chipDrag.axis === 'v' ? 10 : 1;
|
|
var travel = chipDrag.axis === 'v' ? -dy : dx;
|
|
var steps = Math.round(travel / CHIP_PX_PER_STEP);
|
|
var val = chipDrag.startVal + steps * stepSize;
|
|
if (val < cfg.min) val = cfg.min;
|
|
if (val > cfg.max) val = cfg.max;
|
|
if (val !== cfg.get()){
|
|
cfg.set(val);
|
|
var valEl = chipDrag.el.querySelector('.chip-value');
|
|
if (valEl) valEl.textContent = val + cfg.unit;
|
|
chipDrag.el.classList.add('chip-flash');
|
|
setTimeout((function(el){ return function(){ el.classList.remove('chip-flash'); }; })(chipDrag.el), 120);
|
|
if (mode === 'editing' && targetIdx === chipDrag.idx){
|
|
if (chipDrag.field === 'alt') curAlt = val;
|
|
else if (chipDrag.field === 'speed') curSpeed = val;
|
|
}
|
|
redrawMapLayers();
|
|
}
|
|
});
|
|
function endChipDrag(){
|
|
if (!chipDrag.el) return;
|
|
chipDrag = {el:null, field:null, idx:-1, startX:0, startY:0, startVal:0, axis:null, applied:0};
|
|
redraw();
|
|
}
|
|
wpRows.addEventListener('pointerup', endChipDrag);
|
|
wpRows.addEventListener('pointercancel', endChipDrag);
|
|
|
|
function openWaypointsTab(tab){
|
|
listOpen = true;
|
|
sizePanels();
|
|
wpListPanel.style.display = 'flex';
|
|
setActiveTab(tab);
|
|
}
|
|
function renderWarningsList(){
|
|
if (!lastWarnItems.length){ warningsRows.innerHTML = '<div class="wp-empty">No active warnings</div>'; return; }
|
|
var rows = lastWarnItems.map(function(w, idx){
|
|
var actionLabel = w.action === 'altitude' ? 'Open altitude view' : w.action === 'speed' ? 'Open speed view' : 'Show on map';
|
|
return '<div class="wp-row" data-idx="'+idx+'" style="cursor:pointer;">' +
|
|
'<span class="wp-col" style="flex:3;">'+w.text+'</span>' +
|
|
'<span class="wp-col" style="flex:1;text-align:right;color:#4d9dff;font-weight:600;text-decoration:underline;">'+actionLabel+'</span>' +
|
|
'</div>';
|
|
});
|
|
warningsRows.innerHTML = rows.join('');
|
|
}
|
|
warningsBtn.addEventListener('click', function(e){
|
|
e.stopPropagation();
|
|
renderWarningsList();
|
|
sizePanels();
|
|
warningsPanel.style.display = 'flex';
|
|
});
|
|
warningsClose.addEventListener('click', function(e){ e.stopPropagation(); warningsPanel.style.display = 'none'; });
|
|
warningsRows.addEventListener('click', function(e){
|
|
var row = e.target.closest('.wp-row');
|
|
if (!row) return;
|
|
var w = lastWarnItems[parseInt(row.getAttribute('data-idx'), 10)];
|
|
if (!w) return;
|
|
warningsPanel.style.display = 'none';
|
|
if (w.action === 'altitude') openWaypointsTab('altitude');
|
|
else if (w.action === 'speed') openWaypointsTab('speed');
|
|
else fitToWaypoints();
|
|
});
|
|
|
|
function toggleList(){
|
|
listOpen = !listOpen;
|
|
if (!listOpen && rowEditIdx >= 0){
|
|
rowEditIdx = -1;
|
|
}
|
|
if (listOpen) sizePanels();
|
|
wpListPanel.style.display = listOpen ? 'flex' : 'none';
|
|
if (listOpen) setActiveTab('list');
|
|
}
|
|
statsCenter.addEventListener('click', function(e){ e.stopPropagation(); toggleList(); });
|
|
wpListClose.addEventListener('click', function(e){ e.stopPropagation(); toggleList(); });
|
|
|
|
function currentSnapMeters(){
|
|
var reticleRadiusPx = reticleBtn.getBoundingClientRect().width/2;
|
|
var zoom = map.getZoom();
|
|
var lat = map.getCenter().lat;
|
|
var metersPerPixel = 156543.03392 * Math.cos(lat*Math.PI/180) / Math.pow(2, zoom);
|
|
return reticleRadiusPx * metersPerPixel;
|
|
}
|
|
|
|
function redraw(){
|
|
var route = redrawMapLayers();
|
|
var n = waypoints.length;
|
|
var totalDist = 0, totalDur = 0;
|
|
for (var i=0;i<n-1;i++){
|
|
var d = L.latLng(waypoints[i]).distanceTo(L.latLng(waypoints[i+1]));
|
|
totalDist += d;
|
|
var legWC = computeLegWindComponent(waypoints[i], waypoints[i+1]);
|
|
var legSpeed = (legWC !== null) ? Math.max(waypoints[i+1].speed + legWC, 0.5) : waypoints[i+1].speed;
|
|
totalDur += d / legSpeed;
|
|
}
|
|
statWp.textContent = n + ' WP';
|
|
statDist.textContent = Math.round(totalDist) + ' m';
|
|
statDur.textContent = fmtDuration(totalDur);
|
|
|
|
var anyBad = route.vertexBad.some(function(v){return v;}) || route.segments.some(function(s){return s.bad;});
|
|
var windBad = route.legWindBad && route.legWindBad.some(function(v){return v;});
|
|
var rangeExceeded = activeDrone.maxRange > 0 && totalDist > activeDrone.maxRange;
|
|
var enduranceExceeded = activeDrone.maxEndurance > 0 && (totalDur/60) > activeDrone.maxEndurance;
|
|
var terrainCheck = checkTerrainClearance();
|
|
|
|
var warnItems = [];
|
|
if (anyBad) warnItems.push({text:'Route exceeds drone capabilities at highlighted point', action:'map'});
|
|
if (windBad) warnItems.push({text:'Wind too strong for safe ground speed on one or more legs', action:'speed'});
|
|
if (terrainCheck.bad) warnItems.push({text:'Terrain clearance below ' + MIN_TERRAIN_CLEARANCE + 'm minimum on part of the route', action:'altitude'});
|
|
if (rangeExceeded) warnItems.push({text:'Mission distance exceeds max range (' + Math.round(activeDrone.maxRange) + ' m)', action:'map'});
|
|
if (enduranceExceeded) warnItems.push({text:'Mission duration exceeds max endurance (' + activeDrone.maxEndurance + ' min)', action:'map'});
|
|
lastWarnItems = warnItems;
|
|
warnBanner.style.display = warnItems.length ? 'block' : 'none';
|
|
warnBanner.textContent = warnItems.map(function(w){ return w.text; }).join(' \u00b7 ');
|
|
warningsBtn.style.display = warnItems.length ? 'flex' : 'none';
|
|
statDist.style.color = rangeExceeded ? '#ff8a7a' : '';
|
|
statDur.style.color = enduranceExceeded ? '#ff8a7a' : '';
|
|
|
|
lastRouteRowBad = new Array(n).fill(false);
|
|
for (var ri=0; ri<n; ri++){
|
|
if (route.vertexBad[ri]) lastRouteRowBad[ri] = true;
|
|
if (ri > 0 && (route.legClimbBad[ri-1] || (route.legWindBad && route.legWindBad[ri-1]))) lastRouteRowBad[ri] = true;
|
|
if (ri < n-1 && (route.legClimbBad[ri] || (route.legWindBad && route.legWindBad[ri]))) lastRouteRowBad[ri] = true;
|
|
}
|
|
|
|
setReticleIcon();
|
|
haloSvg.style.display = (mode === 'editing') ? 'block' : 'none';
|
|
updateHaloActiveState();
|
|
|
|
updateWheel(altWheel, altTape, curAlt, ALT_MIN, ALT_STEP, ALT_PX);
|
|
updateWheel(spdWheel, spdTape, curSpeed, SPD_MIN, SPD_STEP, SPD_PX);
|
|
updateReadouts();
|
|
|
|
altProfile.innerHTML = buildProfileSvg(route.legClimbBad);
|
|
|
|
if (listOpen){
|
|
if (wpPanelTab === 'list') updateWpList();
|
|
else if (wpPanelTab === 'altitude') buildFullChart('alt');
|
|
else if (wpPanelTab === 'speed') buildFullChart('speed');
|
|
}
|
|
positionIndicators();
|
|
positionWarnBanner();
|
|
}
|
|
|
|
function onMoveEnd(){
|
|
if (autoPanning){ autoPanning = false; return; }
|
|
if (mode === 'editing') return;
|
|
var c = map.getCenter();
|
|
var snapMeters = currentSnapMeters();
|
|
var best = -1, bestD = snapMeters;
|
|
for (var i=0;i<waypoints.length;i++){
|
|
var d = c.distanceTo(L.latLng(waypoints[i]));
|
|
if (d < bestD){ bestD = d; best = i; }
|
|
}
|
|
if (best >= 0){
|
|
mode = 'snapped';
|
|
targetIdx = best;
|
|
var targetLatLng = L.latLng(waypoints[best]);
|
|
if (c.distanceTo(targetLatLng) > 0.5){
|
|
autoPanning = true;
|
|
map.panTo(targetLatLng, {animate:true, duration:0.25});
|
|
}
|
|
} else { mode = 'idle'; targetIdx = -1; }
|
|
redraw();
|
|
}
|
|
map.on('moveend', onMoveEnd);
|
|
map.on('move', function(){
|
|
if (mode === 'editing' && targetIdx >= 0){
|
|
var c = map.getCenter();
|
|
waypoints[targetIdx].lat = c.lat;
|
|
waypoints[targetIdx].lng = c.lng;
|
|
redraw();
|
|
} else if (mode === 'idle' && appMode === 'edit' && waypoints.length > 0 && !map._animatingZoom){
|
|
var lastWp2 = waypoints[waypoints.length-1];
|
|
var center2 = map.getCenter();
|
|
var bandLatLngs2 = [[lastWp2.lat,lastWp2.lng],[center2.lat,center2.lng]];
|
|
if (rubberBandLine) rubberBandLine.setLatLngs(bandLatLngs2);
|
|
}
|
|
});
|
|
|
|
reticleBtn.addEventListener('pointerdown', function(e){ e.stopPropagation(); });
|
|
reticleBtn.addEventListener('click', function(){
|
|
if (mode === 'idle'){
|
|
var c = map.getCenter();
|
|
var wasFirst = waypoints.length === 0;
|
|
waypoints.push({lat:c.lat, lng:c.lng, alt: curAlt, speed: curSpeed, action:null});
|
|
targetIdx = waypoints.length - 1;
|
|
mode = 'snapped';
|
|
if (wasFirst){
|
|
refreshHeaderWind();
|
|
if (missionNeedsPlaceName){
|
|
missionNeedsPlaceName = false;
|
|
nameMissionFromLocation(c.lat, c.lng);
|
|
}
|
|
}
|
|
} else if (mode === 'snapped'){
|
|
mode = 'editing';
|
|
var wp = waypoints[targetIdx];
|
|
curAlt = wp.alt;
|
|
curSpeed = wp.speed;
|
|
} else if (mode === 'editing'){
|
|
mode = 'snapped';
|
|
}
|
|
redraw();
|
|
});
|
|
|
|
bindHaloAction(haloLoiter, function(){
|
|
if (mode !== 'editing' || targetIdx < 0) return;
|
|
var wp = waypoints[targetIdx];
|
|
wp.action = (wp.action === 'loiter') ? null : 'loiter';
|
|
mode = 'snapped';
|
|
redraw();
|
|
});
|
|
bindHaloAction(haloLanding, function(){
|
|
if (mode !== 'editing' || targetIdx < 0) return;
|
|
var wp = waypoints[targetIdx];
|
|
wp.action = (wp.action === 'landing') ? null : 'landing';
|
|
mode = 'snapped';
|
|
redraw();
|
|
});
|
|
bindHaloAction(haloRemove, function(){
|
|
if (mode !== 'editing' || targetIdx < 0) return;
|
|
waypoints.splice(targetIdx, 1);
|
|
mode = 'idle'; targetIdx = -1;
|
|
redraw();
|
|
});
|
|
|
|
function setupWheel(wheelEl, min, max, step, pxPerStep, getVal, setVal){
|
|
var dragId = null, startY0 = 0, startVal = 0;
|
|
wheelEl.addEventListener('pointerdown', function(e){
|
|
e.stopPropagation(); e.preventDefault();
|
|
dragId = e.pointerId;
|
|
wheelEl.setPointerCapture(dragId);
|
|
startY0 = e.clientY;
|
|
startVal = getVal();
|
|
});
|
|
wheelEl.addEventListener('pointermove', function(e){
|
|
if (dragId === null) return;
|
|
e.preventDefault();
|
|
var deltaY = startY0 - e.clientY;
|
|
var deltaSteps = deltaY / pxPerStep;
|
|
var raw = startVal + deltaSteps*step;
|
|
var stepped = Math.round(raw/step)*step;
|
|
if (stepped<min) stepped=min;
|
|
if (stepped>max) stepped=max;
|
|
setVal(stepped);
|
|
redraw();
|
|
});
|
|
wheelEl.addEventListener('pointerup', function(){ dragId = null; });
|
|
wheelEl.addEventListener('pointercancel', function(){ dragId = null; });
|
|
}
|
|
|
|
setupWheel(altWheel, ALT_MIN, ALT_MAX, ALT_STEP, ALT_PX, function(){return curAlt;}, function(v){
|
|
curAlt = v;
|
|
if (mode==='editing' && targetIdx>=0){ waypoints[targetIdx].alt = v; }
|
|
});
|
|
setupWheel(spdWheel, SPD_MIN, SPD_MAX, SPD_STEP, SPD_PX, function(){return curSpeed;}, function(v){
|
|
curSpeed = v;
|
|
if (mode==='editing' && targetIdx>=0){ waypoints[targetIdx].speed = v; }
|
|
});
|
|
|
|
// Defensive fix: some Android browsers report a CSS viewport (100vw/100vh,
|
|
// inset:0) that doesn't quite match the true visible screen once the
|
|
// on-screen nav bar / rounded-corner safe area is involved, leaving a
|
|
// sliver uncovered on each side. Sizing the full-screen panels explicitly
|
|
// in pixels from window.innerWidth/innerHeight sidesteps that mismatch.
|
|
function sizePanels(){
|
|
var w = window.innerWidth + 'px';
|
|
var h = window.innerHeight + 'px';
|
|
[wpListPanel, missionsPanel, droneEditorPanel, warningsPanel, windValidatePanel].forEach(function(p){
|
|
p.style.width = w;
|
|
p.style.height = h;
|
|
p.style.left = '0px';
|
|
p.style.top = '0px';
|
|
});
|
|
}
|
|
|
|
window.addEventListener('resize', function(){
|
|
positionIndicators();
|
|
positionWarnBanner();
|
|
sizePanels();
|
|
redraw();
|
|
});
|
|
window.addEventListener('orientationchange', function(){
|
|
setTimeout(sizePanels, 300);
|
|
});
|
|
|
|
setTimeout(function(){ hint.style.opacity='0'; hint.style.transition='opacity 0.5s'; }, 3500);
|
|
missionNameBtn.textContent = 'Mission: ' + currentMission.name;
|
|
setAppMode('edit');
|
|
positionIndicators();
|
|
positionWarnBanner();
|
|
sizePanels();
|
|
redraw();
|
|
refreshHeaderWind();
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html>
|