1const interstorms = (() => { 2 const config = iconfig || {}; 3 const pmtilesProtocol = new pmtiles.Protocol(); 4 maplibregl.addProtocol('pmtiles', pmtilesProtocol.tile); 5 6 const referenceConfig = { 7 highCoastZoom: 3.95, 8 countyZoom: 5, 9 lakeZoom: 3.25, 10 }; 11 const basemapSourceId = 'openmaptiles'; 12 const referenceOverlayLayerIds = [ 13 'world-low-coast', 14 'world-low-borders', 15 'world-high-coast', 16 'world-high-borders', 17 'us-counties', 18 'lakes', 19 ]; 20 const protectedPlaceLabelIds = [ 21 'label_city', 22 'label_town', 23 'label_country_2', 24 'label_village', 25 'label_country_1', 26 'label_city_capital', 27 ]; 28 29 const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; 30 const BASIN_NAMES = { 31 AL: 'Atlantic', 32 EP: 'East Pacific', 33 CP: 'Central Pacific', 34 WP: 'West Pacific', 35 IO: 'Indian Ocean', 36 SH: 'Southern Hemisphere', 37 }; 38 const UNIT_DEFAULTS = { 39 timeMode: 'utc', 40 timeZone: '', 41 wind: 'kt', 42 temperature: 'c', 43 satelliteTemperature: 'c', 44 distance: 'metric', 45 pressure: 'mb', 46 precip: 'mm', 47 }; 48 const unitSettings = { ...UNIT_DEFAULTS }; 49 let graticuleVisible = false; 50 let graticuleRenderKey = ''; 51 const refreshSources = new Map(); 52 const graticuleOptions = { 53 color: 'rgba(235,245,250,0.24)', 54 labelColor: 'rgba(235,245,250,0.68)', 55 labelHalo: 'rgba(18,18,18,0.92)', 56 lineWidth: 1, 57 dash: [1.5, 5], 58 targetSpacingRatio: 0.18, 59 minSpacingPx: 70, 60 maxSpacingPx: 100, 61 font: '11px Roboto, Arial, sans-serif', 62 minLat: -85, 63 maxLat: 85, 64 }; 65 66 const map = new maplibregl.Map({ 67 container: 'map', 68 center: [-45, 20], 69 zoom: 2.7, 70 minZoom: 1.4, 71 maxZoom: 12, 72 fadeDuration: 0, 73 maxTileCacheSize: 64, 74 preserveDrawingBuffer: true, 75 crossSourceCollisions: false, 76 attributionControl: false, 77 style: '/static/map.json?d=9-24-26k', 78 }); 79 80 map.addControl(new maplibregl.NavigationControl({ showCompass: false }), 'top-left'); 81 const attributionControl = new maplibregl.AttributionControl({ compact: true }); 82 map.addControl(attributionControl, 'bottom-right'); 83 const attributionElement = document.querySelector('#map .maplibregl-ctrl-attrib'); 84 const attributionButton = attributionElement?.querySelector('.maplibregl-ctrl-attrib-button'); 85 if (attributionElement && attributionButton) { 86 const setAttributionOpen = open => { 87 attributionElement.classList.toggle('cw-attrib-open', open); 88 attributionElement.classList.toggle('maplibregl-compact-show', open); 89 attributionButton.setAttribute('aria-expanded', String(open)); 90 }; 91 attributionButton.addEventListener('click', event => { 92 event.preventDefault(); 93 event.stopImmediatePropagation(); 94 setAttributionOpen(!attributionElement.classList.contains('cw-attrib-open')); 95 }, true); 96 document.addEventListener('click', event => { 97 if (!attributionElement.contains(event.target)) setAttributionOpen(false); 98 }, true); 99 new MutationObserver(() => { 100 const open = attributionElement.classList.contains('cw-attrib-open'); 101 if (attributionElement.classList.contains('maplibregl-compact-show') !== open) { 102 attributionElement.classList.toggle('maplibregl-compact-show', open); 103 } 104 }).observe(attributionElement, { attributes: true, attributeFilter: ['class'] }); 105 setAttributionOpen(false); 106 } 107 108 function emptyGeoJson() { 109 return { type: 'FeatureCollection', features: [] }; 110 } 111 112 function setSourceData(id, data) { 113 const source = map.getSource(id); 114 if (source) source.setData(data); 115 } 116 117 function updateLayerVisibility(id, visible) { 118 if (map.getLayer(id)) { 119 map.setLayoutProperty(id, 'visibility', visible ? 'visible' : 'none'); 120 } 121 } 122 123 function moveExistingLayer(id, beforeId) { 124 if (!map.getLayer(id) || id === beforeId) return beforeId; 125 try { 126 map.moveLayer(id, beforeId); 127 return id; 128 } catch (err) { 129 console.warn(`Unable to move map layer ${id}:`, err); 130 return beforeId; 131 } 132 } 133 134 const reorderableLayerGroups = [ 135 { id: 'model-fill', label: 'Model Shading', layers: ['forecast-model-raster', 'forecast-model-filled-contours'] }, 136 { id: 'model-barbs', label: 'Model Wind', layers: ['forecast-model-streamlines', 'forecast-model-barbs-gl'] }, 137 { id: 'sst-anomaly', label: 'SST Anomaly', layers: ['sst-anomaly-fill-layer'] }, 138 { id: 'sst', label: 'Sea Surface Temperature', layers: ['sst-mean-fill-layer', 'sst-contour-layer'] }, 139 { id: 'satellite', label: 'Satellite', layers: ['satellite-custom-layer'] }, 140 { id: 'glm', label: 'GLM Lightning', layers: ['glm-flashes'] }, 141 { id: 'radar', label: 'Radar', layers: ['radar-custom-layer', 'radar-range-outline', 'jma-radar-stations-circle', 'jma-radar-stations-label'] }, 142 { id: 'microwave', label: 'Microwave', layers: ['microwave-custom-layer'] }, 143 { id: 'ascat', label: 'ASCAT', layers: ['ascat-streamlines'], dom: ['.ascat-map-canvas'] }, 144 { id: 'tdr', label: 'Tail Doppler Radar', layers: ['tdr-raster', 'tdr-barbs', 'tdr-cross-section-case', 'tdr-cross-section-line', 'tdr-cross-section-points', 'tdr-cross-section-labels'] },
145 { id: 'metar', label: 'METAR Observations', layers: [], dom: ['.metar-canvas:not(.amedas-canvas)'] }, 146 { id: 'amedas', label: 'AMeDAS Observations', layers: [], dom: ['.amedas-canvas'] }, 147 { id: 'reference', label: 'Coastlines/Borders', layers: [...referenceOverlayLayerIds] }, 148 { id: 'tracks', label: 'Storm Tracks', layers: ['historical-track-old', 'historical-track', 'active-track-old', 'active-track'] }, 149 { id: 'storm-markers', label: 'Storm Markers', layers: [], dom: ['.storm-map-marker'] }, 150 { id: 'model-contours', label: 'Model Contours', layers: ['forecast-model-contour-gl', 'forecast-model-contours', 'forecast-model-contour-labels'], dom: ['.forecast-model-contour-label-overlay'] }, 151 { id: 'ensembles', label: 'Ensembles', layers: ['ensemble-member-line', 'ensemble-mean-outline-line', 'ensemble-mean-line', 'ensemble-mean-hit-line', 'ensemble-pressure-hit', 'ensemble-pressure-l-label', 'ensemble-pressure-value-label'] }, 152 { id: 'storm-radii', label: 'Wind Radii', layers: ['storm-wind-radii-fill', 'storm-wind-radii-outline'] }, 153 { id: 'storm-cone', label: 'Forecast Cone', layers: ['storm-cone-fill', 'storm-cone-outline', 'storm-cone-track'] }, 154 { id: 'storm-warnings', label: 'TC Watches/Warnings', layers: ['storm-warning-fill', 'storm-warning-casing', 'storm-warning-lines'] }, 155 { id: 'guidance', label: 'Model Guidance', layers: ['guidance-line-outline', 'guidance-line', 'trend-line', 'guidance-point', 'guidance-hour-label', 'guidance-label'] }, 156 { id: 'recon', label: 'Recon', layers: ['recon-track-line', 'recon-point-hit'] }, 157 { id: 'two', label: 'Tropical Weather Outlooks', layers: ['hatching-layer', 'polygon-border-layer', 'line-layer', 'points-layer'] }, 158 { id: 'labels', label: 'Map Labels', layers: [] }, 159 ]; 160 const layerOrderSettings = { key: 'interstorms.layerOrder', order: [] }; 161 162 function readLayerOrderSettings() { 163 try { 164 const stored = JSON.parse(localStorage.getItem(layerOrderSettings.key) || '[]'); 165 layerOrderSettings.order = Array.isArray(stored) ? stored.filter(id => reorderableLayerGroups.some(group => group.id === id)) : []; 166 } catch (err) { 167 layerOrderSettings.order = []; 168 } 169 } 170 171 function writeLayerOrderSettings() { 172 try { localStorage.setItem(layerOrderSettings.key, JSON.stringify(layerOrderSettings.order)); } catch (err) {} 173 } 174 175 function orderedLayerGroups() { 176 const saved = new Set(layerOrderSettings.order); 177 const byId = new Map(reorderableLayerGroups.map(group => [group.id, group])); 178 return [ 179 ...layerOrderSettings.order.map(id => byId.get(id)).filter(Boolean), 180 ...reorderableLayerGroups.filter(group => !saved.has(group.id)), 181 ]; 182 } 183 184 function visibleLayerGroups() { 185 return orderedLayerGroups().filter(group => group.layers.some(id => { 186 const layer = map.getLayer(id); 187 return layer && map.getLayoutProperty(id, 'visibility') !== 'none'; 188 }) || (group.dom || []).some(selector => map.getContainer().querySelector(selector))); 189 } 190 191 function setUserLayerOrder(order) { 192 const allowed = new Set(reorderableLayerGroups.map(group => group.id)); 193 layerOrderSettings.order = (Array.isArray(order) ? order : []).filter(id => allowed.has(id)); 194 writeLayerOrderSettings(); 195 enforceLayerOrder(); 196 } 197 198 function basemapLabelAnchor() { 199 return (map.getStyle()?.layers || []).find(layer => ( 200 layer.source === basemapSourceId && layer.type === 'symbol' && map.getLayer(layer.id) 201 ))?.id; 202 } 203 204 function basemapOverlayAnchor() { 205 if (map.getLayer('world-low-coast')) return 'world-low-coast'; 206 return (map.getStyle()?.layers || []).find(layer => ( 207 layer.source === basemapSourceId && layer.type !== 'background' && layer.type !== 'fill' && map.getLayer(layer.id) 208 ))?.id || basemapLabelAnchor(); 209 } 210 211 function syncBasemapLayerGroups() { 212 const styleLayers = map.getStyle()?.layers || []; 213 const referenceGroup = reorderableLayerGroups.find(group => group.id === 'reference'); 214 const labelGroup = reorderableLayerGroups.find(group => group.id === 'labels'); 215 if (referenceGroup) { 216 referenceGroup.layers = [ 217 ...styleLayers.filter(layer => ( 218 layer.source === basemapSourceId && (layer.type === 'line' || layer.id === 'basemap-coastline') 219 )).map(layer => layer.id), 220 ...referenceOverlayLayerIds, 221 ]; 222 } 223 if (labelGroup) { 224 labelGroup.layers = styleLayers 225 .filter(layer => ( 226 layer.source === basemapSourceId && layer.type === 'symbol' && !protectedPlaceLabelIds.includes(layer.id) 227 )) 228 .map(layer => layer.id); 229 } 230 } 231 232 function enforceLayerOrder() { 233 const groups = orderedLayerGroups(); 234 const orderedLayers = [ 235 ...groups.flatMap(group => group.layers), 236 'glm-count-line-case', 'glm-count-line', 237 ]; 238 let beforeId; 239 for (let index = orderedLayers.length - 1; index >= 0; index -= 1) { 240 beforeId = moveExistingLayer(orderedLayers[index], beforeId); 241 } 242 protectedPlaceLabelIds.forEach(id => { 243 if (map.getLayer(id)) map.moveLayer(id); 244 }); 245 groups.forEach((group, index) => {
246 (group.dom || []).forEach(selector => { 247 map.getContainer().querySelectorAll(selector).forEach(node => { 248 node.style.zIndex = String(2 + index); 249 }); 250 }); 251 }); 252 } 253 254 readLayerOrderSettings(); 255 256 function readUnitSettings() { 257 try { 258 const stored = JSON.parse(localStorage.getItem('interstorms.units') || '{}'); 259 Object.assign(unitSettings, Object.fromEntries( 260 Object.entries(stored).filter(([, value]) => typeof value === 'string') 261 )); 262 } catch (err) {} 263 try { 264 if (!localStorage.getItem('interstorms.units') && localStorage.getItem('interstorms.localTime') === 'true') { 265 unitSettings.timeMode = 'local'; 266 } 267 } catch (err) {} 268 normalizeUnitSettings(); 269 syncLegacyTimeGlobals(); 270 } 271 272 function normalizeUnitSettings() { 273 if (!['utc', 'local', 'custom'].includes(unitSettings.timeMode)) unitSettings.timeMode = UNIT_DEFAULTS.timeMode; 274 if (!['kt', 'mph', 'kmh', 'ms'].includes(unitSettings.wind)) unitSettings.wind = UNIT_DEFAULTS.wind; 275 if (!['c', 'f'].includes(unitSettings.temperature)) unitSettings.temperature = UNIT_DEFAULTS.temperature; 276 if (!['c', 'f'].includes(unitSettings.satelliteTemperature)) unitSettings.satelliteTemperature = UNIT_DEFAULTS.satelliteTemperature; 277 if (!['metric', 'imperial'].includes(unitSettings.distance)) unitSettings.distance = UNIT_DEFAULTS.distance; 278 if (!['mb', 'inhg'].includes(unitSettings.pressure)) unitSettings.pressure = UNIT_DEFAULTS.pressure; 279 if (!['mm', 'in'].includes(unitSettings.precip)) unitSettings.precip = UNIT_DEFAULTS.precip; 280 unitSettings.timeZone = String(unitSettings.timeZone || '').trim(); 281 if (unitSettings.timeMode === 'custom' && !unitSettings.timeZone) unitSettings.timeMode = 'utc'; 282 } 283 284 function syncLegacyTimeGlobals() { 285 window.interstormsLocalTime = unitSettings.timeMode === 'local'; 286 window.interstormsTimeZone = unitSettings.timeMode === 'custom' ? unitSettings.timeZone : ''; 287 } 288 289 function saveUnitSettings() { 290 try { 291 localStorage.setItem('interstorms.units', JSON.stringify(unitSettings)); 292 localStorage.setItem('interstorms.localTime', String(unitSettings.timeMode === 'local')); 293 } catch (err) {} 294 } 295 296 function setUnitSettings(next = {}) { 297 Object.assign(unitSettings, next); 298 normalizeUnitSettings(); 299 syncLegacyTimeGlobals(); 300 saveUnitSettings(); 301 document.dispatchEvent(new CustomEvent('interstorms:units-change', { detail: unitSettingsSnapshot() })); 302 document.dispatchEvent(new Event('interstorms:time-format-change')); 303 } 304 305 function unitSettingsSnapshot() { 306 return { ...unitSettings }; 307 } 308 309 readUnitSettings(); 310 311 function formatCoordinateValue(value, digits = 2) { 312 const numeric = Number(value); 313 if (!Number.isFinite(numeric)) return '\u2014'; 314 return Math.abs(numeric).toFixed(digits).replace(/\.?0+$/, ''); 315 } 316 317 function formatCoordinate(lat, lon) { 318 const numericLat = Number(lat); 319 const latHem = numericLat >= 0 ? 'N' : 'S'; 320 const normalizedLon = ((Number(lon) + 180) % 360 + 360) % 360 - 180; 321 const lonHem = normalizedLon >= 0 ? 'E' : 'W'; 322 return `${formatCoordinateValue(numericLat)}\u00b0${latHem}, ${formatCoordinateValue(normalizedLon)}\u00b0${lonHem}`; 323 } 324 325 function formatDTG(dtg) { 326 const value = String(dtg || ''); 327 if (!/^\d{10,12}$/.test(value)) return value || '\u2014'; 328 const y = value.slice(0, 4); 329 const m = parseInt(value.slice(4, 6), 10) - 1; 330 const d = value.slice(6, 8); 331 const h = value.slice(8, 10); 332 const minute = value.length >= 12 ? value.slice(10, 12) : '00'; 333 const date = new Date(Date.UTC(+y, m, +d, +h, +minute)); 334 if (unitSettings.timeMode === 'utc') return `${h}z ${MONTHS[m]} ${d}, ${y}`; 335 const parts = timeFormatter({ minute: '2-digit' }).formatToParts(date); 336 const pick = type => parts.find(part => part.type === type)?.value || ''; 337 return `${pick('hour')}:${pick('minute')} ${pick('dayPeriod')} ${pick('timeZoneName')} ${pick('month')} ${pick('day')}, ${pick('year')}`; 338 } 339 340 function timeFormatter(extra = {}) { 341 const options = { 342 month: 'short', 343 day: 'numeric', 344 year: 'numeric', 345 hour: 'numeric', 346 hour12: true, 347 timeZoneName: 'short', 348 ...extra, 349 }; 350 if (unitSettings.timeMode === 'custom' && unitSettings.timeZone) options.timeZone = unitSettings.timeZone; 351 try { 352 return new Intl.DateTimeFormat(undefined, options); 353 } catch (err) { 354 return new Intl.DateTimeFormat(undefined, { ...options, timeZone: 'UTC', timeZoneName: 'short' }); 355 } 356 } 357 358 function formatDateTime(value, seconds = false) { 359 const date = value instanceof Date ? value : new Date(value); 360 if (Number.isNaN(date.getTime())) return ''; 361 if (unitSettings.timeMode !== 'utc') { 362 return timeFormatter({ minute: '2-digit', ...(seconds ? { second: '2-digit' } : {}) }).format(date); 363 } 364 const time = seconds ? `${String(date.getUTCHours()).padStart(2, '0')}
364:${String(date.getUTCMinutes()).padStart(2, '0')}:${String(date.getUTCSeconds()).padStart(2, '0')}z` : `${String(date.getUTCHours()).padStart(2, '0')}:${String(date.getUTCMinutes()).padStart(2, '0')}z`; 365 return `${time}, ${MONTHS[date.getUTCMonth()]} ${date.getUTCDate()}, ${date.getUTCFullYear()}`; 366 } 367 368 function basinName(basin) { 369 const code = String(basin || '').toUpperCase(); 370 return BASIN_NAMES[code] || basin || '\u2014'; 371 } 372 373 function formatCardinalDirection(degrees) { 374 const directions = ['N', 'NNE', 'NE', 'ENE', 'E', 'ESE', 'SE', 'SSE', 'S', 'SSW', 'SW', 'WSW', 'W', 'WNW', 'NW', 'NNW']; 375 const normalized = ((Number(degrees) % 360) + 360) % 360; 376 return directions[Math.round(normalized / 22.5) % directions.length]; 377 } 378 379 function windColor(wind) { 380 if (wind <= 30) return '#1e90ff'; 381 if (wind <= 60) return '#32cd32'; 382 if (wind <= 80) return '#ffd700'; 383 if (wind <= 95) return '#ff8c00'; 384 if (wind <= 110) return '#dc143c'; 385 if (wind <= 135) return '#bf00bf'; 386 return '#ff00ff'; 387 } 388 389 function numberOrNull(value) { 390 const numeric = Number(value); 391 return Number.isFinite(numeric) ? numeric : null; 392 } 393 394 function parseUtcDate(value) { 395 if (!value) return null; 396 if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : value; 397 if (typeof value === 'number') { 398 const parsedNumber = new Date(value); 399 return Number.isNaN(parsedNumber.getTime()) ? null : parsedNumber; 400 } 401 const text = String(value).trim().replace(' ', 'T'); 402 const parsed = new Date(text + (/(?:[zZ]|[+-]\d{2}:?\d{2})$/.test(text) ? '' : 'Z')); 403 return Number.isNaN(parsed.getTime()) ? null : parsed; 404 } 405 406 function formatNumber(value, suffix = '', digits = 0) { 407 return Number.isFinite(value) ? `${value.toFixed(digits)}${suffix}` : '-'; 408 } 409 410 function convertWindSpeed(valueKt) { 411 if (!Number.isFinite(valueKt)) return null; 412 if (unitSettings.wind === 'mph') return valueKt * 1.150779448; 413 if (unitSettings.wind === 'kmh') return valueKt * 1.852; 414 if (unitSettings.wind === 'ms') return valueKt * 0.514444444; 415 return valueKt; 416 } 417 418 function windUnitLabel() { 419 return ({ kt: 'kt', mph: 'mph', kmh: 'km/h', ms: 'm/s' })[unitSettings.wind] || 'kt'; 420 } 421 422 function formatWindSpeed(valueKt, digits = unitSettings.wind === 'ms' ? 1 : 0) { 423 const value = convertWindSpeed(valueKt); 424 return Number.isFinite(value) ? `${value.toFixed(digits)} ${windUnitLabel()}` : '-'; 425 } 426 427 function formatWindPhrase(direction, speedKt, gustKt = null) { 428 if (!Number.isFinite(speedKt)) return 'Wind unavailable'; 429 if (speedKt <= 0) return 'Calm'; 430 const gust = Number.isFinite(gustKt) && gustKt > speedKt ? ` gusting ${formatWindSpeed(gustKt)}` : ''; 431 if (!Number.isFinite(direction)) return `${formatWindSpeed(speedKt)}${gust}, variable direction`; 432 const degrees = Math.round(direction); 433 return `${formatCardinalDirection(degrees)} (${degrees}\u00b0) at ${formatWindSpeed(speedKt)}${gust}`; 434 } 435 436 function convertTemperature(valueC, kind = 'surface') { 437 if (!Number.isFinite(valueC)) return null; 438 const unit = kind === 'satellite' ? unitSettings.satelliteTemperature : unitSettings.temperature; 439 return unit === 'f' ? valueC * 9 / 5 + 32 : valueC; 440 } 441 442 function temperatureUnitLabel(kind = 'surface') { 443 return (kind === 'satellite' ? unitSettings.satelliteTemperature : unitSettings.temperature) === 'f' ? '\u00b0F' : '\u00b0C'; 444 } 445 446 function formatTemperature(valueC, digits = 1, kind = 'surface') { 447 const value = convertTemperature(valueC, kind); 448 return Number.isFinite(value) ? `${value.toFixed(digits)}${temperatureUnitLabel(kind)}` : '-'; 449 } 450 451 function convertPressure(valueMb) { 452 if (!Number.isFinite(valueMb)) return null; 453 return unitSettings.pressure === 'inhg' ? valueMb * 0.0295299830714 : valueMb; 454 } 455 456 function pressureUnitLabel() { 457 return unitSettings.pressure === 'inhg' ? 'inHg' : 'mb'; 458 } 459 460 function formatPressure(valueMb, digits = unitSettings.pressure === 'inhg' ? 2 : 1) { 461 const value = convertPressure(valueMb); 462 return Number.isFinite(value) ? `${value.toFixed(digits)} ${pressureUnitLabel()}` : '-'; 463 } 464 465 function convertPrecip(valueMm) { 466 if (!Number.isFinite(valueMm)) return null; 467 return unitSettings.precip === 'in' ? valueMm / 25.4 : valueMm; 468 } 469 470 function precipUnitLabel() { 471 return unitSettings.precip === 'in' ? 'in' : 'mm'; 472 } 473 474 function formatPrecip(valueMm, digits = unitSettings.precip === 'in' ? 2 : 1) { 475 const value = convertPrecip(valueMm); 476 return Number.isFinite(value) ? `${value.toFixed(digits)} ${precipUnitLabel()}` : '-'; 477 } 478 479 function formatDistance(valueKm, digits = 1) { 480 if (!Number.isFinite(valueKm)) return '-'; 481 if (unitSettings.distance === 'imperial') return `${(valueKm * 0.621371192).toFixed(digits)} mi`; 482 return `${valueKm.toFixed(digits)} km`; 483 } 484 485 function formatAltitude(valueM, digits = 0) { 486 if (!Number.isFinite(valueM)) return '-'; 487 if (unitSettings.distance === 'imperial') return `${(valueM * 3.280839895).toFixed(digits)} ft`; 488 return `${valueM.toFixed(digits)} m`; 489 } 490
491 function relativeHumidity(tempC, dewpointC) { 492 if (!Number.isFinite(tempC) || !Number.isFinite(dewpointC)) return null; 493 const saturation = Math.exp((17.625 * tempC) / (243.04 + tempC)); 494 const actual = Math.exp((17.625 * dewpointC) / (243.04 + dewpointC)); 495 return Math.max(0, Math.min(100, (actual / saturation) * 100)); 496 } 497 498 function heatIndexC(tempC, humidity) { 499 if (!Number.isFinite(tempC) || !Number.isFinite(humidity)) return null; 500 const tempF = tempC * 9 / 5 + 32; 501 if (tempF < 80 || humidity < 40) return null; 502 const hiF = -42.379 + 2.04901523 * tempF + 10.14333127 * humidity 503 - 0.22475541 * tempF * humidity - 0.00683783 * tempF * tempF 504 - 0.05481717 * humidity * humidity + 0.00122874 * tempF * tempF * humidity 505 + 0.00085282 * tempF * humidity * humidity - 0.00000199 * tempF * tempF * humidity * humidity; 506 return (hiF - 32) * 5 / 9; 507 } 508 509 function windChillC(tempC, windKt) { 510 if (!Number.isFinite(tempC) || !Number.isFinite(windKt)) return null; 511 const windKph = windKt * 1.852; 512 if (tempC > 10 || windKph <= 4.8) return null; 513 return 13.12 + 0.6215 * tempC - 11.37 * Math.pow(windKph, 0.16) 514 + 0.3965 * tempC * Math.pow(windKph, 0.16); 515 } 516 517 function escapeHtml(value) { 518 return String(value ?? '').replace(/[&<>"']/g, char => ({ 519 '&': '&', 520 '<': '<', 521 '>': '>', 522 '"': '"', 523 "'": ''', 524 }[char])); 525 } 526 527 function stormDisplayName(storm) { 528 const id = String(storm?.storm_id || '').toLowerCase(); 529 return config.curInfo?.[id]?.fullname || storm?.name || storm?.storm_id || 'Storm'; 530 } 531 532 function clamp(value, min, max) { 533 return Math.min(max, Math.max(min, value)); 534 } 535 536 function cleanGridValue(value) { 537 const rounded = Math.round(Number(value) * 100) / 100; 538 return Math.abs(rounded) < 1e-9 ? 0 : rounded; 539 } 540 541 function formatGridNumber(value) { 542 return Number.isInteger(value) ? String(value) : String(value).replace(/\.?0+$/, ''); 543 } 544 545 function normalizeGridLongitude(lng) { 546 const normalized = ((Number(lng) + 180) % 360 + 360) % 360 - 180; 547 return cleanGridValue(normalized); 548 } 549 550 function formatLatitudeLabel(lat) { 551 const value = cleanGridValue(lat); 552 if (Math.abs(value) < 1e-9) return '0\u00b0'; 553 return `${formatGridNumber(Math.abs(value))}\u00b0${value > 0 ? 'N' : 'S'}`; 554 } 555 556 function formatLongitudeLabel(lng) { 557 const normalized = normalizeGridLongitude(lng); 558 if (Math.abs(normalized) < 1e-9) return '0\u00b0'; 559 if (Math.abs(Math.abs(normalized) - 180) < 1e-9) return '180\u00b0'; 560 return `${formatGridNumber(Math.abs(normalized))}\u00b0${normalized > 0 ? 'E' : 'W'}`; 561 } 562 563 function graticuleTargetSpacing(width, height) { 564 const shortSide = Math.min(width, height); 565 return clamp( 566 shortSide * graticuleOptions.targetSpacingRatio, 567 graticuleOptions.minSpacingPx, 568 graticuleOptions.maxSpacingPx 569 ); 570 } 571 572 function graticuleStep() { 573 const bounds = map.getBounds(); 574 const canvas = ensureGraticuleCanvas(); 575 const width = canvas?.clientWidth || 1; 576 const height = canvas?.clientHeight || 1; 577 const spacingPx = graticuleTargetSpacing(width, height); 578 const lngSpan = Math.max(1, bounds.getEast() - bounds.getWest()); 579 const latSpan = Math.max(1, bounds.getNorth() - bounds.getSouth()); 580 const targetDegrees = Math.max( 581 lngSpan / Math.max(1, width / spacingPx), 582 latSpan / Math.max(1, height / spacingPx) 583 ); 584 const steps = [0.1, 0.25, 0.5, 1, 2, 5, 10, 15, 20, 30, 45, 90]; 585 return steps.find(step => step >= targetDegrees) || 90; 586 } 587 588 function resizeGraticuleCanvas(canvas) { 589 const dpr = window.devicePixelRatio || 1; 590 const width = Math.max(1, canvas.clientWidth); 591 const height = Math.max(1, canvas.clientHeight); 592 const pixelWidth = Math.round(width * dpr); 593 const pixelHeight = Math.round(height * dpr); 594 if (canvas.width !== pixelWidth || canvas.height !== pixelHeight) { 595 canvas.width = pixelWidth; 596 canvas.height = pixelHeight; 597 } 598 return { width, height, dpr }; 599 } 600 601 function ensureGraticuleCanvas() {
602 let canvas = document.getElementById('graticule-canvas'); 603 const container = map.getContainer(); 604 if (!canvas) { 605 canvas = document.createElement('canvas'); 606 canvas.id = 'graticule-canvas'; 607 canvas.className = 'graticule-canvas'; 608 canvas.hidden = true; 609 } 610 if (canvas.parentElement !== container) container.appendChild(canvas); 611 return canvas; 612 } 613 614 function drawGraticuleLabel(ctx, label, x, y, align) { 615 ctx.textAlign = align; 616 ctx.lineWidth = 3; 617 ctx.strokeStyle = graticuleOptions.labelHalo; 618 ctx.strokeText(label, x, y); 619 ctx.fillStyle = graticuleOptions.labelColor; 620 ctx.fillText(label, x, y); 621 } 622 623 function drawProjectedGraticuleLine(ctx, points) { 624 let started = false; 625 ctx.beginPath(); 626 points.forEach(point => { 627 if (!Number.isFinite(point.x) || !Number.isFinite(point.y)) { 628 started = false; 629 return; 630 } 631 if (!started) { 632 ctx.moveTo(point.x, point.y); 633 started = true; 634 } else { 635 ctx.lineTo(point.x, point.y); 636 } 637 }); 638 ctx.stroke(); 639 } 640 641 function projectedParallel(lat, west, east) { 642 const span = Math.max(1, east - west); 643 const segments = Math.max(24, Math.min(96, Math.ceil(span / 4))); 644 const points = []; 645 for (let i = 0; i <= segments; i += 1) { 646 const lng = west + (span * i) / segments; 647 points.push(map.project({ lng, lat })); 648 } 649 return points; 650 } 651 652 function projectedMeridian(lng, south, north) { 653 const span = Math.max(1, north - south); 654 const segments = Math.max(24, Math.min(96, Math.ceil(span / 3))); 655 const points = []; 656 for (let i = 0; i <= segments; i += 1) { 657 const lat = south + (span * i) / segments; 658 points.push(map.project({ lng, lat })); 659 } 660 return points; 661 } 662 663 function segmentEdgeIntersection(a, b, edge, width, height) { 664 if (![a.x, a.y, b.x, b.y].every(Number.isFinite)) return null; 665 let ratio = null; 666 let x = null; 667 let y = null; 668 669 if (edge === 'left' || edge === 'right') { 670 x = edge === 'left' ? 8 : width - 8; 671 if (Math.abs(a.x - b.x) < 1e-6) return null; 672 ratio = (x - a.x) / (b.x - a.x); 673 y = a.y + (b.y - a.y) * ratio; 674 if (ratio < 0 || ratio > 1 || y < 14 || y > height - 10) return null; 675 } else { 676 y = edge === 'top' ? 15 : height - 8; 677 if (Math.abs(a.y - b.y) < 1e-6) return null; 678 ratio = (y - a.y) / (b.y - a.y); 679 x = a.x + (b.x - a.x) * ratio; 680 if (ratio < 0 || ratio > 1 || x < 24 || x > width - 24) return null; 681 } 682 683 return { edge, x, y }; 684 } 685 686 function lineEdgeIntersections(points, width, height) { 687 const intersections = []; 688 const seen = new Set(); 689 for (let i = 1; i < points.length; i += 1) { 690 const a = points[i - 1]; 691 const b = points[i]; 692 ['left', 'right', 'top', 'bottom'].forEach(edge => { 693 const hit = segmentEdgeIntersection(a, b, edge, width, height); 694 if (!hit) return; 695 const key = `${edge}:${Math.round(hit.x)}:${Math.round(hit.y)}`; 696 if (seen.has(key)) return; 697 seen.add(key); 698 intersections.push(hit); 699 }); 700 } 701 return intersections; 702 } 703 704 function drawEdgeLabel(ctx, label, hit) { 705 if (hit.edge === 'left' || hit.edge === 'right') { 706 ctx.textBaseline = 'middle'; 707 drawGraticuleLabel(ctx, label, hit.x, hit.y, hit.edge === 'left' ? 'left' : 'right'); 708 return; 709 } 710 ctx.textBaseline = hit.edge === 'top' ? 'top' : 'alphabetic'; 711 drawGraticuleLabel(ctx, label, hit.x, hit.y, 'center'); 712 } 713 714 function drawGraticule() { 715 if (!graticuleVisible) return; 716 const canvas = ensureGraticuleCanvas(); 717 if (!canvas) return; 718 const center = map.getCenter(); 719 const renderKey = [ 720 center.lng.toFixed(6), 721 center.lat.toFixed(6), 722 map.getZoom().toFixed(5), 723 map.getBearing().toFixed(3), 724 map.getPitch().toFixed(3), 725 canvas.clientWidth, 726 canvas.clientHeight, 727 window.devicePixelRatio || 1, 728 ].join('|'); 729 if (renderKey === graticuleRenderKey) return; 730 const ctx = canvas.getContext('2d'); 731 const { width, height, dpr } = resizeGraticuleCanvas(canvas); 732 ctx.setTransform(1, 0, 0, 1, 0, 0); 733 ctx.clearRect(0, 0, canvas.width, canvas.height);
734 ctx.setTransform(dpr, 0, 0, dpr, 0, 0); 735 const bounds = map.getBounds(); 736 const step = graticuleStep(); 737 const south = Math.max(graticuleOptions.minLat, bounds.getSouth()); 738 const north = Math.min(graticuleOptions.maxLat, bounds.getNorth()); 739 const west = bounds.getWest(); 740 const east = bounds.getEast(); 741 const parallelLines = []; 742 const meridianLines = []; 743 744 ctx.save(); 745 ctx.lineWidth = graticuleOptions.lineWidth; 746 ctx.strokeStyle = graticuleOptions.color; 747 ctx.setLineDash(graticuleOptions.dash); 748 ctx.lineCap = 'round'; 749 750 const latStart = Math.ceil(south / step) * step; 751 for (let lat = latStart; lat <= north + 1e-9; lat += step) { 752 const points = projectedParallel(lat, west, east); 753 parallelLines.push({ lat, points }); 754 drawProjectedGraticuleLine(ctx, points); 755 } 756 757 const lngStart = Math.ceil(west / step) * step; 758 for (let lng = lngStart; lng <= east + 1e-9; lng += step) { 759 const points = projectedMeridian(lng, south, north); 760 meridianLines.push({ lng, points }); 761 drawProjectedGraticuleLine(ctx, points); 762 } 763 764 ctx.setLineDash([]); 765 ctx.font = graticuleOptions.font; 766 ctx.textBaseline = 'middle'; 767 768 parallelLines.forEach(({ lat, points }) => { 769 const label = formatLatitudeLabel(lat); 770 lineEdgeIntersections(points, width, height) 771 .forEach(hit => drawEdgeLabel(ctx, label, hit)); 772 }); 773 774 meridianLines.forEach(({ lng, points }) => { 775 const label = formatLongitudeLabel(lng); 776 lineEdgeIntersections(points, width, height) 777 .forEach(hit => drawEdgeLabel(ctx, label, hit)); 778 }); 779 780 ctx.restore(); 781 graticuleRenderKey = renderKey; 782 } 783 784 function setGraticuleVisible(visible) { 785 const canvas = ensureGraticuleCanvas(); 786 graticuleVisible = visible; 787 graticuleRenderKey = ''; 788 if (canvas) canvas.hidden = !visible; 789 updateGraticuleControl(visible); 790 drawGraticule(); 791 } 792 793 function formatRefreshTime(date = new Date()) { 794 return formatDateTime(date); 795 } 796 797 function registerRefreshSource(id, sourceConfig) { 798 if (!id || !sourceConfig) return; 799 refreshSources.set(id, sourceConfig); 800 } 801 802 async function refreshVisibleDisplays() { 803 if (window.interstormsMapExportRunning) return []; 804 const activeSources = [...refreshSources.values()].filter(source => source.isVisible?.()); 805 if (!activeSources.length) { 806 if (typeof showMapRefreshToast === 'function') showMapRefreshToast(`Updated ${formatRefreshTime()}`); 807 return []; 808 } 809 810 const preservedTime = typeof binary !== 'undefined' 811 ? binary.temporal?.currentTime?.() 812 : null; 813 const results = await Promise.allSettled(activeSources.map(source => Promise.resolve(source.refresh?.()))); 814 if (window.interstormsMapExportRunning) return results; 815 if (typeof binary !== 'undefined') { 816 if (Number.isFinite(preservedTime)) { 817 await Promise.resolve(binary.temporal?.showTime?.(preservedTime, { waitForUpdates: false })); 818 } 819 binary.temporal?.refreshPlayback?.(); 820 } 821 if (typeof renderMapColorbars === 'function') renderMapColorbars(); 822 if (typeof showMapRefreshToast === 'function') showMapRefreshToast(`Updated ${formatRefreshTime()}`); 823 return results; 824 } 825 826 function addReferenceLayers() { 827 const labelAnchor = basemapLabelAnchor(); 828 map.addSource('reference', { 829 type: 'vector', 830 url: config.referenceTileUrl, 831 }); 832 833 map.addLayer({ 834 id: 'world-low-coast', 835 type: 'line', 836 source: 'reference', 837 'source-layer': 'coast_low', 838 paint: { 839 'line-color': 'rgba(255,255,255,1)', 840 'line-width': 1.5, 841 'line-opacity': ['interpolate', ['linear'], ['zoom'], 3.9, 0.72, 5.1, 0], 842 }, 843 }, labelAnchor); 844 845 map.addLayer({ 846 id: 'world-low-borders', 847 type: 'line', 848 source: 'reference', 849 'source-layer': 'borders_low', 850 paint: { 851 'line-color': 'rgba(255,255,255,1)', 852 'line-width': 0.9, 853 'line-opacity': ['interpolate', ['linear'], ['zoom'], 3.9, 0.62, 5.1, 0], 854 }, 855 }, labelAnchor); 856 857 map.addLayer({ 858 id: 'world-high-coast', 859 type: 'line', 860 source: 'reference', 861 'source-layer': 'coast_high', 862 minzoom: referenceConfig.highCoastZoom, 863 paint: { 864 'line-color': 'rgba(255,255,255,1)', 865 'line-width': 1.5, 866 'line-opacity': ['interpolate', ['linear'], ['zoom'], 3.95, 0, 4.75, 0.72], 867 }, 868 }, labelAnchor); 869 870 map.addLayer({ 871 id: 'world-high-borders', 872 type: 'line', 873 source: 'reference', 874 'source-layer': 'borders_high', 875 minzoom: referenceConfig.highCoastZoom, 876 paint: { 877 'line-color': 'rgba(255,255,255,1)', 878 'line-width': 0.75, 879 'line-opacity': ['interpolate', ['linear'], ['zoom'], 3.95, 0, 4.75, 0.62], 880 }, 881 }, labelAnchor); 882 883 map.addLayer({ 884 id: 'us-counties', 885 type: 'line', 886 source: 'reference', 887 'source-layer': 'counties', 888 minzoom: referenceConfig.countyZoom, 889 maxzoom: 9, 890 paint: { 891 'line-color': 'rgba(255,255,255,1)',
892 'line-width': ['interpolate', ['linear'], ['zoom'], 7, 0.3, 11, 0.45], 893 'line-opacity': 0.5, 894 }, 895 }, labelAnchor); 896 897 map.addLayer({ 898 id: 'lakes', 899 type: 'line', 900 source: 'reference', 901 'source-layer': 'lakes', 902 minzoom: referenceConfig.lakeZoom, 903 paint: { 904 'line-color': 'rgba(255,255,255,1)', 905 'line-width': 0.9, 906 'line-opacity': ['interpolate', ['linear'], ['zoom'], 3.25, 0, 4.05, 0.56], 907 }, 908 }, labelAnchor); 909 910 map.on('move', drawGraticule); 911 map.on('zoom', drawGraticule); 912 map.on('rotate', drawGraticule); 913 map.on('pitch', drawGraticule); 914 map.on('resize', drawGraticule); 915 map.on('moveend', drawGraticule); 916 map.on('zoomend', drawGraticule); 917 map.on('rotateend', drawGraticule); 918 map.on('pitchend', drawGraticule); 919 syncBasemapLayerGroups(); 920 enforceLayerOrder(); 921 } 922 923 function drawWindBarbGlyph(ctx, x, y, lat, speedMs, direction, style = {}) { 924 const speedKt = Math.round(Math.max(0, speedMs * 1.943844) / 5) * 5; 925 const pennants = Math.floor(speedKt / 50); 926 let remainder = speedKt - pennants * 50; 927 const fullBarbs = Math.floor(remainder / 10); 928 remainder -= fullBarbs * 10; 929 const halfBarb = remainder >= 5; 930 const size = style.sizeScale || 1; 931 const side = lat < 0 ? -1 : 1; 932 const shaft = 20 * size; 933 const barbLength = 8 * size; 934 const spacing = 2.85 * size; 935 ctx.save(); 936 ctx.translate(x, y); 937 ctx.rotate(((direction + 180) % 360) * Math.PI / 180); 938 ctx.lineCap = 'round'; 939 ctx.lineJoin = 'round'; 940 [[style.shadowColor || 'rgba(5,8,12,0.78)', 3.35], [style.color || 'rgba(235,235,220,0.9)', 1.25]].forEach(([color, width]) => { 941 let barbY = -shaft; 942 ctx.strokeStyle = color; 943 ctx.fillStyle = color; 944 ctx.lineWidth = width * (style.lineScale || 1); 945 ctx.beginPath(); ctx.moveTo(0, 0); ctx.lineTo(0, -shaft); ctx.stroke(); 946 for (let index = 0; index < pennants; index += 1) { 947 ctx.beginPath(); ctx.moveTo(0, barbY); ctx.lineTo(side * barbLength, barbY - 2.5 * size); ctx.lineTo(0, barbY + 4.9 * size); ctx.closePath(); ctx.fill(); ctx.stroke(); 948 barbY += spacing * 1.45; 949 } 950 for (let index = 0; index < fullBarbs; index += 1) { 951 ctx.beginPath(); ctx.moveTo(0, barbY); ctx.lineTo(side * barbLength, barbY - 2.5 * size); ctx.stroke(); 952 barbY += spacing; 953 } 954 if (halfBarb) { 955 if (!pennants && !fullBarbs) barbY += spacing; 956 ctx.beginPath(); ctx.moveTo(0, barbY); ctx.lineTo(side * barbLength * 0.58, barbY - 1.45 * size); ctx.stroke(); 957 } 958 }); 959 ctx.restore(); 960 } 961 962 function buildWindStreamlinePaths(options = {}) { 963 const { 964 nx, 965 ny, 966 lonAt, 967 latAt, 968 vectorAt, 969 seedStride = Math.max(2, Math.round(Math.min(nx || 20, ny || 20) / 24)), 970 stepScale = 0.62, 971 maxSteps = 26, 972 minSpeed = 0.5, 973 spacingCells = Math.max(2, seedStride * 1.15), 974 maxSeedCandidates = 1600, 975 maxStreamlines = 420, 976 seedOffsetRow = 0, 977 seedOffsetCol = 0, 978 seedWindows = null, 979 occupancyStride = 4, 980 maxPathOverlap = 0.28, 981 cache = null, 982 cacheKey = '', 983 } = options; 984 if (!nx || !ny || typeof lonAt !== 'function' || typeof latAt !== 'function' || typeof vectorAt !== 'function') return []; 985 const candidateStride = Math.max(seedStride, Math.ceil(Math.sqrt((nx * ny) / Math.max(1, maxSeedCandidates)))); 986 const effectiveCacheKey = cacheKey || `${nx}:${ny}:${candidateStride}:${spacingCells}:${maxStreamlines}:${maxSteps}:${stepScale}`; 987 if (cache?.key === effectiveCacheKey && Array.isArray(cache.paths)) return cache.paths; 988 const occupied = new Set(); 989 const key = sample => `${Math.floor(sample.row / spacingCells)},${Math.floor(sample.col / spacingCells)}`; 990 const hashSeed = (row, col) => { 991 let value = ((row + 1) * 73856093) ^ ((col + 1) * 19349663); 992 value ^= value >>> 13; 993 return value >>> 0; 994 }; 995 const samplePoint = (row, col) => { 996 if (row < 0 || row > ny - 1 || col < 0 || col > nx - 1) return null; 997 const vector = vectorAt(row, col); 998 if (!vector) return null; 999 const speed = Math.hypot(vector.u, vector.v); 1000 if (!Number.isFinite(speed) || speed < minSpeed) return null; 1001 const lat = latAt(row); 1002 const lon = lonAt(col); 1003 if (!Number.isFinite(lat) || !Number.isFinite(lon) || Math.abs(lat) > 86) return null;
1004 return { row, col, lon, lat, vector, speed }; 1005 }; 1006 const trace = (startRow, startCol, sign) => { 1007 const path = []; 1008 let row = startRow; 1009 let col = startCol; 1010 for (let step = 0; step < maxSteps; step += 1) { 1011 const sample = samplePoint(row, col); 1012 if (!sample) break; 1013 path.push(sample); 1014 const cosLat = Math.max(0.18, Math.cos(sample.lat * Math.PI / 180)); 1015 col += sign * (sample.vector.u / sample.speed) * stepScale / cosLat; 1016 row -= sign * (sample.vector.v / sample.speed) * stepScale; 1017 } 1018 return path; 1019 }; 1020 const rowStart = ((Math.round(seedOffsetRow) % candidateStride) + candidateStride) % candidateStride; 1021 const colStart = ((Math.round(seedOffsetCol) % candidateStride) + candidateStride) % candidateStride; 1022 const windows = Array.isArray(seedWindows) && seedWindows.length 1023 ? seedWindows 1024 : [{ rowMin: 0, rowMax: ny - 1, colMin: 0, colMax: nx - 1 }]; 1025 const candidates = []; 1026 const seenCandidates = new Set(); 1027 windows.forEach(window => { 1028 const rowMin = Math.max(0, Math.floor(Number(window.rowMin) || 0)); 1029 const rowMax = Math.min(ny - 1, Math.ceil(Number(window.rowMax) || 0)); 1030 const colMin = Math.max(0, Math.floor(Number(window.colMin) || 0)); 1031 const colMax = Math.min(nx - 1, Math.ceil(Number(window.colMax) || 0)); 1032 const firstRow = rowMin + ((rowStart - rowMin) % candidateStride + candidateStride) % candidateStride; 1033 const firstCol = colMin + ((colStart - colMin) % candidateStride + candidateStride) % candidateStride; 1034 for (let row = firstRow; row <= rowMax; row += candidateStride) { 1035 for (let col = firstCol; col <= colMax; col += candidateStride) { 1036 const candidateKey = `${row}:${col}`; 1037 if (seenCandidates.has(candidateKey)) continue; 1038 seenCandidates.add(candidateKey);
1039 candidates.push([row, col, hashSeed(row, col)]); 1040 } 1041 } 1042 }); 1043 candidates.sort((left, right) => left[2] - right[2]); 1044 const paths = []; 1045 for (let index = 0; index < candidates.length && paths.length < maxStreamlines; index += 1) { 1046 const [row, col] = candidates[index]; 1047 const seed = samplePoint(row, col); 1048 if (!seed || occupied.has(key(seed))) continue; 1049 const backward = trace(row, col, -1).reverse(); 1050 const forward = trace(row, col, 1); 1051 const samples = backward.concat(forward.slice(1)); 1052 if (samples.length < 10) continue; 1053 let overlap = 0; 1054 let checked = 0; 1055 const occupancyStep = Math.max(1, Math.round(occupancyStride)); 1056 for (let sampleIndex = 0; sampleIndex < samples.length; sampleIndex += occupancyStep) { 1057 checked += 1; 1058 if (occupied.has(key(samples[sampleIndex]))) overlap += 1; 1059 } 1060 if (checked && overlap / checked > maxPathOverlap) continue; 1061 for (let sampleIndex = 0; sampleIndex < samples.length; sampleIndex += occupancyStep) { 1062 occupied.add(key(samples[sampleIndex])); 1063 } 1064 paths.push(samples.map(sample => ({ 1065 row: sample.row, 1066 col: sample.col, 1067 lon: sample.lon, 1068 lat: sample.lat, 1069 }))); 1070 } 1071 if (cache) { 1072 cache.key = effectiveCacheKey; 1073 cache.paths = paths; 1074 } 1075 return paths; 1076 } 1077 1078 function drawWindStreamlines(ctx, options = {}) { 1079 const { 1080 project = lngLat => map.project(lngLat), 1081 centerLng = map.getCenter().lng, 1082 viewportWidth = map.getContainer().clientWidth, 1083 viewportHeight = map.getContainer().clientHeight, 1084 spacingPx = 44, 1085 stroke = 'rgba(236,238,230,0.72)', 1086 shadow = 'rgba(5,8,12,0.58)', 1087 lineWidth = 1.05, 1088 shadowWidth = 0, 1089 tickEvery = 9, 1090 } = options; 1091 const paths = buildWindStreamlinePaths(options); 1092 if (!paths.length) return; 1093 const visible = point => point.x > -70 && point.x < viewportWidth + 70 && point.y > -70 && point.y < viewportHeight + 70; 1094 const projectSamples = samples => samples.map(sample => { 1095 const drawLon = sample.lon + Math.round((centerLng - sample.lon) / 360) * 360; 1096 return { ...sample, drawLon, point: project([drawLon, sample.lat]) }; 1097 }); 1098 const drawPath = (samples, color, width) => { 1099 if (samples.length < 7 || width <= 0) return; 1100 ctx.strokeStyle = color; 1101 ctx.lineWidth = width; 1102 ctx.beginPath(); 1103 ctx.moveTo(samples[0].point.x, samples[0].point.y); 1104 for (let index = 1; index < samples.length; index += 1) { 1105 const previous = samples[index - 1].point; 1106 const point = samples[index].point; 1107 if (Math.hypot(point.x - previous.x, point.y - previous.y) > spacingPx * 2.2) { 1108 ctx.moveTo(point.x, point.y); 1109 } else { 1110 ctx.lineTo(point.x, point.y); 1111 } 1112 } 1113 ctx.stroke(); 1114 }; 1115 const drawTicks = samples => { 1116 ctx.strokeStyle = stroke; 1117 ctx.lineWidth = Math.max(1, lineWidth); 1118 for (let index = tickEvery; index < samples.length - 2; index += tickEvery) { 1119 const from = samples[index - 1].point; 1120 const to = samples[index + 1].point; 1121 const angle = Math.atan2(to.y - from.y, to.x - from.x); 1122 if (!Number.isFinite(angle)) continue; 1123 const point = samples[index].point; 1124 const size = 4.8; 1125 ctx.beginPath(); 1126 ctx.moveTo(point.x, point.y); 1127 ctx.lineTo(point.x - Math.cos(angle - 0.55) * size, point.y - Math.sin(angle - 0.55) * size); 1128 ctx.moveTo(point.x, point.y); 1129 ctx.lineTo(point.x - Math.cos(angle + 0.55) * size, point.y - Math.sin(angle + 0.55) * size); 1130 ctx.stroke(); 1131 } 1132 }; 1133 1134 ctx.save(); 1135 ctx.lineCap = 'round'; 1136 ctx.lineJoin = 'round'; 1137 paths.forEach(path => { 1138 const samples = projectSamples(path); 1139 if (!samples.some(sample => visible(sample.point))) return; 1140 drawPath(samples, shadow, shadowWidth); 1141 drawPath(samples, stroke, lineWidth); 1142 drawTicks(samples); 1143 }); 1144 ctx.restore(); 1145 } 1146 1147 function createWindStreamlineGlLayer(id, options = {}) { 1148 const style = { 1149 color: options.color || 'rgba(236,238,230,0.72)', 1150 tickColor: options.tickColor || options.color || 'rgba(236,238,230,0.78)', 1151 lineWidth: options.lineWidth || 1, 1152 tickEvery: options.tickEvery || 26, 1153 tickScale: options.tickScale || 0.34, 1154 tickMaxSize: options.tickMaxSize || 0.004, 1155 thicknessPasses: options.thicknessPasses || 1, 1156 tickThicknessPasses: options.tickThicknessPasses || 3, 1157 retainPaths: options.retainPaths !== false, 1158 }; 1159 const state = { 1160 gl: null, 1161 program: null, 1162 position: -1, 1163 matrix: null, 1164 world: null, 1165 color: null, 1166 offset: null, 1167 lineBuffer: null, 1168 tickBuffer: null, 1169 lineCount: 0, 1170 tickCount: 0, 1171 visible: false, 1172 pendingPaths: [], 1173 currentPaths: null, 1174 }; 1175 const vertexSource = 'attribute vec2 a_pos; uniform mat4 u_matrix; uniform float u_world; uniform vec2 u_offset; void main(){gl_Position=u_matrix*vec4(a_pos.x+u_world,a_pos.y,0.,1.);gl_Position.xy+=u_offset*gl_Position.w;}'; 1176 const fragmentSource = 'precision mediump float; uniform vec4 u_color; void main(){gl_FragColor=u_color;}'; 1177 const compile = (gl, type, source) => { 1178 const shader = gl.createShader(type); 1179 gl.shaderSource(shader, source); 1180 gl.compileShader(shader); 1181 if (gl.getShaderParameter(shader, gl.COMPILE_STATUS)) return shader; 1182 console.warn(`${id} shader compile failed:`, gl.getShaderInfoLog(shader)); 1183 gl.deleteShader(shader); 1184 return null; 1185 }; 1186 const colorArray = value => { 1187 const text = String(value || '').trim(); 1188 const rgba = text.match(/^rgba?\(([^)]+)\)$/i); 1189 if (rgba) { 1190 const parts = rgba[1].split(',').map(part => Number(part.trim())); 1191 return [ 1192 Math.max(0, Math.min(1, (parts[0] || 0) / 255)), 1193 Math.max(0, Math.min(1, (parts[1] || 0) / 255)), 1194 Math.max(0, Math.min(1, (parts[2] || 0) / 255)), 1195 Math.max(0, Math.min(1, parts.length > 3 ? parts[3] : 1)), 1196 ]; 1197 } 1198 if (/^#[0-9a-f]{6}$/i.test(text)) { 1199 const int = parseInt(text.slice(1), 16); 1200 return [((int >>
1200 16) & 255) / 255, ((int >> 8) & 255) / 255, (int & 255) / 255, 1]; 1201 } 1202 return [0.925, 0.933, 0.902, 0.72]; 1203 }; 1204 const visibleWorldOffsets = () => { 1205 const base = Math.round(map.getCenter().lng / 360); 1206 return [base - 1, base, base + 1]; 1207 }; 1208 const mercatorPoint = sample => { 1209 const coordinate = maplibregl.MercatorCoordinate.fromLngLat([sample.lon, sample.lat]); 1210 return [coordinate.x, coordinate.y]; 1211 }; 1212 const buildBuffers = paths => { 1213 const line = []; 1214 const ticks = []; 1215 paths.forEach(path => { 1216 if (!Array.isArray(path) || path.length < 2) return; 1217 const points = path.map(mercatorPoint); 1218 for (let index = 1; index < points.length; index += 1) { 1219 const a = points[index - 1]; 1220 const b = points[index]; 1221 if (Math.abs(a[0] - b[0]) > 0.5) continue; 1222 line.push(a[0], a[1], b[0], b[1]); 1223 } 1224 for (let index = style.tickEvery; index < points.length - 2; index += style.tickEvery) { 1225 const previous = points[index - 1]; 1226 const point = points[index]; 1227 const next = points[index + 1]; 1228 const dx = next[0] - previous[0]; 1229 const dy = next[1] - previous[1]; 1230 const length = Math.hypot(dx, dy); 1231 if (!Number.isFinite(length) || length <= 0 || Math.abs(dx) > 0.5) continue; 1232 const ux = dx / length; 1233 const uy = dy / length; 1234 const size = Math.min(length * style.tickScale, style.tickMaxSize); 1235 const leftX = point[0] - (ux * Math.cos(0.55) - uy * Math.sin(0.55)) * size; 1236 const leftY = point[1] - (uy * Math.cos(0.55) + ux * Math.sin(0.55)) * size; 1237 const rightX = point[0] - (ux * Math.cos(-0.55) - uy * Math.sin(-0.55)) * size; 1238 const rightY = point[1] - (uy * Math.cos(-0.55) + ux * Math.sin(-0.55)) * size; 1239 ticks.push(point[0], point[1], leftX, leftY, point[0], point[1], rightX, rightY); 1240 } 1241 }); 1242 return { 1243 lines: new Float32Array(line), 1244 ticks: new Float32Array(ticks), 1245 }; 1246 }; 1247 const upload = paths => { 1248 state.pendingPaths = Array.isArray(paths) ? paths : []; 1249 const gl = state.gl; 1250 if (!gl) return; 1251 if (state.currentPaths === state.pendingPaths) return; 1252 state.currentPaths = state.pendingPaths; 1253 const buffers = buildBuffers(state.pendingPaths); 1254 if (!state.lineBuffer) state.lineBuffer = gl.createBuffer(); 1255 if (!state.tickBuffer) state.tickBuffer = gl.createBuffer(); 1256 gl.bindBuffer(gl.ARRAY_BUFFER, state.lineBuffer); 1257 gl.bufferData(gl.ARRAY_BUFFER, buffers.lines, gl.STATIC_DRAW); 1258 gl.bindBuffer(gl.ARRAY_BUFFER, state.tickBuffer); 1259 gl.bufferData(gl.ARRAY_BUFFER, buffers.ticks, gl.STATIC_DRAW); 1260 gl.bindBuffer(gl.ARRAY_BUFFER, null); 1261 state.lineCount = buffers.lines.length / 2; 1262 state.tickCount = buffers.ticks.length / 2; 1263 if (!style.retainPaths) { 1264 state.pendingPaths = []; 1265 state.currentPaths = null; 1266 } 1267 map.triggerRepaint(); 1268 }; 1269 const drawBuffer = (gl, buffer, count, color, offsets) => { 1270 if (!buffer || !count) return; 1271 gl.bindBuffer(gl.ARRAY_BUFFER, buffer); 1272 gl.vertexAttribPointer(state.position, 2, gl.FLOAT, false, 0, 0); 1273 gl.enableVertexAttribArray(state.position); 1274 gl.uniform4fv(state.color, color); 1275 offsets.forEach(([offsetX, offsetY]) => { 1276 gl.uniform2f(state.offset, offsetX, offsetY); 1277 visibleWorldOffsets().forEach(offset => { 1278 gl.uniform1f(state.world, offset); 1279 gl.drawArrays(gl.LINES, 0, count); 1280 }); 1281 }); 1282 }; 1283 return { 1284 add(beforeId) { 1285 if (map.getLayer(id)) return; 1286 map.addLayer({ 1287 id, 1288 type: 'custom', 1289 renderingMode: '2d', 1290 onAdd(_map, gl) { 1291 const vertex = compile(gl, gl.VERTEX_SHADER, vertexSource); 1292 const fragment = compile(gl, gl.FRAGMENT_SHADER, fragmentSource); 1293 if (!vertex || !fragment) return; 1294 const program = gl.createProgram(); 1295 gl.attachShader(program, vertex); 1296 gl.attachShader(program, fragment); 1297 gl.linkProgram(program); 1298 if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { 1299 console.warn(`${id} program link failed:`, gl.getProgramInfoLog(program)); 1300 return; 1301 } 1302 state.gl = gl; 1303 state.program = program; 1304 state.position = gl.getAttribLocation(program, 'a_pos'); 1305 state.matrix = gl.getUniformLocation(program, 'u_matrix'); 1306 state.world = gl.getUniformLocation(program, 'u_world'); 1307 state.color = gl.getUniformLocation(program, 'u_color'); 1308 state.offset = gl.getUniformLocation(program, 'u_offset'); 1309 upload(state.pendingPaths); 1310 },
1311 render(gl, matrix) { 1312 if (!state.visible || !state.program || state.position < 0) return; 1313 if (gl.bindVertexArray) gl.bindVertexArray(null); 1314 gl.useProgram(state.program); 1315 gl.uniformMatrix4fv(state.matrix, false, matrix); 1316 gl.enable(gl.BLEND); 1317 gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA); 1318 try { gl.lineWidth(style.lineWidth); } catch (err) {} 1319 const width = Math.max(1, map.getContainer().clientWidth); 1320 const height = Math.max(1, map.getContainer().clientHeight); 1321 const px = Number(options.thicknessPx ?? 0.42); 1322 const offsets = style.thicknessPasses >= 5 ? [ 1323 [0, 0], 1324 [px * 2 / width, 0], 1325 [-px * 2 / width, 0], 1326 [0, px * 2 / height], 1327 [0, -px * 2 / height], 1328 ] : style.thicknessPasses >= 3 ? [ 1329 [0, 0], 1330 [px * 2 / width, 0], 1331 [-px * 2 / width, 0], 1332 ] : [ 1333 [0, 0], 1334 ]; 1335 const tickOffsets = style.tickThicknessPasses >= 3 ? [ 1336 [0, 0], 1337 [px * 2 / width, 0], 1338 [-px * 2 / width, 0], 1339 ] : [ 1340 [0, 0], 1341 ]; 1342 drawBuffer(gl, state.lineBuffer, state.lineCount, colorArray(style.color), offsets); 1343 drawBuffer(gl, state.tickBuffer, state.tickCount, colorArray(style.tickColor), tickOffsets); 1344 gl.disableVertexAttribArray(state.position); 1345 gl.bindBuffer(gl.ARRAY_BUFFER, null); 1346 if (gl.bindVertexArray) gl.bindVertexArray(null); 1347 gl.useProgram(null); 1348 }, 1349 }, beforeId); 1350 }, 1351 setPaths(paths) { 1352 state.visible = Array.isArray(paths) && paths.length > 0; 1353 upload(paths); 1354 }, 1355 setStyle(nextStyle = {}) { 1356 let changed = false; 1357 if (typeof nextStyle.color === 'string' && style.color !== nextStyle.color) { 1358 style.color = nextStyle.color; 1359 changed = true; 1360 } 1361 if (typeof nextStyle.tickColor === 'string' && style.tickColor !== nextStyle.tickColor) { 1362 style.tickColor = nextStyle.tickColor; 1363 changed = true; 1364 } 1365 if (Number.isFinite(Number(nextStyle.thicknessPx)) && Number(options.thicknessPx) !== Number(nextStyle.thicknessPx)) { 1366 options.thicknessPx = Number(nextStyle.thicknessPx); 1367 changed = true; 1368 } 1369 if (changed) map.triggerRepaint(); 1370 }, 1371 clear() { 1372 state.visible = false; 1373 state.pendingPaths = []; 1374 state.currentPaths = null; 1375 state.lineCount = 0; 1376 state.tickCount = 0; 1377 const gl = state.gl; 1378 if (gl) { 1379 if (state.lineBuffer) { 1380 gl.bindBuffer(gl.ARRAY_BUFFER, state.lineBuffer); 1381 gl.bufferData(gl.ARRAY_BUFFER, 0, gl.STATIC_DRAW); 1382 } 1383 if (state.tickBuffer) { 1384 gl.bindBuffer(gl.ARRAY_BUFFER, state.tickBuffer); 1385 gl.bufferData(gl.ARRAY_BUFFER, 0, gl.STATIC_DRAW); 1386 } 1387 gl.bindBuffer(gl.ARRAY_BUFFER, null); 1388 } 1389 map.triggerRepaint(); 1390 }, 1391 remove() { 1392 this.clear(); 1393 const gl = state.gl; 1394 if (gl) { 1395 if (state.lineBuffer) gl.deleteBuffer(state.lineBuffer); 1396 if (state.tickBuffer) gl.deleteBuffer(state.tickBuffer); 1397 } 1398 state.lineBuffer = null; 1399 state.tickBuffer = null; 1400 }, 1401 }; 1402 } 1403 1404 function drawOutlinedText(ctx, text, x, y, align = 'left', fillStyle = 'rgba(244,246,238,0.94)', options = {}) { 1405 ctx.save(); 1406 ctx.textAlign = align; 1407 ctx.lineWidth = options.lineWidth || 3.6; 1408 ctx.strokeStyle = options.strokeStyle || 'rgba(4,7,11,0.94)'; 1409 ctx.fillStyle = fillStyle; 1410 ctx.strokeText(text, x, y); 1411 ctx.fillText(text, x, y); 1412 ctx.restore(); 1413 } 1414 1415 function createCanvasObservationLayer(options = {}) { 1416 const layerState = { 1417 visible: false, 1418 reports: [], 1419 drawReports: [], 1420 reportBuckets: new Map(), 1421 drawReportBuckets: new Map(), 1422 drawn: [], 1423 canvas: null, 1424 drawRaf: 0, 1425 hoverKey: null, 1426 cursorActive: false, 1427 tooltip: null, 1428 tooltipFadeTimer: null, 1429 popup: null, 1430 popupFadeTimer: null, 1431 renderCenter: null, 1432 renderZoom: null, 1433 zooming: false, 1434 glyphCache: new Map(), 1435 }; 1436 const layerId = options.id || 'observation'; 1437 const canvasId = options.canvasId || `${layerId}-canvas`; 1438 const labelId = options.labelId || `${layerId}-label`; 1439 const canvasClass = options.canvasClass || 'metar-canvas'; 1440 const labelClass = options.labelClass || 'metar-label label label-with-close'; 1441 const closeToggleId = options.closeToggleId || null; 1442 const densitySpacing = options.densitySpacing || (() => 0); 1443 const hasDensityPriority = typeof options.densityPriority === 'function'; 1444 const densityPriority = hasDensityPriority ? options.densityPriority : (() => 0); 1445 1446 function closestVisibleLongitudeForCenter(lon, centerLng) { 1447 const normalized = ((Number(lon) + 180) % 360 + 360) % 360 - 180; 1448 return normalized + Math.round((centerLng - normalized) / 360) * 360; 1449 } 1450 1451 function normalizedMercatorY(lat) { 1452 const clampedLat = Math.max(-85.05112878, Math.min(85.05112878, lat)); 1453 const sin = Math.sin(clampedLat * Math.PI / 180); 1454 return 0.5 - Math.log((1 + sin) / (1 - sin)) / (4 * Math.PI); 1455 } 1456 1457 function bucketKey(lat, lon) { 1458 const bucketLat = Math.floor((Number(lat) + 90) / 5); 1459 const normalizedLon = ((Number(lon) + 180) % 360 + 360) % 360 - 180; 1460 const bucketLon = Math.floor((normalizedLon + 180) / 5); 1461 return `${bucketLat},${bucketLon}
1461`; 1462 } 1463 1464 function buildReportBuckets(reports) { 1465 const buckets = new Map(); 1466 (reports || []).forEach(report => { 1467 if (!Number.isFinite(report?.lat) || !Number.isFinite(report?.lon)) return; 1468 const key = bucketKey(report.lat, report.lon); 1469 if (!buckets.has(key)) buckets.set(key, []); 1470 buckets.get(key).push(report); 1471 }); 1472 return buckets; 1473 } 1474 1475 function reportsInBounds(buckets, south, north, west, east) { 1476 if (!buckets?.size) return []; 1477 const minLatBucket = Math.floor((Math.max(-90, south) + 90) / 5); 1478 const maxLatBucket = Math.floor((Math.min(90, north) + 90) / 5); 1479 const results = []; 1480 const westFloor = Math.floor(west / 5) * 5; 1481 const eastCeil = Math.ceil(east / 5) * 5; 1482 for (let latBucket = minLatBucket; latBucket <= maxLatBucket; latBucket += 1) { 1483 for (let lon = westFloor; lon <= eastCeil; lon += 5) { 1484 const normalizedLon = ((lon + 180) % 360 + 360) % 360 - 180; 1485 const lonBucket = Math.floor((normalizedLon + 180) / 5); 1486 const bucket = buckets.get(`${latBucket},${lonBucket}`); 1487 if (bucket) results.push(...bucket); 1488 } 1489 } 1490 return results; 1491 } 1492 1493 function ensureCanvas() { 1494 if (layerState.canvas) { 1495 layerState.canvas.hidden = !layerState.visible; 1496 return layerState.canvas; 1497 } 1498 const canvas = document.createElement('canvas'); 1499 canvas.id = canvasId; 1500 canvas.className = canvasClass; 1501 canvas.hidden = !layerState.visible; 1502 (document.getElementById('map') || document.getElementById('map-shell'))?.appendChild(canvas); 1503 layerState.canvas = canvas; 1504 enforceLayerOrder(); 1505 return canvas; 1506 } 1507 1508 function clearCanvas() { 1509 if (layerState.drawRaf) { 1510 window.cancelAnimationFrame(layerState.drawRaf); 1511 layerState.drawRaf = 0; 1512 } 1513 layerState.drawn = []; 1514 layerState.zooming = false; 1515 layerState.renderCenter = null; 1516 layerState.renderZoom = null; 1517 if (!layerState.canvas) return; 1518 layerState.canvas.style.transform = ''; 1519 const ctx = layerState.canvas.getContext('2d'); 1520 ctx.clearRect(0, 0, layerState.canvas.width, layerState.canvas.height); 1521 } 1522 1523 function resizeCanvas(canvas) { 1524 const dpr = window.devicePixelRatio || 1; 1525 const width = Math.max(1, canvas.clientWidth); 1526 const height = Math.max(1, canvas.clientHeight); 1527 const pixelWidth = Math.round(width * dpr); 1528 const pixelHeight = Math.round(height * dpr); 1529 if (canvas.width !== pixelWidth || canvas.height !== pixelHeight) { 1530 canvas.width = pixelWidth; 1531 canvas.height = pixelHeight; 1532 } 1533 return { width, height, dpr }; 1534 } 1535 1536 function updateLabel() { 1537 const existing = document.getElementById(labelId); 1538 if (!layerState.visible) { 1539 existing?.remove(); 1540 return; 1541 } 1542 let label = existing; 1543 if (!label) { 1544 label = document.createElement('div'); 1545 label.id = labelId; 1546 label.className = labelClass; 1547 document.getElementById('map-shell')?.appendChild(label); 1548 } 1549 label.innerHTML = `<span class="map-label-text">${escapeHtml(options.mapLabel?.(layerState.reports) || '')}</span><button type="button" class="map-label-close" aria-label="Hide ${escapeHtml(layerId)}">×</button>`; 1550 label.querySelector('.map-label-close')?.addEventListener('click', () => { 1551 if (closeToggleId) { 1552 const input = document.getElementById(closeToggleId); 1553 if (input) input.checked = false; 1554 } 1555 options.onClose?.(); 1556 }, { once: true }); 1557 } 1558 1559 function draw() { 1560 if (!layerState.visible || !layerState.canvas) return; 1561 layerState.canvas.style.transform = ''; 1562 const ctx = layerState.canvas.getContext('2d'); 1563 const { width, height, dpr } = resizeCanvas(layerState.canvas); 1564 ctx.setTransform(1, 0, 0, 1, 0, 0); 1565 ctx.clearRect(0, 0, layerState.canvas.width, layerState.canvas.height); 1566 layerState.drawn = []; 1567 if (!layerState.reports.length) { 1568 layerState.renderCenter = null; 1569 layerState.renderZoom = null; 1570 return; 1571 } 1572 layerState.renderCenter = map.getCenter(); 1573 layerState.renderZoom = map.getZoom();
1574 ctx.setTransform(dpr, 0, 0, dpr, 0, 0); 1575 const bounds = map.getBounds(); 1576 const south = bounds.getSouth() - 5; 1577 const north = bounds.getNorth() + 5; 1578 const west = bounds.getWest() - 10; 1579 const east = bounds.getEast() + 10; 1580 const centerLng = map.getCenter().lng; 1581 const densityZoom = Math.max(0, Math.floor(map.getZoom() * 2) / 2); 1582 const spacing = densitySpacing(densityZoom); 1583 const worldSize = spacing > 0 ? 512 * Math.pow(2, densityZoom) : 0; 1584 const occupied = new Set(); 1585 const drawReports = reportsInBounds( 1586 spacing > 0 && hasDensityPriority ? layerState.drawReportBuckets : layerState.reportBuckets, 1587 south, 1588 north, 1589 west, 1590 east 1591 ); 1592 drawReports.forEach(report => { 1593 if (report.lat < south || report.lat > north) return; 1594 const visibleLon = closestVisibleLongitudeForCenter(report.lon, centerLng); 1595 if (visibleLon < west || visibleLon > east) return; 1596 const point = map.project({ lng: visibleLon, lat: report.lat }); 1597 if (point.x < -80 || point.x > width + 80 || point.y < -80 || point.y > height + 80) return; 1598 if (spacing > 0) { 1599 const worldX = ((visibleLon + 180) / 360) * worldSize; 1600 const worldY = normalizedMercatorY(report.lat) * worldSize; 1601 const cell = `${Math.floor(worldX / spacing)},${Math.floor(worldY / spacing)}`; 1602 if (occupied.has(cell)) return; 1603 occupied.add(cell); 1604 } 1605 drawStation(ctx, report, point.x, point.y); 1606 layerState.drawn.push({ report, point, lngLat: { lng: visibleLon, lat: report.lat } }); 1607 }); 1608 } 1609 1610 function scheduleDraw() { 1611 if (!layerState.visible || !layerState.canvas || layerState.drawRaf) return; 1612 layerState.drawRaf = window.requestAnimationFrame(() => { 1613 layerState.drawRaf = 0; 1614 draw(); 1615 }); 1616 } 1617 1618 function drawImmediate() { 1619 if (!layerState.visible || !layerState.canvas) return; 1620 if (layerState.drawRaf) { 1621 window.cancelAnimationFrame(layerState.drawRaf); 1622 layerState.drawRaf = 0; 1623 } 1624 draw(); 1625 } 1626 1627 function drawStation(ctx, report, x, y) { 1628 const key = options.glyphCacheKey?.(report) || null; 1629 if (!key) { 1630 options.drawStation?.(ctx, report, x, y); 1631 return; 1632 } 1633 const width = options.glyphWidth || 142; 1634 const height = options.glyphHeight || 76; 1635 const originX = options.glyphOriginX || 60; 1636 const originY = options.glyphOriginY || 38; 1637 const dpr = Math.max(window.devicePixelRatio || 1, 1); 1638 let glyph = layerState.glyphCache.get(key); 1639 if (!glyph || glyph.dpr !== dpr) { 1640 const canvas = document.createElement('canvas'); 1641 canvas.width = Math.round(width * dpr); 1642 canvas.height = Math.round(height * dpr); 1643 const glyphCtx = canvas.getContext('2d'); 1644 glyphCtx.setTransform(dpr, 0, 0, dpr, 0, 0); 1645 glyphCtx.clearRect(0, 0, width, height); 1646 options.drawStation?.(glyphCtx, report, originX, originY); 1647 glyph = { canvas, width, height, originX, originY, dpr }; 1648 layerState.glyphCache.set(key, glyph); 1649 while (layerState.glyphCache.size > (options.maxGlyphCacheEntries || 1000)) { 1650 layerState.glyphCache.delete(layerState.glyphCache.keys().next().value); 1651 } 1652 } 1653 ctx.drawImage(glyph.canvas, x - glyph.originX, y - glyph.originY, glyph.width, glyph.height); 1654 } 1655 1656 function applyPanTransform() { 1657 if ( 1658 layerState.zooming 1659 || map.isZooming?.() 1660 || !layerState.visible 1661 || !layerState.canvas 1662 || !layerState.renderCenter 1663 || !Number.isFinite(layerState.renderZoom) 1664 || Math.abs(map.getZoom() - layerState.renderZoom) > 0.001 1665 || layerState.drawRaf 1666 ) { 1667 return; 1668 } 1669 const from = map.project(layerState.renderCenter); 1670 const to = map.project(map.getCenter()); 1671 const worldSize = 512 * Math.pow(2, map.getZoom()); 1672 let dx = from.x - to.x; 1673 if (Number.isFinite(worldSize) && worldSize > 0) { 1674 dx = ((dx + worldSize / 2) % worldSize + worldSize) % worldSize - worldSize / 2; 1675 } 1676 const dy = from.y - to.y; 1677 layerState.canvas.style.transform = `translate(${dx}px, ${dy}px)`; 1678 } 1679 1680 function handleZoomStart() { 1681 if (!layerState.visible || !layerState.canvas) return; 1682 layerState.zooming = true; 1683 if (layerState.drawRaf) { 1684 window.cancelAnimationFrame(layerState.drawRaf); 1685 layerState.drawRaf = 0; 1686 } 1687 if (layerState.canvas) { 1688 layerState.canvas.style.transform = ''; 1689 } 1690 } 1691 1692 function handleZoomEnd() { 1693 if (!layerState.visible || !layerState.canvas) return; 1694 layerState.zooming = false;
1695 drawImmediate(); 1696 } 1697 1698 function handleMoveEnd() { 1699 if (!layerState.visible || !layerState.canvas || layerState.zooming || map.isZooming?.()) return; 1700 drawImmediate(); 1701 } 1702 1703 function findNearest(point, maxDistance = 26) { 1704 let best = null; 1705 let bestDistance = maxDistance; 1706 layerState.drawn.forEach(entry => { 1707 const distance = Math.hypot(point.x - entry.point.x, point.y - entry.point.y); 1708 if (distance <= bestDistance) { 1709 best = entry; 1710 bestDistance = distance; 1711 } 1712 }); 1713 return best; 1714 } 1715 1716 function clearTooltip() { 1717 layerState.hoverKey = null; 1718 if (!layerState.tooltip) return; 1719 const tooltip = layerState.tooltip; 1720 tooltip.getElement?.()?.classList.add('is-fading'); 1721 if (layerState.tooltipFadeTimer) clearTimeout(layerState.tooltipFadeTimer); 1722 layerState.tooltipFadeTimer = setTimeout(() => { 1723 if (layerState.tooltip === tooltip) { 1724 tooltip.remove(); 1725 layerState.tooltip = null; 1726 } 1727 layerState.tooltipFadeTimer = null; 1728 }, 180); 1729 } 1730 1731 function clearHover() { 1732 clearTooltip(); 1733 if (layerState.cursorActive) { 1734 map.getCanvas().style.cursor = ''; 1735 layerState.cursorActive = false; 1736 } 1737 } 1738 1739 function clearPopup(immediate = false) { 1740 if (layerState.popupFadeTimer) { 1741 clearTimeout(layerState.popupFadeTimer); 1742 layerState.popupFadeTimer = null; 1743 } 1744 if (!layerState.popup) return; 1745 const popup = layerState.popup; 1746 if (immediate) { 1747 popup.remove(); 1748 if (layerState.popup === popup) layerState.popup = null; 1749 return; 1750 } 1751 popup.getElement?.()?.classList.add('is-fading'); 1752 layerState.popupFadeTimer = setTimeout(() => { 1753 popup.remove(); 1754 if (layerState.popup === popup) layerState.popup = null; 1755 layerState.popupFadeTimer = null; 1756 }, 180); 1757 } 1758 1759 function updateHover(event) { 1760 if (!layerState.visible || !layerState.drawn.length) { 1761 clearHover(); 1762 return; 1763 } 1764 const nearest = findNearest(event.point); 1765 if (!nearest) { 1766 clearHover(); 1767 return; 1768 } 1769 const key = options.reportKey?.(nearest.report) || nearest.report.station_id; 1770 map.getCanvas().style.cursor = 'pointer'; 1771 layerState.cursorActive = true; 1772 if (layerState.hoverKey === key && layerState.tooltip) return; 1773 layerState.hoverKey = key; 1774 if (layerState.tooltipFadeTimer) { 1775 clearTimeout(layerState.tooltipFadeTimer); 1776 layerState.tooltipFadeTimer = null; 1777 } 1778 if (!layerState.tooltip) { 1779 layerState.tooltip = new maplibregl.Popup({ className: 'metar-tooltip-popup', closeButton: false, closeOnClick: false, offset: 12 }); 1780 } 1781 layerState.tooltip.getElement?.()?.classList.remove('is-fading'); 1782 layerState.tooltip.setLngLat(nearest.lngLat).setHTML(options.tooltipHtml?.(nearest.report) || '').addTo(map); 1783 } 1784 1785 function handleClick(event) { 1786 if (!layerState.visible || !layerState.drawn.length) return; 1787 const nearest = findNearest(event.point); 1788 if (!nearest) { 1789 clearPopup(); 1790 return; 1791 } 1792 clearTooltip(); 1793 clearPopup(true); 1794 layerState.popup = new maplibregl.Popup({ className: 'metar-observation-popup', closeButton: true, closeOnClick: false, offset: 12 }) 1795 .setLngLat(nearest.lngLat) 1796 .setHTML(options.popupHtml?.(nearest.report) || '') 1797 .addTo(map); 1798 layerState.popup.getElement?.()?.querySelector('.maplibregl-popup-close-button')?.addEventListener('click', event => { 1799 event.preventDefault(); 1800 event.stopPropagation(); 1801 clearPopup(); 1802 }, { once: true }); 1803 } 1804 1805 function bindMapEvents() { 1806 map.on('move', applyPanTransform); 1807 map.on('zoomstart', handleZoomStart); 1808 map.on('zoom', scheduleDraw); 1809 map.on('zoomend', handleZoomEnd); 1810 map.on('moveend', handleMoveEnd); 1811 ['resize', 'rotate', 'pitch'].forEach(event => map.on(event, drawImmediate)); 1812 map.on('mousemove', updateHover);
1813 map.on('mouseout', clearHover); 1814 map.on('click', handleClick); 1815 } 1816 1817 function hide() { 1818 layerState.canvas?.remove(); 1819 layerState.canvas = null; 1820 layerState.drawn = []; 1821 clearHover(); 1822 layerState.tooltip?.remove(); 1823 layerState.tooltip = null; 1824 clearPopup(true); 1825 updateLabel(); 1826 } 1827 1828 return { 1829 ensureCanvas, 1830 draw, 1831 drawImmediate, 1832 scheduleDraw, 1833 bindMapEvents, 1834 updateLabel, 1835 clearHover, 1836 clearPopup, 1837 hide, 1838 setVisible(value) { 1839 layerState.visible = Boolean(value); 1840 if (layerState.canvas) layerState.canvas.hidden = !layerState.visible; 1841 if (!layerState.visible) { 1842 clearCanvas(); 1843 clearHover(); 1844 clearPopup(true); 1845 } 1846 }, 1847 setReports(reports) { 1848 layerState.reports = reports || []; 1849 layerState.drawReports = hasDensityPriority 1850 ? [...layerState.reports].sort((a, b) => densityPriority(b) - densityPriority(a)) 1851 : layerState.reports; 1852 layerState.reportBuckets = buildReportBuckets(layerState.reports); 1853 layerState.drawReportBuckets = hasDensityPriority ? buildReportBuckets(layerState.drawReports) : layerState.reportBuckets; 1854 }, 1855 get visible() { 1856 return layerState.visible; 1857 }, 1858 get reports() { 1859 return layerState.reports; 1860 }, 1861 }; 1862 } 1863 1864 return { 1865 config, 1866 referenceLayers: { 1867 coastLow: { id: 'world-low-coast', source: 'reference', sourceLayer: 'coast_low' }, 1868 bordersLow: { id: 'world-low-borders', source: 'reference', sourceLayer: 'borders_low' }, 1869 coastHigh: { id: 'world-high-coast', source: 'reference', sourceLayer: 'coast_high' }, 1870 bordersHigh: { id: 'world-high-borders', source: 'reference', sourceLayer: 'borders_high' }, 1871 }, 1872 map, 1873 emptyGeoJson, 1874 setSourceData, 1875 updateLayerVisibility, 1876 enforceLayerOrder, 1877 orderedLayerGroups, 1878 visibleLayerGroups, 1879 setUserLayerOrder, 1880 formatCoordinate, 1881 formatDTG, 1882 formatDateTime, 1883 parseUtcDate, 1884 formatNumber, 1885 unitSettings: unitSettingsSnapshot, 1886 setUnitSettings, 1887 convertWindSpeed, 1888 formatWindSpeed, 1889 formatWindPhrase, 1890 convertTemperature, 1891 formatTemperature, 1892 convertPressure, 1893 formatPressure, 1894 convertPrecip, 1895 formatPrecip, 1896 formatDistance, 1897 formatAltitude, 1898 basinName, 1899 formatCardinalDirection, 1900 windColor, 1901 numberOrNull, 1902 relativeHumidity, 1903 heatIndexC, 1904 windChillC, 1905 escapeHtml, 1906 stormDisplayName, 1907 drawWindBarbGlyph, 1908 buildWindStreamlinePaths, 1909 drawWindStreamlines, 1910 createWindStreamlineGlLayer, 1911 drawOutlinedText, 1912 createCanvasObservationLayer, 1913 addReferenceLayers, 1914 basemapLabelAnchor, 1915 basemapOverlayAnchor, 1916 setGraticuleVisible, 1917 graticuleVisible() { 1918 return graticuleVisible; 1919 }, 1920 registerRefreshSource, 1921 refreshVisibleDisplays, 1922 selectedStormId: null, 1923 currentStorm: null, 1924 }; 1925})();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.