Files
protoloon/js/SpotMap.js
2026-04-02 17:39:02 -06:00

2135 lines
67 KiB
JavaScript

/*
Copyright (c) 2023-forever Douglas Malnati. All rights reserved.
See the /faq/tos page for details.
(If this generated header is stamped on a file which is a 3rd party file or under a different license or copyright, then ignore this copyright statement and use that file's terms.)
*/
import * as utl from '/js/Utl.js';
import { Animation } from './Animation.js'
import { AsyncResourceLoader } from './AsyncResourceLoader.js';
import { Base } from './Base.js';
import { CSSDynamic } from './CSSDynamic.js';
import { DialogBox } from './DomWidgets.js';
import { Timeline } from '/js/Timeline.js';
import { WSPREncoded } from '/js/WSPREncoded.js';
let t = new Timeline();
t.SetCcGlobal(true);
t.Event(`SpotMap::AsyncModuleResourceLoad Start`);
let p1 = AsyncResourceLoader.AsyncLoadScript(`https://cdn.jsdelivr.net/npm/ol@v7.3.0/dist/ol.js`);
let p2 = AsyncResourceLoader.AsyncLoadStylesheet(`https://cdn.jsdelivr.net/npm/ol@v7.3.0/ol.css`);
await Promise.all([p1, p2]);
t.Event(`SpotMap::AsyncModuleResourceLoad End`);
export class Spot
{
constructor(spotData)
{
this.spotData = spotData;
this.loc = ol.proj.fromLonLat([this.GetLng(), this.GetLat()]);
}
GetLoc()
{
return this.loc;
}
GetLat()
{
return this.spotData.lat;
}
GetLng()
{
return this.spotData.lng;
}
GetGrid()
{
return this.spotData.grid;
}
GetGrid4()
{
return this.GetGrid().substr(0, 4);
}
GetAccuracy()
{
return this.spotData.accuracy;
}
GetDTLocal()
{
return this.spotData.dtLocal;
}
GetSeenDataList()
{
return this.spotData.seenDataList;
}
}
// https://openlayers.org/en/latest/examples/custom-controls.html
function GetStoredBoolean(storageKey, defaultValue, legacyKeyList = [])
{
for (const key of [storageKey, ...legacyKeyList])
{
const value = localStorage.getItem(key);
if (value != null)
{
return value == "true";
}
}
return defaultValue;
}
class MapButtonControl extends ol.control.Control {
constructor({ label, rightPx, title = "" })
{
const button = document.createElement('button');
button.innerHTML = label;
button.title = title;
button.style.fontWeight = "bold";
button.style.cursor = "pointer";
const element = document.createElement('div');
element.className = 'ol-unselectable ol-control';
element.style.top = "7px";
element.style.right = `${rightPx}px`;
element.appendChild(button);
super({
element: element,
});
this.button = button;
}
SetActive(tf)
{
if (tf)
{
this.button.style.backgroundColor = "rgb(215, 237, 255)";
this.button.style.borderColor = "rgb(120, 160, 210)";
this.button.style.color = "rgb(25, 55, 95)";
this.button.style.boxShadow = "inset 0 1px 4px rgba(0, 0, 0, 0.22)";
this.button.style.transform = "translateY(1px)";
}
else
{
this.button.style.backgroundColor = "rgb(248, 248, 248)";
this.button.style.borderColor = "rgb(180, 180, 180)";
this.button.style.color = "rgb(70, 70, 70)";
this.button.style.boxShadow = "0 1px 1px rgba(255, 255, 255, 0.8) inset";
this.button.style.transform = "";
}
}
}
class MapToggleControl extends MapButtonControl {
constructor({ label, rightPx, title, storageKey, defaultValue, legacyKeyList = [], onToggle })
{
super({ label, rightPx, title });
this.storageKey = storageKey;
this.onToggle = onToggle;
this.enabled = GetStoredBoolean(storageKey, defaultValue, legacyKeyList);
this.SetActive(this.enabled);
this.button.addEventListener('click', () => {
this.SetEnabled(!this.enabled);
});
}
SetEnabled(enabled)
{
this.enabled = enabled;
this.SetActive(this.enabled);
localStorage.setItem(this.storageKey, this.enabled);
this.onToggle(this.enabled);
}
IsEnabled()
{
return this.enabled;
}
}
class MapHelpControl extends MapButtonControl {
constructor(spotMap)
{
super({
label: '?',
rightPx: 30,
title: 'Map control help',
});
this.button.addEventListener('click', () => {
spotMap.ShowControlHelpDialog();
});
}
}
export class SpotMap
extends Base
{
static STORAGE_KEY_SHOW_LINES = "spotMapShowLines";
static STORAGE_KEY_SHOW_RX = "spotMapShowRx";
static STORAGE_KEY_AUTO_MOVE = "spotMapAutoMove";
static STORAGE_KEY_HOVER_EMPHASIS = "spotMapHoverEmphasis";
constructor(cfg)
{
super();
this.cfg = cfg;
this.container = this.cfg.container;
this.t.Event("SpotMap::Constructor");
// Initial state of map
this.initialCenterLocation = ol.proj.fromLonLat([-40, 40]);
this.initialZoom = 1;
this.dataSetPreviously = false;
this.dt__data = new Map();
this.spotDt__idx = new Map();
this.spot__rxFeatureList = new WeakMap();
this.spotFeatureList = [];
this.lineFeatureList = [];
this.lineFeatureListByEndIdx = [];
this.hoverStartIdxBySpotIdx = [];
this.currentHoverWindow = null;
this.currentHoverSpotDt = null;
this.spotOpacityList = [];
this.lineOpacityByEndIdx = [];
this.spotStyleCache = new Map();
this.lineStyleCache = new Map();
this.rxFeatureListKeyLast = null;
this.pendingPointerMove = null;
this.pointerMoveRafId = null;
this.hoverSpotDt = null;
this.rxStyleSeen = new ol.style.Style({
image: new ol.style.Circle({
radius: 3,
fill: new ol.style.Fill({
color: 'rgba(255, 0, 255, 1)',
}),
stroke: new ol.style.Stroke({
color: 'rgba(255, 0, 255, 1)',
width: 0.1,
}),
}),
});
this.spotListLast = [];
this.showLines = GetStoredBoolean(SpotMap.STORAGE_KEY_SHOW_LINES, true);
this.showRxEnabled = GetStoredBoolean(SpotMap.STORAGE_KEY_SHOW_RX, true, ["showRxState"]);
this.autoMove = GetStoredBoolean(SpotMap.STORAGE_KEY_AUTO_MOVE, true);
this.hoverEmphasisEnabled = GetStoredBoolean(SpotMap.STORAGE_KEY_HOVER_EMPHASIS, true);
this.mapControl = new MapToggleControl({
label: 'L',
rightPx: 53,
title: 'Toggle lines between spots',
storageKey: SpotMap.STORAGE_KEY_SHOW_LINES,
defaultValue: true,
onToggle: enabled => this.OnLineToggle(enabled),
});
this.mapControlRx = new MapToggleControl({
label: 'R',
rightPx: 76,
title: 'Toggle receiver markers',
storageKey: SpotMap.STORAGE_KEY_SHOW_RX,
defaultValue: true,
legacyKeyList: ["showRxState"],
onToggle: enabled => this.OnRxToggle(enabled),
});
this.mapControlMove = new MapToggleControl({
label: 'M',
rightPx: 99,
title: 'Toggle map movement to new spots',
storageKey: SpotMap.STORAGE_KEY_AUTO_MOVE,
defaultValue: true,
onToggle: enabled => this.OnMoveToggle(enabled),
});
this.mapControlHover = new MapToggleControl({
label: 'H',
rightPx: 122,
title: 'Toggle hover emphasis',
storageKey: SpotMap.STORAGE_KEY_HOVER_EMPHASIS,
defaultValue: true,
onToggle: enabled => this.OnHoverEmphasisToggle(enabled),
});
this.mapControlHelp = new MapHelpControl(this);
this.showRxState = this.showRxEnabled ? "default" : "disabled";
this.MakeUI();
this.controlHelpDialog = this.MakeControlHelpDialog();
document.body.appendChild(this.controlHelpDialog.GetUI());
this.Load();
this.SetupEscapeHandler();
}
SetDebug(tf)
{
this.t.SetCcGlobal(tf);
}
MakeUI()
{
let cd = new CSSDynamic();
this.ui = document.createElement('div');
this.ui.style.margin = "0px";
this.ui.style.padding = "0px";
this.ui.style.width = "100%";
this.ui.style.height = "100%"
this.ui.style.position = "relative";
this.ui.style.opacity = "0.0"; // initially
this.container.appendChild(this.ui);
this.hoverInfoUi = document.createElement('div');
this.hoverInfoUi.style.position = "absolute";
this.hoverInfoUi.style.top = "34px";
this.hoverInfoUi.style.right = "30px";
this.hoverInfoUi.style.fontSize = "11px";
this.hoverInfoUi.style.color = "rgba(40, 40, 40, 0.9)";
this.hoverInfoUi.style.pointerEvents = "none";
this.hoverInfoUi.style.backgroundColor = "rgba(255, 255, 255, 0.0)";
this.hoverInfoUi.style.zIndex = "2";
this.hoverInfoUi.style.display = "none";
this.hoverInfoUi.style.whiteSpace = "pre";
this.ui.appendChild(this.hoverInfoUi);
// create and style the entire popup
this.popup = document.createElement('div');
this.popup.classList.add('ol-popup');
cd.SetCssClassProperties(`ol-popup`, {
position: "absolute",
backgroundColor: "white",
boxShadow: "0 1px 4px rgba(0,0,0,0.2)",
padding: "15px",
borderRadius: "10px",
border: "1px solid #cccccc",
bottom: "12px",
left: "-50px",
minWidth: "250px",
zIndex: "-1",
});
for (let ccName of ["ol-popup::after", "ol-popup::before"])
{
cd.SetCssClassProperties(ccName, {
top: "100%",
border: "solid transparent",
content: " ",
height: 0,
width: 0,
position: "absolute",
pointerEvents: "none",
});
}
cd.SetCssClassDynamicProperties("ol-popup", "after", " ", `
border-top-color: white;
border-width: 10px;
left: 48px;
margin-left: -10px;
`);
cd.SetCssClassDynamicProperties("ol-popup", "before", " ", `
border-top-color: #cccccc;
border-width: 11px;
left: 48px;
margin-left: -11px;
`);
// create and style the X button
this.popupCloser = document.createElement('div');
this.popupCloser.appendChild(document.createTextNode("✖"));
this.popupCloser.style.cursor = "default";
this.popupCloser.classList.add('ol-popup-closer');
cd.SetCssClassProperties(`ol-popup-closer`, {
textDecoration: "none",
position: "absolute",
top: "2px",
right: "5px",
});
// create container for content
this.popupContent = document.createElement('div');
// assemble
this.popup.appendChild(this.popupCloser);
this.popup.appendChild(this.popupContent);
}
Load()
{
this.MakeMapBase();
this.MakeMapLayers();
this.MakeMapOverlay();
this.SetupEventHandlers();
}
SetupEscapeHandler()
{
document.addEventListener('keydown', e => {
if (e.key == 'Escape')
{
this.ClosePopup();
}
});
}
MakeControlHelpDialog()
{
let dlg = new DialogBox();
dlg.SetTitleBar("Map Controls Help");
dlg.GetUI().style.top = "100px";
dlg.GetUI().style.left = "100px";
let body = dlg.GetContentContainer();
body.style.padding = "12px";
body.style.minWidth = "420px";
body.style.maxWidth = "560px";
body.style.backgroundColor = "rgb(245, 245, 245)";
body.style.fontSize = "14px";
body.style.lineHeight = "1.45";
let intro = document.createElement("div");
intro.innerHTML = `Use the upper-right map controls to change how spots are displayed.`;
intro.style.marginBottom = "8px";
intro.style.fontWeight = "600";
body.appendChild(intro);
let list = document.createElement("ul");
list.style.margin = "0px";
list.style.paddingLeft = "20px";
for (const html of [
`<b>H</b> toggles highlighting the last lap to the hovered point.`,
`<b>M</b> toggles the map panning to new spot locations on update or not.`,
`<b>R</b> toggles showing receivers (purple dots).`,
`<b>L</b> toggles interconnecting the spots with lines.`,
`<b>?</b> opens this help dialog.`,
])
{
let li = document.createElement("li");
li.innerHTML = html;
list.appendChild(li);
}
body.appendChild(list);
let note = document.createElement("div");
note.innerHTML = `These settings are saved and restored automatically.`;
note.style.marginTop = "8px";
body.appendChild(note);
return dlg;
}
ShowControlHelpDialog()
{
this.controlHelpDialog.Show();
}
MakeMapBase()
{
// for base raster, we use Open Street Maps
const source = new ol.source.OSM();
// let's set up a little mini-map in the lower-left corner
const overviewMapControl = new ol.control.OverviewMap({
layers: [
new ol.layer.Tile({
source: source,
}),
],
});
// set up controls for mini-map
let controls = new ol.Collection();
// set up layers
const engOsmLayer = new ol.layer.Tile({
source: new ol.source.XYZ({
url: './tiles/{z}/{x}/{y}.png',
attributions: [
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors.'
]
}),
minZoom: 0,
maxZoom: 7
});
const osmLayer = new ol.layer.Tile({
source: source,
minZoom: 7
});
// set up attribution
const attributionControl = new ol.control.Attribution({
collapsible: false
});
// Load map instance
this.map = new ol.Map({
controls: controls.extend([
overviewMapControl,
new ol.control.FullScreen(),
this.mapControl,
this.mapControlRx,
this.mapControlMove,
this.mapControlHover,
this.mapControlHelp,
attributionControl,
]),
// target: this.container,
target: this.ui,
layers: [
engOsmLayer,
osmLayer,
],
view: new ol.View({
center: this.initialCenterLocation,
zoom: this.initialZoom,
}),
});
// make sure the mini-map is closed by default
overviewMapControl.setCollapsed(true);
}
HeatMapGetWeight(feature)
{
const grid4 = feature.get("grid4");
console.log(`${grid4}`)
// look up pre-cached data about relative grid reception
let data = this.grid4__data.get(grid4);
// calculate weight
let retVal = 0.0;
retVal = data.maxHeard / this.maxHeardGlobal;
retVal = data.maxHeard / 50.0;
if (retVal > 1) { retVal = 1; }
console.log(`returning ${grid4}: ${data.maxHeard} / ${this.maxHeardGlobal} = ${retVal}`)
return retVal;
}
MakeMapLayers()
{
// create heat map
// https://gis.stackexchange.com/questions/418820/creating-heatmap-from-vector-tiles-using-openlayers
//
// let heatmapSource = new ol.source.Vector();
// this.hmLayer = new ol.layer.Heatmap({
// source: heatmapSource,
// weight: feature => {
// return this.HeatMapGetWeight(feature);
// },
// radius: 10,
// blur: 30,
// });
// this.map.addLayer(this.hmLayer);
// create a layer to put rx station markers on
this.rxLayer = new ol.layer.Vector({
source: new ol.source.Vector({
features: [],
}),
});
this.map.addLayer(this.rxLayer);
// create a layer to put spot markers on
this.spotLayer = new ol.layer.Vector({
source: new ol.source.Vector({
features: [],
}),
});
this.map.addLayer(this.spotLayer);
}
MakeMapOverlay()
{
this.overlay = new ol.Overlay({
// element: document.getElementById('popup'),
element: this.popup,
autoPan: {
animation: {
duration: 250,
},
},
});
this.map.addOverlay(this.overlay);
// let closer = document.getElementById('popup-closer');
// closer.onclick = () => {
this.popupCloser.onclick = () => {
this.ClosePopup();
this.popupCloser.blur();
return false;
};
}
ClosePopup()
{
if (this.overlay)
{
if (this.showRxState != "disabled")
{
this.showRxState = "default";
this.HandleSeen(this.spotListLast);
}
this.overlay.setPosition(undefined);
}
}
OnLineToggle(enabled)
{
this.showLines = enabled;
// re-display
this.SetSpotList(this.spotListLast, { preserveView: true });
}
OnRxToggle(enabled)
{
this.showRxEnabled = enabled;
this.SetShowRxState(this.showRxEnabled ? "default" : "disabled");
}
OnMoveToggle(enabled)
{
this.autoMove = enabled;
}
OnHoverEmphasisToggle(enabled)
{
this.hoverEmphasisEnabled = enabled;
this.currentHoverWindow = null;
if (this.hoverEmphasisEnabled)
{
this.PrecomputeHoverEmphasisData();
}
else
{
this.hoverStartIdxBySpotIdx = [];
}
this.ApplyHoverEmphasis();
}
GetSpotStyle(accuracy, opacity = 1.0)
{
const opacityUse = Math.max(0.07, Math.min(1.0, opacity));
const key = `${accuracy}|${opacityUse.toFixed(3)}`;
if (this.spotStyleCache.has(key))
{
return this.spotStyleCache.get(key);
}
const radius =
accuracy == "veryHigh" ? 3 :
accuracy == "high" ? 5 :
5;
const strokeRgb =
accuracy == "veryHigh" ? [55, 143, 205] :
accuracy == "high" ? [55, 143, 205] :
[205, 143, 55];
const style = new ol.style.Style({
image: new ol.style.Circle({
radius: radius,
fill: new ol.style.Fill({
color: `rgba(255, 255, 255, ${(0.4 * opacityUse).toFixed(3)})`,
}),
stroke: new ol.style.Stroke({
color: `rgba(${strokeRgb[0]}, ${strokeRgb[1]}, ${strokeRgb[2]}, ${opacityUse.toFixed(3)})`,
width: 1.1,
}),
}),
});
this.spotStyleCache.set(key, style);
return style;
}
GetLineStyle(opacity = 1.0)
{
const opacityUse = Math.max(0.07, Math.min(1.0, opacity));
const key = opacityUse.toFixed(3);
if (this.lineStyleCache.has(key))
{
return this.lineStyleCache.get(key);
}
const style = new ol.style.Style({
stroke: new ol.style.Stroke({
color: `rgba(0, 128, 0, ${opacityUse.toFixed(3)})`,
width: 1,
}),
});
this.lineStyleCache.set(key, style);
return style;
}
GetHoverEmphasisWindow()
{
if (this.hoverEmphasisEnabled == false || this.hoverSpotDt == null || this.spotFeatureList.length == 0)
{
return null;
}
let hoverIdx = this.spotDt__idx.get(this.hoverSpotDt);
if (hoverIdx == undefined)
{
return null;
}
return {
startIdx: this.hoverStartIdxBySpotIdx[hoverIdx] ?? 0,
hoverIdx: hoverIdx,
};
}
GetHoverEmphasisOpacity(spotIdx, window)
{
const DIMMED_OPACITY = 0.07;
if (window == null)
{
return 1.0;
}
if (spotIdx > window.hoverIdx || spotIdx < window.startIdx)
{
return DIMMED_OPACITY;
}
return 1.0;
}
UpdateHoverInfoUi(window)
{
if (this.hoverSpotDt == null)
{
this.hoverInfoUi.style.display = "none";
this.hoverInfoUi.textContent = "";
return;
}
const hoverIdx = this.spotDt__idx.get(this.hoverSpotDt);
const hoverSpot = hoverIdx == undefined ? null : this.spotFeatureList[hoverIdx]?.get("spot");
if (!hoverSpot)
{
this.hoverInfoUi.style.display = "none";
this.hoverInfoUi.textContent = "";
return;
}
if (this.hoverEmphasisEnabled && window != null)
{
const spotStart = this.spotFeatureList[window.startIdx]?.get("spot");
const spotEnd = this.spotFeatureList[window.hoverIdx]?.get("spot");
if (!spotStart || !spotEnd)
{
this.hoverInfoUi.style.display = "none";
this.hoverInfoUi.textContent = "";
return;
}
const dtStartUtc = utl.ConvertLocalToUtc(spotStart.GetDTLocal());
const dtEndUtc = utl.ConvertLocalToUtc(spotEnd.GetDTLocal());
const dtStartLocal = spotStart.GetDTLocal();
const dtEndLocal = spotEnd.GetDTLocal();
this.hoverInfoUi.textContent =
`UTC ${dtStartUtc.slice(0, 16)} - ${dtEndUtc.slice(0, 16)}\n` +
`LCL ${dtStartLocal.slice(0, 16)} - ${dtEndLocal.slice(0, 16)}`;
}
else
{
const dtUtc = utl.ConvertLocalToUtc(hoverSpot.GetDTLocal());
const dtLocal = hoverSpot.GetDTLocal();
this.hoverInfoUi.textContent =
`UTC ${dtUtc.slice(0, 16)}\n` +
`LCL ${dtLocal.slice(0, 16)}`;
}
this.hoverInfoUi.style.display = "";
}
PrecomputeHoverEmphasisData()
{
const TEN_DAYS_MS = 10 * 24 * 60 * 60 * 1000;
this.hoverStartIdxBySpotIdx = [];
if (this.hoverEmphasisEnabled == false || this.spotFeatureList.length == 0)
{
return;
}
let spotMsList = [];
let unwrappedLngList = [];
let lngLast = null;
for (const feature of this.spotFeatureList)
{
spotMsList.push(utl.ParseTimeToMs(feature.get("spot").GetDTLocal()));
let lng = feature.get("spot").GetLng();
if (lngLast != null)
{
while ((lng - lngLast) > 180) { lng -= 360; }
while ((lng - lngLast) < -180) { lng += 360; }
}
unwrappedLngList.push(lng);
lngLast = lng;
}
let SegmentCrossesEquivalentLongitude = (lngA, lngB, targetLng) => {
let lngMin = Math.min(lngA, lngB);
let lngMax = Math.max(lngA, lngB);
let kMin = Math.ceil((lngMin - targetLng) / 360);
let kMax = Math.floor((lngMax - targetLng) / 360);
return kMin <= kMax;
};
let minLookbackIdx = 0;
for (let hoverIdx = 0; hoverIdx < unwrappedLngList.length; ++hoverIdx)
{
let hoverMs = spotMsList[hoverIdx];
let minAllowedMs = hoverMs - TEN_DAYS_MS;
while (minLookbackIdx < hoverIdx && spotMsList[minLookbackIdx] < minAllowedMs)
{
++minLookbackIdx;
}
let targetLng = unwrappedLngList[hoverIdx];
let startIdx = minLookbackIdx;
for (let i = minLookbackIdx; i >= 1; --i)
{
let lngA = unwrappedLngList[i - 1];
let lngB = unwrappedLngList[i];
if (SegmentCrossesEquivalentLongitude(lngA, lngB, targetLng))
{
startIdx = i;
break;
}
}
this.hoverStartIdxBySpotIdx[hoverIdx] = startIdx;
}
}
ApplyHoverEmphasis()
{
if (this.spotFeatureList.length == 0)
{
this.UpdateHoverInfoUi(null);
return;
}
const window = this.GetHoverEmphasisWindow();
const windowSame =
this.currentHoverWindow?.startIdx == window?.startIdx &&
this.currentHoverWindow?.hoverIdx == window?.hoverIdx;
const hoverSpotSame = this.currentHoverSpotDt == this.hoverSpotDt;
if (windowSame)
{
if (hoverSpotSame)
{
return;
}
}
this.UpdateHoverInfoUi(window);
for (let i = 0; i < this.spotFeatureList.length; ++i)
{
let opacity = this.GetHoverEmphasisOpacity(i, window);
if (this.spotOpacityList[i] != opacity)
{
const feature = this.spotFeatureList[i];
const spot = feature.get("spot");
feature.setStyle(this.GetSpotStyle(spot.GetAccuracy(), opacity));
this.spotOpacityList[i] = opacity;
}
}
for (let endIdx = 1; endIdx < this.lineFeatureListByEndIdx.length; ++endIdx)
{
const opacity = Math.min(
this.GetHoverEmphasisOpacity(endIdx - 1, window),
this.GetHoverEmphasisOpacity(endIdx, window),
);
if (this.lineOpacityByEndIdx[endIdx] == opacity)
{
continue;
}
for (const feature of this.lineFeatureListByEndIdx[endIdx] ?? [])
{
feature.setStyle(this.GetLineStyle(opacity));
}
this.lineOpacityByEndIdx[endIdx] = opacity;
}
this.currentHoverWindow = window ? { ...window } : null;
this.currentHoverSpotDt = this.hoverSpotDt;
}
GetLatestFeatureByType(featureList, type)
{
let featureLatest = null;
let dtLatest = null;
for (const feature of featureList)
{
if (feature.get("type") == type)
{
let dt = feature.get("spot").GetDTLocal();
if (dtLatest == null || dt > dtLatest)
{
dtLatest = dt;
featureLatest = feature;
}
}
}
return featureLatest;
}
GetLatestSpotFeatureAtPixel(pixel)
{
let featureLatest = this.map.forEachFeatureAtPixel(pixel, (feature, layer) => {
if (layer === this.spotLayer && feature.get("type") == "spot")
{
return feature;
}
return undefined;
}, {
hitTolerance: 4,
layerFilter: layer => layer === this.spotLayer,
});
return featureLatest;
}
OnPointerMove(pixel, coordinate, e)
{
// if map moving (by a fling), a mouseover event causes a noticable
// hang in motion. prevent that by only handling this event if we
// are not in motion.
if (this.moveState == "moving") { return; }
if (this.showRxState == "frozen") { return; }
// figure out what you're hovering over.
// prioritize mousing over spots.
//
// update - holy shit this takes like 100ms when the dev console
// is open, but seemingly not when it isn't
let spotFeature = this.GetLatestSpotFeatureAtPixel(pixel);
// accumulate firing of the same spot, and also distinguish between
// hovering over something vs nothing
if (this.spotFeatureLast == spotFeature)
{
// either still the same something, or still nothing, but either
// way we don't care and ignore it
spotFeature = null;
}
else
{
// there was a change
if (spotFeature)
{
// was nothing, now something
const spot = spotFeature.get("spot");
this.hoverSpotDt = spot.GetDTLocal();
this.ApplyHoverEmphasis();
if (this.showRxState != "disabled")
{
this.showRxState = "hover";
this.HandleSeen([spot]);
}
}
else
{
// was something, now nothing
this.hoverSpotDt = null;
this.ApplyHoverEmphasis();
if (this.showRxState != "disabled")
{
this.showRxState = "default";
this.HandleSeen(this.spotListLast);
}
}
// remember for next time
this.spotFeatureLast = spotFeature;
}
}
OnClick(pixel, coordinate, e)
{
let feature = this.GetLatestSpotFeatureAtPixel(pixel);
if (feature)
{
let spotLast = null;
if (feature)
{
spotLast = feature.get("spot");
}
if (spotLast)
{
// if the external click generator passes along the
// specific spot to use, use it instead
if (e.spot)
{
spotLast = e.spot;
}
// set rx location updates frozen since we know we're
// doing a popup here. the rx locations of this spot
// should already be being shown given the mouse
// clicked it, but let's be explicit anyway
if (this.showRxState != "disabled")
{
// temporarily lift a potential freeze
// (from prior popup click) to show the rx for this
// specific spot
this.showRxState = "hover";
this.HandleSeen([spotLast]);
// now freeze
this.showRxState = "frozen";
}
// fill out popup
let td = spotLast.spotData.td;
// let content = document.getElementById('popup-content');
let content = this.popupContent;
// content.innerHTML = `<p>You clicked ${td.Get(0, "DateTimeLocal")}</p>`;
content.innerHTML = ``;
let table = utl.MakeTableTransposed(td.GetDataTable());
content.appendChild(table);
// add additional links
let lat = spotLast.GetLat();
let lng = spotLast.GetLng();
// get altitude but strip comma from it first
let altM = td.Get(0, "AltM");
if (altM)
{
altM = altM.toString();
altM = parseInt(altM.replace(/\,/g,''), 10);
}
else
{
altM = 0;
}
// make jump link active
let domJl = document.createElement("span");
domJl.innerHTML = "jump to data";
domJl.style.cursor = "pointer";
domJl.style.color = "blue";
domJl.style.textDecoration = "underline";
domJl.style.userSelect = "none";
domJl.onclick = () => {
window.parent.postMessage({
type: "JUMP_TO_DATA",
ts: spotLast.GetDTLocal(),
}, "*");
};
// fill out more popup
content.appendChild(domJl);
content.appendChild(document.createElement("br"));
content.appendChild(document.createElement("br"));
content.appendChild(document.createTextNode("Links:"));
content.appendChild(document.createElement("br"));
// create a table of links to show
let dataTableLinks = [
["windy.com", "suncalc.org", "hysplit"]
];
let dataRow = [];
// fill out windy links
let windyLinksList = [];
windyLinksList.push(utl.MakeLink(this.MakeUrlWindyWind(lat, lng, altM), "wind"));
windyLinksList.push(utl.MakeLink(this.MakeUrlWindyCloudtop(lat, lng), "cloudtop"));
windyLinksList.push(utl.MakeLink(this.MakeUrlWindyRain(lat, lng), "rain"));
let windyLinksStr = windyLinksList.join(", ");
dataRow.push(windyLinksStr);
// fill out suncalc links
let suncalcLinksList = [];
suncalcLinksList.push(utl.MakeLink(this.MakeUrlSuncalc(lat, lng), "suncalc"));
let suncalcLinksStr = suncalcLinksList.join(", ");
dataRow.push(suncalcLinksStr);
// fill out hysplit links
let hysplitLinksList = [];
hysplitLinksList.push(utl.MakeLink(this.MakeUrlHysplitTrajectory(), "traj"));
hysplitLinksList.push(utl.MakeLink(this.MakeUrlHysplitTrajectoryBalloon(), "for balloons"));
let hysplitLinksStr = hysplitLinksList.join(", ");
dataRow.push(hysplitLinksStr);
// push data into data table
dataTableLinks.push(dataRow);
// construct html table and insert
let linksTable = utl.MakeTableTransposed(dataTableLinks);
content.appendChild(linksTable);
// position
this.overlay.setPosition(coordinate);
}
}
}
// https://openlayers.org/en/latest/apidoc/module-ol_MapBrowserEvent-MapBrowserEvent.html
SetupEventHandlers()
{
this.map.on('click', e => {
this.OnClick(e.pixel, e.coordinate, e)
});
this.map.on('pointermove', e => {
this.pendingPointerMove = e;
if (this.pointerMoveRafId == null)
{
this.pointerMoveRafId = window.requestAnimationFrame(() => {
let evt = this.pendingPointerMove;
this.pendingPointerMove = null;
this.pointerMoveRafId = null;
if (evt)
{
this.OnPointerMove(evt.pixel, evt.coordinate, evt);
}
});
}
});
this.moveState = "stopped";
this.map.on('movestart', e => {
// console.log("move start")
this.moveState = "moving";
});
this.map.on('moveend', e => {
// console.log("move end")
this.moveState = "stopped";
});
// this.map.on('precompose', e => { console.log("precompose") });
// this.map.on('postcompose', e => { console.log("postcompose") });
// this.map.on('prerender', e => { console.log("prerender") });
// this.map.on('postrender', e => { console.log("postrender") });
// this.map.on('rendercomplete', e => {
// console.log("rendercomplete");
// });
}
MakeUrlHysplitTrajectoryBalloon()
{
return `https://www.ready.noaa.gov/hypub-bin/trajsrc.pl?trjtype=4`;
}
MakeUrlHysplitTrajectory()
{
// save a click from https://www.ready.noaa.gov/HYSPLIT_traj.php
// then https://www.ready.noaa.gov/hypub-bin/trajtype.pl
return `https://www.ready.noaa.gov/hypub-bin/trajsrc.pl`;
}
MakeUrlSuncalc(lat, lng)
{
// seems providing a date and a time will set the page to something other than
// "now," but it expects the date and time to be in the local timezone, which I
// have no way of getting (easily). Does not appear to support UTC.
let mapZoom = 5;
return `https://suncalc.org/#/${lat},${lng},${mapZoom}/null/null/null/null`;
}
MakeUrlWindyRain(lat, lng)
{
return `https://windy.com/?rain,${lat},${lng},5,d:picker`;
}
MakeUrlWindyCloudtop(lat, lng)
{
return `https://windy.com/?cloudtop,${lat},${lng},5,d:picker`;
}
MakeUrlWindyWind(lat, lng, altM)
{
let altLabelList = [
[0, "surface"],
[100, "100m"],
[600, "950h"],
[750, "925h"],
[900, "900h"],
[1500, "850h"],
[2000, "800h"],
[3000, "700h"],
[4200, "600h"],
[5500, "500h"],
[7000, "400h"],
[9000, "300h"],
[10000, "250h"],
[11700, "200h"],
[13500, "150h"],
];
if (altM < 0) { altM = 0; }
// determine the correct elevation for the map
let labelUse = null;
for (let [alt, label] of altLabelList)
{
// console.log(`Checking ${altM} against ${alt}, ${label}`);
if (altM >= alt)
{
labelUse = label;
// console.log(`using ${labelUse} for now`);
}
}
// console.log(`using ${labelUse} final`);
// force at least a single decimal place or the page doesn't drop a pin correctly
let latStr = lat.toFixed(9);
let lngStr = lng.toFixed(9);
return `https://windy.com/?wind,${labelUse},${latStr},${lngStr},5,d:picker`;
}
SetShowRxState(state)
{
this.showRxState = state;
this.rxFeatureListKeyLast = null;
this.HandleSeen(this.spotListLast);
}
HandleSeen(spotList)
{
if (this.showRxState == "frozen") { return ; }
let source = this.rxLayer.getSource();
if (this.showRxState == "disabled")
{
source.clear(true);
this.rxFeatureListKeyLast = null;
return;
}
// decide which rx to show depending on state
let spotListUse = [];
if (this.showRxState == "default")
{
if (spotList.length)
{
spotListUse = [spotList.at(-1)];
}
}
else
{
spotListUse = spotList;
}
let featureListKey = this.showRxState + "|" + spotListUse.map(spot => spot.GetDTLocal()).join("|");
if (featureListKey == this.rxFeatureListKeyLast)
{
return;
}
let featureList = [];
for (const spot of spotListUse)
{
featureList.push(... this.GetRxFeatureListForSpot(spot));
}
source.clear(true);
if (featureList.length)
{
source.addFeatures(featureList);
}
this.rxFeatureListKeyLast = featureListKey;
}
GetRxFeatureListForSpot(spot)
{
if (this.spot__rxFeatureList.has(spot))
{
return this.spot__rxFeatureList.get(spot);
}
let featureList = [];
for (const seenData of spot.GetSeenDataList())
{
let pointSeen = new ol.geom.Point(ol.proj.fromLonLat([seenData.lng, seenData.lat]));
let featureSeen = new ol.Feature({
geometry: pointSeen,
});
featureSeen.setStyle(this.rxStyleSeen);
featureSeen.set("type", "rx");
featureSeen.set("spot", spot);
featureList.push(featureSeen);
}
this.spot__rxFeatureList.set(spot, featureList);
return featureList;
}
// function to pre-process spots such that a heat map can be constructed.
// goal is to:
// - break all spots down into grid4 locations
// - sum up all the confirmed spots in each grid 4
// - or take max?
// - determine the max grid
// - use this as the "top" value by which all others are scaled
// - this data is used to supply the heat map weight (0-1) with a relative
// order
// - heat should avoid giving a metric to every spot in a grid, it'll sum up
// to be too high
// - instead use the "middle"?
HeatMapHandleData(spotList)
{
this.grid4__data = new Map();
// group all spots by grid4
for (const spot of spotList)
{
let grid4 = spot.GetGrid4();
if (this.grid4__data.has(grid4) == false)
{
this.grid4__data.set(grid4, {
spotList: [],
maxHeard: 0,
});
}
let data = this.grid4__data.get(grid4);
data.spotList.push(spot);
}
// find the max per-grid and global grid max
this.maxHeardGlobal = 0;
this.grid4__data.forEach((data, grid4, map) => {
console.log(`grid4 ${grid4}`)
for (const spot of data.spotList)
{
let heard = spot.GetSeenDataList().length;
console.log(` dt ${spot.GetDTLocal()} heard ${heard}`);
if (heard > data.maxHeard)
{
console.log(` that's a new grid max`)
data.maxHeard = heard;
}
if (heard > this.maxHeardGlobal)
{
console.log(` and a new global max`)
this.maxHeardGlobal = heard;
}
}
});
console.log(`global max: ${this.maxHeardGlobal}`)
}
SetSpotList(spotList, options = {})
{
const preserveView = options.preserveView ?? false;
this.t.Reset();
this.t.Event("SpotMap::SetSpotList Start");
// this.HeatMapHandleData(spotList);
// draw first so spots overlap
this.HandleSeen(spotList);
// clear old spot features
if (this.dataSetPreviously == true)
{
let FnCount = (thing) => {
let count = 0;
thing.forEachFeature(t => {
++count;
});
return count;
};
// console.log(`clearing ${FnCount(this.spotLayer.getSource())} features`)
this.spotLayer.getSource().clear(true);
// this.hmLayer.getSource().clear(true);
}
this.spotFeatureList = [];
this.lineFeatureList = [];
this.lineFeatureListByEndIdx = [];
this.spotOpacityList = [];
this.lineOpacityByEndIdx = [];
this.hoverStartIdxBySpotIdx = [];
this.currentHoverWindow = null;
this.currentHoverSpotDt = null;
this.spotDt__idx.clear();
this.UpdateHoverInfoUi(null);
// add points
for (let idx = 0; idx < spotList.length; ++idx)
{
const spot = spotList[idx];
let point = new ol.geom.Point(spot.GetLoc());
let feature = new ol.Feature({
geometry: point,
});
feature.setStyle(this.GetSpotStyle(spot.GetAccuracy(), 1.0));
feature.set("type", "spot");
feature.set("spot", spot);
feature.set("spotIndex", idx);
this.spotLayer.getSource().addFeature(feature);
this.spotFeatureList.push(feature);
this.spotOpacityList[idx] = 1.0;
this.spotDt__idx.set(spot.GetDTLocal(), idx);
}
if (this.hoverEmphasisEnabled)
{
this.PrecomputeHoverEmphasisData();
}
// // heat map driven off of grid4
// for (const grid4 of this.grid4__data.keys())
// {
// let [lat, lng] = WSPREncoded.DecodeMaidenheadToDeg(grid4);
// let point = new ol.geom.Point(ol.proj.fromLonLat([lng, lat]));
// let feature = new ol.Feature({
// geometry: point,
// });
// feature.set("type", "grid4");
// feature.set("grid4", grid4);
// // heat map shows from which locations you're heard the best
// this.hmLayer.getSource().addFeature(feature);
// }
// cache data about spots
for (const spot of spotList)
{
this.dt__data.set(spot.GetDTLocal(), {
spot: spot,
});
}
// add lines
if (spotList.length > 1 && this.showLines)
{
// get latLngList from spots
let latLngList = [];
for (const spot of spotList)
{
latLngList.push([spot.GetLat(), spot.GetLng()]);
}
// do special processing to draw lines, which avoids the 180/-180 boundary issue
let lineSegmentList = this.MakeLineSegmentList(latLngList);
// plot it
for (const { lineString, spotIndexA, spotIndexB } of lineSegmentList)
{
// turn into a line
let feature = new ol.Feature({
geometry: lineString,
});
feature.set("spotIndexA", spotIndexA);
feature.set("spotIndexB", spotIndexB);
feature.setStyle(this.GetLineStyle(1.0));
this.spotLayer.getSource().addFeature(feature);
this.lineFeatureList.push(feature);
if (this.lineFeatureListByEndIdx[spotIndexB] == undefined)
{
this.lineFeatureListByEndIdx[spotIndexB] = [];
}
this.lineFeatureListByEndIdx[spotIndexB].push(feature);
this.lineOpacityByEndIdx[spotIndexB] = 1.0;
}
}
this.ApplyHoverEmphasis();
// keep the map load from being so sudden
Animation.FadeOpacityUp(this.ui);
if (spotList.length == 0)
{
// do nothing, prior spots cleared, we're just a blank map now
}
else if (this.dataSetPreviously == true)
{
if (!preserveView)
{
// leave center and zoom as it was previously
let spotLatest = spotList.at(-1);
if (this.autoMove)
{
// smoothly pan to the new location
let view = this.map.getView();
view.animate({
center: spotLatest.GetLoc(),
duration: 500,
});
}
}
}
else
{
// center map on latest
let spotLatest = spotList.at(-1);
this.map.getView().setCenter(spotLatest.GetLoc());
this.map.getView().setZoom(4);
}
this.dataSetPreviously = true;
this.spotListLast = spotList;
// Always ring the latest spot with a red circle.
this.UnHighlightLatest();
this.HighlightLatest();
this.t.Event("SpotMap::SetSpotList End");
}
FocusOn(ts)
{
// hopefully find the spot based on time right away
let data = this.dt__data.get(ts);
let spot = null;
// console.log(`FocusOn ${ts}`)
if (data)
{
// console.log(`found immediately`)
spot = data.spot;
}
else
{
// console.log(`hunting for it`)
// we don't have that time, find the spot that is closest in time
let tsDiffMin = null;
for (let [keyTs, valueData] of this.dt__data)
{
let spotTmp = valueData.spot;
let tsDiff = Math.abs(utl.MsDiff(keyTs, ts));
// console.log(`${keyTs} - ${ts} = ${tsDiff}`)
if (tsDiffMin == null || tsDiff < tsDiffMin)
{
tsDiffMin = tsDiff;
// console.log(`new spot`)
spot = spotTmp;
}
}
// overwrite the time now that we have a specific spot to focus on
if (tsDiffMin)
{
ts = spot.GetDTLocal();
}
}
// work out where on the screen this spot is
let pixel = this.map.getPixelFromCoordinate(spot.GetLoc());
// if it is out of the screen, things don't seem to work correctly,
// so zoom out so much that everything is on the screen
let [pixX, pixY] = pixel;
let [mapWidth, mapHeight] = this.map.getSize();
if (pixX < 0 || pixX > mapWidth || pixY < 0 || pixY > mapHeight)
{
// console.log(`have to move the screen`)
this.map.getView().setCenter(spot.GetLoc());
this.map.getView().setZoom(1);
}
// async complete the rest after the map has a chance to do stuff for
// potentially zooming out
setTimeout(() => {
let pixel = this.map.getPixelFromCoordinate(spot.GetLoc());
// now that we can see the feature, we use a pixel to point, but now
// need to figure out which specific feature is the one we're
// looking for, since many can be "at" the same pixel
let f = null;
let tsDiffMin = null;
this.map.forEachFeatureAtPixel(pixel, (feature, layer) => {
let fSpot = feature.get("spot");
if (fSpot)
{
let tsDiff = Math.abs(utl.MsDiff(ts, fSpot.GetDTLocal()));
// console.log(`${ts} - ${fSpot.GetDTLocal()} = ${tsDiff}`)
if (tsDiffMin == null || tsDiff < tsDiffMin)
{
tsDiffMin = tsDiff;
// console.log(`new feature`)
f = feature;
}
}
});
// console.log(`done looking at features`)
let coordinate = null;
if (f)
{
let g = f.getGeometry();
let c = g.getCoordinates();
coordinate = c;
}
this.map.dispatchEvent({
type: 'click',
pixel: pixel,
pixel_: pixel,
dragging: false,
coordinate: coordinate,
coordinate_: coordinate,
originalEvent: {},
dragging: false,
map: this.map,
spot: spot,
});
}, 50);
}
HighlightLatest()
{
this.focusFeature = null;
if (this.spotListLast.length != 0)
{
let spot = this.spotListLast[this.spotListLast.length - 1];
let style = new ol.style.Style({
image: new ol.style.Circle({
radius: 8,
fill: new ol.style.Fill({
color: 'rgba(255, 255, 255, 0.1)',
}),
stroke: new ol.style.Stroke({
color: 'rgba(255, 0, 0, 1)',
width: 2.0,
}),
}),
});
let point = new ol.geom.Point(spot.GetLoc());
let feature = new ol.Feature({
geometry: point,
});
feature.setStyle(style);
feature.set("type", "spot");
feature.set("spot", spot);
this.spotLayer.getSource().addFeature(feature);
this.focusFeature = feature;
}
}
UnHighlightLatest()
{
if (this.focusFeature)
{
this.spotLayer.getSource().removeFeature(this.focusFeature);
this.focusFeature = null;
}
}
MakeLineSegmentList(latLngList)
{
let lineSegmentList = [];
function CloseToWrap(lng)
{
return (180 - Math.abs(lng)) < 30;
}
function ToEzLat(lat)
{
return (lat < 0) ? (90 + -lat) : lat;
}
function FromEzLat(latEz)
{
return (latEz > 90) ? (-latEz + 90) : latEz;
}
function CalcCrossingLat(latA, lngA, latB, lngB)
{
let latAEz = ToEzLat(latA);
let latBEz = ToEzLat(latB);
let latCrossEz = latAEz;
if (lngA > 0 && lngB < 0)
{
let lngToMark = 180 - lngA;
let markToLng = lngB - -180;
let dx = lngToMark + markToLng;
let dy = Math.abs(latBEz - latAEz);
if (dx == 0)
{
latCrossEz = latAEz;
}
else if (latAEz < latBEz)
{
let dc = Math.sqrt((dx ** 2) + (dy ** 2));
let a = lngToMark;
let c = lngToMark / dx * dc;
let b = Math.sqrt(Math.max(0, (c ** 2) - (a ** 2)));
latCrossEz = latAEz + b;
}
else if (latAEz > latBEz)
{
let dc = Math.sqrt((dx ** 2) + (dy ** 2));
let a = lngToMark;
let c = lngToMark / dx * dc;
let b = Math.sqrt(Math.max(0, (c ** 2) - (a ** 2)));
latCrossEz = latAEz - b;
}
}
else if (lngA < 0 && lngB > 0)
{
let lngToMark = 180 - lngB;
let markToLng = lngA - -180;
let dx = lngToMark + markToLng;
let dy = Math.abs(latBEz - latAEz);
if (dx == 0)
{
latCrossEz = latAEz;
}
else if (latAEz < latBEz)
{
let dc = Math.sqrt((dx ** 2) + (dy ** 2));
let a = lngToMark;
let c = lngToMark / dx * dc;
let b = Math.sqrt(Math.max(0, (c ** 2) - (a ** 2)));
latCrossEz = latAEz + b;
}
else if (latAEz > latBEz)
{
let dc = Math.sqrt((dx ** 2) + (dy ** 2));
let a = lngToMark;
let c = lngToMark / dx * dc;
let b = Math.sqrt(Math.max(0, (c ** 2) - (a ** 2)));
latCrossEz = latAEz - b;
}
}
return FromEzLat(latCrossEz);
}
for (let i = 1; i < latLngList.length; ++i)
{
let [latA, lngA] = latLngList[i - 1];
let [latB, lngB] = latLngList[i];
let crossesPosToNeg = lngA > 0 && lngB < 0 && (CloseToWrap(lngA) || CloseToWrap(lngB));
let crossesNegToPos = lngA < 0 && lngB > 0 && (CloseToWrap(lngA) || CloseToWrap(lngB));
if (crossesPosToNeg || crossesNegToPos)
{
let latCross = CalcCrossingLat(latA, lngA, latB, lngB);
let breakA = crossesPosToNeg ? [180, latCross] : [-180, latCross];
let breakB = crossesPosToNeg ? [-180, latCross] : [180, latCross];
lineSegmentList.push({
lineString: new ol.geom.LineString([
ol.proj.fromLonLat([lngA, latA]),
ol.proj.fromLonLat(breakA),
]),
spotIndexA: i - 1,
spotIndexB: i,
});
lineSegmentList.push({
lineString: new ol.geom.LineString([
ol.proj.fromLonLat(breakB),
ol.proj.fromLonLat([lngB, latB]),
]),
spotIndexA: i - 1,
spotIndexB: i,
});
}
else
{
lineSegmentList.push({
lineString: new ol.geom.LineString([
ol.proj.fromLonLat([lngA, latA]),
ol.proj.fromLonLat([lngB, latB]),
]),
spotIndexA: i - 1,
spotIndexB: i,
});
}
}
return lineSegmentList;
}
// lng( 179.5846), lat(40.7089) => lng(19991266.226313718), lat(4969498.835332252)
// lng(-176.8324), lat(41.7089) => lng(-19684892.723752473), lat(5117473.325588154)
MakeLineStringList(latLngList)
{
let locListList = [[]];
function CloseToWrap(lng)
{
// if you're within x degrees of the wraparound, let's assume
// this is the case we're dealing with (not the wrap over europe)
return (180 - Math.abs(lng)) < 30;
}
let latLast;
let lngLast;
for (let i = 0; i < latLngList.length; ++i)
{
let [lat, lng] = latLngList[i];
// only check subsequent points to see if they cross the 180/-180 longitude
if (i)
{
if (lngLast > 0 && lng < 0 && (CloseToWrap(lngLast) || CloseToWrap(lng)))
{
// oops, it happened going from +180 to -180
// let's convert latitude to easier to math numbers
// latitude is 90 at the poles, converges to zero at the
// equator.
// the south is depicted as having a negative latitude.
// so let's call it 0 (north pole) to 180 (south pole)
let latEz = (lat < 0) ? (90 + -lat) : lat;
let latLastEz = (latLast < 0) ? (90 + -latLast) : latLast;
// want to determine crossover point
let latCrossoverEz;
// let's look at whether travel toward north or south
if (latLastEz < latEz)
{
// example: 20m, chan 65, VE3OCL, 2023-04-25 to 2023-04-26
// moving north, interpolate
// let's model a giant triangle from last pos to this pos
// measure horizontal distance
let lngToMark = 180 - lngLast;
let markToLng = lng - -180;
let dx = lngToMark + markToLng;
// measure vertical distance
let dy = latEz - latLastEz;
// calculate big triangle hypotenuse
let dc = Math.sqrt((dx ** 2) + (dy ** 2));
// now we can calculate the portion of the big triangle
// that the meridian slices off, which itself is a triangle
// on the left side.
// horizontal distance is lngToMark
let a = lngToMark;
// the small hypotenuse is the same percent of its length
// as the length to the mark is of the entire distance
let c = lngToMark / dx * dc;
// now reverse the Pythagorean theorem
let b = Math.sqrt(Math.sqrt(c) - Math.sqrt(a));
// ok that's our crossover point
latCrossoverEz = latLastEz + b;
}
else if (latLastEz == latEz)
{
// you know the lat
latCrossoverEz = latEz;
}
else if (latLastEz > latEz)
{
// example: 20m, chan 99, VE3KCL, 2023-04-30 to 2023-04-31
// moving south, interpolate
// let's model a giant triangle from last pos to this pos
// measure horizontal distance
let lngToMark = 180 - lngLast;
let markToLng = lng - -180;
let dx = lngToMark + markToLng;
// measure vertical distance
let dy = latLastEz - latEz;
// calculate big triangle hypotenuse
let dc = Math.sqrt((dx ** 2) + (dy ** 2));
// now we can calculate the portion of the big triangle
// that the meridian slices off, which itself is a triangle
// on the left side.
// horizontal distance is lngToMark
let a = lngToMark;
// the small hypotenuse is the same percent of its length
// as the length to the mark is of the entire distance
let c = lngToMark / dx * dc;
// now reverse the Pythagorean theorem
let b = Math.sqrt(Math.sqrt(c) - Math.sqrt(a));
// ok that's our crossover point
latCrossoverEz = latLastEz - b;
}
// convert ez back to real latitude
let latCrossover = (latCrossoverEz > 90) ? (-latCrossoverEz + 90) : latCrossoverEz;
// now break this point into two, one which gets from the prior
// point to the break, and then from the break to this point.
// put in the right (opposite) order for conversion
let one = [180, latCrossover];
let two = [-180, latCrossover];
let three = [lng, lat];
locListList.at(-1).push(ol.proj.fromLonLat(one));
locListList.push([]);
locListList.at(-1).push(ol.proj.fromLonLat(two));
locListList.at(-1).push(ol.proj.fromLonLat(three));
}
else if (lngLast < 0 && lng > 0 && (CloseToWrap(lngLast) || CloseToWrap(lng)))
{
// oops, it happened going from -180 to +180
// let's convert latitude to easier to math numbers
// latitude is 90 at the poles, converges to zero at the
// equator.
// the south is depicted as having a negative latitude.
// so let's call it 0 (north pole) to 180 (south pole)
let latEz = (lat < 0) ? (90 + -lat) : lat;
let latLastEz = (latLast < 0) ? (90 + -latLast) : latLast;
// want to determine crossover point
let latCrossoverEz;
// let's look at whether travel toward north or south
if (latLastEz < latEz)
{
// example: 20m, chan 99, VE3CKL, 2023-03-12 to 2023-03-12
// moving north, interpolate
// let's model a giant triangle from last pos to this pos
// measure horizontal distance
let lngToMark = 180 - lng;
let markToLng = lngLast - -180;
let dx = lngToMark + markToLng;
// measure vertical distance
let dy = latEz - latLastEz;
// calculate big triangle hypotenuse
let dc = Math.sqrt((dx ** 2) + (dy ** 2));
// now we can calculate the portion of the big triangle
// that the meridian slices off, which itself is a triangle
// on the left side.
// horizontal distance is lngToMark
let a = lngToMark;
// the small hypotenuse is the same percent of its length
// as the length to the mark is of the entire distance
let c = lngToMark / dx * dc;
// now reverse the Pythagorean theorem
let b = Math.sqrt(Math.sqrt(c) - Math.sqrt(a));
// ok that's our crossover point
latCrossoverEz = latLastEz + b;
}
else if (latLastEz == latEz)
{
// you know the lat
latCrossoverEz = latEz;
}
else if (latLastEz > latEz)
{
// example: ??
// moving south, interpolate
// let's model a giant triangle from last pos to this pos
// measure horizontal distance
let lngToMark = 180 - lng;
let markToLng = lngLast - -180;
let dx = lngToMark + markToLng;
// measure vertical distance
let dy = latLastEz - latEz;
// calculate big triangle hypotenuse
let dc = Math.sqrt((dx ** 2) + (dy ** 2));
// now we can calculate the portion of the big triangle
// that the meridian slices off, which itself is a triangle
// on the left side.
// horizontal distance is lngToMark
let a = lngToMark;
// the small hypotenuse is the same percent of its length
// as the length to the mark is of the entire distance
let c = lngToMark / dx * dc;
// now reverse the Pythagorean theorem
let b = Math.sqrt(Math.sqrt(c) - Math.sqrt(a));
// ok that's our crossover point
latCrossoverEz = latLastEz - b;
}
// convert ez back to real latitude
let latCrossover = (latCrossoverEz > 90) ? (-latCrossoverEz + 90) : latCrossoverEz;
// now break this point into two, one which gets from the prior
// point to the break, and then from the break to this point.
// put in the right (opposite) order for conversion
let one = [-180, latCrossover];
let two = [180, latCrossover];
let three = [lng, lat];
locListList.at(-1).push(ol.proj.fromLonLat(one));
locListList.push([]);
locListList.at(-1).push(ol.proj.fromLonLat(two));
locListList.at(-1).push(ol.proj.fromLonLat(three));
}
else
{
locListList.at(-1).push(ol.proj.fromLonLat([lng, lat]));
}
}
else
{
locListList.at(-1).push(ol.proj.fromLonLat([lng, lat]));
}
latLast = lat;
lngLast = lng;
}
// convert locListList to LineStringList
let lineStringList = [];
for (let locList of locListList)
{
lineStringList.push(new ol.geom.LineString(locList));
}
return lineStringList
}
}