/* 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 [ `H toggles highlighting the last lap to the hovered point.`, `M toggles the map panning to new spot locations on update or not.`, `R toggles showing receivers (purple dots).`, `L toggles interconnecting the spots with lines.`, `? 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: [ '© OpenStreetMap 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 = `

You clicked ${td.Get(0, "DateTimeLocal")}

`; 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 } }