PageSourceSearch

https://cyclonicwx.com/js/core.js?d=9-27-26b

js cyclonicwx.com collected 2026-09-28 05:48:04 UTC 73,486 bytes, 1,925 lines download raw bytes

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      '&': '&amp;',
520      '<': '&lt;',
521      '>': '&gt;',
522      '"': '&quot;',
523      "'": '&#39;',
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)}">&times;</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.