/**
* SharpJS rendering engine.
*
* createEngine(root, options) returns one independent chart instance. All DOM lookups are
* scoped to `root` (see viewer/template.js for the elements it expects), so several charts
* can live on one page. Hosts normally use the SharpJS() wrapper in viewer/index.js rather
* than calling the engine directly.
*/
import {
GetBM, LiftParcel, GetDCape, LapseRateHght, LapseRatePres,
GetLayerByPres, EffInflowCalc, GetLayerByHght, WindShearFindP,
GetSRH, WindShearFind, WindShear, ConvertPolartoCart,
GetParams, DrawInsetParams, thetae, wetbulb, mean_layer_temp, mean_relh,
GetLayerByTemp, RelHum, mixratio, MeanWind, MeanWindNW, ConvertToInfo, virtemp,
CtoK, clamp, Mag, VirtTemp, degtoRad, Lerp, RMISSD, ROCP, temp_at_mixrat, qc, DewFromTRh,
mean_rh_in_layer, GetCapeColor, CToF, UpdateCSMotStorm, UpdatePresets, VaporPressure, FtoC,
GetAngleFromVector, theta
} from "../calc/skewtCalcs.js";
import { ConvertToRaw, ln, lerp, noop, arrayIndex, absoluteValue } from '../io/general.js';
import { computeWindParams } from '../calc/params.js';
import { Process } from '../io/sharppy.js';
import { exportSounding } from '../io/export.js';
import { DISPLAY_VERSION } from '../version.js';
import numbs from '../data/climo/Cases.js';
import numbs2 from '../data/climo/CasesEF2.js';
// Tornado side-panel percentile tables (~2 MB) load on first use in Tornado mode, not with
// the page. Cases*.js above stay static: the bounds check needs them synchronously.
// The cache is shared by every chart on the page.
let tornadoClimo = null;
let tornadoClimoLoading = null;
const HODO_MAP_TILE_SIZE = 256;
const HODO_MAP_ZOOM = 5;
const HODO_MAP_TILE_URL = 'https://tile.openstreetmap.org';
const hodographMapTiles = new Map();
/**
* Begins, traces, and strokes a single line.
* @param {CanvasRenderingContext2D} ctx Canvas drawing context.
* @param {number} x1 Starting x-coordinate.
* @param {number} y1 Starting y-coordinate.
* @param {number} x2 Ending x-coordinate.
* @param {number} y2 Ending y-coordinate.
* @returns {void}
*/
function strokeCanvasLine(ctx, x1, y1, x2, y2) {
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.stroke();
}
/**
* Begins, traces, and optionally closes, strokes, and fills a path.
* @param {CanvasRenderingContext2D} ctx Canvas drawing context.
* @param {Array<{x: number, y: number}>} points Points in drawing order.
* @param {boolean} closePath Whether to close the path.
* @param {boolean} stroke Whether to stroke the path.
* @param {boolean} fill Whether to fill the path.
* @returns {void}
*/
function drawCanvasPath(ctx, points, closePath = false, stroke = true, fill = false) {
if (!points.length) return;
ctx.beginPath();
ctx.moveTo(points[0].x, points[0].y);
for (let i = 1; i < points.length; i++) {
ctx.lineTo(points[i].x, points[i].y);
}
if (closePath) ctx.closePath();
if (stroke) ctx.stroke();
if (fill) ctx.fill();
}
/**
* Begins and strokes independent line segments in a single canvas path.
* @param {CanvasRenderingContext2D} ctx Canvas drawing context.
* @param {Array<[number, number, number, number]>} segments [x1, y1, x2, y2] segments.
* @returns {void}
*/
function strokeCanvasSegments(ctx, segments) {
if (!segments.length) return;
ctx.beginPath();
for (const [x1, y1, x2, y2] of segments) {
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
}
ctx.stroke();
}
/**
* Projects latitude and longitude into Web Mercator world pixels.
* @param {number} lat Latitude in degrees.
* @param {number} lon Longitude in degrees.
* @param {number} zoom Slippy-map zoom level.
* @returns {{x:number, y:number}} Projected world-pixel coordinates.
*/
function projectHodographMapCoordinate(lat, lon, zoom) {
const worldSize = HODO_MAP_TILE_SIZE * (2 ** zoom);
const clampedLat = Math.max(-85.05112878, Math.min(85.05112878, lat));
const sinLat = Math.sin(clampedLat * Math.PI / 180);
return {
x: ((lon + 180) / 360) * worldSize,
y: (0.5 - Math.log((1 + sinLat) / (1 - sinLat)) / (4 * Math.PI)) * worldSize,
};
}
/**
* Loads or retrieves a cached OpenStreetMap tile.
* @param {number} zoom Slippy-map zoom level.
* @param {number} x Tile x-coordinate before world wrapping.
* @param {number} y Tile y-coordinate.
* @param {Window} view Window that supplies the Image constructor.
* @param {() => void} onReady Schedules a hodograph redraw after loading completes.
* @returns {{image:HTMLImageElement|null, state:string}} Tile image and load state.
*/
function getHodographMapTile(zoom, x, y, view, onReady) {
const tileCount = 2 ** zoom;
if (y < 0 || y >= tileCount) return { image: null, state: 'outside' };
const wrappedX = ((x % tileCount) + tileCount) % tileCount;
const key = `${zoom}/${wrappedX}/${y}`;
let tile = hodographMapTiles.get(key);
if (tile) {
if (tile.state === 'loading') tile.listeners.add(onReady);
return tile;
}
const ImageConstructor = view?.Image;
if (!ImageConstructor) return { image: null, state: 'unavailable' };
tile = { image: new ImageConstructor(), state: 'loading', listeners: new Set([onReady]) };
hodographMapTiles.set(key, tile);
tile.image.crossOrigin = 'anonymous';
tile.image.onload = () => {
tile.state = 'loaded';
for (const listener of tile.listeners) listener();
tile.listeners.clear();
};
tile.image.onerror = () => {
tile.state = 'error';
for (const listener of tile.listeners) listener();
tile.listeners.clear();
};
tile.image.src = `${HODO_MAP_TILE_URL}/${zoom}/${wrappedX}/${y}.png`;
return tile;
}
/**
* Draws an OpenStreetMap inset centered on a sounding's coordinates.
* OpenStreetMap tiles include mapped state and county administrative boundaries.
* @param {CanvasRenderingContext2D} ctx Hodograph drawing context.
* @param {HTMLCanvasElement} canvas Hodograph canvas.
* @param {object} sounding Current sounding object.
* @param {() => void} onTilesReady Schedules a redraw when pending tiles finish.
* @param {{x:number,y:number,width:number,height:number}} [bounds] Optional inset bounds.
* @returns {void}
*/
function drawHodographLocationInset(ctx, canvas, sounding, onTilesReady, bounds) {
const location = sounding?.Coords;
const lat = Number(location?.lat ?? sounding?.Location?.[0]);
const lon = Number(location?.lon ?? sounding?.Location?.[1]);
if (!Number.isFinite(lat) || !Number.isFinite(lon) || Math.abs(lat) > 90 || Math.abs(lon) > 180) return;
const insetWidth = bounds?.width ?? 200;
const insetHeight = bounds?.height ?? 200;
const insetX = bounds?.x ?? canvas.width - insetWidth;
const insetY = bounds?.y ?? canvas.height - insetHeight;
const center = projectHodographMapCoordinate(lat, lon, HODO_MAP_ZOOM);
const north = projectHodographMapCoordinate(Math.min(85.05112878, lat + 2.5), lon, HODO_MAP_ZOOM);
const south = projectHodographMapCoordinate(Math.max(-85.05112878, lat - 2.5), lon, HODO_MAP_ZOOM);
const worldSpanY = Math.max(
HODO_MAP_TILE_SIZE * (2 ** HODO_MAP_ZOOM) * (5 / 360),
south.y - north.y,
);
const worldSpanX = worldSpanY * insetWidth / insetHeight;
const scale = insetHeight / worldSpanY;
const left = center.x - worldSpanX / 2;
const top = center.y - worldSpanY / 2;
const firstTileX = Math.floor(left / HODO_MAP_TILE_SIZE);
const lastTileX = Math.floor((left + worldSpanX) / HODO_MAP_TILE_SIZE);
const firstTileY = Math.floor(top / HODO_MAP_TILE_SIZE);
const lastTileY = Math.floor((top + worldSpanY) / HODO_MAP_TILE_SIZE);
let failedTiles = 0;
let loadedTiles = 0;
ctx.save();
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.fillStyle = '#17232c';
ctx.fillRect(insetX, insetY, insetWidth, insetHeight);
ctx.beginPath();
ctx.rect(insetX, insetY, insetWidth, insetHeight);
ctx.clip();
for (let tileY = firstTileY; tileY <= lastTileY; tileY++) {
for (let tileX = firstTileX; tileX <= lastTileX; tileX++) {
const tile = getHodographMapTile(HODO_MAP_ZOOM, tileX, tileY, canvas.ownerDocument.defaultView, onTilesReady);
if (tile.state === 'loaded' && tile.image) {
const x = insetX + (tileX * HODO_MAP_TILE_SIZE - left) * scale;
const y = insetY + (tileY * HODO_MAP_TILE_SIZE - top) * scale;
const size = HODO_MAP_TILE_SIZE * scale;
ctx.drawImage(tile.image, x, y, size, size);
loadedTiles++;
} else if (tile.state === 'error' || tile.state === 'unavailable') {
failedTiles++;
}
}
}
ctx.restore();
ctx.save();
ctx.setTransform(1, 0, 0, 1, 0, 0);
if (loadedTiles === 0 && failedTiles > 0) {
ctx.fillStyle = 'rgba(0, 0, 0, 0.72)';
ctx.fillRect(insetX, insetY, insetWidth, insetHeight);
ctx.fillStyle = '#fff';
ctx.font = '10px sans-serif';
ctx.textAlign = 'center';
ctx.fillText('Map unavailable', insetX + insetWidth / 2, insetY + insetHeight / 2);
}
ctx.beginPath();
ctx.arc(insetX + insetWidth / 2, insetY + insetHeight / 2, 3, 0, Math.PI * 2);
ctx.fillStyle = '#f04438';
ctx.fill();
ctx.strokeStyle = '#fff';
ctx.lineWidth = 1.5;
ctx.stroke();
ctx.strokeStyle = 'rgba(255,255,255,0.95)';
ctx.lineWidth = 2;
ctx.strokeRect(insetX + 1, insetY + 1, insetWidth - 2, insetHeight - 2);
ctx.fillStyle = 'rgba(0,0,0,0.72)';
ctx.fillRect(insetX + insetWidth - 27, insetY + insetHeight - 12, 25, 10);
ctx.fillStyle = '#fff';
ctx.font = '7px sans-serif';
ctx.textAlign = 'right';
ctx.fillText('© OSM', insetX + insetWidth - 4, insetY + insetHeight - 4);
ctx.restore();
}
/** Lazily loads and caches tornado climatology tables.
* @returns {*} Result of the function.
*/
function loadTornadoClimo() {
tornadoClimoLoading ??= Promise.all([
import('../data/climo/EffSRHClimo.js'),
import('../data/climo/STPFClimo.js'),
import('../data/climo/EffShearClimo.js'),
import('../data/climo/MLCapeClimo.js'),
import('../data/climo/EffSRHClimoEF2.js'),
import('../data/climo/STPFClimoEF2.js'),
import('../data/climo/EffShearClimoEF2.js'),
import('../data/climo/MLCapeClimoEF2.js'),
]).then(([effsrhc, stpfc, effshrc, mlcapec, effsrhc2, stpfc2, effshrc2, mlcapec2]) => {
tornadoClimo = {
effsrhc: effsrhc.default, stpfc: stpfc.default, effshrc: effshrc.default, mlcapec: mlcapec.default,
effsrhc2: effsrhc2.default, stpfc2: stpfc2.default, effshrc2: effshrc2.default, mlcapec2: mlcapec2.default,
};
return tornadoClimo;
}).catch((err) => {
tornadoClimoLoading = null; // allow a retry on the next draw
throw err;
});
return tornadoClimoLoading;
}
/**
* @param {HTMLElement} root element containing the chart markup from viewer/template.js
* @param {object} [options]
* @param {string} [options.brandLabel] host label drawn before "powered by SharpJS" (e.g. "example.com")
* @param {string} [options.savedBy] "Saved by:" line in exported text
* @param {string} [options.filePrefix] exported file name prefix
* @param {(event: string, detail?: any) => void} [options.emit] state-change notifications
* @returns {*} Result of the function.
*/
export function createEngine(root, options = {}) {
// ---------------------------------------------------------------------------------------------
// Everything below is per-chart state and behaviour. It keeps the layout of the original
// single-file script so changes can be compared side by side with older SharpJS builds.
// ---------------------------------------------------------------------------------------------
const elementCache = new Map();
/** Root-scoped replacement for document.getElementById (elements carry data-sjs="<id>").
* @param {*} id Parameter for $.
* @returns {*} Result of the function.
*/
function $(id) {
let el = elementCache.get(id);
if (!el || !el.isConnected) {
el = root.querySelector(`[data-sjs="${id}"]`);
if (el) elementCache.set(id, el);
}
return el;
}
const emit = typeof options.emit === 'function' ? options.emit : noop;
const globalFont = options.font || '"IBM Plex Sans", "Segoe UI", Roboto, Arial, sans-serif';
const verString = DISPLAY_VERSION;
/** "SharpJS v1.7", or "<label><sep>SharpJS v1.7" when the host sets options.brandLabel.
* @param {*} sep Parameter for brandLine.
* @returns {*} Result of the function.
*/
function brandLine(sep) {
const label = typeof options.brandLabel === 'string' ? options.brandLabel.trim() : '';
return label ? `${label}${sep}SharpJS v${verString}` : `SharpJS v${verString}`;
}
var skv = {};
const marginTop = 30;//30;
const marginBottom = 15;
const marginLeft = 30;
const marginRight = 45;
skv.tlx = marginLeft;
skv.tly = marginTop;
skv.brx = 800;
skv.bry = 800;
skv.hspread = 90;
skv.vspread = 70;
skv.brtemp = 50;
skv.type = 1;
skv.font = `10px ${globalFont}`;
skv.pbot = 1100;
skv.ptop = 100;
skv.plevels = new Array(1000, 850, 700, 500, 400, 300, 200, 100);
skv.tleft = -50;
skv.tspread = 102;
const DEFAULT_PBOT = 1100;
const DEFAULT_PTOP = 100;
const DEFAULT_TLEFT = -50;
const DEFAULT_TSPREAD = 102;
/** Keep enough vertical span that skew-T isotherm/adiabat geometry stays readable. */
const MIN_PRESSURE_SPAN = 400;
const MIN_TEMP_SPREAD = 60;
/**
* log-P mapping is undefined at 0. Never fit or draw against ptop/pres <= 0
* or the entire skew-T collapses onto the bottom edge.
*/
const MIN_PTOP = 50;
const MIN_VALID_PRES_MB = 20;
const MAX_VALID_PRES_MB = 1100;
//Why
var PRESSURE_LABEL_CANDIDATES = [1000, 925, 850, 700, 500, 400, 300, 250, 200, 150, 100, 50];
var PRESSURE_TOP_CANDIDATES = [850, 700, 500, 400, 300, 250, 200, 150, 100, 50];
/** Checks is valid sounding pressure.
* @param {*} p Parameter for isValidSoundingPressure.
* @returns {*} Result of the function.
*/
function isValidSoundingPressure(p) {
return Number.isFinite(p) && p >= MIN_VALID_PRES_MB && p <= MAX_VALID_PRES_MB;
}
/** Get the top layer for pressure
* @param {*} desiredTop Parameter for pickPressureTop.
* @param {*} observedTop Parameter for pickPressureTop.
* @returns {*} Result of the function.
*/
function pickPressureTop(desiredTop, observedTop) {
const usable = (p) => Number.isFinite(p) && p >= MIN_PTOP && p < DEFAULT_PBOT;
let ptop = PRESSURE_TOP_CANDIDATES.find((p) => usable(p) && p <= desiredTop) ?? DEFAULT_PTOP;
if (ptop >= observedTop) {
ptop = PRESSURE_TOP_CANDIDATES.find((p) => usable(p) && p < observedTop)
?? Math.max(MIN_PTOP, Math.min(DEFAULT_PTOP, observedTop - 25));
}
return ptop;
}
/** Quality Check
* @param {*} val Parameter for tqc.
* @returns {*} Result of the function.
*/
function tqc(val){
if (val < -998.0 || val > 2.0E+05 || isNaN(val)) return 0;
return 1;
}
/**
* 0 = default
* 1 = metric
* 2 = imperal
*/
let useImperialUnits = 0;
/** Returns position scale data.
* @param {*} x Parameter for GetPositionScale.
* @param {*} y Parameter for GetPositionScale.
* @param {*} canvas Parameter for GetPositionScale.
* @returns {*} Result of the function.
*/
function GetPositionScale(x, y, canvas) {
return { x: (x / 100) * canvas.width, y: (y / 100) * canvas.height };
}
/** Identifies a dropsonde sounding from its metadata.
* @param {*} soundingObj Parameter for isDropsondeSounding.
* @returns {*} Result of the function.
*/
function isDropsondeSounding(soundingObj) {
if (!soundingObj) return false;
if (soundingObj.isDropsonde === true) return true;
const title = `${soundingObj.STitle || ''} ${soundingObj.Title || ''}`;
return /dropsonde|·\s*drop\b/i.test(title);
}
/** Dropsonde surface/splash MSLP (mb), from payload or reduced from SFC level.
* @param {*} soundingObj Parameter for resolveDropsondeMslpMb.
* @param {*} startSlice Parameter for resolveDropsondeMslpMb.
* @returns {*} Result of the function.
*/
function resolveDropsondeMslpMb(soundingObj, startSlice) {
if (!isDropsondeSounding(soundingObj)) return null;
const reported = Number(soundingObj.mslpMb);
if (Number.isFinite(reported) && reported >= 850 && reported <= 1100) {
return Math.round(reported);
}
if (!startSlice || !qc(startSlice.P)) return null;
const p = startSlice.P;
const z = qc(startSlice.H) ? startSlice.H : 0;
if (z < 15) {
return p >= 850 && p <= 1100 ? Math.round(p) : null;
}
// Hypsometric reduction (same approach as recon MSLP estimate).
const RD = 287.05;
const G0 = 9.80665;
const GAMMA = 0.0065;
let tFlK;
if (qc(startSlice.T) && startSlice.T > -90 && startSlice.T < 50) {
tFlK = startSlice.T + 273.15;
} else {
tFlK = Math.max(200, 288.15 - GAMMA * z);
}
const tMeanK = tFlK + (GAMMA * z) / 2;
if (!(tMeanK > 180 && tMeanK < 340)) return null;
const mslp = p * Math.exp(z / ((RD * tMeanK) / G0));
if (!(mslp >= 850 && mslp <= 1100)) return null;
return Math.round(mslp);
}
/** Restores the default skew-T pressure and temperature domains.
* @returns {void} No value.
*/
function resetSkewTDomains() {
skv.pbot = DEFAULT_PBOT;
skv.ptop = DEFAULT_PTOP;
skv.plevels = PRESSURE_LABEL_CANDIDATES.filter((p) => p < skv.pbot && p >= skv.ptop);
skv.tleft = DEFAULT_TLEFT;
skv.tspread = DEFAULT_TSPREAD;
}
/** Finds the valid pressure range in a sounding.
* @param {*} soundingObj Parameter for getSoundingPressureExtents.
* @returns {*} Result of the function.
*/
function getSoundingPressureExtents(soundingObj) {
const sound = soundingObj && soundingObj.Sounding;
if (!sound) return null;
let sfcP = null;
let topP = null;
const consider = (raw) => {
const p = Number(raw);
if (!isValidSoundingPressure(p)) return;
sfcP = sfcP == null ? p : Math.max(sfcP, p);
topP = topP == null ? p : Math.min(topP, p);
};
if (Array.isArray(sound.P) && sound.P.length > 0) {
sound.P.forEach(consider);
} else if (Array.isArray(sound.Slice)) {
sound.Slice.forEach((slice) => consider(slice && slice[0]));
}
if (sfcP == null || topP == null) return null;
return { sfcP, topP };
}
/**
* Dropsonde-only: trim empty upper levels, keep classic bottom (1100)
* and a wide enough span so the skew geometry doesn't collapse.
* Chart top tracks the sounding top (e.g. ~175 mb → 150 mb, not 100 mb).
* @param {*} primarySounding Parameter for setPressureDomainFromSounding.
* @param {*} comparisonSoundings Parameter for setPressureDomainFromSounding.
* @returns {void} No value.
*/
function setPressureDomainFromSounding(primarySounding, comparisonSoundings = []) {
let sfcP = null;
let topP = null;
const consider = (snd) => {
const ext = getSoundingPressureExtents(snd);
if (!ext) return;
sfcP = sfcP == null ? ext.sfcP : Math.max(sfcP, ext.sfcP);
topP = topP == null ? ext.topP : Math.min(topP, ext.topP);
};
consider(primarySounding);
if (Array.isArray(comparisonSoundings)) {
comparisonSoundings.forEach((comp) => {
if (comp && comp.sounding) consider(comp.sounding);
else consider(comp);
});
}
if (sfcP == null || topP == null || !(sfcP > topP)) {
skv.pbot = DEFAULT_PBOT;
skv.ptop = DEFAULT_PTOP;
skv.plevels = PRESSURE_LABEL_CANDIDATES.filter((p) => p < skv.pbot && p >= skv.ptop);
return;
}
const pbot = DEFAULT_PBOT;
// Modest pad above the highest observation so the profile isn't glued to the top.
const pad = topP >= 700 ? 75 : topP >= 400 ? 40 : 25;
const desiredTop = topP - pad;
let ptop = pickPressureTop(desiredTop, topP);
// Never zoom in so far that skew angles look wrong.
const maxPtop = pbot - MIN_PRESSURE_SPAN; // e.g. 700 mb
if (ptop > maxPtop) ptop = maxPtop;
if (!(ptop > 0 && ptop < pbot)) {
ptop = DEFAULT_PTOP;
}
skv.pbot = pbot;
skv.ptop = ptop;
skv.plevels = PRESSURE_LABEL_CANDIDATES.filter((p) => p < pbot && p >= ptop);
if (skv.plevels.length === 0) {
skv.plevels = [Math.round((pbot + ptop) / 2)];
}
}
/**
* Dropsonde-only: fit the temperature axis to observed T so tropical
* profiles sit in the middle of the chart instead of the warm edge.
* @param {*} primarySounding Parameter for setTemperatureDomainFromSounding.
* @returns {void} No value.
*/
function setTemperatureDomainFromSounding(primarySounding) {
const sound = primarySounding && primarySounding.Sounding;
const temps = Array.isArray(sound?.T)
? sound.T.filter((t) => Number.isFinite(t) && t > -90 && t < 55)
: [];
const minT = temps.length ? Math.min(...temps) : 0;
const maxT = temps.length ? Math.max(...temps) : 30;
let tleft = Math.floor((minT - 8) / 10) * 10;
tleft = Math.max(-50, Math.min(0, tleft));
let tright = Math.ceil((maxT + 10) / 10) * 10;
tright = Math.max(40, tright, tleft + MIN_TEMP_SPREAD);
tright = Math.min(50, tright);
if (tright - tleft < MIN_TEMP_SPREAD) {
tleft = Math.max(-50, tright - MIN_TEMP_SPREAD);
}
skv.tleft = tleft;
skv.tspread = tright - tleft;
}
/** Apply dropsonde-only chart fitting; restore classic domains otherwise.
* @param {*} primarySounding Parameter for setSkewTDomainsFromSounding.
* @param {*} comparisonSoundings Parameter for setSkewTDomainsFromSounding.
* @returns {void} No value.
*/
function setSkewTDomainsFromSounding(primarySounding, comparisonSoundings = []) {
if (!isDropsondeSounding(primarySounding)) {
PRESSURE_LABEL_CANDIDATES = [1000, 825, 700, 500, 300, 200, 100, 50];
PRESSURE_TOP_CANDIDATES = [1000, 100, 50];
// Normal soundings use the user's skew; dropsondes below force a low skew.
SkewNumber = UserSkewNumber;
resetSkewTDomains();
return;
}
//Set settings stuff
PRESSURE_LABEL_CANDIDATES = [1000, 900, 800, 700, 600, 500, 400, 300, 200, 100, 50];
PRESSURE_TOP_CANDIDATES = [1000, 900, 800, 700, 600, 550, 500, 450, 400, 350, 300, 250, 200, 150, 100, 50];
// Low skew (same as the "Low" control). 0.9 slammed tropical T/Td off the right edge.
SkewNumber = 0.5;
setPressureDomainFromSounding(primarySounding, comparisonSoundings);
setTemperatureDomainFromSounding(primarySounding);
}
/** Logarithmic pressure scale for skew-T mapping. Returns null if the inputs are invalid.
* @param {*} pbot Parameter for logPressureScale.
* @param {*} ptop Parameter for logPressureScale.
* @returns {*} Result of the function.
*/
function logPressureScale(pbot, ptop) {
if (!(pbot > 0) || !(ptop > 0) || !(pbot > ptop)) return null;
return Math.log(pbot) - Math.log(ptop);
}
/** Convert pressure to pixel coordinates.
* @param {*} pres Parameter for pres_to_pix.
* @returns {*} Result of the function.
*/
function pres_to_pix(pres) {
var scl1 = logPressureScale(skv.pbot, skv.ptop);
if (scl1 == null || !(pres > 0)) return skv.bry;
var scl2 = Math.log(skv.pbot) - Math.log(pres);
return (skv.bry - (scl2 / scl1) * (skv.bry - skv.tly));
}
/** Convert pixel coordinates to pressure.
* @param {*} y Parameter for pix_to_pres.
* @returns {*} Result of the function.
*/
function pix_to_pres(y) {
var scl1 = logPressureScale(skv.pbot, skv.ptop);
if (scl1 == null) return skv.pbot;
var scl2 = skv.bry - y;
var scl3 = skv.bry - skv.tly + 1;
return (skv.pbot / Math.exp((scl2 / scl3) * scl1));
}
/** Convert pressure to skew-T skew coordinates (0 → 1).
* @param {*} N Parameter for PressuretoSkew.
* @param {*} BP Parameter for PressuretoSkew.
* @returns {*} Result of the function.
*/
function PressuretoSkew(N, BP) {
var scl1 = logPressureScale(skv.pbot, skv.ptop);
if (scl1 == null || !(N > 0) || !(skv.bry > 0)) return 1;
var scl2 = Math.log(skv.pbot) - Math.log(N);
return (skv.bry - (scl2 / scl1) * (skv.bry - skv.tly)) / skv.bry;
}
var SkewNumber = 0.75;
/** The skew the user picked (SkewNumber can be overridden per sounding, e.g. dropsondes). */
var UserSkewNumber = 0.75;
/** Convert a level to horizontal skew position
* @param {*} N Parameter for LevelToSkewNum.
* @param {*} BP Parameter for LevelToSkewNum.
* @returns {*} Result of the function.
*/
function LevelToSkewNum(N, BP) {
return SkewNumber - SkewNumber * Lerp(0, 1, PressuretoSkew(N));
}
var LapseType = false;
const SKEW_STEPS = [0.5, 0.625, 0.75, 0.825];
/** Cycle the skew-T skew (Low → Medium → Default → High), or just redraw with Change=false.
* @param {*} Change Parameter for ChangeStateSkewTSkew.
* @returns {*} Result of the function.
*/
function ChangeStateSkewTSkew(Change = true) {
if (Change) {
const i = SKEW_STEPS.indexOf(UserSkewNumber);
UserSkewNumber = SKEW_STEPS[(i + 1) % SKEW_STEPS.length];
SkewNumber = UserSkewNumber;
LoadWithParcel(Snding, DP, Info, ghost, currentComparisonSoundings);
}
return UserSkewNumber;
}
/** Convert meters to feet.
* @param {*} N Parameter for MToFt.
* @returns {*} Result of the function.
*/
function MToFt(N) {
return N * 3.28084;
}
/** Convert knots to miles per hour.
* @param {*} N Parameter for KtsToMph.
* @returns {*} Result of the function.
*/
function KtsToMph(N) {
return N * 1.15078;
}
/** Convert degrees Celsius per kilometer to degrees Celsius per thousand feet.
* @param {*} N Parameter for CPerKmToCPerKft.
* @returns {*} Result of the function.
*/
function CPerKmToCPerKft(N) {
return N / 3.28084;
}
var HodographScaleMulti = 1.0;
var OutsideFitBase = 0;
// Viridis-like colortable (low → high)
const HODO_COLORS = [
"#ffffff",
"#5c3dff",
"#2f8cff",
"#00c8b4",
"#79f04b",
"#04ff1f",
"#ffb000",
"#ff6a00",
"#ff0000"
];
// Convert hex → RGB
/** Convert a hex color to RGB values.
* @param {*} hex Parameter for hexToRgb.
* @returns {*} Result of the function.
*/
function hexToRgb(hex) {
const bigint = parseInt(hex.slice(1), 16);
return {
r: (bigint >> 16) & 255,
g: (bigint >> 8) & 255,
b: bigint & 255
};
}
// Convert RGB → hex
/** Convert RGB values to a hex color.
* @param {*} r Parameter for rgbToHex.
* @param {*} g Parameter for rgbToHex.
* @param {*} b Parameter for rgbToHex.
* @returns {*} Result of the function.
*/
function rgbToHex(r, g, b) {
return "#" + [r, g, b]
.map(x => x.toString(16).padStart(2, "0"))
.join("");
}
// NEXRAD VWP RMS on hodograph: strict errKt→px matches vector units but reads smaller than common VAD/AWIPS-style displays (~2× is typical visual envelope).
const VWP_HODOGRAPH_RMS_RADIUS_SCALE = 1.3;
const VWP_HODOGRAPH_RMS_FILL_ALPHA = 0.15;
/** Returns hodograph color data.
* @param {*} Hght Parameter for GetHodographColor.
* @param {*} ctx Parameter for GetHodographColor.
* @param {*} trans Parameter for GetHodographColor.
* @returns {*} Result of the function.
*/
function GetHodographColor(Hght, ctx, trans=1) {
if (HodographColorTable == 0) {
if (Hght <= 1000) {
return `rgba(225,0,225,${trans})`;
} else if (Hght <= 3000 && Hght > 1000) {
return `rgba(225,0,0,${trans})`;
} else if (Hght <= 6000 && Hght > 3000) {
return `rgba(0,225,0,${trans})`;
} else if (Hght <= 9000 && Hght > 6000) {
return `rgba(225,225,0,${trans})`;
} else if (Hght <= 12000 && Hght > 9000) {
return `rgba(0,225,225,${trans})`;
}
} else if (HodographColorTable == 1) {
if (Hght <= 1000) {
return `rgba(128,0,128,${trans})`;
} else if (Hght <= 3000 && Hght > 1000) {
return `rgba(255,0,0,${trans})`;
} else if (Hght <= 6000 && Hght > 3000) {
return `rgba(255,140,0,${trans})`;
} else if (Hght <= 9000 && Hght > 6000) {
return `rgba(255,215,0,${trans})`;
} else if (Hght <= 12000 && Hght > 9000) {
return `rgba(255,240,159,${trans})`;
}
} else if (HodographColorTable == 2) {
const maxHeight = 12000;
const clamped = Math.max(0, Math.min(Hght, maxHeight));
const scaled = (clamped / maxHeight) * (HODO_COLORS.length - 1);
const lowerIndex = Math.floor(scaled);
const upperIndex = Math.ceil(scaled);
const t = scaled - lowerIndex;
if (lowerIndex === upperIndex) {
return HODO_COLORS[lowerIndex];
}
const c1 = hexToRgb(HODO_COLORS[lowerIndex]);
const c2 = hexToRgb(HODO_COLORS[upperIndex]);
const r = Math.round(lerp(c1.r, c2.r, t));
const g = Math.round(lerp(c1.g, c2.g, t));
const b = Math.round(lerp(c1.b, c2.b, t));
return `rgba(${r}, ${g}, ${b}, ${trans})`;
} else if (HodographColorTable == 3) {
const N = Hght * 205 / 12000 + 50;
return `rgba(${N}, ${N}, ${N}, ${trans})`;
}
}
let mapImage = null;
let hodographDrawVersion = 0;
let hodographMapRedrawFrame = null;
let hodographMapRedrawRequest = null;
// --- ADD THIS EXPORT FUNCTION ---
/** Sets or applies map image state.
* @param {*} img Parameter for SetMapImage.
* @returns {void} No value.
*/
function SetMapImage(img) {
mapImage = img;
}
/**
* Schedules one redraw after one or more map tiles finish loading.
* @param {object} sounding Sounding currently being drawn.
* @param {boolean} ghost Whether this is a ghost sounding redraw.
* @param {object[]} comparisonSoundings Comparison soundings for the current chart.
* @param {number} drawVersion Version of the hodograph render that requested tiles.
* @returns {void}
*/
function scheduleHodographMapRedraw(sounding, ghost, comparisonSoundings, drawVersion) {
hodographMapRedrawRequest = { sounding, ghost, comparisonSoundings, drawVersion };
if (hodographMapRedrawFrame !== null) return;
const view = root.ownerDocument.defaultView;
hodographMapRedrawFrame = view.requestAnimationFrame(() => {
hodographMapRedrawFrame = null;
const request = hodographMapRedrawRequest;
hodographMapRedrawRequest = null;
if (!request || request.drawVersion !== hodographDrawVersion) return;
DrawHodograph(request.sounding, request.ghost, request.comparisonSoundings);
});
}
/** User preference: thin RAOB/import hodograph to SPC-like spacing (default on). Model grid rules unchanged. */
let HodographTrimWindProfile = true;
/**
* Model grid soundings (sounding.meta.isModel) already have modest vertical resolution on the hodograph.
* RAOB / IMET / uploaded profiles often have hundreds of wind levels — thin to SPC-like spacing
* so the hodograph shows bulk shear instead of instrument noise.
* @param {*} meta Parameter for shouldThinHodographForDisplay.
* @returns {*} Result of the function.
*/
function shouldThinHodographForDisplay(meta) {
if (VWPActive) return false;
if (!meta || meta.WWL < 2) return false;
const st = meta.STitle || '';
const low = st.toLowerCase();
const isModelAgua = meta.isModel === true || (meta.meta && meta.meta.isModel === true);
if (!HodographTrimWindProfile) {
if (isModelAgua) {
if (meta.WWL <= 90) return false;
return meta.WWL > 90;
}
return false;
}
if (isModelAgua && meta.WWL <= 90) return false;
if (/radiosonde|raob|upper.?air|imet|sonde|balloon|pibal/.test(low)) return true;
if (!isModelAgua) return true;
return meta.WWL > 90;
}
/** Get the minimum height step for a given altitude.
* @param {*} aglM Parameter for minHeightStepForAglHodo.
* @returns {*} Result of the function.
*/
function minHeightStepForAglHodo(aglM) {
if (aglM < 2200) return 190;
if (aglM < 6000) return 320;
return 520;
}
/**
* @returns {number[] | null} Indices into W[] (ascending height) for endpoints of hodograph segments, or null to use full resolution.
* @param {*} W Parameter for computeThinnedHodographWindIndices.
* @param {*} SH Parameter for computeThinnedHodographWindIndices.
* @param {*} maxAglM Parameter for computeThinnedHodographWindIndices.
*/
function computeThinnedHodographWindIndices(W, SH, maxAglM = 12000) {
const n = W.length;
if (n < 2) return null;
let start = -1;
for (let i = 0; i < n; i++) {
const w = W[i];
if (w.WS !== -9999 && w.WD !== -9999 && !isNaN(w.WS) && !isNaN(w.WD) && w.H - SH < maxAglM) {
start = i;
break;
}
}
if (start < 0) return null;
const idx = [start];
let last = start;
for (let i = start + 1; i < n; i++) {
const w = W[i];
if (w.WS === -9999 || w.WD === -9999 || isNaN(w.WS) || isNaN(w.WD)) continue;
if (w.H - SH >= maxAglM) break;
const aglLast = W[last].H - SH;
const dh = w.H - W[last].H;
const minD = minHeightStepForAglHodo(aglLast);
if (dh >= minD) {
idx.push(i);
last = i;
}
}
let top = last;
for (let j = last + 1; j < n; j++) {
const w = W[j];
if (w.WS === -9999 || w.WD === -9999 || isNaN(w.WS) || isNaN(w.WD)) continue;
if (w.H - SH >= maxAglM) break;
top = j;
}
if (top > idx[idx.length - 1]) {
idx.push(top);
}
return idx.length >= 2 ? idx : null;
}
/** Returns hodograph wind step indices data.
* @param {*} meta Parameter for getHodographWindStepIndices.
* @param {*} W Parameter for getHodographWindStepIndices.
* @param {*} SH Parameter for getHodographWindStepIndices.
* @returns {*} Result of the function.
*/
function getHodographWindStepIndices(meta, W, SH) {
if (!shouldThinHodographForDisplay(meta)) return null;
return computeThinnedHodographWindIndices(W, SH);
}
/** Builds adjacent sounding-index pairs for hodograph segments.
* @param {*} WWL Parameter for hodographSegmentIndexPairs.
* @param {*} stepIdx Parameter for hodographSegmentIndexPairs.
* @returns {*} Result of the function.
*/
function hodographSegmentIndexPairs(WWL, stepIdx) {
if (stepIdx && stepIdx.length >= 2) {
const out = [];
for (let j = 0; j < stepIdx.length - 1; j++) {
out.push([stepIdx[j], stepIdx[j + 1]]);
}
return out;
}
const out = [];
for (let i = 0; i < WWL - 1; i++) {
out.push([i, i + 1]);
}
return out;
}
/** Returns hodograph storm offset mod data.
* @returns {*} Result of the function.
*/
function getHodographStormOffsetMod() {
if (!HodoMode) {
return {
x: isNaN(CSMot.x) ? 0 : -CSMot.x,
y: isNaN(CSMot.y) ? 0 : CSMot.y,
};
}
return { x: 0, y: 0 };
}
/** Converts wind data to canvas coordinates.
* @param {*} Center Parameter for hodographWindToCanvas.
* @param {*} Scale Parameter for hodographWindToCanvas.
* @param {*} slice Parameter for hodographWindToCanvas.
* @returns {*} Result of the function.
*/
function hodographWindToCanvas(Center, Scale, slice) {
let Pos = ConvertPolartoCart(slice);
if (!HodoMode) {
Pos.x -= isNaN(CSMot.x) ? 0 : CSMot.x;
Pos.y += isNaN(CSMot.y) ? 0 : CSMot.y;
}
return {
x: Center.x / 2 + Pos.x * (Scale / 10),
y: Center.y / 2 + Pos.y * (Scale / 10),
};
}
/** Converts storm motion data to canvas coordinates.
* @param {*} Center Parameter for hodographStormMotionToCanvas.
* @param {*} Scale Parameter for hodographStormMotionToCanvas.
* @param {*} Mod Parameter for hodographStormMotionToCanvas.
* @returns {*} Result of the function.
*/
function hodographStormMotionToCanvas(Center, Scale, Mod) {
const mx = isNaN(CSMot.x) ? 0 : CSMot.x;
const my = isNaN(CSMot.y) ? 0 : CSMot.y;
return {
x: Center.x / 2 + (Mod.x + mx) * (Scale / 10),
y: Center.y / 2 + (Mod.y - my) * (Scale / 10),
};
}
/** Checks whether a wind level has valid height and wind data.
* @param {*} slice Parameter for isUsableHodographWind.
* @returns {*} Result of the function.
*/
function isUsableHodographWind(slice) {
return slice &&
slice.WS !== -9999 &&
slice.WD !== -9999 &&
!isNaN(slice.WS) &&
!isNaN(slice.WD);
}
/** Resolves the layer bounds used for hodograph SRH shading.
* @param {*} Sounding Parameter for resolveHodographSrhFillLayer.
* @param {*} inflow Parameter for resolveHodographSrhFillLayer.
* @returns {*} Result of the function.
*/
function resolveHodographSrhFillLayer(Sounding, inflow) {
if (!Sounding) return null;
const sfc = GetLayerByHght(Sounding, "SFC");
if (inflow && inflow !== false && inflow.Top != null && inflow.Top !== 999 && inflow.Bot) {
const bot = inflow.isSfc === true ? GetLayerByPres(Sounding, "SFC") : GetLayerByPres(Sounding, inflow.Bot);
const top = GetLayerByPres(Sounding, inflow.Top);
if (bot && top && isUsableHodographWind(bot) && isUsableHodographWind(top) && top.H > bot.H) {
return { bot, top };
}
}
if (!sfc || !isUsableHodographWind(sfc)) return null;
const top3 = GetLayerByHght(Sounding, 3000, true);
if (!top3 || !isUsableHodographWind(top3) || top3.H <= sfc.H) return null;
return { bot: sfc, top: top3 };
}
/**
* Shade the area swept between the selected storm motion (RM by default)
* and the hodograph curve. Uses effective inflow when it exists, else 0-3 km.
* @param {*} ctx Parameter for drawHodographStormMotionFill.
* @param {*} Sounding Parameter for drawHodographStormMotionFill.
* @param {*} Center Parameter for drawHodographStormMotionFill.
* @param {*} Scale Parameter for drawHodographStormMotionFill.
* @param {*} Mod Parameter for drawHodographStormMotionFill.
* @param {*} W Parameter for drawHodographStormMotionFill.
* @param {*} stepIdx Parameter for drawHodographStormMotionFill.
* @param {*} inflow Parameter for drawHodographStormMotionFill.
* @returns {void} No value.
*/
function drawHodographStormMotionFill(ctx, Sounding, Center, Scale, Mod, W, stepIdx, inflow) {
if (!ctx || !Sounding || !W || !W.length) return;
if (isNaN(CSMot.x) || isNaN(CSMot.y)) return;
const layer = resolveHodographSrhFillLayer(Sounding, inflow);
if (!layer) return;
const sm = hodographStormMotionToCanvas(Center, Scale, Mod);
const pts = [];
const pushWind = (slice) => {
if (!isUsableHodographWind(slice)) return;
pts.push(hodographWindToCanvas(Center, Scale, slice));
};
pushWind(layer.bot);
const indices = stepIdx && stepIdx.length ? stepIdx : W.map(arrayIndex);
for (const i of indices) {
const w = W[i];
if (!isUsableHodographWind(w)) continue;
if (w.H <= layer.bot.H + 0.5 || w.H >= layer.top.H - 0.5) continue;
pushWind(w);
}
pushWind(layer.top);
if (pts.length < 1) return;
ctx.save();
ctx.fillStyle = "rgba(125,200,255,0.18)";
drawCanvasPath(ctx, [sm, ...pts], true, false, true);
ctx.strokeStyle = "rgba(125,200,255,0.55)";
ctx.lineWidth = 1.5;
strokeCanvasSegments(ctx, [
[sm.x, sm.y, pts[0].x, pts[0].y],
[sm.x, sm.y, pts[pts.length - 1].x, pts[pts.length - 1].y],
]);
ctx.restore();
}
/** Draws hodograph content.
* @param {*} Json Parameter for DrawHodograph.
* @param {*} ghst Parameter for DrawHodograph.
* @param {*} comparisonSoundings Parameter for DrawHodograph.
* @returns {void} No value.
*/
function DrawHodograph(Json, ghst = false, comparisonSoundings = []) {
const drawVersion = ++hodographDrawVersion;
const Canv = $("hodograph");
const ctx = Canv.getContext("2d");
Canv.width = 800;
Canv.height = 800;
// --- START AUTO-SCALE LOGIC ---
let maxWindSpeed = 0;
// Check main sounding winds
if (Json.Sounding && Json.Sounding.W) {
for (let i = 0; i < Json.Sounding.W.length; i++) {
if (Json.Sounding.W[i].WS !== -9999 && !isNaN(Json.Sounding.W[i].WS)) {
maxWindSpeed = Math.max(maxWindSpeed, Json.Sounding.W[i].WS);
}
}
}
// Check comparison soundings winds so they fit too
if (comparisonSoundings && comparisonSoundings.length > 0 && !VWPActive) {
comparisonSoundings.forEach(comp => {
if (comp.sounding && comp.sounding.Sounding && comp.sounding.Sounding.W) {
let cw = comp.sounding.Sounding.W;
for (let i = 0; i < cw.length; i++) {
if (cw[i].WS !== -9999 && !isNaN(cw[i].WS)) {
maxWindSpeed = Math.max(maxWindSpeed, cw[i].WS);
}
}
}
});
}
// Calculate base scale to fit max wind within the canvas radius (approx 380px)
// 3800 is derived from: Radius (380px) * 10 (units per scale step)
// We set a minimum denominator of 100 to prevent zooming in too far on calm days
let fitBase = 3800 / Math.max(maxWindSpeed + 20, 100);
// Apply the multiplier
if (HodographScaleMode == 2){
OutsideFitBase = fitBase;
} else {
OutsideFitBase = 3800/160;
fitBase = 3800/160;
}
const Scale = fitBase * HodographScaleMulti;
// 1. Clear the canvas
ctx.fillStyle = "rgba(0,0,0,1)";
ctx.fillRect(0, 0, 1000, 1000);
// 2. Draw the copyright text
ctx.fillStyle = "rgba(255,255,255,1)"
ctx.strokeStyle = "rgb(0, 0, 0)"
ctx.lineWidth = 1.2;
ctx.font = `bold ${Canv.width / 55}px ${globalFont}`;
ctx.textAlign = "left"; // Align to the right
ctx.strokeText(brandLine(" - powered by "), Canv.width * 0.02, Canv.height * 0.03);
ctx.fillText(brandLine(" - powered by "), Canv.width * 0.02, Canv.height * 0.03);
if (comparisonSoundings && comparisonSoundings.length > 0 && !VWPActive) {
let legendY = Canv.height * 0.05; // Starting Y position
const legendX = Canv.width * 0.02;
const legendBoxSize = Canv.width * 0.025;
comparisonSoundings.forEach(comp => {
if (!comp.metadata.isVisible) return;
const metadata = comp.metadata;
const color = metadata.comparisonColor || "rgba(0, 191, 255, 1)";
ctx.fillStyle = color;
ctx.fillRect(legendX, legendY, legendBoxSize, legendBoxSize);
const date = metadata.date;
const formattedDate = `${date.substring(4, 6)}-${date.substring(6, 8)}-${date.substring(0, 4)}`;
let legendText = "";
if (metadata.title == undefined){
legendText = `${metadata.model.toUpperCase()} ${formattedDate} ${metadata.run}Z F${metadata.fh}`;
} else {
legendText = metadata.title;
}
ctx.fillStyle = "rgba(255, 255, 255, 1)";
ctx.font = `bold ${Canv.width / 50}px ${globalFont}`;
ctx.textAlign = "left";
ctx.fillText(legendText, legendX + legendBoxSize + 10, legendY + legendBoxSize / 1.4);
legendY += legendBoxSize + 8; // Move down for the next legend
});
}
ctx.fillStyle = "rgba(255,255,255,1)"
ctx.textAlign = "left";
ctx.strokeStyle = "rgba(255,255,255,0.7)";
ctx.lineWidth = 4;
ctx.lineWidth = 1.5;
ctx.setLineDash([4, 4]);
let Sounding = Json.Sounding;
let BRM = GetBM(Sounding);
ctx.fillStyle = "rgba(255,255,255,1)";
const CT = GetPositionScale(50, 50, Canv);
ctx.font = `bold ${Canv.width / 75}px ${globalFont}`;
ctx.scale(2, 2); // Scaling now happens AFTER the legend is drawn
ctx.font = `${Canv.width / 75}px ${globalFont}`;
let BrmComps = ConvertCartToPolarRealRnd(BRM.RM);
let BlmComps = ConvertCartToPolarRealRnd(BRM.LM);
let CustomComps = ConvertCartToPolarRealRnd(CUMot);
let MeanComps = ConvertCartToPolarRealRnd(BRM.MM);
let USComps = ConvertCartToPolarRealRnd(BRM.US);
let DSComps = ConvertCartToPolarRealRnd(BRM.DS);
let DTMRComps = ConvertCartToPolarRealRnd(BRM.DTMR);
let DTMLComps = ConvertCartToPolarRealRnd(BRM.DTML);
if (HodographScaleMode == 0) { // None (Fixed Center)
HodographCenter = [50, 50];
} else { // Storm Relative or Dynamic (Shifted Center)
HodographCenter = [
50 - ((isNaN(CSMot.x) ? 0 : CSMot.x) * (Scale / 10)) /4 ,
50 + ((isNaN(CSMot.y) ? 0 : CSMot.y) * (Scale / 10)) /4
]
}
// console.log(CT.x)
let Center = GetPositionScale(HodographCenter[0], HodographCenter[1], Canv);
if (!HodoMode) {
Center = GetPositionScale(50, 50, Canv);
}
ctx.strokeStyle = "rgba(255,255,255,0.3)";
ctx.beginPath();
ctx.setLineDash([]);
ctx.moveTo(0, Center.y / 2);
ctx.lineTo(Canv.width, Center.y / 2);
ctx.stroke();
ctx.beginPath();
//ctx.setLineDash([4, 4]);
ctx.moveTo(Center.x / 2, 0);
ctx.lineTo(Center.x / 2, Canv.height);
ctx.stroke();
ctx.strokeStyle = "rgba(255,255,255,0.2)";
ctx.setLineDash([4, 4]);
ctx.strokeStyle = "rgba(255,255,255,0.2)";
// Update loop limit to ensure rings cover the maximum wind speed
for (let i = 10; i < Math.max(200, maxWindSpeed + 20); i += 10) {
ctx.beginPath();
ctx.arc(Center.x / 2, Center.y / 2, i * Scale / 10, 0, 2 * Math.PI);
ctx.stroke();
ctx.fillStyle = "rgba(200,200,200,1)";
ctx.textAlign = "left";
const speedLabel = useImperialUnits === 2 ? Math.round(KtsToMph(i)) : i;
ctx.fillText(speedLabel, Center.x / 2 + i * Scale / 10 - 5, Center.y / 2 + 3 + 10);
ctx.fillText(speedLabel, Center.x / 2 - i * Scale / 10 - 5, Center.y / 2 + 3 + 10);
ctx.textAlign = "center";
ctx.fillText(speedLabel, Center.x / 2 + 10, Center.y / 2 - i * Scale / 10 + 3);
ctx.fillText(speedLabel, Center.x / 2 + 10, Center.y / 2 + i * Scale / 10 + 3);
ctx.closePath();
}
ctx.setLineDash([]);
const SH = GetLayerByHght(Json.Sounding, "SFC").H;
//DrawHodograph
//let Start = ConvertPolartoCart(Sounding.W[0]);
ctx.fillStyle = "rgba(225,0,0,1)";
ctx.strokeStyle = "rgba(225,0,0,1)";
//ctx.moveTo(Center.x, Center.y);
ctx.lineWidth = 1.5;
// ctx.lineStyle = "rgba(255,0,0,1)";
if (ghst != false) {
ghost.forEach((O) => {
let Center = GetPositionScale(HodographCenter[0], HodographCenter[1], Canv);
if (!HodoMode) {
Center = GetPositionScale(50, 50, Canv);
}
ctx.lineWidth = 1;
ctx.strokeStyle = "rgba(25,100,100,0.5)";
ctx.fillStyle = "rgba(25,100,100,0.5)";
let Obj = O.Sounding;
const ghostMeta = { STitle: O.STitle || Json.STitle || '', WWL: O.WWL };
const ghostStepIdx = getHodographWindStepIndices(ghostMeta, Obj.W, SH);
const ghostPairs = hodographSegmentIndexPairs(O.WWL, ghostStepIdx);
ctx.beginPath();
for (const pr of ghostPairs) {
const i = pr[0];
const i2 = pr[1];
if (Obj.H[i] - SH >= 12000 || Obj.W[i].H - SH >= 12000) {
break;
}
if (Obj.W[i].WS == -9999 || Obj.W[i].WD == -9999) {
continue;
}
if (Number.isNaN(Obj.W[i].WS) || Number.isNaN(Obj.W[i].WD)) {
continue;
}
let Pos = ConvertPolartoCart(Obj.W[i]);
let Pos2 = ConvertPolartoCart(Obj.W[i2]);
if (!HodoMode) {
Pos.x -= isNaN(CSMot.x) ? 0 : CSMot.x;
Pos.y += isNaN(CSMot.y) ? 0 : CSMot.y;
Pos2.x -= isNaN(CSMot.x) ? 0 : CSMot.x;
Pos2.y += isNaN(CSMot.y) ? 0 : CSMot.y;
}
//// console.log(Center.x + Pos.x, Center.y + Pos.y);
ctx.moveTo(Center.x / 2 + Pos.x * (Scale / 10), Center.y / 2 + Pos.y * (Scale / 10));
ctx.lineTo(Center.x / 2 + Pos2.x * (Scale / 10), Center.y / 2 + Pos2.y * (Scale / 10));
//ctx.moveTo(Center.x/2 + Pos2.x, Center.y + Pos2.y);
ctx.stroke();
ctx.fill();
}
ctx.stroke();
ctx.fill();
});
ctx.lineWidth = 2;
ctx.fillStyle = "rgba(225,0,0,1)";
ctx.strokeStyle = "rgba(225,0,0,1)";
}
if (comparisonSoundings && comparisonSoundings.length > 0 && !VWPActive) {
comparisonSoundings.forEach(comp => {
if (!comp.metadata.isVisible) return;
const comparisonColor = comp.metadata.comparisonColor || "rgba(0, 191, 255, 1)";
ctx.strokeStyle = comparisonColor;
ctx.lineWidth = 2;
let compObj = comp.sounding.Sounding;
let Center = GetPositionScale(HodographCenter[0], HodographCenter[1], Canv);
if (!HodoMode) {
Center = GetPositionScale(50, 50, Canv);
}
const compSH = GetLayerByHght(compObj, "SFC").H;
const compStepIdx = getHodographWindStepIndices(comp.sounding, compObj.W, compSH);
const compPairs = hodographSegmentIndexPairs(comp.sounding.WWL, compStepIdx);
for (const pr of compPairs) {
const i = pr[0];
const i2 = pr[1];
if (compObj.W[i].H - compSH >= 12000 || compObj.W[i2].H - compSH >= 12000) break;
if (compObj.W[i].WS === -9999 || compObj.W[i].WD === -9999) continue;
if (compObj.W[i2].WS === -9999 || compObj.W[i2].WD === -9999) continue;
let Pos = ConvertPolartoCart(compObj.W[i]);
let Pos2 = ConvertPolartoCart(compObj.W[i2]);
if (!HodoMode) {
Pos.x -= isNaN(CSMot.x) ? 0 : CSMot.x;
Pos.y += isNaN(CSMot.y) ? 0 : CSMot.y;
Pos2.x -= isNaN(CSMot.x) ? 0 : CSMot.x;
Pos2.y += isNaN(CSMot.y) ? 0 : CSMot.y;
}
ctx.beginPath();
ctx.moveTo(Center.x / 2 + Pos.x * (Scale / 10), Center.y / 2 + Pos.y * (Scale / 10));
ctx.lineTo(Center.x / 2 + Pos2.x * (Scale / 10), Center.y / 2 + Pos2.y * (Scale / 10));
ctx.stroke();
}
});
}
let Obj = Json.Sounding;
ctx.strokeStyle = "rgba(225,0,225,1)";
ctx.lineWidth = 2;
const mainStepIdx = getHodographWindStepIndices(Json, Obj.W, SH);
const mainPairs = hodographSegmentIndexPairs(Json.WWL, mainStepIdx);
let Mod = getHodographStormOffsetMod();
drawHodographStormMotionFill(
ctx,
Obj,
Center,
Scale,
Mod,
Obj.W,
mainStepIdx,
typeof Info !== "undefined" ? Info.EffInflow : false
);
for (const pr of mainPairs) {
const i = pr[0];
const iNext = pr[1];
const currentLevel = Obj.W[i];
const nextLevel = Obj.W[iNext];
if (Obj.H[i] - SH >= 12000 || currentLevel.H - SH >= 12000 || nextLevel.H - SH >= 12000) {
break;
}
if (
currentLevel.WS === -9999 ||
currentLevel.WD === -9999 ||
nextLevel.WS === -9999 ||
nextLevel.WD === -9999
) {
continue;
}
if (isNaN(currentLevel.WS) || isNaN(currentLevel.WD) || isNaN(nextLevel.WS) || isNaN(nextLevel.WD)) {
continue;
}
let Hght = currentLevel.H;
let HghtMax = nextLevel.H;
ctx.strokeStyle = GetHodographColor(HghtMax - SH, ctx);
let Pos = ConvertPolartoCart(currentLevel);
let Pos2 = ConvertPolartoCart(nextLevel);
if (!HodoMode) {
Pos.x -= isNaN(CSMot.x) ? 0 : CSMot.x;
Pos.y += isNaN(CSMot.y) ? 0 : CSMot.y;
Pos2.x -= isNaN(CSMot.x) ? 0 : CSMot.x;
Pos2.y += isNaN(CSMot.y) ? 0 : CSMot.y;
}
// NEXRAD VWP RMS: circle radius in hodograph space = RMS (kt) × same scale as wind components
const errKts = Obj.W[i].ERR_KTS;
if (errKts != null && errKts > 0 && Number.isFinite(errKts)) {
const cx = Center.x / 2 + Pos.x * (Scale / 10);
const cy = Center.y / 2 + Pos.y * (Scale / 10);
const rPx = errKts * (Scale / 10) * VWP_HODOGRAPH_RMS_RADIUS_SCALE;
ctx.fillStyle = GetHodographColor(Hght - SH, ctx, VWP_HODOGRAPH_RMS_FILL_ALPHA);
ctx.beginPath();
ctx.arc(cx, cy, rPx, 0, 2 * Math.PI);
ctx.fill();
}
ctx.beginPath();
ctx.moveTo(Center.x / 2 + Pos.x * (Scale / 10), Center.y / 2 + Pos.y * (Scale / 10));
ctx.lineTo(Center.x / 2 + Pos2.x * (Scale / 10), Center.y / 2 + Pos2.y * (Scale / 10));
ctx.stroke();
ctx.fill();
}
const topIdx =
mainStepIdx && mainStepIdx.length >= 1 ? mainStepIdx[mainStepIdx.length - 1] : Json.WWL - 1;
if (topIdx >= 0) {
const top = Obj.W[topIdx];
const errTop = top && top.ERR_KTS;
if (
top &&
errTop != null &&
errTop > 0 &&
Number.isFinite(errTop) &&
top.WS != -9999 &&
top.WD != -9999 &&
top.H - SH < 12000
) {
let Ptop = ConvertPolartoCart(top);
if (!HodoMode) {
Ptop.x -= isNaN(CSMot.x) ? 0 : CSMot.x;
Ptop.y += isNaN(CSMot.y) ? 0 : CSMot.y;
}
const cx = Center.x / 2 + Ptop.x * (Scale / 10);
const cy = Center.y / 2 + Ptop.y * (Scale / 10);
ctx.fillStyle = GetHodographColor(top.H - SH, ctx, VWP_HODOGRAPH_RMS_FILL_ALPHA);
ctx.beginPath();
ctx.arc(cx, cy, errTop * (Scale / 10) * VWP_HODOGRAPH_RMS_RADIUS_SCALE, 0, 2 * Math.PI);
ctx.fill();
}
}
// Drop stray stroke/fill: last path was often the top RMS arc — stroking it drew the red outline bug.
ctx.beginPath();
// Numbers
if (DisplayHodographNumbers) {
ctx.strokeStyle = "rgba(255,225,225,1)";
ctx.font = `bold 8px ${globalFont}`;
for (var x of [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]) {
const Lyr = GetLayerByHght(Json.Sounding, x * 1000);
let Pos = ConvertPolartoCart(Lyr);
if (!HodoMode) {
Pos.x -= isNaN(CSMot.x) ? 0 : CSMot.x;
Pos.y += isNaN(CSMot.y) ? 0 : CSMot.y;
}
ctx.fillStyle = "rgba(255,225,225,0.75)";
ctx.beginPath();
ctx.arc(Center.x / 2 + Pos.x * (Scale / 10), Center.y / 2 + Pos.y * (Scale / 10), 4, 0, 2 * Math.PI);
//ctx.moveTo(Center.x/2 + Pos2.x, Center.y + Pos2.y);
ctx.fill();
ctx.fillStyle = "rgba(0,0,0,1)";
ctx.fillText(`${x}`, Center.x / 2 + Pos.x * (Scale / 10), Center.y / 2 + Pos.y * (Scale / 10) + 2);
}
ctx.font = `${Canv.width / 75}px ${globalFont}`;
}
ctx.font = `10px ${globalFont}`;
const unitSuffix = useImperialUnits === 2 ? "mph" : "kts";
ctx.fillStyle = CSMot == Motions.RM && "rgba(255,255,255,1)" || "rgba(255,255,255,0.7)";
ctx.textAlign = "left";
var rmSpeed = useImperialUnits === 2 ? Math.round(KtsToMph(BrmComps.WS)) : BrmComps.WS;
rmSpeed = CSMot === Motions.RM ? `${BrmComps.WD} / ${rmSpeed}` : rmSpeed;
ctx.fillText(`${rmSpeed}${unitSuffix} RM`, Center.x / 2 + (Mod.x + BRM.RM.x) * (Scale / 10) + 5, Center.y / 2 + (Mod.y + BRM.RM.y) * (Scale / 10) + 10);
ctx.fillStyle = CSMot == Motions.LM && "rgba(255,255,255,1)" || "rgba(255,255,255,0.7)";
ctx.textAlign = "left";
var lmSpeed = useImperialUnits === 2 ? Math.round(KtsToMph(BlmComps.WS)) : BlmComps.WS;
lmSpeed = CSMot === Motions.LM ? `${BlmComps.WD} / ${lmSpeed}` : lmSpeed;
ctx.fillText(`${lmSpeed}${unitSuffix} LM`, Center.x / 2 + (Mod.x + BRM.LM.x) * (Scale / 10) + 5, Center.y / 2 + (Mod.y + BRM.LM.y) * (Scale / 10) + 10);
ctx.fillStyle = CSMot == CUMot && "rgba(255,255,255,1)" || "rgba(255,255,255,0.7)";
ctx.textAlign = "left";
var cmSpeed = useImperialUnits === 2 ? Math.round(KtsToMph(CustomComps.WS)) : CustomComps.WS;
cmSpeed = CSMot === CUMot ? `${(360 + 180 - CustomComps.WD)%360} / ${cmSpeed}` : cmSpeed;
ctx.fillText(`${cmSpeed}${unitSuffix} CM`, Center.x / 2 + (Mod.x + CUMot.x) * (Scale / 10) + 5, Center.y / 2 - (-Mod.y + CUMot.y) * (Scale / 10) + 10);
ctx.font = `8px ${globalFont}`;
ctx.fillStyle = CSMot == Motions.US && "rgba(255,255,255,1)" || "rgba(255,255,255,0.7)";
ctx.textAlign = "left";
var usSpeed = useImperialUnits === 2 ? Math.round(KtsToMph(USComps.WS)) : USComps.WS;
usSpeed = CSMot === Motions.US ? `${USComps.WD} / ${usSpeed}` : usSpeed;
ctx.fillText(`${usSpeed}${unitSuffix} UP`, Center.x / 2 + (Mod.x + BRM.US.x) * (Scale / 10) + 5, Center.y / 2 + (Mod.y + BRM.US.y) * (Scale / 10) + 10);
ctx.fillStyle = CSMot == Motions.DS && "rgba(255,255,255,1)" || "rgba(255,255,255,0.7)";
ctx.textAlign = "left";
var dsSpeed = useImperialUnits === 2 ? Math.round(KtsToMph(DSComps.WS)) : DSComps.WS;
dsSpeed = CSMot === Motions.DS ? `${DSComps.WD} / ${dsSpeed}` : dsSpeed;
ctx.fillText(`${dsSpeed}${unitSuffix} DN`, Center.x / 2 + (Mod.x + BRM.DS.x) * (Scale / 10) + 5, Center.y / 2 + (Mod.y + BRM.DS.y) * (Scale / 10) + 10);
ctx.fillStyle = CSMot == Motions.DTMR && "rgba(161, 161, 161, 1)" || "rgba(161, 161 ,161 ,0.7)";
ctx.textAlign = "left";
var dtmrSpeed = useImperialUnits === 2 ? Math.round(KtsToMph(DTMRComps.WS)) : DTMRComps.WS;
dtmrSpeed = CSMot === Motions.DTMR ? `${DTMRComps.WD} / ${dtmrSpeed}` : dtmrSpeed;
ctx.fillText(`${dtmrSpeed}${unitSuffix} DTMR`, Center.x / 2 + (Mod.x + BRM.DTMR.x) * (Scale / 10) + 5, Center.y / 2 + (Mod.y + BRM.DTMR.y) * (Scale / 10) + 10);
ctx.fillStyle = CSMot == Motions.DTML && "rgba(161, 161, 161, 1)" || "rgba(161, 161 ,161 ,0.7)";
ctx.textAlign = "left";
var dtmlSpeed = useImperialUnits === 2 ? Math.round(KtsToMph(DTMLComps.WS)) : DTMLComps.WS;
dtmlSpeed = CSMot === Motions.DTML ? `${DTMLComps.WD} / ${dtmlSpeed}` : dtmlSpeed;
ctx.fillText(`${dtmlSpeed}${unitSuffix} DTML`, Center.x / 2 + (Mod.x + BRM.DTML.x) * (Scale / 10) + 5, Center.y / 2 + (Mod.y + BRM.DTML.y) * (Scale / 10) + 10);
ctx.font = `10px ${globalFont}`;
ctx.strokeStyle = CSMot == Motions.RM && "rgba(255,0,0,0.9)" || "rgba(255,0,0,0.5)";
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.arc(Center.x / 2 + (Mod.x + BRM.RM.x) * (Scale / 10), Center.y / 2 + (Mod.y + BRM.RM.y) * (Scale / 10), 4, 0, 2 * Math.PI);
ctx.stroke();
ctx.strokeStyle = CSMot == Motions.LM && "rgba(255,0,0,0.9)" || "rgba(255,0,0,0.5)";
ctx.beginPath();
ctx.arc(Center.x / 2 + (Mod.x + BRM.LM.x) * (Scale / 10), Center.y / 2 + (Mod.y + BRM.LM.y) * (Scale / 10), 4, 0, 2 * Math.PI);
ctx.stroke();
ctx.strokeStyle = CSMot == CUMot && "rgba(255,0,0,0.9)" || "rgba(255,0,0,0.5)";
ctx.beginPath();
ctx.arc(Center.x / 2 + (Mod.x + CUMot.x) * (Scale / 10), Center.y / 2 - (-Mod.y + CUMot.y) * (Scale / 10), 4, 0, 2 * Math.PI);
ctx.stroke();
//Others
ctx.strokeStyle = CSMot == Motions.US && "rgba(0, 255, 255, 0.9)" || "rgba(0, 255, 255, 0.4)";
ctx.beginPath();
ctx.arc(Center.x / 2 + (Mod.x + BRM.US.x) * (Scale / 10), Center.y / 2 + (Mod.y + BRM.US.y) * (Scale / 10), 3, 0, 2 * Math.PI);
ctx.stroke();
ctx.strokeStyle = CSMot == Motions.DS && "rgba(0, 255, 255, 0.9)" || "rgba(0, 255, 255, 0.4)";
ctx.beginPath();
ctx.arc(Center.x / 2 + (Mod.x + BRM.DS.x) * (Scale / 10), Center.y / 2 + (Mod.y + BRM.DS.y) * (Scale / 10), 3, 0, 2 * Math.PI);
ctx.stroke();
//Deviant Motions
ctx.strokeStyle = CSMot == Motions.DTMR && "rgba(170, 115, 83, 0.9)" || "rgba(170, 115, 83, 0.4)";
ctx.beginPath();
ctx.arc(Center.x / 2 + (Mod.x + BRM.DTMR.x) * (Scale / 10), Center.y / 2 + (Mod.y + BRM.DTMR.y) * (Scale / 10), 3, 0, 2 * Math.PI);
ctx.stroke();
ctx.strokeStyle = CSMot == Motions.DTML && "rgba(170, 115, 83, 0.9)" || "rgba(170, 115, 83, 0.4)";
ctx.beginPath();
ctx.arc(Center.x / 2 + (Mod.x + BRM.DTML.x) * (Scale / 10), Center.y / 2 + (Mod.y + BRM.DTML.y) * (Scale / 10), 3, 0, 2 * Math.PI);
ctx.stroke();
let hght0km = GetLayerByHght(Sounding, "SFC");
//Possibly Wrong
let hght6km = GetLayerByHght(Sounding, 6000);
let MM = MeanWind(Sounding, hght0km.P, hght6km.P, 1);
ctx.strokeStyle = CSMot == Motions.MM && "rgba(255,100,50,0.9)" || "rgba(200,100,50,0.5)";
ctx.strokeRect(Center.x / 2 + (Mod.x + MM.x) * (Scale / 10), Center.y / 2 + (Mod.y + MM.y) * (Scale / 10), 6, 6);
ctx.fillStyle = CSMot == Motions.MM && "rgba(255,255,255,1)" || "rgba(255,255,255,0.7)";
ctx.textAlign = "left";
var mmSpeed = useImperialUnits === 2 ? Math.round(KtsToMph(MeanComps.WS)) : Math.round(MeanComps.WS);
mmSpeed = CSMot === Motions.MM ? `${MeanComps.WD} / ${mmSpeed}` : mmSpeed;
ctx.fillText(`${mmSpeed}${unitSuffix} MM`, Center.x / 2 + (Mod.x + MM.x) * (Scale / 10) + 10, Center.y / 2 + (Mod.y + MM.y) * (Scale / 10) + 10);
var P = 195;
if (CSMot == CUMot) {
ctx.textAlign = "left";
ctx.fillStyle = "rgba(255,0,0,1)";
ctx.fillText(`Custom Motion`, Center.x / 2 + (Mod.x + CSMot.x) * (Scale / 10) - 180, Center.y / 2 - (-Mod.y + CSMot.y) * (Scale / 10) + P);
P -= 15;
}
if (ModifiedWind) {
ctx.textAlign = "left";
ctx.fillStyle = "rgba(255,0,0,1)";
ctx.fillText("Custom Surface Data", Center.x / 2 + (Mod.x + CSMot.x) * (Scale / 10) - 180, Center.y / 2 - (-Mod.y + CSMot.y) * (Scale / 10) + P);
// console.log("AYO");
P -= 15;
}
if (!HodoMode) {
ctx.textAlign = "left";
ctx.fillStyle = "rgba(255,0,0,1)";
ctx.fillText("Storm-Relative", Center.x / 2 + (Mod.x + CSMot.x) * (Scale / 10) - 180, Center.y / 2 - (-Mod.y + CSMot.y) * (Scale / 10) + P);
}
ctx.textAlign = "center";
if (!VWPActive) {
drawHodographLocationInset(ctx, Canv, Json, () => {
scheduleHodographMapRedraw(Json, ghst, comparisonSoundings, drawVersion);
});
}
if (mapImage && !VWPActive) {
const mapSize = 200;
const padding = 0; // You can increase this if you want some spacing from the edge
// CHANGED: Calculate X to be on the right side
const x = Canv.width - mapSize - padding;
// Y remains at the bottom
const y = Canv.height - mapSize - padding;
ctx.save();
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.drawImage(mapImage, 0, 0, mapImage.width || mapSize, mapImage.height || mapSize, x, y, mapSize, mapSize);
ctx.restore();
}
//ctx.fill();
// ctx.closePath();
}
/** Draws skew numbers content.
* @returns {void} No value.
*/
function DrawSkewNumbers() {
const Canv = $("skewTNumbers");
const ctx = Canv.getContext("2d");
const dropsondeMode = !!root.querySelector(".sjs-viz.dropsonde-mode");
Canv.width = 800;
Canv.height = 45;
//ctx.translate(SkewTPosition.x,0);
ctx.fillStyle = dropsondeMode ? "#0a0c0e" : "rgba(0,0,0,0)";
if (dropsondeMode) ctx.fillRect(0, 0, Canv.width, Canv.height);
ctx.font = `${dropsondeMode ? 600 : 400} ${Canv.width / (dropsondeMode ? 36 : 40)}px ${globalFont}`;
let Bounds = {
l: -SkewTPosition.x,
u: -SkewTPosition.x + Canv.width / SkewTScale,
};
const tStart = Math.ceil(skv.tleft / 10) * 10;
const tEnd = Math.floor((skv.tleft + skv.tspread) / 10) * 10;
for (let i = tStart; i <= tEnd; i += 10) {
let N = GetSkewRatio(i) * Canv.width * SkewTScale * 0.9;
ctx.fillStyle = dropsondeMode ? "#8ea3b3" : "rgba(255,255,255,1)";
ctx.textAlign = "center";
const tempLabel = useImperialUnits === 2 ? Math.round(CToF(i)) : i;
ctx.fillText(`${tempLabel}`, N + SkewTPosition.x * SkewTScale * 0.9, Canv.height * 2 / 3);
}
ctx.fillStyle = dropsondeMode ? "rgba(255,255,255,0.12)" : "rgba(255,255,255,1)";
ctx.fillRect(25, 1, 700, dropsondeMode ? 1 : 1);
}
let iterbb = 0;
/** Returns skew ratio data.
* @param {*} N Parameter for GetSkewRatio.
* @returns {*} Result of the function.
*/
function GetSkewRatio(N) {
return ((N - skv.tleft) / skv.tspread + 0.01);
}
/** Returns the color for a wind barb based on its speed.
* @param {*} N Parameter for BarbColor.
* @returns {*} Result of the function.
*/
function BarbColor(N) {
let ColorIndex = [
"rgba(100,100,255,1)",
"rgba(0,125,255,1)",
"rgba(0,255,0,1)",
"rgba(255,255,0,1)",
"rgba(255,125,0,1)",
"rgba(255,0,0,1)",
"rgba(255,0,125,1)",
"rgba(255,0,255,1)",
"rgba(125,0,255,1)",
"rgba(0,0,255,1)",
"rgba(125,125,125,1)",
"rgba(255,255,255,1)",
];
return ColorIndex[Math.min(Math.floor(N / 10), 11)];
}
/** Saffir–Simpson colors — dropsonde barbs only (matches side-panel badges).
* @param {*} kt Parameter for dropsondeHurricaneBarbColor.
* @returns {*} Result of the function.
*/
function dropsondeHurricaneBarbColor(kt) {
const n = Number(kt);
const v = Number.isFinite(n) ? Math.max(0, n) : 0;
if (v >= 200) return '#a9eaff';
if (v >= 160) return '#372df9';
if (v >= 137) return '#1b239a';
if (v >= 113) return '#561761';
if (v >= 96) return '#f44336';
if (v >= 83) return '#ff9800';
if (v >= 64) return '#ffeb3b';
if (v >= 34) return '#4caf50';
return '#5c6bc0';
}
/** Draws barb vwp content.
* @param {*} params Parameter for drawBarbVWP.
* @param {*} overrideColor Parameter for drawBarbVWP.
* @param {*} lat Parameter for drawBarbVWP.
* @returns {*} Result of the function.
*/
function drawBarbVWP(params, overrideColor = null, lat = 0){
const Canv = $("Values8");
const ctx = Canv.getContext("2d");
// If an override color is provided, use it. Otherwise, calculate it based on speed.
let barbColor = overrideColor;
let wdir = params[1];
let wspd = params[0];
let S = SkewTPosition.y / Canv.height;
//let ScaleBot = PressuretoSkew(SkewTPosition.y
let Bounds = {
l: -SkewTPosition.y,
u: -SkewTPosition.y + Canv.height / SkewTScale,
};
let siz = 1.5;
// --- MODIFIED: Center the barb horizontally ---
let x = params[2];
let y = params[3];
ctx.fillStyle = barbColor;
ctx.lineWidth = 3;
ctx.strokeStyle = barbColor;
if (!qc(wdir) || !qc(wspd)) return RMISSD;
ctx.beginPath();
let Polar = ConvertPolartoCart({ WS: 1, WD: (-wdir) });
var dx = 50 * Polar.x * (siz / 1.5);
var dy = 50 * Polar.y * (siz / 1.5);
var x1 = x;
var y1 = y;
var x2 = x1 + dx;
var y2 = y1 - dy;
// Draw backbone of wind barb, along with origin dot
ctx.rect(x1 - 1, y1 - 1, 2, 2);
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
var sped = wspd;
x1 = x2;
y1 = y2;
var wid = 7; // Number of flags that will fit
var spcx = dx / wid;
var spcy = dy / wid;
x1 = x1 + spcx;
y1 = y1 - spcy;
// Draw wind flags (increments of 50kt)
var flag = 0;
var hgt, x3, y3;
// EDITED: Determine direction of flags based on hemisphere (Lat < 0 is Southern Hemisphere)
const flipFactor = lat < 0 ? -1 : 1;
ctx.fillStyle = ctx.strokeStyle;
//// console.log(wdir + " " + wspd + " " + ctx.strokeStyle + " " + x + " " + y);
while (sped > 50) {
var flag = 1;
x1 = x1 - spcx;
y1 = y1 + spcy;
hgt = .5 * flipFactor; // EDITED: Apply flipFactor
x2 = x1 + (dy * hgt);
y2 = y1 + (dx * hgt);
x3 = x1 - spcx;
y3 = y1 + spcy;
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.lineTo(x3, y3);
ctx.closePath();
ctx.fill();
x2 = (x1 + x2 + x3) / 3;
y2 = (y1 + y2 + y3) / 3;
sped -= 50;
x1 = x3;
y1 = y3;
}
// Draw wind barbs (increments of 5kt)
while (sped > 10) {
hgt = .5 * flipFactor; // EDITED: Apply flipFactor
x2 = x1 + (dy * hgt);
y2 = y1 + (dx * hgt);
x3 = x1 - spcx;
y3 = y1 + spcy;
ctx.moveTo(x3, y3);
ctx.lineTo(x2, y2);
sped -= 10;
x1 = x3;
y1 = y3;
}
// Draw short barb
if (sped >= 5) {
hgt = .5 * flipFactor; // EDITED: Apply flipFactor
x2 = x1 + (dy * hgt);
y2 = y1 + (dx * hgt);
x3 = x1 - spcx;
y3 = y1 + spcy;
dx = (x3 - x2) / 2;
dy = (y3 - y2) / 2;
x2 = x3 - dx;
y2 = y3 - dy;
ctx.moveTo(x3, y3);
ctx.lineTo(x2, y2);
}
ctx.stroke();
ctx.lineWidth = 1;
}
/** Draws barb content.
* @param {*} params Parameter for drawBarb.
* @param {*} overrideColor Parameter for drawBarb.
* @param {*} lat Parameter for drawBarb.
* @param {*} useHurricaneColors Parameter for drawBarb.
* @returns {*} Result of the function.
*/
function drawBarb(params, overrideColor = null, lat = 0, useHurricaneColors = false) {
const Canv = $("barbs");
const ctx = Canv.getContext("2d");
// Override wins; dropsonde uses SS category colors; otherwise classic speed ramp.
let barbColor =
overrideColor ||
(useHurricaneColors ? dropsondeHurricaneBarbColor(params[5]) : BarbColor(params[5]));
let wdir = params[4];
let wspd = params[5];
let S = SkewTPosition.y / Canv.height;
//let ScaleBot = PressuretoSkew(SkewTPosition.y
let Bounds = {
l: -SkewTPosition.y,
u: -SkewTPosition.y + Canv.height / SkewTScale,
};
let siz = 1;
// --- MODIFIED: Center the barb horizontally ---
let x = Canv.width / 2;
let y = 0.05 + 0.99 * Canv.height * (PressuretoSkew(params[0]));
y = (y - Bounds.l) / (Bounds.u - Bounds.l);
y *= Canv.height;
ctx.fillStyle = barbColor;
ctx.lineWidth = 2;
ctx.strokeStyle = barbColor;
if (!qc(wdir) || !qc(wspd)) return RMISSD;
// Calm / 0 kt: station-plot circle only (no staff or barbs).
if (wspd < 2.5) {
const r = Math.max(2.5, Canv.width * 0.035);
ctx.beginPath();
ctx.arc(x, y, r, 0, Math.PI * 2);
ctx.stroke();
ctx.lineWidth = 1;
return;
}
ctx.beginPath();
let Polar = ConvertPolartoCart({ WS: 1, WD: (-wdir) });
var dx = 50 * Polar.x * (siz / 1.5);
var dy = 50 * Polar.y * (siz / 1.5);
var x1 = x;
var y1 = y;
var x2 = x1 + dx;
var y2 = y1 - dy;
// Draw backbone of wind barb, along with origin dot
ctx.rect(x1 - 1, y1 - 1, 2, 2);
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
var sped = wspd;
x1 = x2;
y1 = y2;
var wid = 7; // Number of flags that will fit
var spcx = dx / wid;
var spcy = dy / wid;
x1 = x1 + spcx;
y1 = y1 - spcy;
// Draw wind flags (increments of 50kt)
var flag = 0;
var hgt, x3, y3;
// EDITED: Determine direction of flags based on hemisphere (Lat < 0 is Southern Hemisphere)
const flipFactor = lat < 0 ? -1 : 1;
ctx.fillStyle = ctx.strokeStyle;
//// console.log(wdir + " " + wspd + " " + ctx.strokeStyle + " " + x + " " + y);
while (sped > 50) {
var flag = 1;
x1 = x1 - spcx;
y1 = y1 + spcy;
hgt = .5 * flipFactor; // EDITED: Apply flipFactor
x2 = x1 + (dy * hgt);
y2 = y1 + (dx * hgt);
x3 = x1 - spcx;
y3 = y1 + spcy;
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.lineTo(x3, y3);
ctx.closePath();
ctx.fill();
x2 = (x1 + x2 + x3) / 3;
y2 = (y1 + y2 + y3) / 3;
sped -= 50;
x1 = x3;
y1 = y3;
}
// Draw wind barbs (increments of 5kt)
while (sped > 10) {
hgt = .5 * flipFactor; // EDITED: Apply flipFactor
x2 = x1 + (dy * hgt);
y2 = y1 + (dx * hgt);
x3 = x1 - spcx;
y3 = y1 + spcy;
ctx.moveTo(x3, y3);
ctx.lineTo(x2, y2);
sped -= 10;
x1 = x3;
y1 = y3;
}
// Draw short barb
if (sped >= 5) {
hgt = .5 * flipFactor; // EDITED: Apply flipFactor
x2 = x1 + (dy * hgt);
y2 = y1 + (dx * hgt);
x3 = x1 - spcx;
y3 = y1 + spcy;
dx = (x3 - x2) / 2;
dy = (y3 - y2) / 2;
x2 = x3 - dx;
y2 = y3 - dy;
ctx.moveTo(x3, y3);
ctx.lineTo(x2, y2);
}
ctx.stroke();
ctx.lineWidth = 1;
}
/** Draws barbs content.
* @param {*} Sounding Parameter for DrawBarbs.
* @param {*} comparisonSoundings Parameter for DrawBarbs.
* @returns {void} No value.
*/
function DrawBarbs(Sounding, comparisonSoundings = []) {
const Canv = $("barbs");
const ctx = Canv.getContext("2d");
ctx.fillStyle = "rgba(0,0,0,1)";
ctx.fillRect(0, 0, 1000, 1000);
Canv.height = 800;
Canv.width = 100;
// EDITED: Get main sounding latitude
const mainLat = Sounding.Location ? Sounding.Location[0] : 0;
const hurricaneBarbColors = isDropsondeSounding(Sounding);
if (comparisonSoundings && comparisonSoundings.length > 0) {
comparisonSoundings.forEach(comp => {
if (!comp.metadata.isVisible) return;
// Use the model's assigned color for its barbs
const color = comp.metadata.comparisonColor || "rgba(0, 191, 255, 1)";
const sound = comp.sounding.Sounding;
// EDITED: Get comparison sounding latitude
const compLat = comp.sounding.Location ? comp.sounding.Location[0] : 0;
// Draw a barb for each data level in the comparison sounding
for (let i = 0; i < sound.Slice.length; i++) {
drawBarb(sound.Slice[i], color, compLat, false);
}
});
}
// For radiosonde data, only draw barbs at specific pressure levels
if (Sounding.STitle.toLowerCase().includes('radiosonde') || hurricaneBarbColors) {
// Always draw the first (surface) point
drawBarb(Sounding.Sounding.Slice[0], null, mainLat, hurricaneBarbColors);
// Define the pressure levels we want
const targetPressures = [];
// From 1000 to 800mb: every 50mb
for (let p = 1000; p >= 800; p -= 50) {
targetPressures.push(p);
}
// From 775 to 100mb: every 25mb
for (let p = 775; p >= 100; p -= 25) {
targetPressures.push(p);
}
// Draw barbs only for these pressure levels
targetPressures.forEach(targetP => {
// Find the closest level in the sounding data
let closestIndex = 0;
let minDiff = Math.abs(Sounding.Sounding.Slice[0][0] - targetP);
for (let i = 1; i < Sounding.Sounding.Slice.length; i++) {
const diff = Math.abs(Sounding.Sounding.Slice[i][0] - targetP);
if (diff < minDiff) {
minDiff = diff;
closestIndex = i;
}
}
// Only draw if we're within 5mb of the target pressure and it's not the first point (already drawn)
if (minDiff <= 5 && closestIndex !== 0) {
drawBarb(Sounding.Sounding.Slice[closestIndex], null, mainLat, hurricaneBarbColors);
}
});
} else {
// Original logic for model data
drawBarb(Sounding.Sounding.Slice[0], null, mainLat, false);
for (let i = 1; i < Math.floor(Sounding.TWL / 2); i += 1) {
drawBarb(Sounding.Sounding.Slice[i], null, mainLat, false);
}
for (let i = Math.floor(Sounding.TWL / 2); i < Sounding.TWL; i += 2) {
drawBarb(Sounding.Sounding.Slice[i], null, mainLat, false);
}
}
};
/** Draws mix ratio line content.
* @param {*} val Parameter for MixRatioLine.
* @returns {void} No value.
*/
function MixRatioLine(val) {
const Canv = $("skewT");
const ctx = Canv.getContext("2d");
var pbot = 1045;//Math.min(1045, skv.pbot);
var ptop = 600;//Math.max(skv.ptop, Math.min(600, pbot - 50));
var temp = temp_at_mixrat(val, pbot);
var x0 = Canv.width * (GetSkewRatio(temp) + LevelToSkewNum(pbot));
var y0 = pres_to_pix(pbot);
temp = temp_at_mixrat(val, ptop);
var x1 = Canv.width * (GetSkewRatio(temp) + LevelToSkewNum(ptop));
var y1 = pres_to_pix(ptop);
ctx.save();
ctx.setLineDash([2, 2]);
ctx.strokeStyle = CheckMode("rgba(4,125,4,1)", "rgba(4,75,4,1)");
ctx.lineWidth = 3;
strokeCanvasLine(ctx, x0, y0, x1, y1);
ctx.fillStyle = CheckMode('rgba(45, 200, 45, 1)', 'rgba(25, 100, 25, 1)');
ctx.textAlign = "center";
ctx.fillText(`${val}`, x1, y1);
ctx.restore();
}
/** Draws line content.
* @param {*} Sounding Parameter for DrawLine.
* @param {*} type Parameter for DrawLine.
* @param {*} ghost Parameter for DrawLine.
* @param {*} overrideColor Parameter for DrawLine.
* @returns {void} No value.
*/
function DrawLine(Sounding, type, ghost = false, overrideColor = null) {
const Canv = $("skewT");
const ctx = Canv.getContext("2d");
// --- START GHOST LOGIC ---
// This block handles drawing the faint "ghost" lines from previous model runs.
// If the 'ghost' parameter is provided, we execute this and then exit the function.
if (ghost) {
ghost.forEach((Lop) => {
let Sound = Lop.Sounding;
let Loop, LoopVar, EndVar;
if (type == "Dew") {
Loop = Sound.DW;
LoopVar = Lop.DWL;
EndVar = "D";
} else if (type == "Temp") {
Loop = Sound.TW;
LoopVar = Lop.TWL;
EndVar = "T";
} else if (type == "VTemp") {
Loop = Sound.TW;
LoopVar = Lop.TWL;
EndVar = "VT";
} else if (type == "WetBulb") {
Loop = Sound.TW;
LoopVar = Lop.TWL;
EndVar = "WB";
}
ctx.lineWidth = 3;
ctx.strokeStyle = CheckMode("rgba(0,200,200,0.5)", "rgba(30,144,255,0.5)");
ctx.beginPath();
for (let i = 0; i < LoopVar - 1; i++) {
if (Loop[i].P < 100) {
break;
}
let OBJ = Loop[i];
let OBJ2 = Loop[i + 1];
let PosX = LevelToSkewNum(OBJ.P, Loop[0].P);
let PosY = PressuretoSkew(OBJ.P, Loop[0].P);
let Ratio = GetSkewRatio(OBJ[EndVar]);
let PosX2 = LevelToSkewNum(OBJ2.P, Loop[0].P);
let PosY2 = PressuretoSkew(OBJ2.P, Loop[0].P);
let Ratio2 = GetSkewRatio(OBJ2[EndVar]);
ctx.moveTo((Ratio + PosX) * Canv.width, PosY * Canv.height);
ctx.lineTo((Ratio2 + PosX2) * Canv.width, PosY2 * Canv.height);
}
ctx.stroke();
});
// IMPORTANT: Exit here so we don't draw the main lines afterward.
return;
}
// --- END GHOST LOGIC ---
let Sound = Sounding.Sounding;
let Loop;
let LoopVar;
let EndVar;
let Layers = [];
// --- START OF COMBINED LOGIC FOR PRIMARY AND COMPARISON LINES ---
if (overrideColor) {
// This is a COMPARISON line. Use the provided color.
ctx.lineWidth = 4;
ctx.strokeStyle = overrideColor;
if (type == "Dew") {
Loop = Sound.DW; LoopVar = Sounding.DWL; EndVar = "D";
} else if (type == "Temp") {
Loop = Sound.TW; LoopVar = Sounding.TWL; EndVar = "T";
}
} else {
// This is a PRIMARY line. Use the original logic to determine color and style.
const dropStyle = isDropsondeSounding(Sounding);
if (type == "Dew") {
ctx.lineWidth = dropStyle ? 5 : 4;
ctx.strokeStyle = CheckMode("rgba(0,225,0,1)", "rgba(0,139,0,1)");
Loop = Sound.DW; LoopVar = Sounding.DWL; EndVar = "D";
} else if (type == "Temp") {
ctx.lineWidth = dropStyle ? 5 : 4;
ctx.strokeStyle = CheckMode("rgba(225,0,0,1)", "rgba(139,0,0,1)");
Loop = Sound.TW; LoopVar = Sounding.TWL; EndVar = "T";
} else if (type == "VTemp") {
ctx.lineWidth = 4;
ctx.strokeStyle = CheckMode("rgba(255,0,0,0.7)", "rgba(139,0,0,0.7)");
Loop = Sound.TW; LoopVar = Sounding.TWL; EndVar = "VT";
} else if (type == "WetBulb") {
ctx.lineWidth = 2;
ctx.strokeStyle = CheckMode("rgba(0,200,200,1)", "rgba(30,144,255,1)");
Loop = Sound.TW; LoopVar = Sounding.TWL; EndVar = "WB";
}
if (dropStyle && (type == "Temp" || type == "Dew")) {
ctx.lineCap = "round";
ctx.lineJoin = "round";
} else {
ctx.lineCap = "butt";
ctx.lineJoin = "butt";
}
}
// --- END OF COMBINED LOGIC ---
if (Sounding.Sounding.SliceMin !== undefined && Sounding.Sounding.SliceMax !== undefined && (type == "Temp" || type == "Dew")){
ctx.beginPath();
if (type == "Temp") {
ctx.fillStyle = CheckMode("rgba(255,75,75,.3)", "rgba(139,20,20,.3)");
Loop = Sound.TW;
LoopVar = Sounding.TWL;
EndVar = "T";
} else if (type == "Dew"){
ctx.fillStyle = CheckMode("rgba(0,225,0,.2)", "rgba(0,139,0,.2)");
Loop = Sound.DW;
LoopVar = Sounding.DWL;
EndVar = "D";
}
if (type == "VTemp") {
ctx.setLineDash([5, 5]);
} else {
ctx.setLineDash([]);
}
let StartSlicer = ConvertToInfo(Sounding.Sounding.SliceMin[0], Sounding.Sounding.SliceMin[0]);
let PrevLocation = {x:0, y:0}
let FirstLocation = false;
for (let i = 0; i < Sounding.Sounding.SliceMin.length-1; i++){
let RS = ConvertToInfo(Sounding.Sounding.SliceMin[i], Sounding.Sounding.SliceMin[0]);
let RS2 = ConvertToInfo(Sounding.Sounding.SliceMin[i+1], Sounding.Sounding.SliceMin[0]);
if (RS.P < 100) {
break;
}
if (!tqc(RS[EndVar])){
continue;
}
let OBJ = RS;
let OBJ2 = RS2;
let PosX = LevelToSkewNum(OBJ.P, StartSlicer.P);
let PosY = PressuretoSkew(OBJ.P, StartSlicer.P);
let Ratio = GetSkewRatio(OBJ[EndVar]);
let PosX2 = LevelToSkewNum(OBJ2.P, StartSlicer.P);
let PosY2 = PressuretoSkew(OBJ2.P, StartSlicer.P);
let Ratio2 = GetSkewRatio(OBJ2[EndVar]);
if (FirstLocation == false){
FirstLocation = {x:(Ratio + PosX) * Canv.width, y: PosY * Canv.height}
}
ctx.lineTo((Ratio2 + PosX2) * Canv.width, PosY2 * Canv.height);
PrevLocation = {x: (Ratio2 + PosX2) * Canv.width, y : PosY2 * Canv.height}
}
StartSlicer = ConvertToInfo(Sounding.Sounding.SliceMax[0], Sounding.Sounding.SliceMax[0]);
for (let i = Sounding.Sounding.SliceMax.length-1; i > 0; i--){
let RS = ConvertToInfo(Sounding.Sounding.SliceMax[i], Sounding.Sounding.SliceMax[0]);
let RS2 = ConvertToInfo(Sounding.Sounding.SliceMax[i-1], Sounding.Sounding.SliceMax[0]);
if (RS.P < 100) {
continue;
}
if (!tqc(RS[EndVar])){
continue;
}
let OBJ = RS;
let OBJ2 = RS2;
let PosX = LevelToSkewNum(OBJ.P, StartSlicer.P);
let PosY = PressuretoSkew(OBJ.P, StartSlicer.P);
let Ratio = GetSkewRatio(OBJ[EndVar]);
let PosX2 = LevelToSkewNum(OBJ2.P, StartSlicer.P);
let PosY2 = PressuretoSkew(OBJ2.P, StartSlicer.P);
let Ratio2 = GetSkewRatio(OBJ2[EndVar]);
if (PrevLocation.x != false){
ctx.lineTo((Ratio + PosX) * Canv.width, PosY * Canv.height);
PrevLocation.x = false;
}
ctx.lineTo((Ratio2 + PosX2) * Canv.width, PosY2 * Canv.height);
}
ctx.lineTo(FirstLocation.x, FirstLocation.y);
ctx.fill();
}
ctx.beginPath();
if (type == "VTemp") {
ctx.setLineDash([5, 5]);
} else {
ctx.setLineDash([]);
}
for (let i = 0; i < LoopVar - 1; i++) {
if (!(Loop[i].P > 0)) continue;
if (skv.ptop > 0 && Loop[i].P < skv.ptop) continue;
let OBJ = Loop[i];
let OBJ2 = Loop[i + 1];
if (!OBJ2 || !(OBJ2.P > 0)) continue;
const v1 = OBJ[EndVar];
const v2 = OBJ2[EndVar];
if (!Number.isFinite(v1) || !Number.isFinite(v2)) continue;
if (v1 < -90 || v1 > 60 || v2 < -90 || v2 > 60) continue;
let PosX = LevelToSkewNum(OBJ.P, Loop[0].P);
let PosY = PressuretoSkew(OBJ.P, Loop[0].P);
let Ratio = GetSkewRatio(OBJ[EndVar]);
let PosX2 = LevelToSkewNum(OBJ2.P, Loop[0].P);
let PosY2 = PressuretoSkew(OBJ2.P, Loop[0].P);
let Ratio2 = GetSkewRatio(OBJ2[EndVar]);
ctx.moveTo((Ratio + PosX) * Canv.width, PosY * Canv.height);
ctx.lineTo((Ratio2 + PosX2) * Canv.width, PosY2 * Canv.height);
if (type == "Temp" && !overrideColor) {
if (Loop[Math.max(1, i)].T >= -30 && Loop[Math.min(LoopVar, i)].T <= -10) {
Layers.push(Loop[i]);
} else if (!isDropsondeSounding(Sounding)) {
ctx.strokeStyle = CheckMode("rgba(255,0,0,1)", "rgba(139,0,0,1)");
}
}
}
ctx.stroke();
ctx.fill();
if (Layers.length > 0 && !ghost && !overrideColor && !isDropsondeSounding(Sounding)) { // Don't draw hail growth zone for comparison / dropsonde lines
let Start = GetLayerByTemp(Sound, -10);
if (Start.T < -10){
let CheckEnd = GetLayerByPres(Sound, "Last");
if (CheckEnd.P == Start.P){
return;
}
}
let End = GetLayerByTemp(Sound, -30);
if (End == false) {
End = GetLayerByPres(Sound, "Last");
}
let SfcSlice = ConvertToInfo(Sound.Slice[1]);
let PX = LevelToSkewNum(Start.P, SfcSlice.P);
let PY = PressuretoSkew(Start.P, SfcSlice.P);
let Rio = GetSkewRatio(-10);
let PX2 = LevelToSkewNum(Layers[0].P, SfcSlice.P);
let PY2 = PressuretoSkew(Layers[0].P, SfcSlice.P);
let Rio2 = GetSkewRatio(Layers[0].T);
ctx.strokeStyle = CheckMode("rgba(255,175,126,1)", "rgba(125,85,65,1)");
ctx.lineWidth = 4;
ctx.strokeStyle = CheckMode("rgba(255,175,126,1)", "rgba(125,85,65,1)");
ctx.beginPath();
ctx.moveTo((Rio + PX) * Canv.width, PY * Canv.height);
ctx.lineTo((Rio + PX) * Canv.width, PY * Canv.height);
for (let i = 0; i < Sound.Slice.length - 1; i++) {
if (Math.max(Start.P, End.P) >= Sound.Slice[i][0] && Math.min(Start.P, End.P) <= Sound.Slice[i][0]) {
let Obj = ConvertToInfo(Sound.Slice[i]);
let PosX = LevelToSkewNum(Obj.P, SfcSlice.P);
let PosY = PressuretoSkew(Obj.P, SfcSlice.P);
let Ratio = GetSkewRatio(Obj.T);
ctx.lineTo((Ratio + PosX) * Canv.width, PosY * Canv.height);
ctx.moveTo((Ratio + PosX) * Canv.width, PosY * Canv.height);
}
}
let PsX = LevelToSkewNum(Layers[Layers.length - 1].P, SfcSlice.P);
let PsY = PressuretoSkew(Layers[Layers.length - 1].P, SfcSlice.P);
let Rtio = GetSkewRatio(Layers[Layers.length - 1].T);
let PsX2 = LevelToSkewNum(End.P, SfcSlice.P);
let PsY2 = PressuretoSkew(End.P, SfcSlice.P);
let Rtio2 = GetSkewRatio(Math.max(-30, End.T));
ctx.strokeStyle = "rgba(255,175,126,1)";
ctx.lineTo((Rtio2 + PsX2) * Canv.width, PsY2 * Canv.height);
ctx.stroke();
ctx.fill();
}
}
/** Draws dry adiabat content.
* @param {*} thta Parameter for dry_adiabat.
* @param {*} style Parameter for dry_adiabat.
* @returns {void} No value.
*/
function dry_adiabat(thta, style = null) {
const Canv = $("skewT");
const ctx = Canv.getContext("2d");
const dropsondeMode = !!root.querySelector(".sjs-viz.dropsonde-mode");
ctx.beginPath();
ctx.lineWidth = style?.lineWidth ?? 1;
ctx.strokeStyle = style?.strokeStyle
?? (DarkMode == true && "rgba(255,255,255,0.2)" || "rgba(0,0,0,0.2)");
if (dropsondeMode){
ctx.lineCap = "round";
ctx.lineJoin = "round";
} else {
ctx.lineCap = "butt";
ctx.lineJoin = "butt";
}
const adiabatBot = skv.pbot;
const adiabatTop = Math.max(50, skv.ptop - 20);
for (var pres = adiabatBot; pres >= adiabatTop; pres -= 10) {
// temp = 2;
var temp = ((thta + 273.15) / Math.pow(1000 / pres, ROCP)) - 273.15;
let x = LevelToSkewNum(pres, adiabatBot);
let y = PressuretoSkew(pres, adiabatBot);
let r = GetSkewRatio(temp);
if (pres == adiabatBot) {
ctx.moveTo((r + x) * Canv.width, y * Canv.height);
}
ctx.lineTo((r + x) * Canv.width, y * Canv.height);
}
ctx.stroke();
}
/** Returns 3 cape color data.
* @param {*} N Parameter for Get3CapeColor.
* @returns {*} Result of the function.
*/
function Get3CapeColor(N) {
if (N <= 50) {
return "rgba(255,255,255,1)";
} else if (N <= 100 && N > 50) {
return "rgba(255,255,0,1)";
} else if (N <= 200 && N > 100) {
return "rgba(255,0,0,1)";
} else {
return "rgba(255,0,255,1)";
}
}
/** Returns 6 cape color data.
* @param {*} N Parameter for Get6CapeColor.
* @returns {*} Result of the function.
*/
function Get6CapeColor(N) {
if (N <= 400) {
return "rgba(255,255,255,1)";
} else if (N <= 800 && N > 400) {
return "rgba(255,255,0,1)";
} else if (N <= 1200 && N > 800) {
return "rgba(255,0,0,1)";
} else {
return "rgba(255,0,255,1)";
}
}
/** Returns cin color data.
* @param {*} N Parameter for GetCinColor.
* @returns {*} Result of the function.
*/
function GetCinColor(N) {
if (N > -50) {
return "rgba(0,255,0,1)";
} else if (N > -100) {
return "rgba(255,255,0,1)";
} else if (N > -200) {
return "rgba(255,0,0,1)";
} else {
return "rgba(125,175,0,1)";
}
}
var __ShowECape = true;
/** Draws values key content.
* @param {*} Text Parameter for ValuesKeyRep.
* @param {*} Scaler Parameter for ValuesKeyRep.
* @param {*} Add Parameter for ValuesKeyRep.
* @param {*} Total Parameter for ValuesKeyRep.
* @param {*} ST Parameter for ValuesKeyRep.
* @param {*} HeaderSize Parameter for ValuesKeyRep.
* @returns {*} Result of the function.
*/
function ValuesKeyRep(Text, Scaler, Add, Total, ST, HeaderSize) {
const Canv = $("Values1");
const ctx = Canv.getContext("2d");
ctx.font = `bold ${HeaderSize}px ${globalFont}`;
ctx.textAlign = "center";
ctx.fillText(Text, Canv.width * (ST + Scaler / Total), Canv.height * 0.15);
return Scaler + Add;
}
/** Draws values key content.
* @returns {void} No value.
*/
function DrawValuesKey() {
const Canv = $("Values1");
const ctx = Canv.getContext("2d");
Canv.width = 900;
Canv.height = 175;
let Scale = 1;
let typeR = "Parcel";
let Scaler = 0;
let ST = 0.085;
let Total = 33;
let HeaderSize = Canv.width / 42.5;
ctx.fillStyle = "rgba(255,255,255,1)";
ctx.fillRect(Canv.width * 0.02, Canv.height * 0.175, Canv.width * 0.96, 2);
Scaler = ValuesKeyRep(`Parcel`, Scaler, 3.85, Total, ST, HeaderSize);
Scaler = ValuesKeyRep(`ECAPE`, Scaler, 3.25, Total, ST, HeaderSize);
Scaler = ValuesKeyRep(`NCAPE`, Scaler, 2.95, Total, ST, HeaderSize);
Scaler = ValuesKeyRep(`CAPE`, Scaler, 3, Total, ST, HeaderSize);
Scaler = ValuesKeyRep(`3CAPE`, Scaler, 3, Total, ST, HeaderSize);
Scaler = ValuesKeyRep(`6CAPE`, Scaler, 3, Total, ST, HeaderSize);
Scaler = ValuesKeyRep(`CINH`, Scaler, 2.9, Total, ST, HeaderSize);
Scaler = ValuesKeyRep(`LCL`, Scaler, 3, Total, ST, HeaderSize);
Scaler = ValuesKeyRep(`LFC`, Scaler, 3, Total, ST, HeaderSize);
Scaler = ValuesKeyRep(`EL`, Scaler, 3, Total, ST, HeaderSize);
}
/** Draws values content.
* @param {*} Text Parameter for ValuesRep.
* @param {*} Color Parameter for ValuesRep.
* @param {*} Scale Parameter for ValuesRep.
* @param {*} Scaler Parameter for ValuesRep.
* @param {*} Add Parameter for ValuesRep.
* @param {*} Total Parameter for ValuesRep.
* @param {*} ST Parameter for ValuesRep.
* @param {*} HeaderSize Parameter for ValuesRep.
* @param {*} cs Parameter for ValuesRep.
* @param {*} left Parameter for ValuesRep.
* @returns {*} Result of the function.
*/
function ValuesRep(Text, Color, Scale, Scaler, Add, Total, ST, HeaderSize, cs = false, left = false) {
const Canv = $("Values1");
const ctx = Canv.getContext("2d");
if (cs) {
if (left) {
ctx.font = `700 ${HeaderSize}px ${globalFont}`;
ctx.textAlign = "left";
} else {
ctx.font = `550 ${HeaderSize}px ${globalFont}`;
ctx.textAlign = "center";
}
} else {
ctx.font = `${HeaderSize}px ${globalFont}`;
ctx.textAlign = "center";
}
ctx.fillStyle = Color;
ctx.fillText(Text, Canv.width * (ST + Scaler / Total), Canv.height * (0.1 + 0.2 * Scale));
return Scaler + Add;
}
/** Draws values content.
* @param {*} type Parameter for DrawValues.
* @param {*} Values Parameter for DrawValues.
* @param {*} Selected Parameter for DrawValues.
* @returns {void} No value.
*/
function DrawValues(type, Values, Selected = false) {
const Canv = $("Values1");
const ctx = Canv.getContext("2d");
let Scale = 1;
let typeR = "Parcel";
let Scaler = 0;
let ST = 0.01;
let Total = 33;
if (type == "SB") {
Scale = 1.2;
typeR = "Surface-Based";
} else if (type == "ML") {
Scale = 2.2;
typeR = "Mixed-Layer";
} else if (type == "MU") {
Scale = 3.2;
typeR = "Most Unstable";
} else if (type == "CU") {
Scale = 4.2;
if (CUPrcl.Path == false) {
typeR = `Custom (900mb)`;
} else {
typeR = `Custom (${Math.round(CUPrcl.Start.P)}mb)`;
}
}
if (Selected == true) {
ctx.strokeStyle = "rgba(255,255,255,0.9)";
ctx.lineWidth = 2;
ctx.strokeRect(Canv.width * (0.005 + Scaler / 22), Canv.height * (0.165 + 0.2 * (Scale - 1)), 0.99 * Canv.width, 0.2 * Canv.height);
}
let HeaderSize = Canv.width / 50;
let WhiteColor = "rgba(255,255,255,1)";
const heightUnit = useImperialUnits === 2 ? "ft" : "m";
ctx.fillStyle = WhiteColor;
//Parcel Type
Scaler = ValuesRep(`${typeR}`, WhiteColor, Scale, Scaler, 6.5, Total, ST, HeaderSize, true, true);
//Ecape
Scaler = ValuesRep(`${(Values.ECape.Cape !== "--" ? Math.floor(Values.ECape.Cape) : "--")} `, GetCapeColor(Values.ECape.Cape), Scale, Scaler, 3, Total, ST, HeaderSize, true);
//NCape
Scaler = ValuesRep(`${(Values.NCape !== "--" ? Math.floor(100 * Values.NCape) / 100 : "--")} `, GetCapeColor(Values.NCape * 10000), Scale, Scaler, 3, Total, ST, HeaderSize, true);
//Cape
Scaler = ValuesRep(`${Math.floor(Values.Cape)}`, GetCapeColor(Values.Cape), Scale, Scaler, 3, Total, ST, HeaderSize, true);
//3cape
Scaler = ValuesRep(`${Math.floor(Values.Cape3)}`, Get3CapeColor(Values.Cape3), Scale, Scaler, 3, Total, ST, HeaderSize, true);
//6Cape
Scaler = ValuesRep(`${Math.floor(Values.Cape6)}`, Get6CapeColor(Values.Cape6), Scale, Scaler, 3, Total, ST, HeaderSize, true);
//CINH
Scaler = ValuesRep(`${Math.floor(Values.CINH)}`, GetCinColor(Values.CINH), Scale, Scaler, 3, Total, ST, HeaderSize, true);
//LCL
const lclValue = Values.LCL === -9999 ? "--" : Math.floor(useImperialUnits === 2 ? MToFt(Values.LCL) : Values.LCL);
Scaler = ValuesRep(`${lclValue}${heightUnit}`, WhiteColor, Scale, Scaler, 3, Total, ST, HeaderSize, true);
//LFC
const lfcValue = Values.LFC === -9999 ? "--" : Math.floor(useImperialUnits === 2 ? MToFt(Values.LFC) : Values.LFC);
Scaler = ValuesRep(`${lfcValue}${heightUnit}`, WhiteColor, Scale, Scaler, 3, Total, ST, HeaderSize, true);
//EL
const elValue = Values.EL === -9999 ? "--" : Math.floor(useImperialUnits === 2 ? MToFt(Values.EL) : Values.EL);
Scaler = ValuesRep(`${elValue}${heightUnit}`, WhiteColor, Scale, Scaler, 3, Total, ST, HeaderSize, true);
}
var HodographCenter = [50, 50]
/** Draws inflow hodo content.
* @param {*} Sounding Parameter for DrawInflowHodo.
* @param {*} inflowInfo Parameter for DrawInflowHodo.
* @param {*} BRM Parameter for DrawInflowHodo.
* @returns {void} No value.
*/
function DrawInflowHodo(Sounding, inflowInfo, BRM) {
// SRH fill is painted inside DrawHodograph so it stays aligned with
// the wind trace, scale, and storm-relative offset. Kept as a no-op
// because existing callers still invoke this after a hodograph redraw.
}
/** Draws inflow content.
* @param {*} Sounding Parameter for DrawInflow.
* @param {*} Info Parameter for DrawInflow.
* @returns {void} No value.
*/
function DrawInflow(Sounding, Info) {
const Canv = $("skewT");
const ctx = Canv.getContext("2d");
if (Info == false) {
return;
}
let BotLayer = GetLayerByPres(Sounding, Info.Bot);
let TopLayer = GetLayerByPres(Sounding, Info.Top);
ctx.fillStyle = "rgb(125, 200, 255)";
let Y0 = PressuretoSkew(Info.Bot);
let Y1 = PressuretoSkew(Info.Top);
let SRH = GetSRH(Sounding, BotLayer.P, TopLayer.P, "Pres");
let ST = GetLayerByPres(Sounding, "SFC").H;
ctx.fillRect(0.24 * Canv.width, Y0 * Canv.height, 0.05 * Canv.width, 2 + 0.005 * Canv.height);
ctx.font = `normal 550 ${Canv.width / 50}px ${globalFont}`;
ctx.textAlign = "center";
if (Info.isSfc) {
ctx.fillText(`SFC`, Canv.width * (0.2), Y0 * Canv.height + (2 + 0.005 * Canv.height));
} else {
ctx.fillText(`${Math.round(BotLayer.H - ST)}m`, Canv.width * (0.2), Y0 * Canv.height + (2 + 0.005 * Canv.height));
}
ctx.fillText(`${Math.round(TopLayer.H - ST)}m`, Canv.width * (0.2), Y1 * Canv.height - (2 + 0.005 * Canv.height));
ctx.textAlign = "left";
ctx.fillText(`${Math.round(SRH.SRHP + SRH.SRHM)} m²/s²`, Canv.width * (0.24), (Y1 - 0.02) * Canv.height);
ctx.fillRect(0.265 * Canv.width - 2, Y0 * Canv.height, 4, (Y1 - Y0) * Canv.height);
ctx.fillRect(0.24 * Canv.width, Y1 * Canv.height - (2 + 0.005 * Canv.height), 0.05 * Canv.width, 2 + 0.005 * Canv.height);
}
/** Returns the color for a lapse rate based on its value.
* @param {*} N Parameter for LapseRateColor.
* @returns {*} Result of the function.
*/
function LapseRateColor(N) {
if (N == "--") {
return "rgba(125,125,125,1)";
}
if (N < 5.5) {
return "rgba(125,125,125,1)";
} else if (N >= 5.5 && N < 6) {
return "rgba(125,125,200,1)";
} else if (N >= 6 && N < 6.5) {
return "rgba(75,255,75,1)";
} else if (N >= 6.5 && N < 7) {
return "rgba(200,200,50,1)";
} else if (N >= 7 && N < 7.5) {
return "rgba(200,100,0,1)";
} else if (N >= 7.5 && N < 8) {
return "rgba(255,55,55,1)";
} else if (N >= 8 && N < 9) {
return "rgba(255,0,125,1)";
} else {
return "rgba(255,255,255,1)";
}
}
/** Converts lapse values.
* @param {*} rate Parameter for convertLapse.
* @returns {*} Result of the function.
*/
const convertLapse = (rate) => {
if (rate === "--") return "--";
return useImperialUnits === 2 ? Math.round(10 * CPerKmToCPerKft(rate)) / 10 : rate;
};
/** Draws storm motion content.
* @param {*} Sounding Parameter for StormMotionBox.
* @returns {void} No value.
*/
function StormMotionBox(Sounding){
const Canv2 = $("Values2");
const ctx2 = Canv2.getContext("2d");
Canv2.width = 500;//Canv.style.width;
Canv2.height = 275;
ctx2.fillStyle = "rgb(0, 0, 0)";
ctx2.fillRect(0, 0, 1000, 1000)
const impSpd = useImperialUnits === 2;
const srSpdMax = impSpd ? KtsToMph(80) : 80;
const envSpdMax = impSpd ? KtsToMph(140) : 140;
const spdUnitLbl = impSpd ? "mph" : "kt";
/**
* Scale storm-relative wind speed to the storm-motion chart's horizontal percentage.
* @param {number} wndKts Wind speed (in kt).
* @returns {number} Horizontal chart position (in percent).
*/
function srWindXfract(wndKts) {
const v = impSpd ? KtsToMph(wndKts) : wndKts;
return 100 * (v / srSpdMax);
}
/**
* Scale environmental wind speed to the environmental-wind chart's horizontal percentage.
* @param {number} wndKts Wind speed (in kt).
* @returns {number} Horizontal chart position (in percent).
*/
function envWindXfract(wndKts) {
const v = impSpd ? KtsToMph(wndKts) : wndKts;
return 100 * (v / envSpdMax);
}
let BRM = GetBM(Sounding);
let FntSiz = Canv2.width / 20;
ctx2.fillStyle = "rgba(255,255,255,1)";
ctx2.font = `600 ${FntSiz*1.25}px ${globalFont}`;
ctx2.textAlign = "center";
ctx2.fillText(`Storm Motions:`, Canv2.width * 0.5, Canv2.height * 0.1);
ctx2.fillRect(Canv2.width * 0.05, Canv2.height * 0.13, Canv2.width * 0.9, 3);
let MotionRows = [
["Bunkers Right", ConvertCartToPolarRealRnd(BRM.RM)],
["Bunkers Left", ConvertCartToPolarRealRnd(BRM.LM)],
["Mean Motion", ConvertCartToPolarRealRnd(BRM.MM)],
["Corfidi Upshear", ConvertCartToPolarRealRnd(BRM.US)],
["Corfidi Downshear", ConvertCartToPolarRealRnd(BRM.DS)],
["Nixon Deviant Right", ConvertCartToPolarRealRnd(BRM.DTMR)],
["Nixon Deviant Left", ConvertCartToPolarRealRnd(BRM.DTML)]
];
MotionRows[3][1].WD = 180 - MotionRows[3][1].WD + 360;
MotionRows[3][1].WD %= 360;
let XP = 0;
let XR = 333;
let YP = 0.255;
let YR = 66;
MotionRows.forEach((N) => {
ctx2.fillStyle = "rgb(208, 255, 253)";
ctx2.font = `700 ${FntSiz * 1.1}px ${globalFont}`;
ctx2.textAlign = "left";
ctx2.fillText(`${N[0]}:`, 10, Canv2.height * YP);
ctx2.fillStyle = "rgb(255, 211, 211)";
ctx2.font = `500 ${FntSiz * 1.1}px ${globalFont}`;
ctx2.textAlign = "right";
const motWs = N[1].WS;
const motWsDisp =
isNaN(motWs)
? "--"
: impSpd
? Math.round(KtsToMph(motWs))
: Math.round(100 * motWs) / 100;
ctx2.fillText(
`${isNaN(N[1].WD) ? "--" : Math.round(10 * N[1].WD) / 10}° / ${motWsDisp}${motWsDisp === "--" ? "" : spdUnitLbl}`,
490,
Canv2.height * YP,
);
YP += 0.75 * 1/6.4;
})
}
/** Returns lapse box data.
* @param {*} Sounding Parameter for GetLapseBox.
* @returns {void} No value.
*/
function GetLapseBox(Sounding) {
const Canv = $("Values2");
const ctx = Canv.getContext("2d");
Canv.width = 500;//Canv.style.width;
Canv.height = 275;
if (DisplayStormMotions){
StormMotionBox(Sounding)
return;
}
let StartSlice = GetLayerByPres(Sounding, "SFC");
const sh = StartSlice.H;
let LLLR = LapseRateHght(Sounding, "SFC", 3000 + sh, LapseType);
let LSLR = LapseRateHght(Sounding, "SFC", ((DP.Start != undefined && DP.Start.H || 0) + DP.LCL+ sh) || 1000, LapseType);
let LFCLR = LapseRateHght(Sounding, "SFC", ((DP.Start != undefined && DP.Start.H || 0) + DP.LFC+ sh) || 1000, LapseType);
let MLLR = LapseRateHght(Sounding, 3000+ sh, 6000 + sh, LapseType);
let MMR = LapseRatePres(Sounding, 700, 500, LapseType);
let LMR = LapseRatePres(Sounding, 850, 500, LapseType);
let MaxLR = 0;
for (let i = 2000; i < 5000; i+=250){
MaxLR = Math.max(MaxLR, LapseRateHght(Sounding, i+ sh, i + 1000 + sh, LapseType))
}
MaxLR = Math.round(10 * MaxLR)/10;
let CL = CustomLapseLayer.Lapse;
let Scale = .75;
const lapseUnit = useImperialUnits === 2 ? "C/kft" : "C/KM";
const lapseType = LapseType ? "AvLR" : "LR";
ctx.font = `bold ${Canv.width / 16}px ${globalFont}`;
if (Presets['WindLapse'] == "Default" || Presets['WindLapse'] == "Dryline") {
Scale = LapseBoxStep(ctx, Canv, "rgb(255, 255, 255)", `Sfc-3km ${lapseType}`, Scale, LLLR, lapseUnit, 1)
Scale = LapseBoxStep(ctx, Canv, "rgb(255, 210, 210)", `3km-6km ${lapseType}`, Scale, MLLR, lapseUnit, 1)
Scale = LapseBoxStep(ctx, Canv, "rgb(255, 165, 165)", `Sfc-LCL ${lapseType}`, Scale, LSLR, lapseUnit, 1)
Scale = LapseBoxStep(ctx, Canv, "rgb(255, 120, 120)", `850-500mb ${lapseType}`, Scale, LMR, lapseUnit, 1)
Scale = LapseBoxStep(ctx, Canv, "rgb(255, 65, 65)", `700-500mb ${lapseType}`, Scale, MMR, lapseUnit, 1)
Scale = LapseBoxStep(ctx, Canv, "rgb(255, 255, 255)", `Max 2-6km ${lapseType}`, Scale, MaxLR, lapseUnit, 1)
Scale = LapseBoxStep(ctx, Canv, "rgb(255, 255, 255)", `${CustomLapseLayer.Title} ${lapseType}`, Scale, CL, lapseUnit, 1)
} else if (Presets['WindLapse'] == "Severe" || Presets['WindLapse'] == "Elevated") {
let LFCELLR = LapseRateHght(Sounding, ((DP.Start != undefined && DP.Start.H || 0) + DP.LFC+ sh) || 0, ((DP.Start != undefined && DP.Start.H || 0) + DP.EL+ sh) || 3000, LapseType);
Scale = LapseBoxStep(ctx, Canv, "rgb(255, 255, 255)", `Sfc-3km ${lapseType}`, Scale, LLLR, lapseUnit, 1)
Scale = LapseBoxStep(ctx, Canv, "rgb(255, 210, 210)", `3km-6km ${lapseType}`, Scale, MLLR, lapseUnit, 1)
Scale = LapseBoxStep(ctx, Canv, "rgb(255, 165, 165)", `LFC-EL ${lapseType}`, Scale, LFCELLR, lapseUnit, 1)
Scale = LapseBoxStep(ctx, Canv, "rgb(255, 120, 120)", `850-500mb ${lapseType}`, Scale, LMR, lapseUnit, 1)
Scale = LapseBoxStep(ctx, Canv, "rgb(255, 65, 65)", `700-500mb ${lapseType}`, Scale, MMR, lapseUnit, 1)
Scale = LapseBoxStep(ctx, Canv, "rgb(255, 255, 255)", `Max 2-6km ${lapseType}`, Scale, MaxLR, lapseUnit, 1)
Scale = LapseBoxStep(ctx, Canv, "rgb(255, 255, 255)", `${CustomLapseLayer.Title} ${lapseType}`, Scale, CL, lapseUnit, 1)
} else if (Presets['WindLapse'] == "Fire") {
let SLLLR = LapseRateHght(Sounding, "SFC", 1000+ sh, LapseType);
Scale = LapseBoxStep(ctx, Canv, "rgb(255, 255, 255)", `Sfc-1km ${lapseType}`, Scale, SLLLR, lapseUnit, 1)
Scale = LapseBoxStep(ctx, Canv, "rgb(255, 210, 210)", `Sfc-3km ${lapseType}`, Scale, LLLR, lapseUnit, 1)
Scale = LapseBoxStep(ctx, Canv, "rgb(255, 165, 165)", `Sfc-LFC ${lapseType}`, Scale, LFCLR == LFCLR ? LFCLR : LSLR, lapseUnit, 1)
Scale = LapseBoxStep(ctx, Canv, "rgb(255, 120, 120)", `850-500mb ${lapseType}`, Scale, LMR, lapseUnit, 1)
Scale = LapseBoxStep(ctx, Canv, "rgb(255, 65, 65)", `700-500mb ${lapseType}`, Scale, MMR, lapseUnit, 1)
Scale = LapseBoxStep(ctx, Canv, "rgb(255, 255, 255)", `Max 2-6km ${lapseType}`, Scale, MaxLR, lapseUnit, 1)
Scale = LapseBoxStep(ctx, Canv, "rgb(255, 255, 255)", `${CustomLapseLayer.Title} ${lapseType}`, Scale, CL, lapseUnit, 1)
} else if (Presets['WindLapse'] == "Winter") {
let DGZLR = LapseRatePres(Sounding, GetLayerByTemp(Sounding, -12).P, GetLayerByTemp(Sounding, -18).P, LapseType);
let FRZLR = LapseRateHght(Sounding, "SFC", Math.max(GetLayerByTemp(Sounding, 0).H, 250)+ sh, LapseType);
Scale = LapseBoxStep(ctx, Canv, "rgb(255, 255, 255)", `Sfc-3km ${lapseType}`, Scale, LLLR, lapseUnit, 1)
Scale = LapseBoxStep(ctx, Canv, "rgb(255, 210, 210)", `3km-6km ${lapseType}`, Scale, MLLR, lapseUnit, 1)
Scale = LapseBoxStep(ctx, Canv, "rgb(255, 165, 165)", `DGZ ${lapseType}`, Scale, DGZLR, lapseUnit, 1)
Scale = LapseBoxStep(ctx, Canv, "rgb(255, 120, 120)", `SFC-0C ${lapseType}`, Scale, FRZLR, lapseUnit, 1)
Scale = LapseBoxStep(ctx, Canv, "rgb(255, 65, 65)", `700-500mb ${lapseType}`, Scale, MMR, lapseUnit, 1)
Scale = LapseBoxStep(ctx, Canv, "rgb(255, 255, 255)", `Max 2-6km ${lapseType}`, Scale, MaxLR, lapseUnit, 1)
Scale = LapseBoxStep(ctx, Canv, "rgb(255, 255, 255)", `${CustomLapseLayer.Title} ${lapseType}`, Scale, CL, lapseUnit, 1)
}
}
/** Lapse rate box step function.
* @param {*} ctx Parameter for LapseBoxStep.
* @param {*} Canv Parameter for LapseBoxStep.
* @param {*} color Parameter for LapseBoxStep.
* @param {*} Title Parameter for LapseBoxStep.
* @param {*} Scale Parameter for LapseBoxStep.
* @param {*} Value Parameter for LapseBoxStep.
* @param {*} lapseUnit Parameter for LapseBoxStep.
* @param {*} Step Parameter for LapseBoxStep.
* @returns {*} Result of the function.
*/
function LapseBoxStep(ctx, Canv, color, Title, Scale, Value, lapseUnit, Step) {
ctx.fillStyle = color;
ctx.textAlign = "left";
ctx.font = `bold ${Canv.width / 17}px ${globalFont}`;
ctx.fillText(Title, 0.05 * Canv.width, Canv.height * Scale / 7);
ctx.fillStyle = LapseRateColor(Value);
ctx.textAlign = "right";
ctx.font = `bold ${Canv.width / 17}px ${globalFont}`;
ctx.fillText(`${convertLapse(Value)} ${lapseUnit}`, 0.95 * Canv.width, Canv.height * Scale / 7);
return Scale + Step;
}
/** Returns the color for a step based on its value.
* @param {*} N Parameter for StepColor.
* @returns {*} Result of the function.
*/
function StepColor(N) {
if (N <= 1) {
return "rgba(125,125,125,1)";
} else if (N <= 3 && N > 1) {
return "rgba(0, 139, 0,1)";
} else if (N <= 8 && N > 3) {
return "rgba(139, 71, 38 ,1)";
} else if (N <= 15 && N > 8) {
return "rgba(255, 200, 0 ,1)";
} else if (N <= 25 && N > 15) {
return "rgba(255,0,0,1)";
} else if (N <= 45 && N > 25) {
return "rgba(255, 0, 255 ,1)";
} else if (N <= 60 && N > 45) {
return "rgba(145, 44, 238 ,1)";
} else if (N <= 85 && N > 60) {
return "rgba(35, 110, 185,1)";
} else {
return "rgba(255,255,255,1)";
}
}
/** Cleans the canvas.
* @returns {void} No value.
*/
function Clean() {
let tempN = DarkMode == true && "rgba(0,0,0,1)" || "rgba(255,255,255,1)";
const clearId = (id) => {
const el = $(id);
if (!el) return;
const c = el.getContext("2d");
if (!c) return;
c.fillStyle = tempN;
c.fillRect(0, 0, 1000, 1000);
};
clearId("hodograph");
clearId("skewT");
clearId("Values1");
clearId("Values2");
clearId("Values3");
clearId("Values4");
clearId("Values5");
clearId("verticalGraph");
}
let CSMot = {};
/** Returns the critical angle between storm motion and wind shear.
* @param {*} srWind Parameter for criticalAngle.
* @param {*} shear Parameter for criticalAngle.
* @returns {*} Result of the function.
*/
function criticalAngle(srWind, shear) {
const dot = srWind.x * shear.x + srWind.y * shear.y;
const magA = Math.sqrt(srWind.x**2 + srWind.y**2);
const magB = Math.sqrt(shear.x**2 + shear.y**2);
const cosAngle = dot / (magA * magB);
return Math.acos(Math.max(-1, Math.min(1, cosAngle))) * 180 / Math.PI;
}
/** Draws wind params content.
* @param {*} Sounding Parameter for DrawWindParams.
* @param {*} WindParams Parameter for DrawWindParams.
* @returns {void} No value.
*/
function DrawWindParams(Sounding, WindParams) {
coerceTornadoClimoToGraphIfNeeded(Sounding);
const Canv = $("Values5");
const ctx = Canv.getContext("2d");
Canv.width = 700;
Canv.height = 400;
let Scale = 1.2;
let Scaler = 0.5;
let TS = 17.4;
ctx.fillStyle = "rgba(255,255,255,1)";
ctx.fillRect(0.025 * Canv.width, 0.125 * Canv.height, 0.95 * Canv.width, 2);
ctx.font = `bold ${Canv.width / 30}px ${globalFont}`;
ctx.textAlign = "center";
ctx.fillText(`Level`, Canv.width * (0.1 + Scaler / TS), Canv.height * 0.1);
Scaler += 4.2;
ctx.fillStyle = "rgba(255,255,255,1)";
ctx.font = `bold ${Canv.width / 30}px ${globalFont}`;
ctx.textAlign = "center";
ctx.fillText(`Shear`, Canv.width * (0.1 + Scaler / TS), Canv.height * 0.1);
Scaler += 3.55;
ctx.fillStyle = "rgba(255,255,255,1)";
ctx.font = `bold ${Canv.width / 30}px ${globalFont}`;
ctx.textAlign = "center";
ctx.fillText(`SRH`, Canv.width * (0.1 + Scaler / TS), Canv.height * 0.1);
Scaler += 3.6;
ctx.fillStyle = "rgba(255,255,255,1)";
ctx.font = `bold ${Canv.width / 30}px ${globalFont}`;
ctx.textAlign = "center";
ctx.fillText(`SRWind`, Canv.width * (0.1 + Scaler / TS), Canv.height * 0.1);
Scaler += 2.65;
ctx.fillStyle = "rgba(255,255,255,1)";
ctx.font = `bold ${Canv.width / 30}px ${globalFont}`;
ctx.textAlign = "center";
ctx.fillText(`EHI`, Canv.width * (0.1 + Scaler / TS), Canv.height * 0.1);
let Ps = [];
if (Presets['WindLapse'] == "Default") {
Ps = [
["Sfc-500m", WindParams.BWD005, WindParams.SRH005, WindParams.MeanWind.M005, WindParams.EHI005, "rgba(255,255,255,1)"],
["Sfc-1km", WindParams.BWD01, WindParams.SRH01, WindParams.MeanWind.M01, WindParams.EHI01, "rgba(225,225,255,1)"],
["Eff Inflow", WindParams.BWDE, WindParams.ESRH == 0 ? {SRHM: NaN, SRHP: NaN} : WindParams.ESRH, WindParams.MeanWind.ME, WindParams.EHIE, "rgba(0,255,255,1)"],
["Sfc-3km", WindParams.BWD03, WindParams.SRH03, WindParams.MeanWind.M03, WindParams.EHI03, "rgba(200,200,255,1)"],
["1km-3km", WindParams.BWD13, WindParams.SRH13, WindParams.MeanWind.M13, WindParams.EHI13, "rgba(175,175,255,1)"],
["3km-6km", WindParams.BWD36, WindParams.SRH36, WindParams.MeanWind.M36, WindParams.EHI36, "rgba(150,150,255,1)"],
["Sfc-6km", WindParams.BWD06, WindParams.SRH06, WindParams.MeanWind.M06, WindParams.EHI06, "rgba(125,125,255,1)"],
[CustomWindLayer.Title, CustomWindLayer.SHR, CustomWindLayer.SRH, CustomWindLayer.MW, CustomWindLayer.EHI, "rgba(255,255,255,1)", true],
];
} else if (Presets['WindLapse'] == "Dryline"){
Ps = [
["Sfc-500m", WindParams.BWD005, WindParams.SRH005, WindParams.MeanWind.M005, WindParams.EHI005, "rgba(255,255,255,1)"],
["Sfc-1km", WindParams.BWD01, WindParams.SRH01, WindParams.MeanWind.M01, WindParams.EHI01, "rgba(225,225,255,1)"],
["Eff Inflow", WindParams.BWDE, WindParams.ESRH == 0 ? {SRHM: NaN, SRHP: NaN} : WindParams.ESRH, WindParams.MeanWind.ME, WindParams.EHIE, "rgba(0,255,255,1)"],
["Sfc-3km", WindParams.BWD03, WindParams.SRH03, WindParams.MeanWind.M03, WindParams.EHI03, "rgba(200,200,255,1)"],
["Sfc-MLLFC", WindParams.BWD0MLLFC, WindParams.SRH0MLLFC, WindParams.MeanWind.M0MLLFC, WindParams.EHI0MLLFC, "rgba(175,175,255,1)"],
["Sfc-SBLCL", WindParams.BWD0SBLCL, WindParams.SRH0SBLCL, WindParams.MeanWind.M0SBLCL, WindParams.EHI0SBLCL, "rgba(150,150,255,1)"],
["Sfc-6km", WindParams.BWD06, WindParams.SRH06, WindParams.MeanWind.M06, WindParams.EHI06, "rgba(125,125,255,1)"],
[CustomWindLayer.Title, CustomWindLayer.SHR, CustomWindLayer.SRH, CustomWindLayer.MW, CustomWindLayer.EHI, "rgba(255,255,255,1)", true],
];
} else if (Presets['WindLapse'] == "Elevated"){
Ps = [
["Eff Inflow", WindParams.BWDE, WindParams.ESRH == 0 ? {SRHM: NaN, SRHP: NaN} : WindParams.ESRH, WindParams.MeanWind.ME, WindParams.EHIE, "rgba(0,255,255,1)"],
["Sfc-1km", WindParams.BWD01, WindParams.SRH01, WindParams.MeanWind.M01, WindParams.EHI01, "rgba(255,255,255,1)"],
["Sfc-3km", WindParams.BWD03, WindParams.SRH03, WindParams.MeanWind.M03, WindParams.EHI03, "rgba(210,210,210,1)"],
["Sfc-6km", WindParams.BWD06, WindParams.SRH06, WindParams.MeanWind.M06, WindParams.EHI06, "rgba(175,175,175,1)"],
["Eff Bot-1km", WindParams.BWDEb01, WindParams.SRHEb01, WindParams.MeanWind.MEb01, WindParams.EHIEb01, "rgba(255, 225, 225, 1)"],
["Eff Bot-3km", WindParams.BWDEb03, WindParams.SRHEb03, WindParams.MeanWind.MEb03, WindParams.EHIEb03, "rgba(210, 195, 195, 1)"],
["Eff Bot-6km", WindParams.BWDEb06, WindParams.SRHEb06, WindParams.MeanWind.MEb06, WindParams.EHIEb06, "rgba(175, 150, 150, 1)"],
[CustomWindLayer.Title, CustomWindLayer.SHR, CustomWindLayer.SRH, CustomWindLayer.MW, CustomWindLayer.EHI, "rgba(255,255,255,1)", true],
];
} else if (Presets['WindLapse'] == "Severe") {
Ps = [
["Sfc-3km", WindParams.BWD03, WindParams.SRH03, WindParams.MeanWind.M03, WindParams.EHI03, "rgba(255,255,255,1)"],
["Sfc-6km", WindParams.BWD06, WindParams.SRH06, WindParams.MeanWind.M06, WindParams.EHI06, "rgba(225,225,255,1)"],
["Eff Inflow", WindParams.BWDE, WindParams.ESRH == 0 ? {SRHM: NaN, SRHP: NaN} : WindParams.ESRH, WindParams.MeanWind.ME, WindParams.EHIE, "rgba(0,255,255,1)"],
["HGZ", WindParams.BWDHGZ, WindParams.SRHHGZ, WindParams.MeanWind.MHGZ, WindParams.EHIHGZ, "rgb(255, 197, 150)"],
["Sfc-9km", WindParams.BWD09, WindParams.SRH09, WindParams.MeanWind.M09, WindParams.EHI09, "rgba(200,200,255,1)"],
["Sfc-12km", WindParams.BWD012, WindParams.SRH012, WindParams.MeanWind.M012, WindParams.EHI012, "rgba(175,175,255,1)"],
["9-12km", WindParams.BWD912, WindParams.SRH912, WindParams.MeanWind.M912, WindParams.EHI912, "rgba(125,125,255,1)"],
[CustomWindLayer.Title, CustomWindLayer.SHR, CustomWindLayer.SRH, CustomWindLayer.MW, CustomWindLayer.EHI, "rgba(255,255,255,1)", true],
];
} else if (Presets['WindLapse'] == "Fire") {
Ps = [
["Sfc-500m", WindParams.BWD005, WindParams.SRH005, WindParams.MeanWind.M005, WindParams.EHI005, "rgba(255,255,255,1)"],
["Sfc-1km", WindParams.BWD01, WindParams.SRH01, WindParams.MeanWind.M01, WindParams.EHI01, "rgba(225,225,255,1)"],
["Eff Inflow", WindParams.BWDE, WindParams.ESRH == 0 ? {SRHM: NaN, SRHP: NaN} : WindParams.ESRH, WindParams.MeanWind.ME, WindParams.EHIE, "rgba(0,255,255,1)"],
["Sfc-3km", WindParams.BWD03, WindParams.SRH03, WindParams.MeanWind.M03, WindParams.EHI03, "rgba(200,200,255,1)"],
["1km-3km", WindParams.BWD13, WindParams.SRH13, WindParams.MeanWind.M13, WindParams.EHI13, "rgba(175,175,255,1)"],
["Sfc-700mb", WindParams.BWD0mb700, WindParams.SRH0mb700, WindParams.MeanWind.M0mb700, WindParams.EHI0mb700, "rgba(150,150,255,1)"],
["Sfc-500mb", WindParams.BWD0mb500, WindParams.SRH0mb500, WindParams.MeanWind.M0mb500, WindParams.EHI0mb500, "rgba(125,125,255,1)"],
[CustomWindLayer.Title, CustomWindLayer.SHR, CustomWindLayer.SRH, CustomWindLayer.MW, CustomWindLayer.EHI, "rgba(255,255,255,1)", true],
];
} else if (Presets['WindLapse'] == "Winter") {
Ps = [
["Sfc-3km", WindParams.BWD03, WindParams.SRH03, WindParams.MeanWind.M03, WindParams.EHI03, "rgba(255,255,255,1)"],
["Sfc-6km", WindParams.BWD06, WindParams.SRH06, WindParams.MeanWind.M06, WindParams.EHI06, "rgba(225,225,255,1)"],
["Sfc-9km", WindParams.BWD09, WindParams.SRH09, WindParams.MeanWind.M09, WindParams.EHI09, "rgba(200, 200, 255,1)"],
["DGZ", WindParams.BWDDGZ, WindParams.SRHDGZ, WindParams.MeanWind.MDGZ, WindParams.EHIDGZ, "rgb(255, 197, 150)"],
["SFC-DGZ", WindParams.BWD0T18, WindParams.SRH0T18, WindParams.MeanWind.M0T18, WindParams.EHI0T18, "rgba(175, 200,255,1)"],
["SFC-500mb", WindParams.BWD0mb500, WindParams.SRH0mb500, WindParams.MeanWind.M0mb500, WindParams.EHI0mb500, "rgba(150,175,255,1)"],
["9-12km", WindParams.BWD912, WindParams.SRH912, WindParams.MeanWind.M912, WindParams.EHI912, "rgba(125,125,255,1)"],
[CustomWindLayer.Title, CustomWindLayer.SHR, CustomWindLayer.SRH, CustomWindLayer.MW, CustomWindLayer.EHI, "rgba(255,255,255,1)", true],
];
}
const FntSize = Canv.width / 33;
Ps.forEach((obj) => {
Scaler = 0.5;
ctx.fillStyle = obj[5];
if (obj[6] == true) {
ctx.font = `700 ${Canv.width / 27.5}px ${globalFont}`;
} else {
ctx.font = `700 ${Canv.width / 27.5}px ${globalFont}`;
}
ctx.textAlign = "left";
ctx.fillText(`${obj[0]}:`, Canv.width * (0.01 + Scaler / TS), Canv.height * (0.1 + Scale / Ps.length * 0.8));
Scaler += 3;
const speedUnit = useImperialUnits === 2 ? "mph" : "kt";
let BWDS = ConvertCartToPolarRealRnd(obj[1]);
let BWDAng = BWDS.WD;
let bwdSpeed = useImperialUnits === 2 ? Math.round(KtsToMph(BWDS.WS)) : Math.round(BWDS.WS);
bwdSpeed = isNaN(bwdSpeed) ? "--" : bwdSpeed
BWDAng = isNaN(BWDAng) ? "--" : Math.round(BWDAng)
ctx.font = `600 ${FntSize}px ${globalFont}`;
ctx.textAlign = "left";
ctx.fillText(`${BWDAng} / ${bwdSpeed} ${speedUnit}`, Canv.width * (0.1 + Scaler / TS), Canv.height * (0.1 + Scale / Ps.length * 0.8));
Scaler += 3.7;
if (obj[0] == "Eff Inflow") {
ctx.font = `600 ${FntSize}px ${globalFont}`;
ctx.textAlign = "left";
ctx.fillText(`${isNaN(obj[2]) ? "--" : Math.round(obj[2])} m²/s²`, Canv.width * (0.1 + Scaler / TS), Canv.height * (0.1 + Scale / Ps.length * 0.8));
} else {
ctx.font = `600 ${FntSize}px ${globalFont}`;
ctx.textAlign = "left";
ctx.fillText(`${isNaN(obj[2].SRHM) ? "--" : Math.round(obj[2].SRHP + obj[2].SRHM)} m²/s²`, Canv.width * (0.1 + Scaler / TS), Canv.height * (0.1 + Scale / Ps.length * 0.8));
}
Scaler += 3.4;
let SRWind = {
x: obj[3].x - CSMot.x,
y: obj[3].y + CSMot.y,
}
let srws = ConvertCartToPolarRealRnd(SRWind);
let srwSpeed = useImperialUnits === 2 ? Math.round(KtsToMph(srws.WS)) : Math.round(srws.WS);
let srwAng = Math.round(srws.WD)
srwSpeed = isNaN(srwSpeed) ? "--" : srwSpeed
srwAng = isNaN(srwAng) ? "--" : srwAng
ctx.font = `600 ${FntSize}px ${globalFont}`;
ctx.textAlign = "left";
ctx.fillText(`${srwAng} / ${srwSpeed} ${speedUnit}`, Canv.width * (0.1 + Scaler / TS), Canv.height * (0.1 + Scale / Ps.length * 0.8));
Scaler += 3.5;
ctx.font = `600 ${FntSize}px ${globalFont}`;
ctx.textAlign = "left";
ctx.fillText(`${isNaN(obj[4]) ? "--" : obj[4]}`, Canv.width * (0.1 + Scaler / TS), Canv.height * (0.1 + Scale / Ps.length * 0.8));
Scale += 0.925;
})
ctx.fillStyle = "rgba(255,255,255,0.5)";
ctx.fillRect(0.1 * Canv.width, 0.9 * Canv.height, 0.8 * Canv.width, 2);
Scale += 0.2;
ctx.fillStyle = "rgba(255,255,255,1)";
ctx.font = `600 ${FntSize}px ${globalFont}`;
ctx.textAlign = "left";
ctx.fillText(`EBWD: `, Canv.width * (0.1 + 0.25 / TS), Canv.height * (0.1 + Scale / Ps.length * 0.787));
const spdUnit = useImperialUnits === 2 ? "mph" : "kt";
let ebwds = ConvertCartToPolarRealRnd(WindParams['EBWD']);
let ebwdang = ebwds.WD;
let ebwdspeed = useImperialUnits === 2 ? Math.round(KtsToMph(Mag(WindParams['EBWD']))) : Math.round(Mag(WindParams['EBWD']));
ebwdspeed = isNaN(ebwdspeed) ? "--" : ebwdspeed
ebwdang = isNaN(ebwdang) ? "--" : Math.round(ebwdang)
ctx.font = `600 ${FntSize}px ${globalFont}`;
ctx.textAlign = "right";
ctx.fillText(`${ebwdang} / ${ebwdspeed} ${spdUnit}`, Canv.width * (0.1 + 4.75 / TS), Canv.height * (0.1 + Scale / Ps.length * 0.787));
ctx.fillStyle = "rgba(255,255,255,1)";
ctx.font = `600 ${FntSize}px ${globalFont}`;
ctx.textAlign = "left";
ctx.fillText(`LCL - EL Shear: `, Canv.width * (0.9 - 7 / TS), Canv.height * (0.1 + Scale / Ps.length * 0.787));
let lclel = ConvertCartToPolarRealRnd(WindParams['LCLELSHR']);
let lclelang = lclel.WD;
let lclelspeed = useImperialUnits === 2 ? Math.round(KtsToMph( Mag(WindParams['LCLELSHR']))) : Math.round( Mag(WindParams['LCLELSHR']));
lclelspeed = isNaN(lclelspeed) ? "--" : lclelspeed
lclelang = isNaN(lclelang) ? "--" : Math.round(lclelang)
ctx.font = `600 ${FntSize}px ${globalFont}`;
ctx.textAlign = "right";
ctx.fillText(`${lclelang} / ${lclelspeed} ${spdUnit}`, Canv.width * (0.9 - 0.25 / TS), Canv.height * (0.1 + Scale / Ps.length * 0.787));
const V8 = $("Values8");
const V7 = $("Values7");
const V6 = $("Values6");
const V5 = $("Values5");
const V4 = $("Values4");
const hodoselect = $("hodoselector");
const hodoin = $("hodoinsetcheck");
if (VWPActive){
hodoin.classList.toggle('hodographInsetVWP', true);
hodoin.classList.toggle('hodographInset', false);
hodoselect.classList.toggle('hodographValuesVWP', true);
hodoselect.classList.toggle('hodographValues', false);
V8.classList.toggle('skewthidden', false);
V7.classList.toggle('skewthidden', false);
V6.classList.toggle('skewthidden', true);
V4.classList.toggle('skewthidden', true);
V5.classList.toggle('Values5VWP', true);
V5.classList.toggle('Values5', false);
} else {
hodoin.classList.toggle('hodographInsetVWP', false);
hodoin.classList.toggle('hodographInset', true);
V5.classList.toggle('Values5VWP', false);
V5.classList.toggle('Values5', true);
V8.classList.toggle('skewthidden', true);
V7.classList.toggle('skewthidden', true);
const graphCanvas = $("Values4");
const climoCanvas = $("Values6");
if (SidePanelMode === "Graph") {
if (graphCanvas) graphCanvas.classList.remove('skewthidden');
if (climoCanvas) climoCanvas.classList.add('skewthidden');
} else {
if (graphCanvas) graphCanvas.classList.add('skewthidden');
if (climoCanvas) climoCanvas.classList.remove('skewthidden');
}
hodoselect.classList.toggle('hodographValuesVWP', false);
hodoselect.classList.toggle('hodographValues', true);
}
if (VWPActive){
DrawVWPInsets(Sounding);
}
};
/**
* Converts radians to degrees.
* @param {number} radians The angle in radians.
* @returns {number} The angle in degrees.
*/
function radiansToDegrees(radians) {
return radians * (180 / Math.PI);
}
/** Draws vwpinsets content.
* @param {*} Sounding Parameter for DrawVWPInsets.
* @returns {void} No value.
*/
function DrawVWPInsets(Sounding){
const Canv2 = $("Values7");
const ctx2 = Canv2.getContext("2d");
Canv2.width = 700;
Canv2.height = 200;
ctx2.fillStyle = "rgb(0, 0, 0)";
ctx2.fillRect(0, 0, 1000, 1000)
const impSpd = useImperialUnits === 2;
const srSpdMax = impSpd ? KtsToMph(80) : 80;
const envSpdMax = impSpd ? KtsToMph(140) : 140;
const spdUnitLbl = impSpd ? "mph" : "kt";
/**
* Scale storm-relative wind speed to the VWP inset's horizontal percentage.
* @param {number} wndKts Wind speed (in kt).
* @returns {number} Horizontal chart position (in percent).
*/
function srWindXfract(wndKts) {
const v = impSpd ? KtsToMph(wndKts) : wndKts;
return 100 * (v / srSpdMax);
}
/**
* Scale environmental wind speed to the VWP inset's horizontal percentage.
* @param {number} wndKts Wind speed (in kt).
* @returns {number} Horizontal chart position (in percent).
*/
function envWindXfract(wndKts) {
const v = impSpd ? KtsToMph(wndKts) : wndKts;
return 100 * (v / envSpdMax);
}
let BRM = GetBM(Sounding.Sounding);
let FntSiz = Canv2.width / 35;
ctx2.fillStyle = "rgba(255,255,255,1)";
ctx2.font = `600 ${FntSiz*1.5}px ${globalFont}`;
ctx2.textAlign = "center";
ctx2.fillText(`Storm Motions:`, Canv2.width * 0.5, Canv2.height * 0.135);
ctx2.fillRect(Canv2.width * 0.05, Canv2.height * 0.1475, Canv2.width * 0.9, 3);
ctx2.fillRect(Canv2.width * 0.4925, Canv2.height * 0.16, 2, Canv2.height * 0.74);
let MotionRows = [
[
["Bunkers Right", ConvertCartToPolarRealRnd(BRM.RM)],
["Bunkers Left", ConvertCartToPolarRealRnd(BRM.LM)],
["Mean Motion", ConvertCartToPolarRealRnd(BRM.MM)],
["Custom Motion", ConvertCartToPolarRealRnd(CUMot)],
],
[
["Corfidi Upshear", ConvertCartToPolarRealRnd(BRM.US)],
["Corfidi Downshear", ConvertCartToPolarRealRnd(BRM.DS)],
["Nixon Deviant Right", ConvertCartToPolarRealRnd(BRM.DTMR)],
["Nixon Deviant Left", ConvertCartToPolarRealRnd(BRM.DTML)]
]
];
MotionRows[0][3][1].WD = 180 - MotionRows[0][3][1].WD + 360;
MotionRows[0][3][1].WD %= 360;
let XP = 0;
let XR = 333;
let YP = 0.5;
let YR = 66;
MotionRows.forEach((N) => {
YP = 0.5;
N.forEach((P) => {
ctx2.fillStyle = "rgb(208, 255, 253)";
ctx2.font = `700 ${FntSiz * 1.1}px ${globalFont}`;
ctx2.textAlign = "left";
ctx2.fillText(`${P[0]}:`, 20 + XP * XR, Canv2.height * 0.15 + YP * YR);
ctx2.fillStyle = "rgb(255, 211, 211)";
ctx2.font = `500 ${FntSiz}px ${globalFont}`;
ctx2.textAlign = "right";
const motWs = P[1].WS;
const motWsDisp =
isNaN(motWs)
? "--"
: impSpd
? Math.round(KtsToMph(motWs))
: Math.round(100 * motWs) / 100;
ctx2.fillText(
`${isNaN(P[1].WD) ? "--" : Math.round(10 * P[1].WD) / 10}° / ${motWsDisp}${motWsDisp === "--" ? "" : spdUnitLbl}`,
20 + (XP + 0.95) * XR,
Canv2.height * 0.15 + YP * YR
);
YP += 0.65;
})
XP += 1;
})
const Canv3 = $("Values8");
const ctx3 = Canv3.getContext("2d");
Canv3.width = 900;
Canv3.height = 700;
let BoundsX = [0, 300];
let BoundsY = [0, 700];
ctx3.strokeStyle = "rgba(255,255,255,1)";
ctx3.lineWidth = 3;
ctx3.fillStyle = "rgba(0,0,0,1)";
ctx3.fillRect(0, 0, 300, 700);
ctx3.strokeRect(0, 0, 300, 700);
ctx3.strokeRect(300, 0, 300, 700);
ctx3.strokeRect(600, 0, 300, 700);
ctx3.strokeRect(600, 0, 300, 350);
let Sound = Sounding.Sounding;
let TopWnd = Math.round(clamp(Math.max(...Sound.H), 4000, 16000)/1000);
let TopWndSc = TopWnd*1000;
//First Box, SR Wind
ctx3.beginPath();
ctx3.lineWidth = 5;
ctx3.setLineDash([5, 5]);
ctx3.strokeStyle = "rgba(255,0,0,1)";
ctx3.fillStyle = "rgba(255,0,0,0.1)";
ctx3.lineTo(0, 700);
let pl = 0;
for (let i = 0; i < Sound.W.length; i++) {
let Slice = Sound.W[i];
if (Slice.H > 16000) {
pl = 0;
break;
}
let Wind = ConvertPolartoCart(Slice);
Wind.x -= CSMot.x;
Wind.y += CSMot.y;
let Wnd = Math.round(Mag(Wind));
let Pos = GetBoxPos(BoundsX, BoundsY, srWindXfract(Wnd), 100 * (1 - Slice.H / TopWndSc));
ctx3.lineTo(Pos.x, Pos.y);
pl = Pos.y;
//ctx3.moveTo(Pos.x,Pos.y);
}
ctx3.lineTo(0, pl);
ctx3.stroke();
ctx3.fill();
ctx3.font = `bold ${25}px ${globalFont}`;
ctx3.fillStyle = "rgba(255,255,255,1)";
ctx3.textAlign = "center";
let Pos = GetBoxPos(BoundsX, BoundsY, 50, 20);
ctx3.fillText(`SR Wind (${spdUnitLbl})`, Pos.x, Pos.y);
ctx3.fillText(`v`, Pos.x, Pos.y + 30);
ctx3.fillText(`Height (km)`, Pos.x, Pos.y + 60);
for (let i = 2; i < TopWnd; i += 2) {
let Pos = GetBoxPos(BoundsX, BoundsY, 0, 100 * (1 - (i / TopWnd)));
let Pos2 = GetBoxPos(BoundsX, BoundsY, 100, 100 * (1 - (i / TopWnd)));
ctx3.fillRect(Pos.x, Pos.y, 15, 4);
ctx3.fillRect(Pos2.x - 20, Pos.y, 15, 4);
ctx3.font = `${250 * 0.075}px ${globalFont}`;
ctx3.fillText(`${i}`, Pos.x + 30, Pos.y + 5);
}
for (let i = 10; i <= 70; i += 10) {
let Pos = GetBoxPos(BoundsX, BoundsY, 100 * ((i / 80)), 0);
let Pos2 = GetBoxPos(BoundsX, BoundsY, 100 * ((i / 80)), 100);
ctx3.fillRect(Pos.x, Pos.y, 4, 5);
ctx3.fillRect(Pos.x, Pos2.y - 20, 4, 5);
ctx3.font = `${250 * 0.075}px ${globalFont}`;
const tickLbl = impSpd ? Math.round(KtsToMph(i)) : i;
ctx3.fillText(`${tickLbl}`, Pos.x, Pos2.y - 10);
}
ctx3.setLineDash([]);
//Next Draw The Vertical Bar for the Wind Direction/Wind Speed
BoundsX = [300, 600];
BoundsY = [0, 350];
ctx3.strokeStyle = "rgba(255,255,255,1)";
ctx3.lineWidth = 3;
ctx3.fillStyle = "rgba(0,0,0,1)";
ctx3.fillRect(300, 0, 300, 350);
ctx3.strokeRect(300, 0, 300, 350);
ctx3.beginPath();
ctx3.lineWidth = 5;
ctx3.setLineDash([]);
ctx3.strokeStyle = "rgb(0, 255, 242)";
ctx3.fillStyle = "rgba(0, 255, 213, 0.1)";
ctx3.lineTo(300, 350);
pl = 0;
for (let i = 0; i < Sound.W.length; i++) {
let Slice = Sound.W[i];
if (Slice.H > TopWndSc) {
pl = 0;
break;
}
let Wind = ConvertPolartoCart(Slice);
let Wnd = Math.round(Mag(Wind));
let Pos = GetBoxPos(BoundsX, BoundsY, envWindXfract(Wnd), 100 * (1 - Slice.H / TopWndSc));
ctx3.lineTo(Pos.x, Pos.y);
pl = Pos.y;
//ctx3.moveTo(Pos.x,Pos.y);
}
ctx3.lineTo(300, pl);
ctx3.stroke();
ctx3.fill();
//ERROR BARS (RMS uncertainty crosses — keep fairly light so profile stays readable)
const ErrHght = 10;
const errCrossStroke = "rgba(255, 0, 0, 0.55)";
pl = 0;
for (let i = 0; i < Sound.W.length; i+=2) {
let Slice = Sound.W[i];
ctx3.lineWidth = 5;
let Error = 0; //5%
ctx3.beginPath();
if (Slice.H > 16000) {
pl = 0;
break;
}
let Wind = ConvertPolartoCart(Slice);
let Wnd = Math.round(Mag(Wind));
Error = Sound.W[i].ERR_KTS;
if (Error == null || !Number.isFinite(Error) || Error <= 0) continue;
let Pos = GetBoxPos(BoundsX, BoundsY, envWindXfract(Wnd), 100 * (1 - Slice.H / TopWndSc));
let PosErr = GetBoxPos(BoundsX, BoundsY, envWindXfract(Wnd + Error), 100 * (1 - Slice.H / TopWndSc));
let PosM = { x: Math.abs(PosErr.x - Pos.x), y: Math.abs(PosErr.y - Pos.y) };
ctx3.strokeStyle = errCrossStroke;
ctx3.moveTo(clamp(Pos.x + PosM.x, 300, 600), Pos.y - ErrHght);
ctx3.lineTo(clamp(Pos.x + PosM.x, 300, 600), Pos.y + ErrHght);
ctx3.moveTo(clamp(Pos.x + PosM.x, 300, 600), Pos.y);
ctx3.lineTo(clamp(Pos.x - PosM.x, 300, 600), Pos.y);
ctx3.moveTo(clamp(Pos.x - PosM.x, 300, 600), Pos.y - ErrHght);
ctx3.lineTo(clamp(Pos.x - PosM.x, 300, 600), Pos.y + ErrHght);
ctx3.stroke();
ctx3.fill();
ctx3.beginPath();
ctx3.fillStyle = "rgba(0, 255, 213, 1)";
ctx3.lineWidth = 2;
ctx3.strokeStyle = "rgb(0, 0, 0)";
ctx3.arc(Pos.x, Pos.y, ErrHght/2, 0, 2 * Math.PI);
ctx3.stroke();
ctx3.fill();
//ctx3.moveTo(Pos.x,Pos.y);
}
ctx3.font = `bold ${25}px ${globalFont}`;
ctx3.fillStyle = "rgba(255,255,255,1)";
ctx3.textAlign = "center";
Pos = GetBoxPos(BoundsX, BoundsY, 50, 20);
ctx3.fillText(`Wind Spd (${spdUnitLbl})`, Pos.x, Pos.y);
for (let i = 2; i < TopWnd; i += 2) {
let Pos = GetBoxPos(BoundsX, BoundsY, 0, 100 * (1 - (i / TopWnd)));
let Pos2 = GetBoxPos(BoundsX, BoundsY, 100, 100 * (1 - (i / TopWnd)));
ctx3.fillRect(Pos.x, Pos.y, 15, 4);
ctx3.fillRect(Pos2.x - 20, Pos.y, 15, 4);
ctx3.font = `${250 * 0.075}px ${globalFont}`;
ctx3.fillText(`${i}`, Pos.x + 30, Pos.y + 5);
}
for (let i = 20; i <= 140; i += 20) {
let Pos = GetBoxPos(BoundsX, BoundsY, 100 * ((i / 140)), 0);
let Pos2 = GetBoxPos(BoundsX, BoundsY, 100 * ((i / 140 )), 100);
ctx3.fillRect(Pos.x, Pos.y, 4, 5);
ctx3.fillRect(Pos.x, Pos2.y - 20, 4, 5);
ctx3.font = `${250 * 0.075}px ${globalFont}`;
const envTick = impSpd ? Math.round(KtsToMph(i)) : i;
ctx3.fillText(`${envTick}`, Pos.x, Pos2.y - 10);
}
//Next Wind Direction
//Next Draw The Vertical Bar for the Wind Direction/Wind Speed
BoundsX = [300, 600];
BoundsY = [350, 700];
ctx3.strokeStyle = "rgba(255,255,255,1)";
ctx3.lineWidth = 3;
ctx3.fillStyle = "rgba(0,0,0,1)";
ctx3.fillRect(300, 350, 300, 350);
ctx3.strokeRect(300, 350, 300, 350);
ctx3.beginPath();
ctx3.lineWidth = 3;
ctx3.setLineDash([]);
ctx3.strokeStyle = "rgb(0, 255, 242)";
ctx3.fillStyle = "rgba(0, 255, 213, 0.1)";
//ctx3.lineTo(0, 350);
pl = 0;
let stAng = -9999;
for (let i = 0; i < Sound.W.length; i++) {
let Slice = Sound.W[i];
let SliceBefore = Sound.W[Math.max(0, i-1)];
if (Slice.H > TopWndSc) {
pl = 350;
break;
}
let Wind = ConvertPolartoCart(Slice);
let Ang = Math.round(GetAngleFromVector(Wind));
let Ang2 = Math.round(GetAngleFromVector(ConvertPolartoCart(SliceBefore)))
if (stAng == -9999){
stAng = Ang;
}
let XScale = (50 + 100 * (((Ang - stAng + 180) % 360 + 360) % 360 - 180) / 360)
let XScaleBefore = (50 + 100 * (((Ang2 - stAng+ 180) % 360 + 360) % 360 - 180) / 360)
if (XScale >= 100 && XScaleBefore < 100){
let PointWhenCross = (100 - XScale)/(XScaleBefore - XScale);
let Hght = Lerp(SliceBefore.H, Slice.H, PointWhenCross);
let Pos2 = GetBoxPos(BoundsX, BoundsY, 100, 100 * (1 - Hght / TopWndSc));
let Pos2L = GetBoxPos(BoundsX, BoundsY, 0, 100 * (1 - Hght / TopWndSc));
let PosTrue = GetBoxPos(BoundsX, BoundsY, XScale%100, 100 * (1 - Slice.H / TopWndSc));
ctx3.lineTo(Pos2.x, Pos2.y);
ctx3.lineTo(Pos2L.x, Pos2.y);
ctx3.lineTo(PosTrue.x, PosTrue.y);
pl = PosTrue.y;
} else if (XScale >= 0 && XScaleBefore < 0){
let PointWhenCross = (0 - XScale)/(XScaleBefore - XScale);
let Hght = Lerp(SliceBefore.H, Slice.H, PointWhenCross);
let Pos2 = GetBoxPos(BoundsX, BoundsY, 0, 100 * (1 - Hght / TopWndSc));
let Pos2L = GetBoxPos(BoundsX, BoundsY, 100, 100 * (1 - Hght / TopWndSc));
let PosTrue = GetBoxPos(BoundsX, BoundsY, XScale%100, 100 * (1 - Slice.H / TopWndSc));
ctx3.lineTo(Pos2.x, Pos2.y);
ctx3.lineTo(Pos2L.x, Pos2.y);
ctx3.lineTo(PosTrue.x, PosTrue.y);
pl = PosTrue.y;
} else {
let Pos = GetBoxPos(BoundsX, BoundsY, XScale%100, 100 * (1 - Slice.H / TopWndSc));
ctx3.lineTo(Pos.x, Pos.y);
pl = Pos.y;
}
//ctx3.moveTo(Pos.x,Pos.y);
}
ctx3.lineTo((BoundsX[1] - BoundsX[0])/2 + BoundsX[0], pl);
ctx3.lineTo((BoundsX[1] - BoundsX[0])/2 + BoundsX[0], 700);
ctx3.stroke();
ctx3.fill();
ctx3.fillStyle = "rgba(255, 255, 255, 0.75)";
ctx3.fillRect((BoundsX[1] - BoundsX[0])/2 + BoundsX[0], 350, 3, 350);
ctx3.fillStyle = "rgba(0, 255, 213, 0.1)";
//ERROR BARS
pl = 0;
for (let i = 0; i < Sound.W.length; i+=2) {
let Slice = Sound.W[i];
ctx3.lineWidth = 5;
let Error = 0; //5%
ctx3.beginPath();
if (Slice.H > TopWndSc) {
pl = 0;
break;
}
let Wind = ConvertPolartoCart(Slice);
let Ang = Math.round(GetAngleFromVector(Wind));
const errKtsDir = Sound.W[i].ERR_KTS;
if (errKtsDir == null || !Number.isFinite(errKtsDir) || errKtsDir <= 0) continue;
const wsMag = Mag(Wind);
Error = clamp(
radiansToDegrees(Math.atan(errKtsDir / (wsMag > 1e-6 ? wsMag : 1e-6))),
-180,
180
);
let Pos = GetBoxPos(BoundsX, BoundsY, (50 + 100 * (((Ang - stAng + 180) % 360 + 360) % 360 - 180) / 360), 100 * (1 - Slice.H / TopWndSc));
let PosM = GetBoxPos(BoundsX, BoundsY, (50 + 100 * (((Ang - stAng + Error + 180) % 360 + 360) % 360 - 180) / 360), 100 * (1 - Slice.H / TopWndSc));
PosM.x = Math.abs(PosM.x - Pos.x);
PosM.y = Math.abs(PosM.y - Pos.y);
ctx3.strokeStyle = errCrossStroke;
if (Pos.x + PosM.x > BoundsX[1] && Pos.x < BoundsX[1]){
ctx3.moveTo((Pos.x + PosM.x - BoundsX[0])%(BoundsX[1] - BoundsX[0]) + BoundsX[0], Pos.y - ErrHght);
ctx3.lineTo((Pos.x + PosM.x - BoundsX[0])%(BoundsX[1] - BoundsX[0]) + BoundsX[0], Pos.y + ErrHght);
ctx3.moveTo((Pos.x + PosM.x - BoundsX[0])%(BoundsX[1] - BoundsX[0]) + BoundsX[0], Pos.y);
ctx3.lineTo(BoundsX[0], Pos.y);
ctx3.moveTo(BoundsX[1], Pos.y);
ctx3.lineTo(Pos.x, Pos.y);
} else {
ctx3.moveTo(Pos.x + PosM.x, Pos.y - ErrHght);
ctx3.lineTo(Pos.x + PosM.x, Pos.y + ErrHght);
ctx3.moveTo(Pos.x + PosM.x, Pos.y);
ctx3.lineTo(Pos.x, Pos.y);
}
ctx3.moveTo(Pos.x, Pos.y);
if (Pos.x - PosM.x < BoundsX[0] && Pos.x > BoundsX[0]){
ctx3.moveTo(Pos.x - PosM.x + (BoundsX[1] - BoundsX[0]), Pos.y - ErrHght);
ctx3.lineTo(Pos.x - PosM.x + (BoundsX[1] - BoundsX[0]), Pos.y + ErrHght);
ctx3.moveTo(Pos.x - PosM.x + (BoundsX[1] - BoundsX[0]), Pos.y);
ctx3.lineTo(BoundsX[1], Pos.y);
ctx3.moveTo(BoundsX[0], Pos.y);
ctx3.lineTo(Pos.x, Pos.y);
ctx3.stroke();
ctx3.fill();
} else {
ctx3.lineTo(Pos.x - PosM.x, Pos.y);
ctx3.moveTo(Pos.x - PosM.x, Pos.y - ErrHght);
ctx3.lineTo(Pos.x - PosM.x, Pos.y + ErrHght);
ctx3.stroke();
ctx3.fill();
}
ctx3.beginPath();
ctx3.fillStyle = "rgba(0, 255, 213, 1)";
ctx3.lineWidth = 2;
ctx3.strokeStyle = "rgb(0, 0, 0)";
ctx3.arc(Pos.x, Pos.y, ErrHght/2, 0, 2 * Math.PI);
ctx3.stroke();
ctx3.fill();
//ctx3.moveTo(Pos.x,Pos.y);
}
ctx3.font = `bold ${25}px ${globalFont}`;
ctx3.fillStyle = "rgba(255,255,255,1)";
ctx3.textAlign = "center";
Pos = GetBoxPos(BoundsX, BoundsY, 50, 20);
ctx3.fillText(`Wind Dir (Deg)`, Pos.x, Pos.y);
for (let i = 2; i < TopWnd; i += 2) {
let Pos = GetBoxPos(BoundsX, BoundsY, 0, 100 * (1 - (i / TopWnd)));
let Pos2 = GetBoxPos(BoundsX, BoundsY, 100, 100 * (1 - (i / TopWnd)));
ctx3.fillRect(Pos.x, Pos.y, 15, 4);
ctx3.fillRect(Pos2.x - 20, Pos.y, 15, 4);
ctx3.font = `${250 * 0.075}px ${globalFont}`;
ctx3.fillText(`${i}`, Pos.x + 30, Pos.y + 5);
}
for (let i = 90; i <= 360; i += 90) {
let Pos = GetBoxPos(BoundsX, BoundsY, (50 + 100 * (((i - stAng + 180) % 360 + 360) % 360 - 180) / 360)%100, 0);
let Pos2 = GetBoxPos(BoundsX, BoundsY, (50 + 100 * (((i - stAng + 180) % 360 + 360) % 360 - 180) / 360)%100, 100);
ctx3.fillRect(Pos.x%(BoundsX[1] - BoundsX[0]) + BoundsX[0], Pos.y, 4, 5);
ctx3.fillRect(Pos.x%(BoundsX[1] - BoundsX[0]) + BoundsX[0], Pos2.y - 20, 4, 5);
ctx3.font = `${250 * 0.075}px ${globalFont}`;
ctx3.fillText(`${i%360}`, Pos.x%(BoundsX[1] - BoundsX[0]) + BoundsX[0], Pos2.y - 10);
}
//Next Draw The Vertical Bar for the Barbs
BoundsX = [600, 900];
BoundsY = [350, 700];
ctx3.strokeStyle = "rgba(255,255,255,1)";
ctx3.lineWidth = 3;
ctx3.fillStyle = "rgba(0,0,0,1)";
ctx3.fillRect(600, 350, 300, 350);
ctx3.strokeRect(600, 350, 300, 350);
let Psv = GetBoxPos(BoundsX, BoundsY, 35, 50);
let xi = 0;
for (const x of [0, 0.5, 1, 2, 3, 6, 9, 12]){
let Hght = x*1000;
let hd;
if (Hght <= 1000) {
hd = "rgba(225,0,225,1)";
} else if (Hght <= 3000 && Hght > 1000) {
hd = "rgba(225,0,0,1)";
} else if (Hght <= 6000 && Hght > 3000) {
hd = "rgba(0,225,0,1)";
} else if (Hght <= 9000 && Hght > 6000) {
hd = "rgba(225,225,0,1)";
} else if (Hght <= 12000 && Hght > 9000) {
hd = "rgba(0,225,225,1)";
}
ctx3.fillStyle = hd;
ctx3.font = `500 ${250 * 0.1}px ${globalFont}`;
let PSL = GetBoxPos(BoundsX, BoundsY, 96, 90 - 80 * (xi/7));
ctx3.textAlign = "right";
xi += 1;
let Layer = GetLayerByHght(Sound, Hght);
if (!Layer.isAbove){
drawBarbVWP([
Layer.WS,
Layer.WD,
Psv.x,
(Psv.y + PSL.y * 3)/4
], hd, 0); // EDITED: Pass lat
ctx3.fillText(`${x}km AGL`, PSL.x, PSL.y);
}
}
const mapSize = 300;
const padding = 0; // You can increase this if you want some spacing from the edge
if (mapImage) {
ctx3.fillStyle = "rgba(255, 255, 255,1)";
ctx3.save();
ctx3.drawImage(mapImage, 0, 0, mapImage.width || mapSize, mapImage.height || mapSize, 600, 0, 300, 350);
ctx3.restore();
}
drawHodographLocationInset(ctx3, Canv3, Sounding, () => {
if (VWPActive) DrawVWPInsets(VWPSounding);
}, { x: 600, y: 0, width: 300, height: 350 });
}
/** Returns box pos data.
* @param {*} BoundX Parameter for GetBoxPos.
* @param {*} BoundY Parameter for GetBoxPos.
* @param {*} X Parameter for GetBoxPos.
* @param {*} Y Parameter for GetBoxPos.
* @returns {*} Result of the function.
*/
function GetBoxPos(BoundX, BoundY, X, Y) {
return { x: BoundX[0] + (BoundX[1] - BoundX[0]) * (X / 100), y: BoundY[0] + (BoundY[1] - BoundY[0]) * (Y / 100) };
}
/** Checks check mode.
* @param {*} C1 Parameter for CheckMode.
* @param {*} C2 Parameter for CheckMode.
* @returns {*} Result of the function.
*/
function CheckMode(C1, C2) {
return DarkMode == true && C1 || C2;
}
/** Radians to degrees
* @param {*} radians Parameter for RtoDeg.
* @returns {*} Result of the function.
*/
function RtoDeg(radians) {
return radians * (180 / Math.PI);
}
/** Converts cart to polar values.
* @param {*} N Parameter for ConvertCartToPolar.
* @returns {*} Result of the function.
*/
function ConvertCartToPolar(N) {
return {
WD: 180 + RtoDeg(Math.atan(N.y / N.x)),
WS: Math.sqrt(Math.pow(N.x, 2) + Math.pow(N.y, 2)),
}
}
/** Converts cart to polar2 values.
* @param {*} N Parameter for ConvertCartToPolar2.
* @returns {*} Result of the function.
*/
function ConvertCartToPolar2(N) {
return {
WD: (270 + RtoDeg(Math.atan(N.y / N.x))) % 360,
WS: Math.sqrt(Math.pow(N.x, 2) + Math.pow(N.y, 2)),
}
}
var Climo = [];
const LatBounds = [25, 50];
const LonBounds = [-124, -64];
/** Checks whether a sounding is within tornado climatology bounds.
* @param {*} Sounding Parameter for isSoundingInTornadoClimoBounds.
* @returns {*} Result of the function.
*/
function isSoundingInTornadoClimoBounds(Sounding) {
if (!Sounding || !Sounding.Location2 || Sounding.Location2.length < 2) return false;
const lat = Sounding.Location2[0];
const westLon = -Sounding.Location2[1];
if (
lat >= LatBounds[0] &&
lat <= LatBounds[1] &&
westLon >= LonBounds[0] &&
westLon <= LonBounds[1]
) {
const LatB = Math.round(Sounding.Location2[0] - 25);
const LonB = Math.round(Sounding.Location2[1] - 64);
const nms = ClimoSwitch(numbs2, numbs)
if (LatB >= nms.length){
return false;
}
if (LonB >= nms[LatB].length){
return false;
}
const truenms = nms[LatB][LonB];
if (truenms >= 5){
return true;
} else {
return false;
}
} else {
return false;
}
}
/** Whether the current sounding sits inside the CONUS tornado climatology grid. */
let tornadoClimoAvailable = true;
/** Normalizes tornado climatology data for graphing.
* @param {*} Sounding Parameter for coerceTornadoClimoToGraphIfNeeded.
* @returns {void} No value.
*/
function coerceTornadoClimoToGraphIfNeeded(Sounding) {
const avail =
Sounding && Sounding.Location2 && Sounding.Location2.length >= 2
? isSoundingInTornadoClimoBounds(Sounding)
: false;
if (avail !== tornadoClimoAvailable) {
tornadoClimoAvailable = avail;
emit("change");
}
if (typeof VWPActive !== "undefined" && VWPActive) return;
if (SidePanelMode !== "Tornado") return;
if (avail) return;
SidePanelMode = "Graph";
applySidePanelVisibility();
emit("change");
}
/** Show the height graph (Values4) or the climatology / winter panel (Values6).
* @returns {void} No value.
*/
function applySidePanelVisibility() {
const graphCanvas = $("Values4");
const climoCanvas = $("Values6");
if (SidePanelMode === "Graph") {
if (graphCanvas) graphCanvas.classList.remove('skewthidden');
if (climoCanvas) climoCanvas.classList.add('skewthidden');
DisplayGraphMode = true;
} else {
if (graphCanvas) graphCanvas.classList.add('skewthidden');
if (climoCanvas) climoCanvas.classList.remove('skewthidden');
DisplayGraphMode = false;
}
}
var ClimaSet = false;
/** Derived from SHARPpy
*
* @param {*} Sounding
* @returns {*} Result of the function.
*/
function Init_Phase(Sounding) {
let phase = -1;
let st = "N/A";
let Avails = 0;
Sounding.Sounding.Slice.forEach((P) => {
if (P[7] < .0001) {
Avails += 1;
}
});
if (Avails < 5) {
}
let HighestLyr = 0;
let Find = false;
for (let x = 0; x < Sounding.Sounding.Slice.length-1; x++) {
let Lyr = ConvertToInfo(Sounding.Sounding.Slice[x])
let Lyr2 = ConvertToInfo(Sounding.Sounding.Slice[x+1])
if (RelHum(Lyr.T, Lyr.D) > 80 && RelHum(Lyr2.T, Lyr2.D) > 80) {
Find = true;
HighestLyr = Lyr.P;
}
}
if (!Find) {
return ["N/A", -1, "--", "N/A"]
} else {
let Lyr = GetLayerByPres(Sounding.Sounding, HighestLyr - 25);
let Tmpc = Lyr.T;
if (Tmpc > 0) {
phase = 0
st = "Rain"
} else if (Tmpc > -5) {
phase = 1
st = "Freezing Rain"
} else if (Tmpc > -9) {
phase = 2
st = "ZR/S Mix"
} else if (Tmpc <= -9) {
phase = 3
st = "Snow"
} else {
st = "N/A"
phase = -1
}
return [Math.round(10 * (HighestLyr - 25))/10, phase, Math.round(100 * Tmpc)/100, st];
}
}
/** Estimates precipitation type from the sounding.
* @param {*} Sounding Parameter for BestGuessPrecip.
* @returns {*} Result of the function.
*/
function BestGuessPrecip(Sounding) {
let Sfc = GetLayerByPres(Sounding.Sounding, "SFC");
if (!Sfc || Sfc.T === -9999) {
return "Unknown";
}
// Build temperature profile from surface to 500mb
let profile = [];
for (let p = Sfc.P; p >= 500; p -= 25) {
let layer = GetLayerByPres(Sounding.Sounding, p);
if (layer && layer.T !== -9999) {
profile.push({
P: layer.P,
H: layer.H,
T: layer.T,
depth: layer.H - Sfc.H
});
}
}
// console.log("Precip Profile:", profile);
if (profile.length < 3) {
return "Unknown";
}
// --- ANALYZE WARM/COLD LAYERS ---
// 1. Identify all layers above freezing
let warmLayers = profile.filter(l => l.T > 0);
let hasWarmAir = warmLayers.length > 0;
// 2. Find the maximum temperature in the warm layer
let maxWarmTemp = -999;
let warmLayerIntegral = 0; // "Strength" of the warm layer
if (hasWarmAir) {
maxWarmTemp = Math.max(...warmLayers.map(l => l.T));
warmLayerIntegral = warmLayers.reduce((sum, l) => sum + l.T, 0);
}
// 3. Analyze the Cold Layer (for Freezing Rain/Sleet logic)
let coldLayerDepth = 0;
let coldLayerIntegral = 0;
// Iterate from surface up to find depth of surface cold layer
for (let layer of profile) {
if (layer.T <= 0) {
coldLayerDepth = layer.depth;
coldLayerIntegral += Math.abs(layer.T);
} else {
break; // Stop once we hit warm air
}
}
// --- DECISION LOGIC ---
// SCENARIO 1: SURFACE IS ABOVE FREEZING
if (Sfc.T > 0) {
// If the Max Temp is very warm (> 4C), it's definitely Rain.
if (maxWarmTemp > 4.0) {
return "Rain";
}
// **THE FIX**: Marginal Surface Temp Logic
// If the entire warm layer is very weak (Max T < 1.5C / 35F),
// snow will survive the fall to the surface.
// In your data: MaxT is 0.24C. This will correctly return Snow.
if (maxWarmTemp < 1.5) {
return "Snow";
}
// If between 1.5C and 4.0C, it transitions to rain.
return "Rain";
}
// SCENARIO 2: SURFACE IS FREEZING (Sfc.T <= 0)
else {
// If there is NO warm air aloft, it is Snow.
if (!hasWarmAir) {
return "Snow";
}
// SCENARIO 3: SURFACE FREEZING + WARM AIR ALOFT (ZR / IP)
// Calculate the ratio of warm layer strength to cold layer strength
let warmToColdRatio = warmLayerIntegral / Math.max(coldLayerIntegral, 1);
// Calculate warm layer depth
let firstWarm = warmLayers[warmLayers.length - 1]; // Top
let lastWarm = warmLayers[0]; // Bottom
let warmLayerDepth = 0;
if (firstWarm && lastWarm) warmLayerDepth = lastWarm.H - firstWarm.H; // Approximation based on list order
if (coldLayerDepth < 300) {
// Very shallow cold layer (e.g. freezing rain on contact)
return "Freezing Rain";
} else if (warmLayerDepth > coldLayerDepth * 2) {
// Warm layer dominates
return "Freezing Rain";
} else if (warmToColdRatio > 3) {
// Warm layer much stronger than cold layer
return "Freezing Rain";
} else if (coldLayerDepth > 600 && maxWarmTemp > 3) {
// Deep cold layer + Strong warm nose = Sleet
return "Ice Pellets (Sleet)";
} else if (coldLayerDepth > 400) {
// Moderate cold layer depth usually refreezes raindrops
return "Ice Pellets (Sleet)";
} else {
// Default borderline case
return "Freezing Rain";
}
}
}
/** Side panel: "Graph" (height graph), "Tornado" (CONUS climatology) or "Winter".
* @param {*} mode Parameter for ChangeSidePanelMode.
* @returns {void} No value.
*/
function ChangeSidePanelMode(mode) {
if (
mode === "Tornado" &&
typeof Snding !== "undefined" &&
Snding &&
Snding.Location2 &&
Snding.Location2.length >= 2 &&
!isSoundingInTornadoClimoBounds(Snding)
) {
mode = "Graph";
}
SidePanelMode = mode;
applySidePanelVisibility();
emit("change");
// Redraw
if (Snding && Snding.Sounding) {
LoadWithWind(Snding, Info, ghost, currentComparisonSoundings);
}
}
/** Sets or applies hodo mode state.
* @param {*} mode Parameter for SetHodoMode.
* @returns {void} No value.
*/
function SetHodoMode(mode) {
if (mode === "Relative") {
HodoMode = true;
} else {
HodoMode = false;
}
if (Snding && Snding.Sounding) {
LoadWithWind(Snding, Info, ghost, currentComparisonSoundings);
}
}
let SidePanelMode = "Winter";
let currentEFScale = "EF2+"; // Default to significant
/** Sets or applies efscale state.
* @param {*} scale Parameter for SetEFScale.
* @returns {void} No value.
*/
function SetEFScale(scale) {
currentEFScale = scale; // Expects "EF0+" or "EF2+"
if (Snding && Snding.Sounding) {
LoadWithWind(Snding, Info, ghost, currentComparisonSoundings);
}
}
/** Switches significance of climatology
* @param {*} ef2_val Parameter for ClimoSwitch.
* @param {*} ef0_val Parameter for ClimoSwitch.
* @returns {*} Result of the function.
*/
function ClimoSwitch(ef2_val, ef0_val) {
return currentEFScale === "EF2+" ? ef2_val : ef0_val;
}
/** Draws winter stats content.
* @param {*} ctx Parameter for DrawWinterStats.
* @param {*} Canv Parameter for DrawWinterStats.
* @param {*} Sounding Parameter for DrawWinterStats.
* @param {*} Info Parameter for DrawWinterStats.
* @returns {void} No value.
*/
function DrawWinterStats(ctx, Canv, Sounding, Info) {
const W = Canv.width;
const H = Canv.height;
const cx = W * 0.5;
const fs = (frac) => `${Math.round(Math.min(H, W) * frac)}px`;
ctx.fillStyle = "rgba(0,0,0,1)";
ctx.fillRect(0, 0, W, H);
let DGOmega = mean_layer_temp(Sounding, -12, -18, "Omega");
let DGPWAT = mean_layer_temp(Sounding, -12, -18, "PWAT");
let DGRH = mean_layer_temp(Sounding, -12, -18, "RH");
let DGMX = mean_layer_temp(Sounding, -12, -18, "MixRat");
let LayerTopH = Math.max(GetLayerByTemp(Sounding.Sounding, -12).H, GetLayerByTemp(Sounding.Sounding, -18).H);
let LayerDepth = Math.abs(GetLayerByTemp(Sounding.Sounding, -12).H - GetLayerByTemp(Sounding.Sounding, -18).H);
let OPRH = DGOmega * DGPWAT * DGRH;
let Initphase = Init_Phase(Sounding);
let PrecipType = BestGuessPrecip(Sounding);
// --- Header ---
ctx.font = `bold ${fs(0.055)} ${globalFont}`;
ctx.fillStyle = "rgba(255,255,255,1)";
ctx.textAlign = "center";
ctx.fillText(`Dendritic Growth Zone (DGZ) Stats`, cx, H * 0.07);
ctx.fillRect(W * 0.03, H * 0.095, W * 0.94, 1);
// --- OPRH ---
ctx.font = `bold ${fs(0.09)} ${globalFont}`;
ctx.fillStyle = OPRH < 0 ? "rgba(100,200,255,1)" : "rgba(255,100,100,1)";
ctx.fillText(`OPRH = ${OPRH.toFixed(4)}`, cx, H * 0.20);
// --- Stats list ---
const lx = W * 0.05;
const lineH = H * 0.085;
let yPos = H * 0.28;
ctx.textAlign = "left";
// Depth row
ctx.font = `bold ${fs(0.057)} ${globalFont}`;
ctx.fillStyle = "rgba(230,230,230,1)";
ctx.fillText(`Depth: ${LayerDepth.toFixed(0)}m`, lx, yPos);
ctx.font = `${fs(0.048)} ${globalFont}`;
ctx.fillStyle = "rgba(160,160,160,1)";
ctx.fillText(`(${Math.round(LayerTopH - LayerDepth)}m – ${Math.round(LayerTopH)}m)`, W * 0.48, yPos);
yPos += lineH;
ctx.font = `${fs(0.056)} ${globalFont}`;
ctx.fillStyle = "rgba(220,220,220,1)";
ctx.fillText(`Mean Layer RH: ${DGRH.toFixed(0)}%`, lx, yPos); yPos += lineH;
ctx.fillText(`Mean Layer PWAT: ${DGPWAT.toFixed(3)} in`, lx, yPos); yPos += lineH;
ctx.fillText(`Mean Layer MixRat: ${DGMX.toFixed(2)} g/kg`, lx, yPos); yPos += lineH;
ctx.fillText(`Mean Layer Omega: ${(1000*DGOmega).toFixed(2)} ub/s`, lx, yPos); yPos += lineH;
// --- Separator ---
ctx.fillStyle = "rgba(255,255,255,0.2)";
ctx.fillRect(W * 0.03, yPos - H * 0.04, W * 0.94, 1);
yPos += H * 0.045;
// --- Initial Phase ---
ctx.font = `bold ${fs(0.062)} ${globalFont}`;
ctx.fillStyle = "rgba(255,255,255,1)";
ctx.textAlign = "center";
ctx.fillText(`Initial Phase: ${Initphase[3]}`, cx, yPos);
yPos += H * 0.06;
ctx.font = `${fs(0.048)} ${globalFont}`;
ctx.fillStyle = "rgba(180,180,180,1)";
ctx.fillText(`From ${Initphase[0]}mb (${Initphase[2]}°C)`, cx, yPos);
yPos += H * 0.075;
// --- Best Guess Precip ---
ctx.font = `bold ${fs(0.058)} ${globalFont}`;
ctx.fillStyle = "rgba(255,255,255,1)";
ctx.fillText(`Best Guess Precip Type:`, cx, yPos);
yPos += H * 0.090;
ctx.font = `bold ${fs(0.095)} ${globalFont}`;
if (PrecipType.includes("Rain") && !PrecipType.includes("Freezing")) ctx.fillStyle = "rgba(50,255,50,1)";
else if (PrecipType.includes("Snow")) ctx.fillStyle = "rgba(100,200,255,1)";
else if (PrecipType.includes("Freezing Rain")) ctx.fillStyle = "rgba(255,50,255,1)";
else if (PrecipType.includes("Ice Pellets") || PrecipType.includes("Sleet")) ctx.fillStyle = "rgba(200,100,255,1)";
else ctx.fillStyle = "rgba(255,255,100,1)";
ctx.fillText(`${PrecipType}`, cx, yPos);
}
/** Draws tornado climo content.
* @param {*} ctx Parameter for DrawTornadoClimo.
* @param {*} Canv Parameter for DrawTornadoClimo.
* @param {*} Sounding Parameter for DrawTornadoClimo.
* @param {*} Info Parameter for DrawTornadoClimo.
* @param {*} Wnd Parameter for DrawTornadoClimo.
* @returns {void} No value.
*/
function DrawTornadoClimo(ctx, Canv, Sounding, Info, Wnd) {
const W = Canv.width;
const H = Canv.height;
const fs = (frac) => `${Math.round(Math.min(H, W) * frac)}px`;
ctx.fillStyle = "rgba(0,0,0,1)";
ctx.fillRect(0, 0, W, H);
let Parcels = { SB: Info.SBParcel };
let stpf = GetParams({ Wnd, Parcels }, "STPF");
if (Sounding.Location2 && Sounding.Location2[0] >= LatBounds[0] && Sounding.Location2[0] <= LatBounds[1] &&
-Sounding.Location2[1] >= LonBounds[0] && -Sounding.Location2[1] <= LonBounds[1]) {
const LatB = Math.round(Sounding.Location2[0] - 25);
const LonB = Math.round(Sounding.Location2[1] - 64);
const nms = ClimoSwitch(numbs2, numbs)[LatB][LonB];
const cx = W * 0.5;
const fs = (frac) => `${Math.round(H * frac)}px`;
// Header
ctx.font = `bold ${fs(0.052)} ${globalFont}`;
ctx.fillStyle = "rgba(255,255,255,1)";
ctx.textAlign = "center";
ctx.fillText(`Tornado Climatology ${currentEFScale} (2003-2024)`, cx, H * 0.055);
ctx.fillRect(W * 0.03, H * 0.075, W * 0.94, 1);
if (nms >= 5) {
ctx.font = `${fs(0.040)} ${globalFont}`;
ctx.fillText(`${nms} Cases`, cx, H * 0.12);
const topY = H * 0.13;
const Chunk = (H * 0.87) / 4;
// Bar spans nearly full width with small margins
const barX = W * 0.04;
const barW = W * 0.92;
// DrawClimo only calls this once loadTornadoClimo() has resolved.
const { stpfc, stpfc2, mlcapec, mlcapec2, effsrhc, effsrhc2, effshrc, effshrc2 } = tornadoClimo;
const Datasets = [
[`STP Fixed: ${stpf.toFixed(2)}`, [0,15], Array.from({length:16},(_,i)=>i), ClimoSwitch(stpfc2,stpfc), stpf],
[`MLCape: ${Info.MLParcel.Cape.toFixed(0)} J/kg`, [0,6000], Array.from({length:7}, (_,i)=>i*1000), ClimoSwitch(mlcapec2,mlcapec), Info.MLParcel.Cape],
[`Eff. SRH: ${Wnd.ESRH==false?0:Wnd.ESRH.toFixed(0)} m²/s²`, [0,1000], Array.from({length:11},(_,i)=>i*100), ClimoSwitch(effsrhc2,effsrhc), Wnd.ESRH==false?0:Wnd.ESRH],
[`Eff. Shear: ${Wnd.BWDE==false?0:Mag(Wnd.BWDE).toFixed(0)} kts`, [0,70], Array.from({length:8}, (_,i)=>i*10), ClimoSwitch(effshrc2,effshrc), Wnd.BWDE==false?0:Mag(Wnd.BWDE)],
];
Datasets.forEach((P, Idx) => {
const baseY = topY + Chunk * Idx;
const labelY = baseY + Chunk * 0.25; // label near top of chunk
const barY = baseY + Chunk * 0.55; // bar in middle
const tickY = barY + Chunk * 0.22; // tick numbers just below bar
const pct = (v) => (Math.max(P[1][0], Math.min(P[1][1], v)) - P[1][0]) / (P[1][1] - P[1][0]);
// --- Label (centered) ---
ctx.font = `bold ${fs(0.046)} ${globalFont}`;
ctx.fillStyle = "rgba(255,255,255,1)";
ctx.textAlign = "center";
ctx.fillText(P[0], cx, labelY);
// --- Percentile (top-right of chunk) ---
let Gotten = false;
ctx.font = `bold ${fs(0.060)} ${globalFont}`;
ctx.fillStyle = "rgba(47,255,0,1)";
ctx.textAlign = "right";
for (let x = 0; x < P[3][LatB][LonB].length; x++) {
if (P[4] <= P[3][LatB][LonB][x]) {
ctx.fillText(x === 0 ? `<1st` : `${x*5}-${x*5+5}th`, W * 0.98, labelY);
Gotten = true;
break;
}
}
if (!Gotten) ctx.fillText(`>99th`, W * 0.98, labelY);
// --- Bar background (thin line) ---
ctx.fillStyle = "rgba(255,255,255,0.25)";
ctx.fillRect(barX, barY, barW, 1);
const P0 = P[3][LatB][LonB][0];
const P10 = P[3][LatB][LonB][2];
const P30 = P[3][LatB][LonB][6];
const P50 = P[3][LatB][LonB][10];
const P70 = P[3][LatB][LonB][14];
const P90 = P[3][LatB][LonB][18];
const P100 = P[3][LatB][LonB][20];
// --- Axis tick labels ---
ctx.fillStyle = "rgba(255,255,255,0.7)";
ctx.font = `${fs(0.028)} ${globalFont}`;
ctx.textAlign = "center";
P[2].forEach(v => {
const x = barX + barW * pct(v);
ctx.fillText(v, x, tickY);
ctx.fillStyle = "rgba(255,255,255,0.3)";
ctx.fillRect(x, barY - 2, 1, 5);
ctx.fillStyle = "rgba(255,255,255,0.7)";
});
// --- Min/Max dots ---
ctx.fillStyle = "rgba(255,0,0,1)";
ctx.fillRect(barX + barW * pct(P0), barY - 1, 2, 4);
ctx.fillRect(barX + barW * pct(P100), barY - 1, 2, 4);
// --- IQR box (30th–70th) — thin ---
const boxStart = barX + barW * pct(P30);
ctx.fillStyle = "rgba(255,0,0,0.75)";
ctx.fillRect(boxStart, barY - 7, barW * (pct(P70) - pct(P30)), 7);
// --- Median line ---
ctx.fillStyle = "rgba(255,255,255,1)";
ctx.fillRect(barX + barW * pct(P50), barY - 8, 2, 8);
// --- Whiskers (10th–90th) ---
const w1 = barX + barW * pct(P10);
const w2 = barX + barW * pct(P90);
ctx.fillRect(w1, barY, w2 - w1, 1);
ctx.fillRect(w1, barY - 3, 2, 7);
ctx.fillRect(w2, barY - 3, 2, 7);
// --- Current value (yellow) ---
ctx.fillStyle = "rgba(255,234,0,1)";
ctx.fillRect(barX + barW * pct(P[4]), barY - 8, 3, 10);
});
} else {
ctx.font = `${fs(0.052)} ${globalFont}`;
ctx.fillText(`Insufficient Cases! (${nms} < 5)`, cx, H * 0.5);
}
}
// Out-of-bounds: UI should coerce to Graph w/ Height (see coerceTornadoClimoToGraphIfNeeded); canvas stays black.
}
let lastClimoDraw = null;
let tornadoClimoRedrawQueued = false;
/** Draws climo content.
* @param {*} Sounding Parameter for DrawClimo.
* @param {*} Info Parameter for DrawClimo.
* @param {*} Wnd Parameter for DrawClimo.
* @returns {void} No value.
*/
function DrawClimo(Sounding, Info, Wnd) {
lastClimoDraw = [Sounding, Info, Wnd];
const Canv = $("Values6");
const ctx = Canv.getContext("2d");
// Account for device pixel ratio for sharp rendering
const dpr = window.devicePixelRatio * 2 || 2;
const cssWidth = Canv.offsetWidth;
const cssHeight = Canv.offsetHeight;
Canv.width = cssWidth * dpr;
Canv.height = cssHeight * dpr;
// Scale all drawing operations up to match
ctx.scale(dpr, dpr);
// Now W/H passed to draw functions should be CSS size (not pixel size)
// so coordinates stay intuitive
const FakeCanv = { width: cssWidth, height: cssHeight };
if (SidePanelMode === "Winter") {
DrawWinterStats(ctx, FakeCanv, Sounding, Info);
} else if (SidePanelMode === "Tornado") {
if (tornadoClimo) {
DrawTornadoClimo(ctx, FakeCanv, Sounding, Info, Wnd);
return;
}
ctx.fillStyle = "rgba(0,0,0,1)";
ctx.fillRect(0, 0, cssWidth, cssHeight);
ctx.font = `${Math.round(Math.min(cssWidth, cssHeight) * 0.05)}px ${globalFont}`;
ctx.fillStyle = "rgba(255,255,255,0.8)";
ctx.textAlign = "center";
ctx.fillText("Loading tornado climatology…", cssWidth / 2, cssHeight / 2);
if (!tornadoClimoRedrawQueued) {
tornadoClimoRedrawQueued = true;
loadTornadoClimo()
.then(() => {
// Redraw the sounding on screen now (it may have changed while loading).
if (SidePanelMode === "Tornado" && lastClimoDraw && $("Values6")) {
DrawClimo(...lastClimoDraw);
}
})
.catch((err) => console.error("Tornado climatology failed to load:", err))
.finally(() => { tornadoClimoRedrawQueued = false; });
}
} else {
ctx.fillStyle = "rgba(0,0,0,1)";
ctx.fillRect(0, 0, cssWidth, cssHeight);
}
}
/** Calculates the angle between two vectors.
* @param {*} a Parameter for angleBetween.
* @param {*} b Parameter for angleBetween.
* @returns {*} Result of the function.
*/
function angleBetween(a, b) {
return ((b - a + 180) % 360 + 360) % 360 - 180;
}
/** Draws boxes content.
* @param {*} Sounding Parameter for DrawBoxes.
* @param {*} Info Parameter for DrawBoxes.
* @param {*} wind Parameter for DrawBoxes.
* @returns {void} No value.
*/
function DrawBoxes(Sounding, Info, wind) {
if (VWPActive){
return
}
DrawClimo(Sounding, Info, wind);
const Canv = $("Values4");
const ctx = Canv.getContext("2d");
let SSlice = GetLayerByHght(Sounding.Sounding, "SFC");
Canv.width = 800;//Canv.style.width;
Canv.height = 800;
let PosX = 0;
let PosY = 0;
let BoundsX = [0, 400];
let BoundsY = [0, 400];
ctx.strokeStyle = "rgba(255,255,255,1)";
ctx.lineWidth = 3;
ctx.fillStyle = "rgba(0,0,0,1)";
ctx.fillRect(0, 0, 400, 400);
ctx.strokeRect(0, 0, 400, 400);
ctx.strokeRect(0, 400, 400, 400);
ctx.strokeRect(400, 400, 400, 400);
let Sound = Sounding.Sounding;
//First Box, SR Wind
ctx.beginPath();
ctx.lineWidth = 5;
ctx.setLineDash([5, 5]);
ctx.strokeStyle = "rgba(255,0,0,1)";
ctx.fillStyle = "rgba(255,0,0,0.1)";
ctx.lineTo(0, 400);
let pl = 0;
for (let i = 0; i < Sound.W.length; i++) {
let Slice = Sound.W[i];
if (Slice.H > 16000) {
pl = 0;
break;
}
let Wind = ConvertPolartoCart(Slice);
Wind.x -= CSMot.x;
Wind.y += CSMot.y;
let Wnd = Math.round(Mag(Wind));
let Pos = GetBoxPos(BoundsX, BoundsY, 100 * (Wnd / 80), 100 * (1 - Slice.H / 16000));
ctx.lineTo(Pos.x, Pos.y);
pl = Pos.y;
//ctx.moveTo(Pos.x,Pos.y);
}
ctx.lineTo(0, pl);
ctx.stroke();
ctx.fill();
ctx.font = `bold ${400 * 30/400}px ${globalFont}`;
ctx.fillStyle = "rgba(255,255,255,1)";
ctx.textAlign = "center";
let Pos = GetBoxPos(BoundsX, BoundsY, 50, 20);
ctx.fillText(`SR Wind (kt)`, Pos.x, Pos.y);
ctx.fillText(`v`, Pos.x, Pos.y + 30);
ctx.fillText(`Height (km)`, Pos.x, Pos.y + 60);
for (let i = 2; i < 16; i += 2) {
let Pos = GetBoxPos(BoundsX, BoundsY, 0, 100 * (1 - (i / 16)));
let Pos2 = GetBoxPos(BoundsX, BoundsY, 100, 100 * (1 - (i / 16)));
ctx.fillRect(Pos.x, Pos.y, 15, 4);
ctx.fillRect(Pos2.x - 20, Pos.y, 15, 4);
ctx.font = `${400 * 0.075}px ${globalFont}`;
ctx.fillText(`${i}`, Pos.x + 30, Pos.y + 5);
}
for (let i = 10; i <= 70; i += 10) {
let Pos = GetBoxPos(BoundsX, BoundsY, 100 * ((i / 80)), 0);
let Pos2 = GetBoxPos(BoundsX, BoundsY, 100 * ((i / 80)), 100);
ctx.fillRect(Pos.x, Pos.y, 4, 15);
ctx.fillRect(Pos.x, Pos2.y - 20, 4, 15);
}
//Next Box, Mean Mix ratio
BoundsX = [400, 800];
ctx.fillStyle = "rgba(0,0,0,1)";
ctx.fillRect(400, 0, 400, 400);
ctx.strokeStyle = "rgba(255,255,255,1)";
ctx.setLineDash([]);
ctx.lineWidth = 3;
ctx.strokeRect(400, 0, 400, 400);
let EFFRH = "--";
if (Info.EffInflow == false) {
EFFRH = "--";
} else {
EFFRH = mean_relh(Sound, Info.EffInflow.Bot, Info.EffInflow.Top);
}
ctx.font = `bold ${400 * 0.08}px ${globalFont}`;
ctx.fillStyle = "rgba(255,255,255,1)";
ctx.textAlign = "center";
Pos = GetBoxPos(BoundsX, BoundsY, 50, 20);
ctx.fillText(`Mix Ratio (g/kg)`, Pos.x, Pos.y);
ctx.font = `${400 * 0.08}px ${globalFont}`;
ctx.fillText(`Eff RH: ${Math.round(EFFRH)}%`, Pos.x, Pos.y + 35);
for (let i = 0.5; i < 3; i += 0.5) {
let Pos = GetBoxPos(BoundsX, BoundsY, 0, 100 * (1 - (i / 3)));
let Pos2 = GetBoxPos(BoundsX, BoundsY, 100, 100 * (1 - (i / 3)));
ctx.fillRect(Pos.x, Pos.y, 15, 4);
ctx.fillRect(Pos2.x - 20, Pos.y, 15, 4);
ctx.font = `${400 * 0.075}px ${globalFont}`;
ctx.fillText(`${i}`, Pos.x + 30, Pos.y + 5);
}
for (let i = 5; i < 25; i += 5) {
let Pos = GetBoxPos(BoundsX, BoundsY, 100 * ((i / 25)), 0);
let Pos2 = GetBoxPos(BoundsX, BoundsY, 100 * ((i / 25)), 100);
ctx.fillRect(Pos.x, Pos.y, 4, 15);
ctx.fillRect(Pos.x, Pos2.y - 20, 4, 15);
//ctx.textAlign = "left";
ctx.font = `${400 * 0.075}px ${globalFont}`;
ctx.fillText(`${i}`, Pos.x, Pos2.y - 25);
}
ctx.beginPath();
ctx.lineWidth = 5;
ctx.strokeStyle = "rgba(0,255,0,0.7)";
ctx.fillStyle = "rgba(0,255,0,0.1)";
for (let i = 0; i < Sound.DW.length; i++) {
let Slice = Sound.DW[i];
if (!Slice || !SSlice) continue;
if (Slice.H - SSlice.H > 3000) {
const prevDw = i > 0 ? Sound.DW[i - 1] : null;
if ((prevDw && prevDw.H - SSlice.H < 3000) || i === 0) {
Slice = GetLayerByHght(Sound, 3000 + SSlice.H);
if (!Slice) break;
} else {
break;
}
}
let MMR = mixratio(Slice.P, Slice.D);
let Pos = GetBoxPos(BoundsX, BoundsY, 100 * (MMR / 25), 100 * (1 - (Slice.H- SSlice.H) / 3000));
if (i == 0) {
ctx.lineTo(400, Pos.y);
}
ctx.lineTo(Pos.x, Pos.y);
//ctx.moveTo(Pos.x,Pos.y);
}
ctx.lineTo(400, 0);
ctx.fill();
ctx.stroke();
//Theta now
BoundsX = [0, 400];
BoundsY = [400, 800];
ctx.fillStyle = "rgba(0,0,0,1)";
ctx.fillRect(0, 400, 400, 400);
ctx.strokeStyle = "rgba(255,255,255,1)";
ctx.setLineDash([]);
ctx.lineWidth = 3;
ctx.strokeRect(0, 400, 400, 400);
ctx.font = `${400 * 0.08}px ${globalFont}`;
ctx.fillStyle = "rgba(255,255,255,1)";
ctx.textAlign = "center";
for (let i = 500; i < 1000; i += 100) {
let Pos = GetBoxPos(BoundsX, BoundsY, 0, 100 * ((i - 400) / 600));
let Pos2 = GetBoxPos(BoundsX, BoundsY, 100, 100 * ((i - 400) / 600));
ctx.fillRect(Pos.x, Pos.y, 15, 4);
ctx.fillRect(Pos2.x - 20, Pos.y, 15, 4);
ctx.font = `${400 * 0.075}px ${globalFont}`;
ctx.fillText(`${i}`, Pos.x + 50, Pos.y + 5);
}
let MinTta = 999;
let MaxTta = 0;
let Tta = [];
let TtaPres = [];
for (let i = 0; i < Sound.DW.length; i++) {
let obj = ConvertToInfo(Sound.Slice[i]);
if (obj.P < 400) {
break;
}
let TTa = CtoK(thetae(obj.P, obj.T, obj.D)) || -9999;
if (TTa != TTa || TTa == -9999) {
continue;
}
Tta.push(TTa);
TtaPres.push(obj.P);
MinTta = Math.min(MinTta, TTa - 10);
MaxTta = Math.max(MaxTta, TTa + 10);
}
ctx.beginPath();
ctx.lineWidth = 5;
ctx.strokeStyle = "rgba(255,0,0,0.7)";
ctx.fillStyle = "rgba(255, 0, 0, 0.1)";
let PosL = 50;
for (let i = 0; i < Tta.length; i++) {
let P = TtaPres[i];
let T = Tta[i];
let Lerped = (T - MinTta) / (MaxTta - MinTta);
let Lerpd = ((P - 400) / 600);
let Pos = GetBoxPos(BoundsX, BoundsY, 100 * Lerped, 100 * Lerpd);
if (i == 0) {
ctx.lineTo(0, Pos.y);
}
ctx.lineTo(Pos.x, Pos.y);
PosL = Pos.y;
if (i + 1 == Tta.length) {
PosL = 400;
}
//ctx.moveTo(Pos.x,Pos.y);
}
ctx.lineTo(0, PosL);
ctx.fill();
ctx.stroke();
ctx.fillStyle = "rgba(255, 255, 255, 1)";
for (let i = 320; i < 400; i += 10) {
let Lerped = (i - MinTta) / (MaxTta - MinTta);
let Pos = GetBoxPos(BoundsX, BoundsY, 100 * Lerped, 0);
let Pos2 = GetBoxPos(BoundsX, BoundsY, 100 * Lerped, 100);
ctx.fillRect(Pos.x, Pos.y, 4, 15);
ctx.fillRect(Pos.x, Pos2.y - 20, 4, 15);
//ctx.textAlign = "left";
ctx.font = `${400 * 0.08}px ${globalFont}`;
ctx.fillText(`${i}`, Pos.x, Pos2.y - 25);
}
ctx.font = `bold ${400 * 0.08}px ${globalFont}`;
ctx.fillStyle = "rgba(255,255,255,1)";
ctx.textAlign = "center";
Pos = GetBoxPos(BoundsX, BoundsY, 50, 20);
ctx.fillText(`Theta E v Pres`, Pos.x, Pos.y);
//Last Box
BoundsX = [400, 800];
ctx.fillStyle = "rgba(0,0,0,1)";
ctx.fillRect(400, 400, 400, 400);
ctx.lineWidth = 3;
ctx.strokeStyle = "rgba(255,255,255,1)";
ctx.strokeRect(400, 400, 400, 400);
ctx.font = `${400 * 0.1}px ${globalFont}`;
ctx.fillStyle = "rgba(255,0,0,0.1)";
ctx.textAlign = "center";
let Nn = [
[400, 800]
];
if (Presets['Vertical'] == "Default" || Presets['Vertical'] == "Severe") {
ctx.beginPath();
ctx.lineWidth = 5;
ctx.strokeStyle = "rgba(255,0,0,0.7)";
//ctx.moveTo(400,800);
let LPos = 0;
let Switch = false;
for (let i = 0; i < Sound.W.length-1; i++) {
let P = ConvertPolartoCart(Sound.W[i]);
P.H = Sound.W[i].H - SSlice.H;
let N = ConvertPolartoCart(Sound.W[i + 1]);
N.H = Sound.W[i + 1].H - SSlice.H;
if (Math.abs(P.H - N.H) < 100) {
N = ConvertPolartoCart(GetLayerByHght(Sound, P.H + SSlice.H + 100));
}
if (P.H > 3000) {
Switch = true;
}
let R = {
x: P.x - N.x,
y: P.y - N.y,
};
let B = {
x: P.x - CSMot.x,
y: -(P.y + CSMot.y),
}
let TrueR = {
x: -R.y,
y: -R.x,
}
let BPol = ConvertCartToPolar_Regular(B);
let RPol = ConvertCartToPolar_Regular(TrueR);
BPol.WD = BPol.WD%360;
RPol.WD = RPol.WD%360;
if (RPol.WD - BPol.WD > 180) {
BPol.WD += 360;
}
if (BPol.WD - RPol.WD > 180) {
RPol.WD += 360;
}
let dot = (TrueR.x * B.x + TrueR.y * B.y) / (RPol.WS * BPol.WS);
let Lerped = clamp(Math.abs(dot), 0, 1);
let Pos = GetBoxPos(BoundsX, BoundsY, 100 * Lerped, clamp(100 * (1 - P.H / 3000), 0, 100));
if (i == 0) {
ctx.lineTo(400, Pos.y);
}
ctx.lineTo(Pos.x, Pos.y);
LPos = Pos;
if (Switch){
break;
}
//ctx.moveTo(Pos.x,Pos.y);
}
ctx.lineTo(LPos.x, LPos.y);
ctx.lineTo(400, LPos.y);
ctx.lineTo(400, 800);
ctx.stroke();
ctx.fill();
ctx.lineWidth = 3;
ctx.strokeStyle = "rgba(255,255,255,1)";
ctx.strokeRect(400, 400, 400, 400);
ctx.fillStyle = "rgba(255, 255, 255 ,1)";
for (let i = 0.5; i < 3; i += 0.5) {
let Pos = GetBoxPos(BoundsX, BoundsY, 0, 100 * (1 - (i / 3)));
let Pos2 = GetBoxPos(BoundsX, BoundsY, 100, 100 * (1 - (i / 3)));
ctx.fillRect(Pos.x, Pos.y, 15, 4);
ctx.fillRect(Pos2.x - 20, Pos.y, 15, 4);
ctx.font = `${400 * 0.075}px ${globalFont}`;
ctx.fillText(`${i}`, Pos.x + 30, Pos.y + 5);
}
for (let i = 20; i < 100; i += 20) {
let Pos = GetBoxPos(BoundsX, BoundsY, 100 * ((i / 100)), 0);
let Pos2 = GetBoxPos(BoundsX, BoundsY, 100 * ((i / 100)), 100);
ctx.fillRect(Pos.x, Pos.y, 4, 15);
ctx.fillRect(Pos.x, Pos2.y - 20, 4, 15);
//ctx.textAlign = "left";
ctx.font = `${400 * 0.075}px ${globalFont}`;
ctx.fillText(`${Math.round(i)}%`, Pos.x, Pos2.y - 25);
}
ctx.font = `bold ${400 * 0.1}px ${globalFont}`;
ctx.fillStyle = "rgba(255,255,255,1)";
ctx.textAlign = "center";
Pos = GetBoxPos(BoundsX, BoundsY, 50, 20);
ctx.fillText(`SW% ωₛ`, Pos.x, Pos.y);
} else if (Presets['Vertical'] == "Fire") {
ctx.beginPath();
ctx.lineWidth = 5;
ctx.strokeStyle = "rgba(255,0,0,0.7)";
//ctx.moveTo(400,800);
for (let i = 0; i < Sound.DW.length; i++) {
let obj = ConvertToInfo(Sound.Slice[i]);
if (obj.H > 3000) {
const prevSlice = i > 0 ? ConvertToInfo(Sound.Slice[i - 1]) : null;
if ((prevSlice && prevSlice.H < 3000) || i === 0) {
obj = GetLayerByHght(Sound, 3000);
if (!obj) break;
} else {
break;
}
}
let TTa = VaporPressure(obj.T) - VaporPressure(obj.D);
if (TTa != TTa || TTa == -9999) {
continue;
}
let Pos = GetBoxPos(BoundsX, BoundsY, 100 * TTa / 50, clamp(100 * (1 - obj.H / 3000), 0, 100));
if (i == 0) {
ctx.lineTo(400, Pos.y);
}
ctx.lineTo(Pos.x, Pos.y);
//ctx.moveTo(Pos.x,Pos.y);
}
ctx.lineTo(400, 400);
ctx.lineTo(400, 800);
ctx.stroke();
ctx.fill();
ctx.lineWidth = 3;
ctx.strokeStyle = "rgba(255,255,255,1)";
ctx.strokeRect(400, 400, 400, 400);
ctx.fillStyle = "rgba(255, 255, 255 ,1)";
for (let i = 0.5; i < 3; i += 0.5) {
let Pos = GetBoxPos(BoundsX, BoundsY, 0, 100 * (1 - (i / 3)));
let Pos2 = GetBoxPos(BoundsX, BoundsY, 100, 100 * (1 - (i / 3)));
ctx.fillRect(Pos.x, Pos.y, 15, 4);
ctx.fillRect(Pos2.x - 20, Pos.y, 15, 4);
ctx.font = `${400 * 0.075}px ${globalFont}`;
ctx.fillText(`${i}`, Pos.x + 30, Pos.y + 5);
}
for (let i = 0; i < 50; i += 10) {
let Pos = GetBoxPos(BoundsX, BoundsY, 100 * ((i / 50)), 0);
let Pos2 = GetBoxPos(BoundsX, BoundsY, 100 * ((i / 50)), 100);
ctx.fillRect(Pos.x, Pos.y, 4, 15);
ctx.fillRect(Pos.x, Pos2.y - 20, 4, 15);
//ctx.textAlign = "left";
ctx.font = `${400 * 0.075}px ${globalFont}`;
ctx.fillText(`${Math.round(i)}`, Pos.x, Pos2.y - 25);
}
ctx.font = `bold ${400 * 0.1}px ${globalFont}`;
ctx.fillStyle = "rgba(255,255,255,1)";
ctx.textAlign = "center";
Pos = GetBoxPos(BoundsX, BoundsY, 50, 20);
ctx.fillText(`VPD`, Pos.x, Pos.y);
} else if (Presets['Vertical'] == "Winter") {
ctx.fillStyle = "rgba(0, 128, 255, 0.5)";
ctx.fillRect(400, 400, 400 * 10 / 30, 400)
ctx.fillStyle = "rgba(231, 95, 255, 0.5)";
ctx.fillRect(400 + 400 * 10 / 30, 400, 400 * 5 / 30, 400)
ctx.fillStyle = "rgba(152, 13, 124, 0.5)";
ctx.fillRect(400 + 400 * 15 / 30, 400, 400 * 5 / 30, 400)
ctx.fillStyle = "rgba(68, 255, 0, 0.5)";
ctx.fillRect(400 + 400 * 20 / 30, 400, 400 * 10 / 30, 400)
ctx.fillStyle = "rgba(255,0,0,0.1)";
ctx.beginPath();
ctx.lineWidth = 4;
ctx.strokeStyle = "rgba(255, 100, 100, 0.7)";
//ctx.moveTo(400,800);
for (let i = 0; i < Sound.DW.length; i++) {
let obj = ConvertToInfo(Sound.Slice[i]);
if (obj.H > 3000) {
const prevSlice = i > 0 ? ConvertToInfo(Sound.Slice[i - 1]) : null;
if ((prevSlice && prevSlice.H < 3000) || i === 0) {
obj = GetLayerByHght(Sound, 3000);
if (!obj) break;
} else {
break;
}
}
if ((obj.T != obj.T || obj.T == -9999) && (obj.WB != obj.WB || obj.WB == -9999)) {
continue;
}
let Pos = GetBoxPos(BoundsX, BoundsY, 100 * (obj.T + 15) / 30, clamp(100 * (1 - obj.H / 3000), 0, 100));
if (i == 0) {
ctx.lineTo(400, Pos.y);
}
ctx.lineTo(Pos.x, Pos.y);
}
ctx.lineTo(400, 400);
ctx.lineTo(400, 800);
ctx.stroke();
ctx.fill();
//now WB
ctx.beginPath();
ctx.lineWidth = 4;
ctx.strokeStyle = "rgba(147, 255, 237, 0.7)";
//ctx.moveTo(400,800);
for (let i = 0; i < Sound.DW.length; i++) {
let obj = ConvertToInfo(Sound.Slice[i]);
if (obj.H > 3000) {
const prevSlice = i > 0 ? ConvertToInfo(Sound.Slice[i - 1]) : null;
if ((prevSlice && prevSlice.H < 3000) || i === 0) {
obj = GetLayerByHght(Sound, 3000);
if (!obj) break;
} else {
break;
}
}
if ((obj.T != obj.T || obj.T == -9999) && (obj.WB != obj.WB || obj.WB == -9999)) {
continue;
}
let Pos = GetBoxPos(BoundsX, BoundsY, 100 * (obj.WB + 15) / 30, clamp(100 * (1 - obj.H / 3000),0,100));
if (i == 0) {
ctx.lineTo(400, Pos.y);
}
ctx.lineTo(Pos.x, Pos.y);
}
ctx.lineTo(400, 400);
ctx.lineTo(400, 800);
ctx.stroke();
ctx.fill();
ctx.lineWidth = 3;
ctx.strokeStyle = "rgba(255,255,255,1)";
ctx.strokeRect(400, 400, 400, 400);
ctx.fillStyle = "rgba(255, 255, 255 ,1)";
for (let i = 0.5; i < 3; i += 0.5) {
let Pos = GetBoxPos(BoundsX, BoundsY, 0, 100 * (1 - (i / 3)));
let Pos2 = GetBoxPos(BoundsX, BoundsY, 100, 100 * (1 - (i / 3)));
ctx.fillRect(Pos.x, Pos.y, 15, 4);
ctx.fillRect(Pos2.x - 20, Pos.y, 15, 4);
ctx.font = `${400 * 0.075}px ${globalFont}`;
ctx.fillText(`${i}`, Pos.x + 30, Pos.y + 5);
}
for (let i = 15; i < -15; i += 5) {
let Pos = GetBoxPos(BoundsX, BoundsY, 100 * ((i + 15) / 30), 0);
let Pos2 = GetBoxPos(BoundsX, BoundsY, 100 * ((i + 15) / 30), 100);
ctx.fillRect(Pos.x, Pos.y, 4, 15);
ctx.fillRect(Pos.x, Pos2.y - 20, 4, 15);
//ctx.textAlign = "left";
ctx.font = `${400 * 0.075}px ${globalFont}`;
ctx.fillText(`${Math.round(i)}`, Pos.x, Pos2.y - 25);
}
ctx.font = `bold ${400 * 0.1}px ${globalFont}`;
ctx.fillStyle = "rgba(255,255,255,1)";
ctx.textAlign = "center";
Pos = GetBoxPos(BoundsX, BoundsY, 50, 20);
ctx.fillText(`PType Estimation`, Pos.x, Pos.y);
}
ctx.strokeStyle = "rgba(255,255,255,1)";
ctx.lineWidth = 3;
ctx.strokeRect(0, 0, 400, 400);
ctx.strokeRect(0, 400, 400, 400);
ctx.strokeRect(400, 0, 400, 400);
ctx.strokeRect(400, 400, 400, 400);
}
// Add these exported setter functions alongside your existing exports
/** Sets or applies sounding design state state.
* @param {*} design Parameter for setSoundingDesignState.
* @returns {void} No value.
*/
const setSoundingDesignState = (design) => {
if (typeof design.hodographNumbers === 'boolean') {
DisplayHodographNumbers = design.hodographNumbers;
}
if (typeof design.hodographColorTable === 'number') {
HodographColorTable = ((design.hodographColorTable % 4) + 4) % 4;
}
if (typeof design.hodographScaleMode === 'number') {
HodographScaleMode = ((design.hodographScaleMode % 3) + 3) % 3;
}
if (typeof design.hodographTrimWindProfile === 'boolean') {
HodographTrimWindProfile = design.hodographTrimWindProfile;
}
if (typeof design.readOutOn === 'boolean') {
setReadOutOnState(design.readOutOn);
}
if (typeof design.skewTSkew === 'number' && SKEW_STEPS.includes(design.skewTSkew)) {
UserSkewNumber = design.skewTSkew;
SkewNumber = design.skewTSkew;
}
};
/** Sets or applies sounding units state.
* @param {*} unitSystem Parameter for setSoundingUnits.
* @returns {void} No value.
*/
function setSoundingUnits(unitSystem) {
switch (unitSystem) {
case 'default':
useImperialUnits = 0;
break;
case 'metric':
useImperialUnits = 1;
break;
case 'imperial':
useImperialUnits = 2;
break;
default:
useImperialUnits = 0;
}
}
/** Returns the color for a temperature based on its value.
* @param {*} tempC Parameter for tempStyle.
* @returns {*} Result of the function.
*/
function tempStyle(tempC) {
if (tempC == null) return { bg: 'rgba(48, 58, 68, 0.7)', color: '#dce7ee' };
if (tempC >= 30) return { bg: 'rgba(186, 62, 42, 0.82)', color: '#fff4f0' };
if (tempC >= 26) return { bg: 'rgba(196, 108, 48, 0.8)', color: '#fff6ee' };
if (tempC >= 20) return { bg: 'rgba(170, 128, 58, 0.72)', color: '#fffaf0' };
if (tempC >= 10) return { bg: 'rgba(58, 122, 88, 0.7)', color: '#f0fff5' };
if (tempC >= 0) return { bg: 'rgba(48, 108, 148, 0.75)', color: '#eef8ff' };
if (tempC >= -10) return { bg: 'rgba(58, 92, 168, 0.8)', color: '#eef2ff' };
return { bg: 'rgba(78, 70, 155, 0.85)', color: '#f2f0ff' };
}
/** Returns the color for a relative humidity based on its value.
* @param {*} rh Parameter for rhStyle.
* @returns {*} Result of the function.
*/
function rhStyle(rh) {
if (rh == null) return { bg: 'rgba(48, 58, 68, 0.7)', color: '#dce7ee' };
if (rh >= 90) return { bg: 'rgba(58, 48, 138, 0.85)', color: '#f0eaff' };
if (rh >= 80) return { bg: 'rgba(42, 92, 155, 0.8)', color: '#eef6ff' };
if (rh >= 65) return { bg: 'rgba(38, 125, 135, 0.75)', color: '#eefcff' };
if (rh >= 50) return { bg: 'rgba(52, 118, 82, 0.7)', color: '#f0fff4' };
if (rh >= 35) return { bg: 'rgba(135, 125, 55, 0.7)', color: '#fffcf0' };
return { bg: 'rgba(145, 92, 50, 0.75)', color: '#fff6ee' };
}
/** Adds a rounded rectangle to the current canvas path.
* @param {*} ctx Parameter for roundRectPath.
* @param {*} x Parameter for roundRectPath.
* @param {*} y Parameter for roundRectPath.
* @param {*} w Parameter for roundRectPath.
* @param {*} h Parameter for roundRectPath.
* @param {*} r Parameter for roundRectPath.
* @returns {void} No value.
*/
function roundRectPath(ctx, x, y, w, h, r) {
const radius = Math.max(0, Math.min(r, w / 2, h / 2));
ctx.beginPath();
ctx.moveTo(x + radius, y);
ctx.arcTo(x + w, y, x + w, y + h, radius);
ctx.arcTo(x + w, y + h, x, y + h, radius);
ctx.arcTo(x, y + h, x, y, radius);
ctx.arcTo(x, y, x + w, y, radius);
ctx.closePath();
}
/** Fills a rounded rectangle on the canvas.
* @param {*} ctx Parameter for fillRoundRect.
* @param {*} x Parameter for fillRoundRect.
* @param {*} y Parameter for fillRoundRect.
* @param {*} w Parameter for fillRoundRect.
* @param {*} h Parameter for fillRoundRect.
* @param {*} r Parameter for fillRoundRect.
* @param {*} fill Parameter for fillRoundRect.
* @returns {void} No value.
*/
function fillRoundRect(ctx, x, y, w, h, r, fill) {
roundRectPath(ctx, x, y, w, h, r);
ctx.fillStyle = fill;
ctx.fill();
}
/** Strokes a rounded rectangle on the canvas.
* @param {*} ctx Parameter for strokeRoundRect.
* @param {*} x Parameter for strokeRoundRect.
* @param {*} y Parameter for strokeRoundRect.
* @param {*} w Parameter for strokeRoundRect.
* @param {*} h Parameter for strokeRoundRect.
* @param {*} r Parameter for strokeRoundRect.
* @param {*} stroke Parameter for strokeRoundRect.
* @param {*} lineWidth Parameter for strokeRoundRect.
* @returns {void} No value.
*/
function strokeRoundRect(ctx, x, y, w, h, r, stroke, lineWidth = 1) {
roundRectPath(ctx, x, y, w, h, r);
ctx.strokeStyle = stroke;
ctx.lineWidth = lineWidth;
ctx.stroke();
}
/** Chooses text with contrast against a category background.
* @param {*} kt Parameter for categoryTextColor.
* @returns {*} Result of the function.
*/
function categoryTextColor(kt) {
const n = Number(kt);
if (n >= 64 && n < 96) return '#1a1a1a';
return '#f4fffc';
}
/** Generates the looks for the drop sonde.
* @param {*} props Parameter for GenerateDropSondeLooks.
* @param {*} reductions Parameter for GenerateDropSondeLooks.
* @param {*} times Parameter for GenerateDropSondeLooks.
* @param {*} intensityNumberFunction Parameter for GenerateDropSondeLooks.
* @returns {void} No value.
*/
function GenerateDropSondeLooks(props, reductions, times, intensityNumberFunction){
const Canv = $("Values11");
const ctx = Canv.getContext("2d");
const bg = DarkMode ? "#0a0c0e" : "#f7f8fa";
const fg = DarkMode ? "#e8eef2" : "#1a2228";
const muted = DarkMode ? "#8ea3b3" : "#5a6b78";
const grid = DarkMode ? "rgba(255,255,255,0.1)" : "rgba(0,0,0,0.1)";
const headerBg = DarkMode ? "rgba(255,255,255,0.04)" : "rgba(0,0,0,0.04)";
const cellBg = DarkMode ? "rgba(255,255,255,0.025)" : "rgba(0,0,0,0.03)";
const W = 750;
const padX = 14;
// Branding lives under the skew-T title — start the table near the top
const tableTop = 8;
const rowWidths = [145, 130, 210, 120, 115];
const tableW = rowWidths.reduce((a, b) => a + b, 0);
const colH = 56;
const rowTitles = ["Pressure", "Height", "Wind", "Temp", "RH"];
const rows = [{ __header: true }, ...(props.windRows || [])];
const maxRows = Math.min(rows.length, 9); // header + 8 levels
const cornerR = 5;
const contentRight = padX + tableW;
// Compact summary so the level table can claim more vertical space
const tableBottom = tableTop + maxRows * colH;
const summaryH = 14 + 56 + 8 + 56 + 12 + 28 + 28 + 12;
const H = tableBottom + summaryH;
Canv.width = W;
Canv.height = H;
ctx.fillStyle = bg;
ctx.fillRect(0, 0, W, H);
for (let rowIdx = 0; rowIdx < maxRows; rowIdx++) {
const n = rows[rowIdx];
const isHeader = !!n.__header;
const y = tableTop + rowIdx * colH;
let x = padX;
for (let i = 0; i < rowTitles.length; i++) {
const vTitle = isHeader ? "Plain" : rowTitles[i];
const vWidth = rowWidths[i];
let value = "—";
let bgColor = isHeader ? headerBg : cellBg;
let textColor = isHeader ? muted : fg;
let showCat = false;
let cat = null;
switch (vTitle) {
case "Pressure":
value = n.heightM === 0 ? `${n.pressureMb} mb (SFC)` : `${n.pressureMb} mb`;
break;
case "Height":
value = `${n.heightM} m`;
break;
case "Wind":
value = `${n.speedKt} kt / ${n.dirDeg}°`;
bgColor = dropsondeHurricaneBarbColor(n.speedKt);
textColor = categoryTextColor(n.speedKt);
showCat = true;
cat = intensityNumberFunction(n.speedKt);
break;
case "Temp":
value = n.tempC != null ? `${n.tempC}°C` : "—";
bgColor = tempStyle(n.tempC).bg;
textColor = tempStyle(n.tempC).color;
break;
case "RH":
value = n.rh != null ? `${n.rh}%` : "—";
bgColor = rhStyle(n.rh).bg;
textColor = rhStyle(n.rh).color;
break;
case "Plain":
value = rowTitles[i];
break;
default:
value = "—";
}
const cellPad = 2;
const cx = x + cellPad;
const cy = y + cellPad;
const cw = vWidth - cellPad * 2;
const ch = colH - cellPad * 2;
fillRoundRect(ctx, cx, cy, cw, ch, cornerR, bgColor);
strokeRoundRect(ctx, cx, cy, cw, ch, cornerR, grid, 1);
ctx.font = `${isHeader ? 600 : 700} ${W / 38}px ${globalFont}`;
ctx.textBaseline = "middle";
ctx.fillStyle = textColor;
if (showCat && cat) {
ctx.textAlign = "left";
ctx.fillText(value, cx + 10, cy + ch / 2);
const pillW = Math.max(48, cw * 0.28);
const pillH = ch * 0.52;
const pillX = cx + cw - pillW - 8;
const pillY = cy + (ch - pillH) / 2;
fillRoundRect(ctx, pillX, pillY, pillW, pillH, pillH / 2, cat.color);
strokeRoundRect(ctx, pillX, pillY, pillW, pillH, pillH / 2, "rgba(0,0,0,0.4)", 1.25);
ctx.font = `800 ${W / 44}px ${globalFont}`;
ctx.textAlign = "center";
ctx.fillStyle = categoryTextColor(n.speedKt);
ctx.fillText(cat.shortLabel, pillX + pillW / 2, pillY + pillH / 2 + 0.5);
} else {
ctx.textAlign = "center";
ctx.fillText(value, cx + cw / 2, cy + ch / 2);
}
x += vWidth;
}
}
const tableBottomY = tableTop + maxRows * colH;
const summaryTop = tableBottomY + 14;
const mean150 = props.mean150;
const mean500 = props.mean500;
/** Draws mean block content.
* @param {*} label Parameter for drawMeanBlock.
* @param {*} mean Parameter for drawMeanBlock.
* @param {*} reduction Parameter for drawMeanBlock.
* @param {*} y0 Parameter for drawMeanBlock.
* @returns {*} Result of the function.
*/
const drawMeanBlock = (label, mean, reduction, y0) => {
const pillW = 170;
const pillH = 28;
ctx.textAlign = "left";
ctx.textBaseline = "middle";
ctx.fillStyle = muted;
ctx.font = `600 ${W / 40}px ${globalFont}`;
ctx.fillText(label, padX, y0 + 10);
if (!mean) {
ctx.fillStyle = fg;
ctx.font = `700 ${W / 32}px ${globalFont}`;
ctx.fillText("—", padX, y0 + 38);
return y0 + 52;
}
const speed = Math.round(10 * mean.speedKt) / 10;
const reduced = Math.round(10 * mean.speedKt * reduction) / 10;
const cat = intensityNumberFunction(reduced);
// Pill aligned with the label row on the right
const pillX = contentRight - pillW;
fillRoundRect(ctx, pillX, y0 + 1, pillW, pillH, pillH / 2, cat.color);
ctx.font = `800 ${W / 42}px ${globalFont}`;
ctx.textAlign = "center";
ctx.fillStyle = categoryTextColor(reduced);
ctx.fillText(cat.label, pillX + pillW / 2, y0 + 1 + pillH / 2 + 0.5);
// Wind + surface reduction on one compact line
ctx.textAlign = "left";
ctx.fillStyle = fg;
ctx.font = `700 ${W / 32}px ${globalFont}`;
const windText = `${speed} kt / ${Math.round(mean.dirDeg)}° ${mean.dirLabel}`;
ctx.fillText(windText, padX, y0 + 42);
const windW = ctx.measureText(windText).width;
ctx.fillStyle = muted;
ctx.font = `600 ${W / 40}px ${globalFont}`;
ctx.fillText("· surface", padX + windW + 10, y0 + 42);
const surfLabelW = ctx.measureText("· surface").width;
ctx.fillStyle = fg;
ctx.font = `700 ${W / 32}px ${globalFont}`;
ctx.fillText(`${reduced} kt`, padX + windW + 10 + surfLabelW + 8, y0 + 42);
return y0 + 56;
};
let cursorY = summaryTop;
cursorY = drawMeanBlock("0–150 m mean wind", mean150, reductions.red150m, cursorY);
cursorY += 8;
cursorY = drawMeanBlock("0–500 m mean wind", mean500, reductions.red500m, cursorY);
cursorY += 12;
ctx.textAlign = "left";
ctx.textBaseline = "middle";
ctx.fillStyle = muted;
ctx.font = `600 ${W / 40}px ${globalFont}`;
ctx.fillText("Drop", padX, cursorY + 10);
ctx.fillStyle = fg;
ctx.font = `700 ${W / 34}px ${globalFont}`;
ctx.fillText(times.drop || "—", padX + 78, cursorY + 10);
cursorY += 28;
ctx.fillStyle = muted;
ctx.font = `600 ${W / 40}px ${globalFont}`;
ctx.fillText("Splash", padX, cursorY + 10);
ctx.fillStyle = fg;
ctx.font = `700 ${W / 34}px ${globalFont}`;
ctx.fillText(times.splash || "—", padX + 78, cursorY + 10);
}
/** Loads with parcel data.
* @param {*} Sounding Parameter for LoadWithParcel.
* @param {*} DisplayParcel Parameter for LoadWithParcel.
* @param {*} Info Parameter for LoadWithParcel.
* @param {*} ghst Parameter for LoadWithParcel.
* @param {*} comparisonSoundings Parameter for LoadWithParcel.
* @returns {void} No value.
*/
function LoadWithParcel(Sounding, DisplayParcel, Info, ghst = false, comparisonSoundings = []) {
const Canv = $("skewT");
const ctx = Canv.getContext("2d");
const dropsondeMode = isDropsondeSounding(Sounding);
const dropBg = DarkMode ? "#0a0c0e" : "#f7f8fa";
const dropMuted = DarkMode ? "#8ea3b3" : "#5a6b78";
let P = dropsondeMode
? dropBg
: (DarkMode == true && "rgba(0,0,0,1)" || "rgba(255,255,255,1)");
let PR = DarkMode == false && "rgba(0,0,0,1)" || "rgba(255,255,255,1)"
ctx.fillStyle = P;
ctx.fillRect(0, 0, 1000, 1000);
let Sound = Sounding.Sounding;
let StartSlice = GetLayerByPres(Sound, "SFC");
let EndSlice = GetLayerByPres(Sound, "Last");
const adiabatStyle = dropsondeMode
? { lineWidth: 1, strokeStyle: DarkMode ? "rgba(255,255,255,0.055)" : "rgba(0,0,0,0.06)" }
: null;
for (var th = -30; th < 230; th += 20) {
dry_adiabat(th, adiabatStyle)
};
ctx.font = `600 ${Canv.width / (dropsondeMode ? 55 : 65)}px ${globalFont}`;
(skv.plevels || PRESSURE_LABEL_CANDIDATES).forEach((object) => {
if (!(object > 0) || object >= skv.pbot || object < skv.ptop) return;
let Line = pres_to_pix(object);
ctx.fillStyle = dropsondeMode ? dropMuted : PR;
ctx.textAlign = "left";
ctx.fillText(`${object} mb`, 0.005 * Canv.width, (-2 + Line));
ctx.fillStyle = dropsondeMode
? (DarkMode ? "rgba(255,255,255,0.12)" : "rgba(0,0,0,0.12)")
: (DarkMode == true && `rgba(255,255,255,0.55)` || `rgba(0,0,0,0.55)`);
ctx.fillRect(0, (-2 + Line), Canv.width, dropsondeMode ? 1.25 : 1);
})
const minorBot = Math.floor(skv.pbot / 100) * 100;
const minorTop = Math.ceil(skv.ptop / 100) * 100;
for (let i = minorBot; minorTop <= i; i -= 100) {
if (i >= skv.pbot || i < skv.ptop) continue;
let Line = pres_to_pix(i);
ctx.fillStyle = dropsondeMode
? (DarkMode ? "rgba(255,255,255,0.045)" : "rgba(0,0,0,0.05)")
: (DarkMode == true && `rgba(255,255,255,0.1)` || `rgba(0,0,0,0.1)`);
ctx.fillRect(0, (-2 + Line), Canv.width, 1);
}
for (let i = -110; i <= 80; i += 10) {
let PX = LevelToSkewNum(skv.pbot, skv.pbot);
let PY = PressuretoSkew(skv.pbot, skv.pbot);
let R = GetSkewRatio(i);
let PX2 = LevelToSkewNum(skv.ptop, skv.pbot);
let PY2 = PressuretoSkew(skv.ptop, skv.pbot);
let R2 = GetSkewRatio(i);
if (i == -20 || i == 0) {
ctx.lineWidth = dropsondeMode ? 2 : 3;
ctx.strokeStyle = dropsondeMode
? (DarkMode ? "rgba(125,160,220,0.28)" : "rgba(0,0,125,0.28)")
: (DarkMode == true && "rgba(125,125,255,0.35)" || "rgba(0,0,125,0.35)");
} else {
ctx.lineWidth = dropsondeMode ? 1.25 : 2;
ctx.strokeStyle = dropsondeMode
? (DarkMode ? "rgba(255,255,255,0.1)" : "rgba(0,0,0,0.1)")
: (DarkMode == true && "rgba(255,255,255,0.25)" || "rgba(0,0,0,0.25)");
}
ctx.beginPath();
ctx.setLineDash(dropsondeMode ? [5, 5] : [8, 3]);
ctx.lineCap = "butt";
if (dropsondeMode) ctx.lineCap = "round";
//ctx.lineCap = "round";
ctx.moveTo((R + PX) * Canv.width, PY * Canv.height);
ctx.lineTo((R2 + PX2) * Canv.width, PY2 * Canv.height);
ctx.stroke();
}
if (!dropsondeMode && (!comparisonSoundings || comparisonSoundings.length === 0)) {
if (DisplayParcel.ECape.Path != false) {
//EcapePrcl
ctx.beginPath();
ctx.setLineDash([7, 7]);
ctx.lineWidth = 3;
ctx.strokeStyle = `rgba(177, 214, 255, 0.66)`;
for (let i = 0; i < DisplayParcel.ECape.Path.length - 1; i++) {
let N = DisplayParcel.ECape.Path[i];
let PX = LevelToSkewNum(DisplayParcel.Start.P - i, DisplayParcel.Start.P);
let PY = PressuretoSkew(DisplayParcel.Start.P - i, DisplayParcel.Start.P);
let R = GetSkewRatio(N);
let N2 = DisplayParcel.ECape.Path[i + 1];
let PX2 = LevelToSkewNum(DisplayParcel.Start.P - (i + 1), DisplayParcel.Start.P);
let PY2 = PressuretoSkew(DisplayParcel.Start.P - (i + 1), DisplayParcel.Start.P);
let R2 = GetSkewRatio(N2);
if (i == 0) {
ctx.moveTo((R + PX) * Canv.width, PY * Canv.height);
}
ctx.lineTo((R2 + PX2) * Canv.width, PY2 * Canv.height);
}
ctx.stroke();
}
ctx.beginPath();
ctx.setLineDash([14, 7]);
ctx.lineWidth = 3;
ctx.strokeStyle = PR;
for (let i = 0; i < DisplayParcel.Path.length - 1; i++) {
let N = DisplayParcel.Path[i];
let PX = LevelToSkewNum(DisplayParcel.Start.P - i, DisplayParcel.Start.P);
let PY = PressuretoSkew(DisplayParcel.Start.P - i, DisplayParcel.Start.P);
let R = GetSkewRatio(N);
let N2 = DisplayParcel.Path[i + 1];
let PX2 = LevelToSkewNum(DisplayParcel.Start.P - (i + 1), DisplayParcel.Start.P);
let PY2 = PressuretoSkew(DisplayParcel.Start.P - (i + 1), DisplayParcel.Start.P);
let R2 = GetSkewRatio(N2);
if (i == 0) {
ctx.moveTo((R + PX) * Canv.width, PY * Canv.height);
}
ctx.lineTo((R2 + PX2) * Canv.width, PY2 * Canv.height);
}
ctx.stroke();
}
if (!dropsondeMode) {
ctx.fillStyle = CheckMode("rgba(255,0,0,0.5)", "rgba(125,0,0,0.5)");
ctx.fillRect(0, pres_to_pix(DisplayParcel.Start != undefined && DisplayParcel.Start.P || 0), Canv.width, 2);
ctx.beginPath();
ctx.lineWidth = 2;
ctx.setLineDash([2, 2]);
ctx.strokeStyle = CheckMode("rgba(200,0,125,1)", "rgba(100,0,75,1)");
DrawValuesKey();
DrawValues("SB", Info.SBParcel, Info.SBParcel == DisplayParcel);
DrawValues("ML", Info.MLParcel, Info.MLParcel == DisplayParcel);
DrawValues("MU", Info.MUParcel, Info.MUParcel == DisplayParcel);
DrawValues("CU", CUPrcl, CUPrcl == DisplayParcel);
let DCapeProf = Info.DCapeProf;
for (let i = 0; i < DCapeProf.PresTrace.length; i++) {
let N = DCapeProf.TempTrace[i];
let PX = LevelToSkewNum(DCapeProf.StartPres.P + i, DCapeProf.StartPres.P);
let PY = PressuretoSkew(DCapeProf.StartPres.P + i, DCapeProf.StartPres.P);
let R = GetSkewRatio(N);
let N2 = DCapeProf.TempTrace[i + 1];
let PX2 = LevelToSkewNum(DCapeProf.StartPres.P + (i + 1), DCapeProf.StartPres.P);
let PY2 = PressuretoSkew(DCapeProf.StartPres.P + (i + 1), DCapeProf.StartPres.P);
let R2 = GetSkewRatio(N2);
if (i == 0) {
ctx.moveTo((R + PX) * Canv.width, PY * Canv.height);
}
ctx.lineTo((R2 + PX2) * Canv.width, PY2 * Canv.height);
}
ctx.stroke();
}
ctx.setLineDash([]);
ctx.save();
ctx.restore();
if (!dropsondeMode) {
ctx.font = `bold ${Canv.width / 75}px ${globalFont}`;
for (var w of [2, 5, 10, 15, 20, 25]) {
MixRatioLine(w);
}
} else {
// Softer mixing-ratio traces for dropsonde charts
ctx.font = `600 ${Canv.width / 70}px ${globalFont}`;
for (var w of [10, 15, 20]) {
const CanvMix = Canv;
const ctxMix = ctx;
var pbot = Math.min(1045, skv.pbot);
var ptop = Math.max(skv.ptop, Math.min(600, pbot - 50));
if (pbot <= ptop) { pbot = skv.pbot; ptop = skv.ptop; }
var temp = temp_at_mixrat(w, pbot);
var x0 = CanvMix.width * (GetSkewRatio(temp) + LevelToSkewNum(pbot));
var y0 = pres_to_pix(pbot);
temp = temp_at_mixrat(w, ptop);
var x1 = CanvMix.width * (GetSkewRatio(temp) + LevelToSkewNum(ptop));
var y1 = pres_to_pix(ptop);
ctxMix.save();
ctxMix.beginPath();
ctxMix.setLineDash([3, 6]);
ctxMix.strokeStyle = DarkMode ? "rgba(61, 206, 168, 0.22)" : "rgba(20, 120, 90, 0.25)";
ctxMix.lineWidth = 1.5;
ctxMix.lineCap = "round";
ctxMix.moveTo(x0, y0);
ctxMix.lineTo(x1, y1);
ctxMix.stroke();
ctxMix.fillStyle = DarkMode ? "rgba(61, 206, 168, 0.55)" : "rgba(20, 120, 90, 0.55)";
ctxMix.textAlign = "center";
ctxMix.fillText(`${w}`, x1, y1);
ctxMix.restore();
}
}
if (!dropsondeMode) {
ctx.fillStyle = PR;
ctx.fillText(`Omega`, 0.05 * Canv.width, 15);
}
ctx.font = `${dropsondeMode ? 600 : 700} ${Canv.width / (dropsondeMode ? 48 : 52)}px ${globalFont}`;
[0, 1, 3, 6, 9, 12, 15].forEach((object) => {
if (EndSlice.H / 1000 < object) {
return;
}
let GetPresLayer = GetLayerByHght(Sound, object * 1000);
if (GetPresLayer != null) {
let Loc = pres_to_pix(GetPresLayer.P, Sound.DW[0].P);
ctx.fillStyle = dropsondeMode ? "rgba(255, 107, 90, 0.9)" : "rgba(255,10,10,1)";
const labelX = dropsondeMode ? 0.135 * Canv.width : 0.13 * Canv.width;
ctx.textAlign = dropsondeMode ? "left" : "center";
if (object == 0) {
ctx.fillText(`SFC`, labelX, Loc);
ctx.fillRect(0.09 * Canv.width, (Loc - 1) - 5, 0.02 * Canv.width, dropsondeMode ? 2.5 : 2);
} else {
ctx.fillText(`${object} km`, labelX, Loc);
ctx.fillRect(0.09 * Canv.width, (Loc - 1) - 5, 0.02 * Canv.width, dropsondeMode ? 2.5 : 2);
}
}
});
if (comparisonSoundings && comparisonSoundings.length > 0) {
comparisonSoundings.forEach(comp => {
if (!comp.metadata.isVisible) return;
const color = comp.metadata.comparisonColor || "rgba(0, 191, 255, 0.9)";
if (comp.sounding) {
DrawLine(comp.sounding, "Temp", false, color);
DrawLine(comp.sounding, "Dew", false, color);
}
});
}
if (ghst) {
DrawLine(Sounding, "Dew", ghost);
DrawLine(Sounding, "Temp", ghost);
}
DrawLine(Sounding, "Dew");
if (!dropsondeMode && (!comparisonSoundings || comparisonSoundings.length === 0)) {
DrawLine(Sounding, "WetBulb");
DrawLine(Sounding, "VTemp");
}
DrawLine(Sounding, "Temp");
if (!dropsondeMode) {
let sp = pres_to_pix(StartSlice.P);
let ep = pres_to_pix(EndSlice.P);
ctx.fillStyle = "rgba(255,255,255,0.5)";
ctx.fillRect(0.065 * Canv.width, sp, 2, (ep - sp));
if (Sound.OMEGA.length > 0) {
for (let i = 0; i < Sound.OMEGA.length; i++) {
let Pres = Sound.P[i];
let Omg = Sound.OMEGA[i];
let Center = 0.065;
if (Omg > 0) {
ctx.fillStyle = "rgba(255,0,0,0.8)";
} else {
ctx.fillStyle = "rgba(125,125,255,0.8)";
}
let pp = pres_to_pix(Pres);
ctx.fillRect(Center * Canv.width + 1, pp, Omg * Canv.width, 5);
}
}
const omgDgzTop = GetLayerByTemp(Sound, -18);
const omgDgzBottom = GetLayerByTemp(Sound, -12);
if (omgDgzTop && omgDgzBottom) {
const y_top = pres_to_pix(omgDgzTop.P);
const y_bottom = pres_to_pix(omgDgzBottom.P);
ctx.fillStyle = "rgba(255, 175, 126, 1)";
ctx.fillRect(0, y_top - 1, 0.1 * Canv.width, 2);
ctx.fillRect(0, y_bottom - 1, 0.1 * Canv.width, 2);
}
DrawInflow(Sounding.Sounding, Info.EffInflow);
}
ctx.font = `bold ${Canv.width / 40}px ${globalFont}`;
if (!dropsondeMode && (!comparisonSoundings || comparisonSoundings.length === 0)) {
ctx.fillStyle = `rgba(0,200,200,1)`;
let ddx = (GetSkewRatio(StartSlice.WB) + LevelToSkewNum(StartSlice.P)) * Canv.width;
let ddy = pres_to_pix(StartSlice.P) + 0.025 * Canv.height;
const wbLabel = Math.round(useImperialUnits === 1 ? StartSlice.WB : CToF(StartSlice.WB));
ctx.fillText(`${wbLabel}`, ddx, ddy);
}
ctx.fillStyle = `rgba(255,0,0,1)`;
ctx.textAlign = "left";
let ddx = (GetSkewRatio(StartSlice.T) + LevelToSkewNum(StartSlice.P)) * Canv.width;
let ddy = pres_to_pix(StartSlice.P) + 0.025 * Canv.height;
const tLabel = Math.round(useImperialUnits === 1 ? StartSlice.T : CToF(StartSlice.T));
ctx.fillText(`${tLabel}`, ddx, ddy);
ctx.textAlign = "right";
ctx.fillStyle = `rgba(0,255,0,1)`;
ddx = (GetSkewRatio(StartSlice.D) + LevelToSkewNum(StartSlice.P)) * Canv.width;
ddy = pres_to_pix(StartSlice.P) + 0.025 * Canv.height;
const dLabel = Math.round(useImperialUnits === 1 ? StartSlice.D : CToF(StartSlice.D));
ctx.fillText(`${dLabel}`, ddx, ddy);
if (dropsondeMode) {
const mslpMb = resolveDropsondeMslpMb(Sounding, StartSlice);
if (mslpMb != null) {
const xT = (GetSkewRatio(StartSlice.T) + LevelToSkewNum(StartSlice.P)) * Canv.width;
const xD = (GetSkewRatio(StartSlice.D) + LevelToSkewNum(StartSlice.P)) * Canv.width;
const xMid = (xT + xD) / 2;
const yMslp = pres_to_pix(StartSlice.P) + 0.055 * Canv.height;
ctx.fillStyle = DarkMode == true ? "rgba(232, 238, 242, 0.95)" : "rgba(0,0,0,0.9)";
ctx.textAlign = "center";
ctx.font = `600 ${Canv.width / 48}px ${globalFont}`;
ctx.fillText(`${mslpMb} mb`, xMid, yMslp);
}
// Skip LCL/LFC/EL/HGZ/FZL clutter — side panel covers dropsonde-relevant stats.
return;
}
//LFC, LCL, EL
let LCL = false; let LFC = false; let EL = false; let MPL = false;
try {
if (DisplayParcel.LCL == -9999) {
LCL = false;
} else {
LCL = GetLayerByHght(Sound, DisplayParcel.LCL + DisplayParcel.Start.H, true);
}
if (DisplayParcel.LFC == -9999) {
LFC = false;
} else {
LFC = GetLayerByHght(Sound, DisplayParcel.LFC + DisplayParcel.Start.H, true);
}
if (DisplayParcel.EL == -9999) {
EL = false;
} else {
EL = GetLayerByHght(Sound, DisplayParcel.EL + DisplayParcel.Start.H, true);
}
if (DisplayParcel.MPL == -9999) {
MPL = false;
} else {
MPL = GetLayerByHght(Sound, DisplayParcel.MPL + DisplayParcel.Start.H, true);
}
} catch (E) {
}
let FZL = GetLayerByTemp(Sound, 0);
let WBZ0 = GetLayerByTemp(Sound, 0, true);
let T10 = GetLayerByTemp(Sound, -10);
let T12 = GetLayerByTemp(Sound, -12);
let T18 = GetLayerByTemp(Sound, -18);
let T20 = GetLayerByTemp(Sound, -20);
let T30 = GetLayerByTemp(Sound, -30);
const t10Label = useImperialUnits === 2 ? Math.round(CToF(-10)) : -10;
const t20Label = useImperialUnits === 2 ? Math.round(CToF(-20)) : -20;
const t30Label = useImperialUnits === 2 ? Math.round(CToF(-30)) : -30;
const fzlHeightVal = FZL && FZL.H ? FZL.H : (FZL = false, undefined);
const fzlHeight = fzlHeightVal === undefined ? "--" : Math.round(useImperialUnits === 2 ? MToFt(fzlHeightVal) : fzlHeightVal);
const wb0Val = WBZ0 && WBZ0.H ? WBZ0.H : (WBZ0 = false, undefined);
const wb0 = wb0Val === undefined ? "--" : Math.round(useImperialUnits === 2 ? MToFt(wb0Val) : wb0Val);
const heightUnit = useImperialUnits === 2 ? "ft" : "m";
ctx.textAlign = "center";
ctx.font = `bold ${Canv.width / 60}px ${globalFont}`;
if (Presets['Vertical'] == 'Winter'){
ctx.fillStyle = `rgba(200,200,200,1)`;
ddy = pres_to_pix(T12.P);
ctx.fillText(`<- ${Math.round(DisplayParcel.PreDGZCape)}`, 0.75 * Canv.width, ddy + 0.005 * Canv.height);
ddy = (pres_to_pix(T18.P) + pres_to_pix(T12.P)) / 2;
ctx.font = `bold ${Canv.width / 50}px ${globalFont}`;
ctx.fillText(`<- ${Math.round(DisplayParcel.DGZCape)} DGZCape`, 0.725 * Canv.width, ddy);
ctx.font = `bold ${Canv.width / 60}px ${globalFont}`;
ddy = pres_to_pix(T18.P);
ctx.fillText(`<- ${Math.round(DisplayParcel.DGZCape + DisplayParcel.PreDGZCape)}`, 0.75 * Canv.width, ddy - 0.005 * Canv.height);
} else {
ctx.fillStyle = `rgba(200,200,200,1)`;
ddy = pres_to_pix(T10.P);
ctx.fillText(`<- ${Math.round(DisplayParcel.PreHGZCape)}`, 0.75 * Canv.width, ddy - 0.005 * Canv.height);
ddy = (pres_to_pix(T30.P) + pres_to_pix(T10.P)) / 2;
ctx.font = `bold ${Canv.width / 50}px ${globalFont}`;
ctx.fillText(`<- ${Math.round(DisplayParcel.HGZCape)} HGZCape`, 0.725 * Canv.width, ddy - 0.005 * Canv.height);
ctx.font = `bold ${Canv.width / 60}px ${globalFont}`;
ddy = pres_to_pix(T30.P);
ctx.fillText(`<- ${Math.round(DisplayParcel.HGZCape + DisplayParcel.PreHGZCape)}`, 0.75 * Canv.width, ddy - 0.005 * Canv.height);
}
const InitPosTemps = 0.83;
ctx.font = `bold ${Canv.width / 45}px ${globalFont}`;
ctx.fillStyle = `rgba(200,0,200,1)`;
ddy = pres_to_pix(T10.P);
ctx.fillRect((0.01 + InitPosTemps) * Canv.width, ddy, 0.05 * Canv.width, 1 + 0.002 * Canv.height);
ctx.fillText(`${t10Label}`, (0.03 + InitPosTemps) * Canv.width, ddy - 0.005 * Canv.height);
ddy = pres_to_pix(T20.P);
ctx.fillRect((0.01 + InitPosTemps) * Canv.width, ddy, 0.05 * Canv.width, 1 + 0.002 * Canv.height);
ctx.fillText(`${t20Label}`, (0.03 + InitPosTemps) * Canv.width, ddy - 0.005 * Canv.height);
ddy = pres_to_pix(T30.P);
ctx.fillRect((0.01 + InitPosTemps) * Canv.width, ddy, 0.05 * Canv.width, 1 + 0.002 * Canv.height);
ctx.fillText(`${t30Label}`, (0.03 + InitPosTemps) * Canv.width, ddy - 0.005 * Canv.height);
ctx.font = `bold ${Canv.width / 50}px ${globalFont}`;
if (FZL && FZL.H < EndSlice.H) {
ctx.fillStyle = `rgba(0,100,200,1)`;
ddy = pres_to_pix(FZL.P);
ctx.fillRect(InitPosTemps * Canv.width, ddy, 0.05 * Canv.width, 1 + 0.002 * Canv.height);
ctx.fillText(`FZL ${fzlHeight}${heightUnit}`, (0.03 + InitPosTemps) * Canv.width, ddy - 0.005 * Canv.height);
}
if (WBZ0 && WBZ0.H < EndSlice.H) {
ctx.fillStyle = `rgb(0, 200, 180)`;
ddy = pres_to_pix(WBZ0.P);
if (ddy - pres_to_pix(FZL.P) < 0.05 * Canv.height){
ctx.fillRect(InitPosTemps * Canv.width, ddy, 0.05 * Canv.width, 1 + 0.002 * Canv.height);
ctx.fillText(`WBZ0 ${wb0}${heightUnit}`, (0.03 + InitPosTemps) * Canv.width, ddy + Canv.height/60 + 0.005 * Canv.height);
} else {
ctx.fillRect(InitPosTemps * Canv.width, ddy, 0.05 * Canv.width, 1 + 0.002 * Canv.height);
ctx.fillText(`WBZ0 ${wb0}${heightUnit}`, (0.03 + InitPosTemps) * Canv.width, ddy - 0.005 * Canv.height);
}
}
// --- ADDED: Calculate and Draw DGZ RH on Skew-T ---
const dgzTop = GetLayerByTemp(Sound, -18);
const dgzBottom = GetLayerByTemp(Sound, -12);
if (dgzTop && dgzBottom) {
// Calculate the average RH in the layer
const avgRH = mean_rh_in_layer(Sound, dgzBottom.P, dgzTop.P);
if (!isNaN(avgRH)) {
// Find the vertical midpoint of the DGZ
const y_top = pres_to_pix(dgzTop.P);
const y_bottom = pres_to_pix(dgzBottom.P);
const mid_y = (y_top + y_bottom) / 2;
// Set text properties for the new label
ctx.font = `bold ${Canv.width / 50}px ${globalFont}`;
ctx.fillStyle = 'rgba(255, 165, 0, 1)'; // Bright Orange
ctx.textAlign = "left";
// Draw the text on the left side of the Skew-T, near the Omega display
// ctx.fillText(`DGZ RH: ${Math.round(avgRH)}%`, 0.86 * Canv.width, mid_y);
}
}
// --- END of DGZ RH code ---
ctx.textAlign = "center";
if (LCL != false) {
ctx.fillStyle = `rgba(0,200,0,1)`;
ddy = pres_to_pix(LCL.P);
ctx.fillRect(0.92 * Canv.width, ddy, 0.06 * Canv.width, 1 + 0.002 * Canv.height);
ctx.fillText(`LCL`, 0.95 * Canv.width, ddy + 0.02 * Canv.height);
}
if (LFC != false) {
ctx.fillStyle = `rgba(200,200,0,1)`;
ddy = pres_to_pix(LFC.P);
ctx.fillRect(0.93 * Canv.width, ddy, 0.04 * Canv.width, 1 + 0.002 * Canv.height);
ctx.fillText(`LFC`, 0.95 * Canv.width, ddy - 0.005 * Canv.height);
}
if (EL != false) {
ctx.fillStyle = `rgba(200,0,200,1)`;
ddy = pres_to_pix(EL.P);
ctx.fillRect(0.93 * Canv.width, ddy, 0.04 * Canv.width, 1 + 0.002 * Canv.height);
ctx.fillText(`EL`, 0.95 * Canv.width, ddy - 0.005 * Canv.height);
}
if (MPL != false) {
ctx.fillStyle = `rgba(255,0,255,1)`;
ddy = pres_to_pix(MPL.P);
ctx.fillRect(0.93 * Canv.width, ddy, 0.04 * Canv.width, 1 + 0.002 * Canv.height);
ctx.fillText(`MPL`, 0.95 * Canv.width, ddy - 0.005 * Canv.height);
}
GetLapseBox(Sounding.Sounding);
}
/** Converts polarto cart no90 values.
* @param {*} Slice Parameter for ConvertPolartoCart_No90.
* @returns {*} Result of the function.
*/
function ConvertPolartoCart_No90(Slice) {
// // console.log(Slice);
return {
x: Slice.WS * Math.cos(degtoRad((Slice.WD) % 360)),
y: Slice.WS * Math.sin(degtoRad((Slice.WD) % 360))
}
}
/** Loads with wind data.
* @param {*} Sounding Parameter for LoadWithWind.
* @param {*} Info Parameter for LoadWithWind.
* @param {*} ghost Parameter for LoadWithWind.
* @param {*} comparisonSoundings Parameter for LoadWithWind.
* @returns {void} No value.
*/
function LoadWithWind(Sounding, Info, ghost = false, comparisonSoundings = []) {
UpdateCSMotStorm(CSMot);
let Sound = Sounding.Sounding;
// Dropsondes: wind barbs only — skip hodograph / shear / SRH panels.
if (isDropsondeSounding(Sounding)) {
DrawBarbs(Sounding, comparisonSoundings);
return;
}
DrawHodograph(Sounding, ghost, comparisonSoundings);
computeWindParams(Sounding, Info, DP, CSMot, WindParams);
DrawWindParams(Sounding, WindParams);
DrawBoxes(Sounding, Info, WindParams);
let TPerms = {
DCapeProf: Info.DCapeProf,
};
DrawInsetParams(Sounding, WindParams, TPerms, Info.SBParcel, Info.MLParcel, Info.MUParcel, false, Presets, $("Values3"));
//let SRH03 = GetSRH(Sound, 0, 3000, "Hght");
//// console.log(SRH);
DrawInflowHodo(Sound, Info.EffInflow);
DrawBarbs(Sounding, comparisonSoundings);
}
let VWPActive = false;
/** Sets or applies vwp active state.
* @param {*} value Parameter for setVwpActive.
* @returns {void} No value.
*/
function setVwpActive(value) {
VWPActive = value === true;
}
let Info = {};
let Snding = {};
let currentComparisonSoundings = [];
let DP = {};
let CUPrcl = [];
let Motions = {};
let CUMot = {};
let rStart = {};
let WindParams = {
['MeanWind']: {},
};
let CustomWindLayer = {};
let CustomLapseLayer = {};
let relStart = false;
let FrontActive = false;
let dStart = { x: 0, y: 0 };
let dEnd = { x: 0, y: 0 };
/** Handles mouse movement over the hodograph.
* @param {*} e Parameter for HodographMouseMoved.
* @returns {void} No value.
*/
function HodographMouseMoved(e) {
if (ChangeMod && activeChange2) {
const Canv = $("hodographOverHang3");
const ctx = Canv.getContext("2d");
ctx.clearRect(0, 0, 1000, 1000);
const Cnv = $("skewTOverHang2");
const cx = Cnv.getContext("2d");
cx.clearRect(0, 0, 1000, 1000);
let PosC = getMousePos(Canv, e);
dEnd2 = PosC;
dStart = { x: 0, y: 0 };
dEnd = { x: 0, y: 0 };
ctx.fillStyle = "rgba(255,255,255,0.25)";
ctx.fillRect(dStart2.x, dStart2.y, dEnd2.x - dStart2.x, dEnd2.y - dStart2.y);
} else if (ChangeMod && !activeChange2) {
const Canv = $("hodographOverHang3");
const ctx = Canv.getContext("2d");
ctx.clearRect(0, 0, 1000, 1000);
ctx.fillStyle = "rgba(255,255,255,0.25)";
ctx.fillRect(dStart2.x, dStart2.y, dEnd2.x - dStart2.x, dEnd2.y - dStart2.y);
}
}
let SkewMouseP = false;
let def = {};
var md = false;
/** Handles mouse movement over the skew-T.
* @param {*} e Parameter for SkewTMouseMoved.
* @returns {void} No value.
*/
function SkewTMouseMoved(e) {
CursorReadout(e);
if (altScrollActive && !ChangeMod && md) {
const cnv = $("skewTOverHang");
if (SkewMouseP == false) {
SkewMouseP = getMousePos(cnv, e);
def = SkewTPosition;
}
var CPos = getMousePos(cnv, e);
SkewTPosition = {
x: def.x + (CPos.x - SkewMouseP.x) * (1 / SkewTScale),
y: def.y + (CPos.y - SkewMouseP.y) * (1 / SkewTScale),
}
SkewTPosition.x = clamp(SkewTPosition.x, -(cnv.width - cnv.width / SkewTScale), 0);
SkewTPosition.y = clamp(SkewTPosition.y, -(cnv.height - cnv.height / SkewTScale), 0);
DrawSkewT(Snding, false, false, false, false, currentComparisonSoundings);
CursorReadout(false, true, true);
}
if (ChangeMod && activeChange) {
const Canv = $("skewTOverHang2");
const ctx = Canv.getContext("2d");
ctx.clearRect(0, 0, 1000, 1000);
const Cnv = $("hodographOverHang3");
const cx = Cnv.getContext("2d");
cx.clearRect(0, 0, 1000, 1000);
let PosC = getMousePos(Canv, e);
dEnd = PosC;
dStart2 = { x: 0, y: 0 };
dEnd2 = { x: 0, y: 0 };
ctx.fillStyle = "rgba(255,255,255,0.25)";
ctx.fillRect(dStart.x, dStart.y, dEnd.x - dStart.x, dEnd.y - dStart.y);
} else if (ChangeMod && !activeChange) {
const Canv = $("skewTOverHang2");
const ctx = Canv.getContext("2d");
ctx.clearRect(0, 0, 1000, 1000);
ctx.fillStyle = "rgba(255,255,255,0.25)";
ctx.fillRect(dStart.x, dStart.y, dEnd.x - dStart.x, dEnd.y - dStart.y);
}
}
/** Toggles the cursor visibility.
* @returns {void} No value.
*/
function Flip() {
if (ReadOutOn) {
CursorActive = !CursorActive;
}
}
let SelectedLayer = false;
let activeChange = false;
let activeChange2 = false;
let Slices = [];
let dStart2 = { x: 0, y: 0 };
let dEnd2 = { x: 0, y: 0 }
let ScrollCD = true;
let SkewTScale = 1;
let SkewTPosition = { x: 0, y: 0 };
let ScrollSkewCD = true;
let altScrollActive = false;
let DisplayHodographNumbers = false;
let HodographColorTable = 0;
let HodographScaleMode = 0;
/** Hodograph scale: 0 = none, 1 = storm relative, 2 = dynamic. Cycles when mode is omitted.
* @param {*} mode Parameter for ChangeStateDynamicHodo.
* @returns {*} Result of the function.
*/
function ChangeStateDynamicHodo(mode) {
HodographScaleMode = typeof mode === 'number' ? ((mode % 3) + 3) % 3 : (HodographScaleMode + 1) % 3;
redrawHodographPanels();
return HodographScaleMode;
}
/** Hodograph color table: 0 = Default, 1 = SounderPY, 2 = Rainbow, 3 = Greyscale. Cycles when omitted.
* @param {*} index Parameter for ChangeStateHodographColorTable.
* @returns {*} Result of the function.
*/
function ChangeStateHodographColorTable(index) {
HodographColorTable = typeof index === 'number' ? ((index % 4) + 4) % 4 : (HodographColorTable + 1) % 4;
redrawHodographPanels();
return HodographColorTable;
}
/** Height labels along the hodograph trace. Toggles when `on` is omitted.
* @param {*} on Parameter for ChangeStateHodographNumbers.
* @returns {*} Result of the function.
*/
function ChangeStateHodographNumbers(on) {
DisplayHodographNumbers = typeof on === 'boolean' ? on : !DisplayHodographNumbers;
redrawHodographPanels();
return DisplayHodographNumbers;
}
/** Thin dense observed wind profiles to SPC-like spacing on the hodograph.
* @param {*} on Parameter for setHodographTrimWindProfile.
* @returns {*} Result of the function.
*/
function setHodographTrimWindProfile(on) {
HodographTrimWindProfile = on !== false;
redrawHodographPanels();
return HodographTrimWindProfile;
}
/** Redraws the hodograph panels.
* @returns {void} No value.
*/
function redrawHodographPanels() {
if (!Snding || !Snding.Sounding) return;
DrawHodograph(Snding, ghost, currentComparisonSoundings);
if (Info && Info.EffInflow !== undefined) DrawInflowHodo(Snding.Sounding, Info.EffInflow);
}
/** Handles wheel events for the skew-T.
* @param {*} e Parameter for SkewTWheel.
* @returns {void} No value.
*/
function SkewTWheel(e) {
e.preventDefault();
const Canv = $("skewT");
const rect = Canv.getBoundingClientRect();
const Canv2 = $("skewTOverHang2");
if (!ScrollSkewCD) {
return;
}
ScrollSkewCD = false;
setTimeout(() => { ScrollSkewCD = true }, 100);
let PosC = getMousePos(Canv2, e);
if (PosC.x >= Canv2.width || PosC.x <= 0 || PosC.y >= Canv2.height || PosC.y <= 0) {
return;
}
let ScrollFactor = {
x: PosC.x / Canv2.width - 0.5,
y: PosC.y / Canv2.height - 0.5,
}
var x = -(e.offsetX / rect.width) * Canv.width;
var y = -(e.offsetY / rect.height) * Canv.height;
if (e.wheelDelta > 0 && SkewTScale < 3) {
const newScale = SkewTScale;
SkewTScale = clamp(SkewTScale + 0.2, 1, 3);
SkewTPosition.x = x - (x - SkewTPosition.x) * (newScale / SkewTScale);
SkewTPosition.y = y - (y - SkewTPosition.y) * (newScale / SkewTScale);
SkewTPosition.x = clamp(SkewTPosition.x, -(Canv2.width - Canv2.width / SkewTScale), 0);
SkewTPosition.y = clamp(SkewTPosition.y, -(Canv2.height - Canv2.height / SkewTScale), 0);
} else if (e.wheelDelta <= 0 && SkewTScale > 1) {
const newScale = SkewTScale;
SkewTScale = clamp(SkewTScale - 0.2, 1, 3);
SkewTPosition.x = x - (x - SkewTPosition.x) * (newScale / SkewTScale);
SkewTPosition.y = y - (y - SkewTPosition.y) * (newScale / SkewTScale);
SkewTPosition.x = clamp(SkewTPosition.x, -(Canv2.width - Canv2.width / SkewTScale), 0);
SkewTPosition.y = clamp(SkewTPosition.y, -(Canv2.height - Canv2.height / SkewTScale), 0);
}
if (Snding.Sounding) {
DrawSkewT(Snding, false, false, false, false, currentComparisonSoundings);
CursorReadout(false, true, true);
}
}
/** Handles wheel events for the hodograph.
* @param {*} e Parameter for HodographWheel.
* @returns {void} No value.
*/
function HodographWheel(e) {
e.preventDefault();
const Canv = $("hodograph");
if (!ScrollCD) {
return;
}
ScrollCD = false;
let PosC = getMousePos(Canv, e);
if (PosC.x >= Canv.width || PosC.x <= 0 || PosC.y >= Canv.height || PosC.y <= 0) {
return;
}
if (e.wheelDelta > 0) {
HodographScaleMulti = clamp(HodographScaleMulti + 0.2, 1, 8);
} else {
HodographScaleMulti = clamp(HodographScaleMulti - 0.2, 1, 8);
}
if (Snding.Sounding && Info.EffInflow != undefined) {
DrawHodograph(Snding, false, currentComparisonSoundings);
DrawFront(false, true);
DrawInflowHodo(Snding.Sounding, Info.EffInflow);
CursorReadout(false, true, true);
}
setTimeout(() => { ScrollCD = true }, 100);
};
let ctrldown = false;
/** Handles on window key up events.
* @param {*} e Parameter for onWindowKeyUp.
* @returns {void} No value.
*/
const onWindowKeyUp = (e) => {
if (e.key == "Control") {
ctrldown = false;
}
if (e.key == "Alt") {
SkewMouseP = false;
altScrollActive = false;
}
};
/** Initializes the remote data stores.
* @returns {void} No value.
*/
function RemoteDataStores(){
Slices = [];
ChangeMod = false;
dStart2 = {x:0, y:0};
dEnd2 = {x:0, y:0};
}
/** Handles on window key down events.
* @param {*} e Parameter for onWindowKeyDown.
* @returns {void} No value.
*/
const onWindowKeyDown = (e) => {
if (altScrollActive == false) {
altScrollActive = e.key == "Alt";
};
if (Slices.length > 0 && ChangeMod) {
let Dir = 0;
let Diry = 0;
let I = 0;
let FS = Slices[0];
let M = false;
let N = false;
let L = false;
if (ctrldown == false) {
ctrldown = e.key == "Control";
}
if (e.key == "ArrowLeft") {
Dir = -1;
e.preventDefault();
} else if (e.key == "ArrowRight") {
Dir = 1;
e.preventDefault();
} else if (e.key == "ArrowUp" && !FS.Temp) {
Diry = -1;
e.preventDefault();
} else if (e.key == "ArrowDown" && !FS.Temp) {
Diry = 1;
e.preventDefault();
} else if (e.key == "i" && !FS.Temp) {
I = 1;
e.preventDefault();
} else if (e.key == "o" && !FS.Temp) {
I = -1;
e.preventDefault();
} else if (e.key == "l" && FS.Temp) {
L = true;
e.preventDefault();
} else if (e.key == "m" && FS.Temp) {
M = true;
e.preventDefault();
} else if (e.key == "n" && FS.Temp) {
N = true;
e.preventDefault();
} else {
return;
}
let Last = Slices[Slices.length - 1];
let First = Slices[0];
for (let i = 0; i < Slices.length; i++) {
let nSlice = Slices[i];
if (nSlice.Temp != undefined) {
if (M == false && N == false && L == false) {
if (nSlice.Temp == true) {
nSlice.T += Dir;
nSlice.T = Math.max(nSlice.T, nSlice.D);
}
if (nSlice.Dew == true) {
nSlice.D += Dir;
nSlice.D = Math.min(nSlice.T, nSlice.D);
}
} else if (M == true) {
nSlice.T = Lerp(First.T, Last.T, (nSlice.H - First.H) / (Last.H - First.H));
nSlice.D = Lerp(First.D, Last.T - 0.5, clamp((1.2 * (nSlice.H - First.H) / (Last.H - First.H)) ** 0.5, 0, 1));
} else if (L == true) {
nSlice.T = Lerp(First.T, Last.T, (nSlice.H - First.H) / (Last.H - First.H));
nSlice.D = Lerp(First.D, Last.D, (nSlice.H - First.H) / (Last.H - First.H));
} else if (N == true) {
nSlice.T = Last.T + 9.81 * (Last.H - nSlice.H) / 1000;
nSlice.D = Last.D + 2 * (Last.H - nSlice.H) / 1000;
}
nSlice.VT = VirtTemp(nSlice, GetLayerByPres(Snding.Sounding, "SFC").P, false, true);
nSlice.WB = wetbulb(nSlice.P, nSlice.T, nSlice.D)
Snding.Sounding.TW[nSlice.Pos].T = nSlice.T;
Snding.Sounding.TW[nSlice.Pos].VT = nSlice.VT;
Snding.Sounding.TW[nSlice.Pos].WB = nSlice.WB;
Snding.Sounding.T[nSlice.Pos] = nSlice.T;
Snding.Sounding.TW[nSlice.Pos].VT = nSlice.VT;
Snding.Sounding.TW[nSlice.Pos].WB = nSlice.WB;
Snding.Sounding.D[nSlice.Pos] = nSlice.D;
Snding.Sounding.DW[nSlice.Pos].D = nSlice.D;
Snding.Sounding.Slice[nSlice.Pos] = ConvertToRaw(nSlice);
} else {
if (nSlice.Wind == true) {
nSlice.WS += I;
nSlice.WS = clamp(nSlice.WS, 0.1, 5000);
let P = ConvertPolartoCart(nSlice);
P.x += Dir;
P.y += Diry;
let D = ConvertCartToPolarReal(P);
nSlice.WD = D.WD;
nSlice.WS = D.WS;
}
Snding.Sounding.W[nSlice.Pos].WD = nSlice.WD;
Snding.Sounding.W[nSlice.Pos].WS = nSlice.WS;
Snding.Sounding.Slice[nSlice.Pos] = ConvertToRaw(nSlice);
};
}
if (!Snding.Title.includes("(Modified)")) {
Snding.Title += " (Modified)";
}
ShowObjects();
SetTitle(Snding);
DrawHodograph(Snding);
DrawSkewT(Snding, false, currentComparisonSoundings);
}
};
/** Finishes mouse input on skew-t
* @param {*} e Parameter for FinishMouseSkew.
* @param {*} PosC Parameter for FinishMouseSkew.
* @returns {void} No value.
*/
function FinishMouseSkew(e, PosC) {
const Canv = $("skewTOverHang2");
const c2v = $("skewT");
const ctx = Canv.getContext("2d");
let PresL = Math.min(pix_to_pres(dStart.y), pix_to_pres(PosC.y));
let PresU = Math.max(pix_to_pres(dStart.y), pix_to_pres(PosC.y));
if (PresL != PresU && PresU > PresL) {
let sfclyr = GetLayerByHght(Snding.Sounding, "SFC")
Slices = [];
let Highest = {
x: Math.max(dStart.x, dEnd.x) / Canv.width,
y: Math.max(dStart.y, dEnd.y) / Canv.height,
}
let Lowest = {
x: Math.min(dStart.x, dEnd.x) / Canv.width,
y: Math.min(dStart.y, dEnd.y) / Canv.height,
}
let Boundsx = {
l: -SkewTPosition.x,
u: -SkewTPosition.x + Canv.width / SkewTScale,
};
let Boundsy = {
l: -SkewTPosition.y,
u: -SkewTPosition.y + Canv.height / SkewTScale,
};
Lowest.x = Boundsx.l + (Boundsx.u - Boundsx.l) * Lowest.x;
Lowest.y = Boundsy.l + (Boundsy.u - Boundsy.l) * Lowest.y;
Highest.x = Boundsx.l + (Boundsx.u - Boundsx.l) * Highest.x;
Highest.y = Boundsy.l + (Boundsy.u - Boundsy.l) * Highest.y;
PresL = Math.min(pix_to_pres(Highest.y), pix_to_pres(Lowest.y));
PresU = Math.max(pix_to_pres(Highest.y), pix_to_pres(Lowest.y));
for (let i = 0; i < Snding.Sounding.Slice.length; i++) {
let N = ConvertToInfo(Snding.Sounding.Slice[i]);
if (N.P <= PresU && N.P >= PresL) {
N.Pos = i;
let PXT = LevelToSkewNum(N.P);
let RT = GetSkewRatio(N.T);
let RD = GetSkewRatio(N.D);
let PosT = {
x: (RT + PXT) * Canv.width * SkewTScale,
}
let PosD = {
x: (RD + PXT) * Canv.width * SkewTScale,
}
if ((PosT.x <= Highest.x && PosT.x >= Lowest.x)) {
N.Temp = true;
} else {
N.Temp = false;
}
if ((PosD.x <= Highest.x && PosD.x >= Lowest.x)) {
N.Dew = true;
} else {
N.Dew = false;
}
Slices.push(N);
}
}
DrawSkewT(Snding, false, currentComparisonSoundings);
} else {
Slices = [];
}
ctx.fillStyle = "rgba(255,255,255,0.25)";
ctx.fillRect(dStart.x, dStart.y, dEnd.x - dStart.x, dEnd.y - dStart.y);
}
/** Finishes mouse input on the hodograph.
* @param {*} e Parameter for FinishMouseHodo.
* @param {*} PosC Parameter for FinishMouseHodo.
* @returns {void} No value.
*/
function FinishMouseHodo(e, PosC) {
const Canv = $("hodographOverHang3");
const c2v = $("hodograph");
const ctx = Canv.getContext("2d");
if (dStart2.x != dEnd2.x && dStart2.y != dEnd2.y) {
let sfclyr = GetLayerByHght(Snding.Sounding, "SFC")
Slices = [];
let Highest = {
x: Math.max(dStart2.x, dEnd2.x),
y: Math.max(dStart2.y, dEnd2.y),
}
let Lowest = {
x: Math.min(dStart2.x, dEnd2.x),
y: Math.min(dStart2.y, dEnd2.y),
}
const Center = GetPositionScale(HodographCenter[0], HodographCenter[1], c2v);
// Change 27 to OutsideFitBase
const Scale = OutsideFitBase * HodographScaleMulti;
for (let i = 0; i < Snding.Sounding.Slice.length; i++) {
let N = ConvertToInfo(Snding.Sounding.Slice[i]);
let Pos = ConvertPolartoCart(N);
if (!HodoMode) {
Pos.x -= CSMot.x;
Pos.y += CSMot.y;
}
if (N.H <= 12000) {
N.Pos = i;
let PosM = {
x: Center.x / 2 + Pos.x * (Scale / 10),
y: Center.y / 2 + Pos.y * (Scale / 10)
}
PosM.x *= 2;
PosM.y *= 2;
if ((PosM.x <= Highest.x && PosM.x >= Lowest.x) && (PosM.y <= Highest.y && PosM.y >= Lowest.y)) {
N.Wind = true;
} else {
N.Wind = false;
}
Slices.push(N);
}
}
} else {
Slices = [];
}
ctx.fillStyle = "rgba(255,255,255,0.25)";
ctx.fillRect(dStart2.x, dStart2.y, dEnd2.x - dStart2.x, dEnd2.y - dStart2.y);
}
/** Handles on window mouse up events.
* @param {*} e Parameter for onWindowMouseUp.
* @returns {void} No value.
*/
const onWindowMouseUp = (e) => {
md = false;
if (altScrollActive) {
SkewMouseP = false;
}
if (ChangeMod) {
const Canv = $("skewTOverHang2");
const Canv2 = $("hodographOverHang3");
if (!Canv || !Canv2) return;
const ctx = Canv.getContext("2d");
ctx.clearRect(0, 0, 1000, 1000);
const ctx2 = Canv2.getContext("2d");
ctx2.clearRect(0, 0, 1000, 1000);
if (activeChange) {
let PosC = getMousePos(Canv, e);
dEnd = PosC;
if (PosC.x >= Canv.width / SkewTScale && PosC.x <= 0 && PosC.y >= Canv.height / SkewTScale && PosC.y <= 0) {
ctx.fillStyle = "rgba(255,255,255,0.25)";
ctx.fillRect(dStart.x, dStart.y, dEnd.x - dStart.x, dEnd.y - dStart.y);
dEnd2 = { x: 0, y: 0 };
dStart2 = { x: 0, y: 0 };
} else {
FinishMouseSkew(e, PosC);
}
}
if (activeChange2) {
let PosC2 = getMousePos(Canv2, e);
dEnd2 = PosC2;
if (PosC2.x >= Canv2.width / HodographScaleMulti && PosC2.x <= 0 && PosC2.y >= Canv2.height / HodographScaleMulti && PosC2.y <= 0) {
ctx2.fillStyle = "rgba(255,255,255,0.25)";
ctx2.fillRect(dStart2.x, dStart2.y, dEnd2.x - dStart2.x, dEnd2.y - dStart2.y);
dEnd = { x: 0, y: 0 };
dStart = { x: 0, y: 0 };
} else {
FinishMouseHodo(e, PosC2);
}
}
activeChange = false;
activeChange2 = false;
}
};
/** Handles mouse down events on the skew-T.
* @param {*} e Parameter for SkewTMouseDown.
* @returns {void} No value.
*/
function SkewTMouseDown(e) {
md = true;
if (ChangeMod && !activeChange) {
const Canv = $("skewT");
const ctx = Canv.getContext("2d");
// console.log("a");
activeChange = true;
activeChange2 = false;
dEnd = { x: 0, y: 0 };
dStart = { x: 0, y: 0 };
let PosC = getMousePos(Canv, e);
SkewMouseP = PosC;
dStart = PosC;
}
}
/** Handles mouse down events on the hodograph.
* @param {*} e Parameter for HodographMouseDown.
* @returns {void} No value.
*/
function HodographMouseDown(e) {
if (ChangeMod && !activeChange) {
const Canv = $("hodographOverHang3");
const ctx = Canv.getContext("2d");
activeChange = false;
activeChange2 = true;
dEnd2 = { x: 0, y: 0 };
dStart2 = { x: 0, y: 0 };
let PosC = getMousePos(Canv, e);
dStart2 = PosC;
}
}
/** Handles front-related events.
* @param {*} e Parameter for Front.
* @returns {void} No value.
*/
function Front(e) {
if (FrontActive) {
DrawFront(e);
}
}
/** Changes front state.
* @returns {void} No value.
*/
function ChangeFront() {
setFrontActiveState(!FrontActive);
if (!FrontActive) {
const hang = $("hodographOverHang2");
hang?.getContext("2d")?.clearRect(0, 0, 1000, 1000);
}
}
/** Sets or applies front active state state.
* @param {*} value Parameter for setFrontActiveState.
* @returns {void} No value.
*/
function setFrontActiveState(value) {
FrontActive = value;
}
/** Sets or applies change mod state state.
* @param {*} value Parameter for setChangeModState.
* @returns {void} No value.
*/
function setChangeModState(value) {
ChangeMod = value;
}
/** Returns mouse pos data.
* @param {*} canvas Parameter for getMousePos.
* @param {*} evt Parameter for getMousePos.
* @returns {*} Result of the function.
*/
function getMousePos(canvas, evt) {
var rect = canvas.getBoundingClientRect(),
scaleX = canvas.width / rect.width,
scaleY = canvas.height / rect.height;
return {
x: (evt.clientX - rect.left) * scaleX,
y: (evt.clientY - rect.top) * scaleY
}
}
let CursorActive = true;
let ReadOutOn = true;
/** Converts cart to polar regular values.
* @param {*} V1 Parameter for ConvertCartToPolar_Regular.
* @returns {*} Result of the function.
*/
function ConvertCartToPolar_Regular(V1){
const dy = V1.y - 0;
const dx = V1.x - 0;
const radians = Math.atan2(dy, dx); // Angle in radians
const degrees = radians * 180 / Math.PI; // Convert to degrees
return { WD: degrees%360, WS: Mag(V1) };
}
/** Converts cart to polar real values.
* @param {*} V1 Parameter for ConvertCartToPolarReal.
* @returns {*} Result of the function.
*/
function ConvertCartToPolarReal(V1) {
const dy = V1.y - 0;
const dx = V1.x - 0;
const radians = Math.atan2(dy, dx); // Angle in radians
const degrees = radians * 180 / Math.PI; // Convert to degrees
return { WD: (degrees + 270) % 360, WS: Mag(V1) };
}
/** Converts cart to polar real rnd values.
* @param {*} V1 Parameter for ConvertCartToPolarRealRnd.
* @returns {*} Result of the function.
*/
function ConvertCartToPolarRealRnd(V1) {
const dy = V1.y - 0;
const dx = V1.x - 0;
const radians = Math.atan2(dy, dx); // Angle in radians
const degrees = radians * 180 / Math.PI; // Convert to degrees
return { WD: Math.round((degrees + 270) % 360), WS: Math.round(Mag(V1)) };
}
let LastMousePosF;
/** Draws front content.
* @param {*} e Parameter for DrawFront.
* @param {*} just Parameter for DrawFront.
* @returns {void} No value.
*/
function DrawFront(e, just = false) {
if (!FrontActive) {
return;
}
const Canv = $("hodographOverHang2");
const ctx = Canv.getContext("2d");
Canv.width = 800;
Canv.height = 800;
ctx.scale(2, 2);
let PosC = getMousePos(Canv, e);
if ((PosC.x <= Canv.width && PosC.x > 0 && PosC.y <= Canv.height && PosC.y > 0 || just == true) && !VWPActive) {
let Center = GetPositionScale(HodographCenter[0], HodographCenter[1], Canv);
if (!HodoMode){
Center = GetPositionScale(50, 50, Canv);
}
let Scale = OutsideFitBase * HodographScaleMulti;
let TX = -Center.x / (Scale / 10) + (PosC.x) / (Scale / 10);
let TY = -Center.y / (Scale / 10) + (PosC.y ) / (Scale / 10);
if (!HodoMode) {
TX = -Center.x / (Scale / 10) + (PosC.y - CSMot.x ) / (Scale / 10);
TY = -Center.y / (Scale / 10) + (PosC.y + CSMot.y ) / (Scale / 10);
}
let Loc = just == true && LastMousePosF || ConvertCartToPolarReal({
x: TX/2,
y: TY/2
});
LastMousePosF = Loc;
let TPos = ConvertPolartoCart(Loc);
let AngleToCenter = ConvertCartToPolarReal({
x: TX,
y: TY,
});
ctx.fillStyle = "rgba(255,125,75,0.5)";
ctx.save();
ctx.translate(Center.x / 2 + TPos.x * (Scale / 10), Center.y / 2 + TPos.y * (Scale / 10));
ctx.rotate(degtoRad(Loc.WD));
ctx.fillRect(-500, 0, 1000, 3);
ctx.restore();
ctx.fillStyle = "rgba(255,125,75,1)";
ctx.fillText(`${Math.round(Loc.WD)}°`, Center.x / 2 + TPos.x * (Scale / 10), Center.y / 2 + TPos.y * (Scale / 10) - 20);
ctx.fillText(`@ ${Math.round(Loc.WS)}Kts`, Center.x / 2 + TPos.x * (Scale / 10), Center.y / 2 + TPos.y * (Scale / 10) - 10);
}
ctx.restore();
}
var LastMousePos;
var LastPres;
/** Handles cursor readout functionality.
* @param {*} e Parameter for CursorReadout.
* @param {*} just Parameter for CursorReadout.
* @param {*} n Parameter for CursorReadout.
* @returns {void} No value.
*/
function CursorReadout(e, just = false, n = false) {
const Canv = $("skewTOverHang");
const cr = $("skewT");
if (!Canv || !cr) return;
const ctx = Canv.getContext("2d");
const Canv2 = $("hodographOverHang");
const ctx2 = Canv2?.getContext("2d");
ctx.width = 800;//Canv.style.width;
ctx.height = 800;
if (ReadOutOn == true && CursorActive == true || just == true) {
if (just == true && ReadOutOn == false) {
return;
}
// Blank / pre-BUFKIT skew-T has no sounding yet — skip readout.
if (!Snding?.Sounding?.P?.length || !Snding?.End) {
return;
}
let PosC = just == true && LastMousePos || getMousePos(cr, e);
let Bounds = {
l: -SkewTPosition.y,
u: -SkewTPosition.y + Canv.height / SkewTScale,
};
LastMousePos = PosC;
if (PosC.x <= Canv.width && PosC.x > 0 && PosC.y <= Canv.height && PosC.y > 0 && !VWPActive) {
if (!ctx2) return;
Canv2.width = 800;
Canv2.height = 800;
ctx2.scale(2, 2);
ctx.clearRect(0, 0, Canv.width, Canv.height);
ctx2.clearRect(0, 0, Canv2.width, Canv2.height);
let yy = just == true && LastMousePos.y / cr.height || getMousePos(cr, e).y / cr.height;
yy = Bounds.l + (Bounds.u - Bounds.l) * yy;
let Pres = n == true && LastPres || pix_to_pres(yy);
LastPres = Pres;
let Prs = Math.max(Snding.End.P, Pres) > Snding.Sounding.P[0] ? Snding.Sounding.P[0] : Math.max(Snding.End.P, Pres);
let Lyr = GetLayerByPres(Snding.Sounding, Prs);
if (Lyr == undefined) {
Lyr = { VT: "--", T: "--", D: "--", WB: "--", H: undefined, WD: undefined, WS: undefined }
}
PosC.y = n == true && Lerp(0, Canv.height, (pres_to_pix(LastPres) - Bounds.l) / (Bounds.u - Bounds.l)) || PosC.y;
ctx.fillStyle = "rgba(255,255,255,1)";
ctx.fillRect(0, PosC.y, Canv.width, 2);
ctx.textAlign = "right";
ctx.font = `bold ${Canv.width / 30}px ${globalFont}`;
const heightLabel = Lyr.H === undefined ? "--" : Math.round(useImperialUnits === 2 ? MToFt(Lyr.H) : Lyr.H);
const heightUnit = useImperialUnits === 2 ? "ft" : "m";
let TE = (Lyr.T === "--" || Lyr.D === "--") ? "--" : Math.round(thetae(Lyr.P, Lyr.T, Lyr.D) + 273.15);
ctx.fillText(`${Math.round(Pres)}mb`, PosC.x + 25, PosC.y - 5);
ctx.fillText(`${heightLabel}${heightUnit}`, PosC.x + 25, PosC.y - 35);
ctx.fillText(`${TE}K`, PosC.x + 25, PosC.y - 65);
let PrcT = "--";
if (DP){
try {
PrcT = DP.Path[clamp(Math.round(DP.Start.P - Lyr.P), 0, Math.round(DP.Path.length))]
} catch (e) {
PrcT = "--";
}
}
ctx.textAlign = "right";
const shouldShowFahrenheit = useImperialUnits === 2;
const tempUnit = shouldShowFahrenheit ? "F" : "C";
let VT = Lyr.VT === "--" || Lyr.VT === undefined ? "--" : Math.round(10 * (useImperialUnits === 2 ? CToF(Lyr.VT) : Lyr.VT)) / 10;
let T = Lyr.T === "--" || Lyr.T === undefined ? "--" : Math.round(10 * (useImperialUnits === 2 ? CToF(Lyr.T) : Lyr.T)) / 10;
let ParT = PrcT === "--" ? "--" : Math.round(10 * (useImperialUnits === 2 ? CToF(PrcT) : PrcT)) / 10;
let D = Lyr.D === "--" || Lyr.D === undefined ? "--" : Math.round(10 * (useImperialUnits === 2 ? CToF(Lyr.D) : Lyr.D)) / 10;
let WB = Lyr.WB === "--" || Lyr.WB === undefined ? "--" : Math.round(10 * (useImperialUnits === 2 ? CToF(Lyr.WB) : Lyr.WB)) / 10;
// --- ADDED: Calculate Relative Humidity ---
let RH = (Lyr.T === "--" || Lyr.D === "--") ? "--" : Math.round(RelHum(Lyr.T, Lyr.D));
let Omg = (Lyr.OM === "--" || isNaN(Lyr.OM)) ? "--" : Math.round(Lyr.OM * 10000)/100;
Omg = Omg == null ? "--" : Omg;
let LR = Math.round(100 * LapseRatePres(Snding.Sounding, Prs, Prs - 1, false)) / 100;
LR = LR == null ? "--" : LR;
ctx.fillStyle = "rgba(255,255,255,1)";
ctx.strokeStyle = "rgba(0,0,0,1)";
ctx.lineWidth = 2;
let LocOfText = [
['Temp:', -200, -65, `${T}°${tempUnit}`, -100],
['Dew:', -200, -45, `${D}°${tempUnit}`, -100],
['Wet Bulb:', -200, -25, `${WB}°${tempUnit}`, -100],
['RH:', -200, -5, `${RH}%`, -100],
['Virt Temp:', -400, -5, `${VT}°${tempUnit}`, -300],
['Parcel Temp:', -400, -25, `${ParT}°${tempUnit}`, -300],
['Omega:', -400, -45, `${Omg} ub/s`, -300],
['Lapse Rate:', -400, -65, `${LR}°C/Km`, -300],
]
LocOfText.forEach((ps) => {
ctx.textAlign = "right";
ctx.font = `600 ${Canv.width / 45}px ${globalFont}`;
ctx.strokeText(ps[0], PosC.x + ps[1], PosC.y + ps[2]);
ctx.fillText(ps[0], PosC.x + ps[1], PosC.y + ps[2]);
ctx.textAlign = "right";
ctx.font = `${Canv.width / 45}px ${globalFont}`;
ctx.strokeText(ps[3], PosC.x + ps[4], PosC.y + ps[2]);
ctx.fillText(ps[3], PosC.x + ps[4], PosC.y + ps[2]);
});
let Center = GetPositionScale(HodographCenter[0], HodographCenter[1], Canv);
if (!HodoMode) {
Center = GetPositionScale(50, 50, Canv);
}
// Change 27 to OutsideFitBase to match the auto-scaling logic
let Scale = OutsideFitBase * HodographScaleMulti;
let Loc = ConvertPolartoCart(Lyr);
if (!HodoMode) {
Loc.x -= CSMot.x;
Loc.y += CSMot.y;
}
const speedLabel = Lyr.WS === undefined ? "--" : Math.round(useImperialUnits === 2 ? KtsToMph(Lyr.WS) : Lyr.WS);
const speedUnit = useImperialUnits === 2 ? "mph" : "kts";
const hodoHeightLabel = Lyr.H === undefined ? "--" : Math.round(useImperialUnits === 2 ? MToFt(Lyr.H) : Lyr.H);
ctx2.strokeStyle = "rgba(255,255,255,0.6)";
ctx2.fillStyle = "rgba(255,255,255,1)";
ctx2.font = `bold ${Canv.width / 80}px ${globalFont}`;
ctx2.lineWidth = 8;
ctx2.beginPath();
ctx2.arc(Center.x / 2 - 1.5 + Loc.x * (Scale / 10), Center.y / 2 - 1.5 + Loc.y * (Scale / 10), 3, 0, 2 * Math.PI);
ctx2.stroke();
LocOfText = [
[`${Math.round(Lyr.WD)}°`, -10, -10, 'right'],
[`${speedLabel}${speedUnit}`, -10, 0, 'right'],
[`${hodoHeightLabel}${heightUnit} AGL`, 10, -10, 'left'],
[`${Math.round(Pres)}mb`, 10, 0, 'left'],
]
ctx2.font = `bold ${Canv.width / 75}px ${globalFont}`;
ctx2.strokeStyle = "rgba(0,0,0,1)";
ctx2.lineWidth = 2;
LocOfText.forEach((ps) => {
ctx2.textAlign = ps[3];
ctx2.strokeText(ps[0], Center.x / 2 + Loc.x * (Scale / 10) + ps[1], Center.y / 2 + Loc.y * (Scale / 10) + ps[2]);
ctx2.fillText(ps[0], Center.x / 2 + Loc.x * (Scale / 10) + ps[1], Center.y / 2 + Loc.y * (Scale / 10) + ps[2]);
})
ctx2.strokeStyle = "rgba(255,255,255,0.6)";
}
} else if (!ReadOutOn) {
ctx.clearRect(0, 0, Canv.width, Canv.height);
ctx2.clearRect(0, 0, Canv2.width, Canv2.height);
}
}
/** Changes read out state.
* @returns {*} Result of the function.
*/
function ChangeReadOut() {
CursorActive = true;
setReadOutOnState(!ReadOutOn);
if (ReadOutOn == true) {
setChangeModState(false);
} else {
CursorReadout(false);
}
return ReadOutOn;
}
/** Sets or applies read out on state state.
* @param {*} value Parameter for setReadOutOnState.
* @returns {void} No value.
*/
function setReadOutOnState(value) {
ReadOutOn = value;
}
let ghost = false;
// Helper function to create a smooth gradient color based on LapseRate
/** Returns lrgradient color data.
* @param {*} lr Parameter for getLRGradientColor.
* @returns {*} Result of the function.
*/
function getLRGradientColor(lr) {
// Define color stops: [percent, [r, g, b]]
const colorStops = [
[0, [255, 255, 255]], // White at 0C/km
[49.9, [75, 75, 75]], // Grey at 4.99C/km
[50, [75, 75, 200]], // Cobalt at 5C/km
[59.9, [30, 30, 150]], // Navy at 5.99C/km
[60, [255, 255, 0]], // Yellow at 6C/km
[69.9, [130, 130, 0]], // Dark Yellow at 6.99C/km
[70, [255, 125, 0]], // Orange at 7C/km
[79.9, [125, 50, 0]], // Dark Orange at 7.99C/km
[80, [255, 0, 0]], // Red at 8C/km
[89.9, [125, 0, 0]], // Dark Red at 8.99C/km
[90, [200, 0, 200]], // Pink at 9C/km
[100, [255, 255, 255]] // White at 10C/km
];
const tlr = lr * 10;
// Find the two stops the current LR value is between
let lowerStop = colorStops[0];
let upperStop = colorStops[colorStops.length - 1];
for (let i = 0; i < colorStops.length - 1; i++) {
if (tlr >= colorStops[i][0] && tlr <= colorStops[i + 1][0]) {
lowerStop = colorStops[i];
upperStop = colorStops[i + 1];
break;
}
}
// Calculate the position (factor) between the two stops
const range = upperStop[0] - lowerStop[0];
const factor = range === 0 ? 0 : (tlr - lowerStop[0]) / range;
// Linearly interpolate the R, G, and B values
const r = Math.round(lowerStop[1][0] + factor * (upperStop[1][0] - lowerStop[1][0]));
const g = Math.round(lowerStop[1][1] + factor * (upperStop[1][1] - lowerStop[1][1]));
const b = Math.round(lowerStop[1][2] + factor * (upperStop[1][2] - lowerStop[1][2]));
return `rgba(${r}, ${g}, ${b}, 0.8)`;
}
//
/** Returns temp adv gradient color data.
* @param {*} tadv Parameter for getTempAdvGradientColor.
* @returns {*} Result of the function.
*/
function getTempAdvGradientColor(tadv) {
// Define color stops: [percent, [r, g, b]]
const colorStops = [
[-40, [255, 255, 255]],
[-20, [0, 255, 255]],
[-15, [0, 125, 125]],
[-10, [0, 0, 125]],
[-5, [0, 0, 255]],
[-1, [125, 125, 255]],
[0, [125, 125, 125]],
[1, [200, 100, 100]],
[5, [255, 0, 0]],
[10, [125, 0, 0]],
[15, [125, 0, 125]],
[20, [255, 0, 255]],
[40, [255, 255, 255]],
];
// Find the two stops the current RH value is between
let lowerStop = colorStops[0];
let upperStop = colorStops[colorStops.length - 1];
for (let i = 0; i < colorStops.length - 1; i++) {
if (tadv >= colorStops[i][0] && tadv <= colorStops[i + 1][0]) {
lowerStop = colorStops[i];
upperStop = colorStops[i + 1];
break;
}
}
// Calculate the position (factor) between the two stops
const range = upperStop[0] - lowerStop[0];
const factor = range === 0 ? 0 : (tadv - lowerStop[0]) / range;
// Linearly interpolate the R, G, and B values
const r = Math.round(lowerStop[1][0] + factor * (upperStop[1][0] - lowerStop[1][0]));
const g = Math.round(lowerStop[1][1] + factor * (upperStop[1][1] - lowerStop[1][1]));
const b = Math.round(lowerStop[1][2] + factor * (upperStop[1][2] - lowerStop[1][2]));
return `rgba(${r}, ${g}, ${b}, 0.8)`;
}
// Helper function to create a smooth gradient color based on Relative Humidity
/** Returns rhgradient color data.
* @param {*} rh Parameter for getRHGradientColor.
* @returns {*} Result of the function.
*/
function getRHGradientColor(rh) {
// Define color stops: [percent, [r, g, b]]
const colorStops = [
[0, [139, 69, 19]], // Brown at 0%
[25, [255, 165, 0]], // Orange at 25%
[50, [255, 255, 0]], // Yellow at 50%
[75, [0, 200, 0]], // Green at 75%
[100, [30, 144, 255]] // Blue at 100%
];
// Find the two stops the current RH value is between
let lowerStop = colorStops[0];
let upperStop = colorStops[colorStops.length - 1];
for (let i = 0; i < colorStops.length - 1; i++) {
if (rh >= colorStops[i][0] && rh <= colorStops[i + 1][0]) {
lowerStop = colorStops[i];
upperStop = colorStops[i + 1];
break;
}
}
// Calculate the position (factor) between the two stops
const range = upperStop[0] - lowerStop[0];
const factor = range === 0 ? 0 : (rh - lowerStop[0]) / range;
// Linearly interpolate the R, G, and B values
const r = Math.round(lowerStop[1][0] + factor * (upperStop[1][0] - lowerStop[1][0]));
const g = Math.round(lowerStop[1][1] + factor * (upperStop[1][1] - lowerStop[1][1]));
const b = Math.round(lowerStop[1][2] + factor * (upperStop[1][2] - lowerStop[1][2]));
return `rgba(${r}, ${g}, ${b}, 0.8)`;
}
/** Returns wsgradient color data.
* @param {*} ws Parameter for getWSGradientColor.
* @returns {*} Result of the function.
*/
function getWSGradientColor(ws) {
// Define color stops: [speed_kts, [r, g, b]]
const colorStops = [
[0, [100, 100, 255]],
[10, [0, 125, 255]],
[20, [0, 255, 0]],
[30, [255, 255, 0]],
[40, [255, 125, 0]],
[50, [255, 0, 0]],
[60, [255, 0, 125]],
[70, [255, 0, 255]],
[80, [125, 0, 255]],
[90, [0, 0, 255]],
[100, [125, 125, 125]],
[125, [255, 255, 255]],
[150, [150, 200, 255]],
];
/*const colorStops = [
[0, [0, 100, 200]], // Light Blue
[20, [0, 200, 150]], // Cyan/Green
[40, [0, 255, 0]], // Bright Green
[60, [255, 255, 0]], // Yellow
[80, [255, 128, 0]], // Orange
[100, [255, 0, 0]], // Red
[125, [255, 0, 255]], // Magenta
[150, [150, 0, 255]] // Purple
];*/
// Find the two stops the current WS value is between
let lowerStop = colorStops[0];
let upperStop = colorStops[colorStops.length - 1];
for (let i = 0; i < colorStops.length - 1; i++) {
if (ws >= colorStops[i][0] && ws <= colorStops[i + 1][0]) {
lowerStop = colorStops[i];
upperStop = colorStops[i + 1];
break;
}
}
// Handle values above the max range by clamping to the last color
if (ws > upperStop[0]) {
return `rgba(${upperStop[1][0]}, ${upperStop[1][1]}, ${upperStop[1][2]}, 0.5)`;
}
const range = upperStop[0] - lowerStop[0];
const factor = range === 0 ? 0 : (ws - lowerStop[0]) / range;
// Linearly interpolate the R, G, and B values
const r = Math.round(lowerStop[1][0] + factor * (upperStop[1][0] - lowerStop[1][0]));
const g = Math.round(lowerStop[1][1] + factor * (upperStop[1][1] - lowerStop[1][1]));
const b = Math.round(lowerStop[1][2] + factor * (upperStop[1][2] - lowerStop[1][2]));
return `rgba(${r}, ${g}, ${b}, 0.5)`;
}
/** Infers temperature advection from sounding temperature and wind profiles.
* @param {*} Sound Parameter for inferred_temp_adv.
* @param {*} dp Parameter for inferred_temp_adv.
* @param {*} lat Parameter for inferred_temp_adv.
* @returns {*} Result of the function.
*/
function inferred_temp_adv(Sound, dp=-50, lat=35){
const Snd = Sound.Sounding;
const OMEGA = (2.0 * Math.PI) / 86164.0;
// QC checks
if (!Snd.W || Snd.W.every(v => v.WD === null || isNaN(v.WD))) {
return { temp_adv: null, pressure_bounds: null };
}
//if (Math.max(...Snd.P.filter(p => !isNaN(p))) <= 50) {
// return { temp_adv: null, pressure_bounds: null };
//}
// Get surface index
const sfc_idx = GetLayerByPres(Snd, "SFC");
const sfc_pres = sfc_idx.P;
// Filter pressures >= 100
const valid_pres = Snd.P.filter(p => p >= 100);
const top_pres_val = valid_pres[valid_pres.length - 1];
// Build pressure array from sfc down to top, stepping by dp
const pressures = [];
for (let p = sfc_pres; p > top_pres_val; p += dp) {
pressures.push(GetLayerByPres(Snd, p));
}
if (pressures.length < 2) {
return { temp_adv: null, pressure_bounds: null };
}
// Interpolate temps (C->K), heights, and wind directions at each pressure
const temps = pressures.map(p => CtoK(p.T));
const heights = pressures.map(p => p.H);
const dirs = pressures.map(p => p.WD);
const n = pressures.length - 1;
const temp_adv = new Array(n);
const pressure_bounds = new Array(n).fill(null).map(() => [0, 0]);
// Check latitude QC
if (lat === null || isNaN(lat)) {
temp_adv.fill(NaN);
return { temp_adv, pressure_bounds };
}
const f = 2.0 * OMEGA * Math.sin(lat * Math.PI / 180.0);
const multiplier = (f / 9.81) * (Math.PI / 180.0);
for (let i = 1; i < pressures.length; i++) {
const bottom_pres = pressures[i - 1];
const top_pres = pressures[i];
const btemp = temps[i - 1];
const ttemp = temps[i];
const bhght = heights[i - 1];
const thght = heights[i];
let bottom_wdir = dirs[i - 1];
let top_wdir = dirs[i];
const avg_temp = (ttemp + btemp) * 2.0;
// Mean wind between layers
const meanW = MeanWind(Snd, bottom_pres.P, top_pres.P, 1);
let Cart = ConvertCartToPolarReal(meanW);
// Change in wind direction with height
const mod = 180 - bottom_wdir;
top_wdir = top_wdir + mod;
if (top_wdir < 0) {
top_wdir += 360;
} else if (top_wdir >= 360) {
top_wdir -= 360;
}
const d_theta = top_wdir - 180.0;
// Temperature advection (K/s -> K/hr)
const dz = thght - bhght;
const t_adv = multiplier * Math.pow(Cart.WS, 2) * avg_temp * (d_theta / dz);
pressure_bounds[i - 1] = [bottom_pres.P, top_pres.P];
temp_adv[i - 1] = t_adv * 3600.0;
}
return { temp_adv, pressure_bounds };
}
/** Draws vertical bars content.
* @param {*} Sounding Parameter for drawVerticalBars.
* @param {*} comparisonSoundings Parameter for drawVerticalBars.
* @param {*} fromVwpRedraw Parameter for drawVerticalBars.
* @returns {void} No value.
*/
function drawVerticalBars(Sounding, comparisonSoundings = [], fromVwpRedraw = false) {
const Canv = $("verticalBar");
if (!Canv) return;
const ctx = Canv.getContext("2d");
/** Returns the gradient stop for a given y-coordinate.
* @param {*} y Parameter for gradStop.
* @returns {*} Result of the function.
*/
const gradStop = (y) => clamp(y / 800, 0, 1);
var TAdVMax;
var TAdVMax2;
var SSlice = GetLayerByHght(Sounding.Sounding, "SFC");
var ESlice = GetLayerByPres(Sounding.Sounding, "Last");
// Increase canvas width for three wider plots (150px each)
Canv.width = 400;
Canv.height = 800;
ctx.clearRect(0, 0, Canv.width, Canv.height);
ctx.fillStyle = "rgba(0,0,0,1)";
ctx.fillRect(0, 0, Canv.width, Canv.height);
ctx.save();
ctx.scale(1, SkewTScale);
ctx.translate(0, SkewTPosition.y);
ctx.strokeStyle = "rgba(255,255,255,1)";
ctx.lineWidth = 3;
ctx.strokeRect(0, 0, 100, 800); // Wind Speed
ctx.strokeRect(100, 0, 150, 800); // RH
ctx.strokeRect(250, 0, 150, 800); // LR
const soundData = Sounding.Sounding.Slice;
ctx.lineWidth = 3;
ctx.strokeStyle = "rgba(255,255,255,1)";
var lowestPN, highestPN;
// --- 1. WIND SPEED PLOT [X: 0-150] ---
const maxWindSpeed = 150;
const linGradWS = ctx.createLinearGradient(0, 0, 0, 800);
lowestPN = 0; highestPN = 0;
ctx.beginPath();
for (let i = 0; i < soundData.length; i++) {
const slice = soundData[i];
const p = slice[0], ws = slice[5];
if (ws === -9999 || isNaN(ws)) continue;
const y = pres_to_pix(p);
if (lowestPN === 0) { lowestPN = y; ctx.moveTo(0, y); }
highestPN = y;
if (y < 0) {
linGradWS.addColorStop(0, getWSGradientColor(ws));
ctx.lineTo((ws / maxWindSpeed) * 100, 0);
break;
}
linGradWS.addColorStop(gradStop(y), getWSGradientColor(ws));
ctx.lineTo((ws / maxWindSpeed) * 100, y);
}
ctx.lineTo(0, highestPN);
ctx.closePath();
ctx.fillStyle = linGradWS;
ctx.fill();
ctx.beginPath();
let firstWSPoint = true;
for (let i = 0; i < soundData.length; i++) {
const slice = soundData[i];
const p = slice[0], ws = slice[5];
if (ws === -9999 || isNaN(ws)) { firstWSPoint = true; continue; }
const y = pres_to_pix(p);
const x = (ws / maxWindSpeed) * 100;
if (firstWSPoint) { ctx.moveTo(x, y); firstWSPoint = false; }
else { ctx.lineTo(x, y); }
}
ctx.stroke();
// ctx.fillStyle = "rgba(255,255,255,1)";
// ctx.font = "bold 30px Arial"; // Increased font size
// ctx.textAlign = "center";
// ctx.fillText("Wind Spd", 75, 25);
// --- 2. Inferred Temp Adv PLOT [X: 150-300] ---
if (Presets['Vertical'] != "Default"){
const linGradRH = ctx.createLinearGradient(0, 0, 0, 800);
lowestPN = 0; highestPN = 0;
ctx.beginPath();
for (let i = 0; i < soundData.length; i++) {
const slice = soundData[i];
const p = slice[0], t = slice[2], d = slice[3];
if (t === -9999 || d === -9999) continue;
const rh = RelHum(t, d);
if (isNaN(rh) || rh < 0) continue;
const y = pres_to_pix(p);
if (lowestPN === 0) { lowestPN = y; ctx.moveTo(100, y); }
highestPN = y;
if (y < 0) {
linGradRH.addColorStop(0, getRHGradientColor(rh));
ctx.lineTo(100 + (rh / 100) * 150, 0);
break;
}
linGradRH.addColorStop(gradStop(y), getRHGradientColor(rh));
ctx.lineTo(100 + (rh / 100) * 150, y);
}
ctx.lineTo(100, highestPN);
ctx.closePath();
ctx.fillStyle = linGradRH;
ctx.fill();
ctx.beginPath();
let firstRHPoint = true;
for (let i = 0; i < soundData.length; i++) {
const slice = soundData[i];
const p = slice[0], t = slice[2], d = slice[3];
if (t === -9999 || d === -9999) { firstRHPoint = true; continue; }
const rh = RelHum(t, d);
if (isNaN(rh)) { firstRHPoint = true; continue; }
const y = pres_to_pix(p);
const x = 100 + (rh / 100) * 150;
if (firstRHPoint) { ctx.moveTo(x, y); firstRHPoint = false; }
else { ctx.lineTo(x, y); }
}
ctx.stroke();
} else {
var LATD;
ctx.lineWidth = 1;
ctx.strokeStyle = "rgba(255,255,255,0.5)";
ctx.strokeRect(175, 0, 1, 800); // RH
ctx.lineWidth = 3;
ctx.strokeStyle = "rgba(255,255,255,1)";
//TEMP ADV
if (Sounding.Location2 == null){
LATD = 35;
} else {
LATD = Sounding.Location2[0];
}
const linGradTA = ctx.createLinearGradient(0, 0, 0, 800);
lowestPN = 0; highestPN = 0;
const TAdv = fromVwpRedraw ? { temp_adv: null, pressure_bounds: null } : inferred_temp_adv(Sounding, -50, LATD);
if (TAdv && Array.isArray(TAdv.temp_adv) && Array.isArray(TAdv.pressure_bounds)) {
if (TAdv.temp_adv.every(Number.isNaN)){
TAdv.temp_adv.fill(0);
for (let i = 0; i < TAdv.pressure_bounds.length; i++){
TAdv.pressure_bounds[i] = [SSlice.P - 50*i, SSlice.P - ((i+1)*50)]
}
}
TAdVMax = Math.round(clamp(1 + Math.max(...TAdv.temp_adv.map(absoluteValue)), 10, 20));
TAdVMax2 = TAdVMax*2;
ctx.beginPath();
for (let i = 0; i < TAdv.pressure_bounds.length; i++){
const PresLvl = TAdv.pressure_bounds[i][0];
const TAd = TAdv.temp_adv[i];
if (TAd === null || PresLvl === null) continue;
const y = pres_to_pix(PresLvl);
const y2 = pres_to_pix(TAdv.pressure_bounds[i][1]);
if (lowestPN === 0) { lowestPN = y; ctx.moveTo(175, y); }
highestPN = y2;
if (y < 0) {
linGradTA.addColorStop(0, getTempAdvGradientColor(TAd));
linGradTA.addColorStop(gradStop(y2 - 1), getTempAdvGradientColor(TAd));
ctx.moveTo(175 + ((clamp(TAd, -TAdVMax, TAdVMax)) / TAdVMax2) * 150, 0);
ctx.lineTo(175 + ((clamp(TAd, -TAdVMax, TAdVMax)) / TAdVMax2) * 150, y2);
break;
}
linGradTA.addColorStop(gradStop(y - 1), getTempAdvGradientColor(TAd));
linGradTA.addColorStop(gradStop(y2), getTempAdvGradientColor(TAd));
ctx.lineTo(175 + ((clamp(TAd, -TAdVMax, TAdVMax)) / TAdVMax2) * 150, y);
ctx.lineTo(175 + ((clamp(TAd, -TAdVMax, TAdVMax)) / TAdVMax2) * 150, y2);
}
ctx.lineTo(175, highestPN);
ctx.closePath();
ctx.fillStyle = linGradTA;
ctx.fill();
ctx.beginPath();
let firstRHPoint = true;
for (let i = 0; i < TAdv.pressure_bounds.length; i++){
const PresLvl = TAdv.pressure_bounds[i][0];
const TAd = TAdv.temp_adv[i];
if (PresLvl === null || TAd === null) { firstRHPoint = true; continue; }
if (isNaN(TAd)) { firstRHPoint = true; continue; }
const y = pres_to_pix(PresLvl);
const y2 = pres_to_pix(TAdv.pressure_bounds[i][1])
const x = 175 + ((clamp(TAd, -TAdVMax, TAdVMax)) / TAdVMax2) * 150;
if (firstRHPoint) { ctx.moveTo(x, y); ctx.lineTo(x, y2); firstRHPoint = false; }
else { ctx.lineTo(x, y); ctx.lineTo(x, y2); }
}
ctx.stroke();
}
}
if (Presets['Vertical'] === "Winter") {
var TAdVMaxW;
var TAdVMax2W;
var LATDW;
ctx.lineWidth = 1;
ctx.strokeStyle = "rgba(255,255,255,0.5)";
ctx.strokeRect(325, 0, 1, 800);
ctx.lineWidth = 3;
ctx.strokeStyle = "rgba(255,255,255,1)";
if (Sounding.Location2 == null) {
LATDW = 35;
} else {
LATDW = Sounding.Location2[0];
}
const linGradTAW = ctx.createLinearGradient(0, 0, 0, 800);
lowestPN = 0; highestPN = 0;
const TAdvW = fromVwpRedraw ? { temp_adv: null, pressure_bounds: null } : inferred_temp_adv(Sounding, -50, LATDW);
if (TAdvW && Array.isArray(TAdvW.temp_adv) && Array.isArray(TAdvW.pressure_bounds)) {
TAdVMaxW = Math.round(clamp(1 + Math.max(...TAdvW.temp_adv.map(absoluteValue)), 10, 20));
TAdVMax2W = TAdVMaxW * 2;
ctx.beginPath();
for (let i = 0; i < TAdvW.pressure_bounds.length; i++) {
const PresLvl = TAdvW.pressure_bounds[i][0];
const TAd = TAdvW.temp_adv[i];
if (TAd === null || PresLvl === null) continue;
const y = pres_to_pix(PresLvl);
const y2 = pres_to_pix(TAdvW.pressure_bounds[i][1]);
if (lowestPN === 0) { lowestPN = y; ctx.moveTo(325, y); }
highestPN = y2;
if (y < 0) {
linGradTAW.addColorStop(0, getTempAdvGradientColor(TAd));
linGradTAW.addColorStop(gradStop(y2 - 1), getTempAdvGradientColor(TAd));
ctx.moveTo(325 + ((clamp(TAd, -TAdVMaxW, TAdVMaxW)) / TAdVMax2W) * 150, 0);
ctx.lineTo(325 + ((clamp(TAd, -TAdVMaxW, TAdVMaxW)) / TAdVMax2W) * 150, y2);
break;
}
linGradTAW.addColorStop(gradStop(y - 1), getTempAdvGradientColor(TAd));
linGradTAW.addColorStop(gradStop(y2), getTempAdvGradientColor(TAd));
ctx.lineTo(325 + ((clamp(TAd, -TAdVMaxW, TAdVMaxW)) / TAdVMax2W) * 150, y);
ctx.lineTo(325 + ((clamp(TAd, -TAdVMaxW, TAdVMaxW)) / TAdVMax2W) * 150, y2);
}
ctx.lineTo(325, highestPN);
ctx.closePath();
ctx.fillStyle = linGradTAW;
ctx.fill();
ctx.beginPath();
let firstTAWPoint = true;
for (let i = 0; i < TAdvW.pressure_bounds.length; i++) {
const PresLvl = TAdvW.pressure_bounds[i][0];
const TAd = TAdvW.temp_adv[i];
if (PresLvl === null || TAd === null || isNaN(TAd)) { firstTAWPoint = true; continue; }
const y = pres_to_pix(PresLvl);
const y2 = pres_to_pix(TAdvW.pressure_bounds[i][1]);
const x = 325 + ((clamp(TAd, -TAdVMaxW, TAdVMaxW)) / TAdVMax2W) * 150;
if (firstTAWPoint) { ctx.moveTo(x, y); ctx.lineTo(x, y2); firstTAWPoint = false; }
else { ctx.lineTo(x, y); ctx.lineTo(x, y2); }
}
ctx.stroke();
// store for footer labels
var _winterTAdVMax = TAdVMaxW;
}
} else {
const linGradLR = ctx.createLinearGradient(0, 0, 0, 800);
lowestPN = 0; highestPN = 0;
ctx.beginPath();
for (let i = SSlice.H; i < (ESlice.H - 1000); i += 250) {
const slice = GetLayerByHght(Sounding.Sounding, i);
const NS = GetLayerByHght(Sounding.Sounding, i + 1000);
if (!slice || !NS || slice.VT === -9999 || NS.VT == -9999) continue;
const lr = clamp(slice.VT - NS.VT, 0, 10);
if (isNaN(lr) || lr < 0) continue;
const y_start = pres_to_pix(slice.P);
if (lowestPN == 0) { lowestPN = y_start; ctx.moveTo(250, y_start); }
highestPN = y_start;
if (slice.P < 100) {
linGradLR.addColorStop(0, getLRGradientColor(lr));
ctx.lineTo(250 + (lr / 10) * 150, 0);
break;
}
linGradLR.addColorStop(gradStop(y_start), getLRGradientColor(lr));
ctx.lineTo(250 + (lr / 10) * 150, y_start);
}
ctx.lineTo(250, highestPN);
ctx.closePath();
ctx.fillStyle = linGradLR;
ctx.fill();
ctx.beginPath();
let firstLRPoint = true;
for (let i = SSlice.H; i < (ESlice.H - 1000); i += 250) {
const slice = GetLayerByHght(Sounding.Sounding, i);
const NS = GetLayerByHght(Sounding.Sounding, i + 1000);
if (!slice || !NS || slice.VT === -9999 || NS.VT === -9999) { firstLRPoint = true; continue; }
const lr = clamp(slice.VT - NS.VT, 0, 10);
if (isNaN(lr)) { firstLRPoint = true; continue; }
const y = pres_to_pix(slice.P);
const x = 250 + (lr / 10) * 150;
if (firstLRPoint) { ctx.moveTo(x, y); firstLRPoint = false; }
else { ctx.lineTo(x, y); }
}
ctx.stroke();
}
// --- 4. Draw Comparison Model Outlines ---
if (comparisonSoundings && comparisonSoundings.length > 0) {
comparisonSoundings.forEach(comp => {
if (!comp.metadata.isVisible) return;
ctx.strokeStyle = comp.metadata.comparisonColor || "rgba(0, 191, 255, 1)";
ctx.lineWidth = 8;
const compSounding = comp.sounding.Sounding;
const compS_Slice = GetLayerByHght(compSounding, "SFC");
const compE_Slice = GetLayerByPres(compSounding, "Last");
ctx.beginPath();
let firstPointWS = true;
for (let i = 0; i < compSounding.Slice.length; i++) {
const slice = compSounding.Slice[i];
const p = slice[0], ws = slice[5];
if (ws === -9999 || isNaN(ws)) { firstPointWS = true; continue; }
const y = pres_to_pix(p);
const x = (ws / maxWindSpeed) * 100;
if (firstPointWS) { ctx.moveTo(x, y); firstPointWS = false; }
else { ctx.lineTo(x, y); }
}
ctx.stroke();
if (Presets['Vertical'] != "Default"){
ctx.beginPath();
let firstPointRH = true;
for (let i = 0; i < compSounding.Slice.length; i++) {
const slice = compSounding.Slice[i];
const p = slice[0], t = slice[2], d = slice[3];
if (t === -9999 || d === -9999) { firstPointRH = true; continue; }
const rh = RelHum(t, d);
if (isNaN(rh)) { firstPointRH = true; continue; }
const y = pres_to_pix(p);
const x = 100 + (rh / 100) * 150;
if (firstPointRH) { ctx.moveTo(x, y); firstPointRH = false; }
else { ctx.lineTo(x, y); }
}
ctx.stroke();
}
if (Presets['Vertical'] !== "Winter") {
ctx.beginPath();
let firstPointLR = true;
for (let i = compS_Slice.H; i < (compE_Slice.H - 1000); i += 250) {
const slice = GetLayerByHght(compSounding, i);
const nextSlice = GetLayerByHght(compSounding, i + 1000);
if (!slice || !nextSlice || slice.VT === -9999 || nextSlice.VT === -9999) { firstPointLR = true; continue; }
const lr = (slice.VT - nextSlice.VT);
if (isNaN(lr)) { firstPointLR = true; continue; }
const y = pres_to_pix(slice.P);
const x = 250 + clamp(lr / 10, 0, 1) * 150;
if (firstPointLR) { ctx.moveTo(x, y); firstPointLR = false; }
else { ctx.lineTo(x, y); }
}
ctx.stroke();
}
});
}
ctx.save();
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.fillStyle = "rgba(255,255,255,1)";
ctx.font = `bold 20px ${globalFont}`;
ctx.textAlign = "center";
ctx.fillText("Wind Spd", 45, 25);
ctx.fillText("kts", 45, 45);
ctx.font = `bold 27px ${globalFont}`;
if (Presets['Vertical'] !== "Default"){
ctx.fillText("RH %", 175, 25);
} else {
ctx.font = `bold 25px ${globalFont}`;
ctx.fillText("Inferred", 175, 25);
ctx.fillText("Temp. Adv.", 175, 48);
ctx.font = `900 20px ${globalFont}`;
ctx.fillText("K / H", 175, 75);
}
if (Presets['Vertical'] === "Winter") {
ctx.font = `bold 25px ${globalFont}`;
ctx.fillText("Inferred", 325, 25);
ctx.fillText("Temp. Adv.", 325, 48);
ctx.font = `900 20px ${globalFont}`;
ctx.fillText("K / H", 325, 75);
} else {
ctx.font = `bold 25px ${globalFont}`;
ctx.fillText("LR 1km", 325, 25);
ctx.font = `900 20px ${globalFont}`;
ctx.fillText("C / km", 325, 45);
}
ctx.restore();
// --- 5. Draw DGZ Lines & Restore ---
const dgzTopLayer = GetLayerByTemp(Sounding.Sounding, -18);
const dgzBottomLayer = GetLayerByTemp(Sounding.Sounding, -12);
if (dgzTopLayer && dgzBottomLayer) {
const y_top = pres_to_pix(dgzTopLayer.P);
const y_bottom = pres_to_pix(dgzBottomLayer.P);
ctx.fillStyle = "rgba(255, 175, 126, 1)";
ctx.fillRect(5, y_top - 1, 440, 2);
ctx.fillRect(5, y_bottom - 1, 440, 2);
}
ctx.restore();
ctx.save();
ctx.setTransform(1, 0, 0, 1, 0, 0); // Reset transform for text
ctx.fillStyle = "rgba(255,255,255,0.7)";
ctx.font = `900 20px ${globalFont}`;
const labelY = Canv.height - 8;
ctx.textAlign = "left";
ctx.fillText("0", 5, labelY);
ctx.textAlign = "right";
ctx.fillText("150", 95, labelY);
if (Presets['Vertical'] !== "Default"){
ctx.fillText("100", 245, labelY);
ctx.textAlign = "left";
ctx.fillText("0", 105, labelY);
} else {
ctx.fillText(`${TAdVMax}`, 245, labelY);
ctx.textAlign = "center";
ctx.fillText(`0`, (105 + 245)/2, labelY);
ctx.textAlign = "left";
ctx.fillText(`-${TAdVMax}`, 105, labelY);
}
if (Presets['Vertical'] === "Winter") {
ctx.fillText(`${_winterTAdVMax}`, 395, labelY);
ctx.textAlign = "center";
ctx.fillText(`0`, (255 + 395) / 2, labelY);
ctx.textAlign = "left";
ctx.fillText(`-${_winterTAdVMax}`, 255, labelY);
} else {
ctx.textAlign = "right";
ctx.fillText("10", 395, labelY);
ctx.textAlign = "center";
ctx.fillText("5", 320, labelY);
ctx.textAlign = "left";
ctx.fillText("0", 255, labelY);
}
ctx.fontStretch = 'normal';
ctx.restore();
if (noWSBox){
ctx.drawImage(
Canv, // Use the canvas itself as the source
100, 0, // The source slice (x=150 to 450)
300, 800, // Source dimensions
0, 0, // Destination coordinates
400, 800 // Destination dimensions (the stretch)
);
}
}
/** Vertical bar panel: full, or without the wind-speed column. Toggles when omitted.
* @param {*} showWindSpeed Parameter for ChangeWBox.
* @returns {*} Result of the function.
*/
function ChangeWBox(showWindSpeed){
noWSBox = typeof showWindSpeed === 'boolean' ? !showWindSpeed : !noWSBox;
if (Snding && Snding.Sounding) drawVerticalBars(Snding, currentComparisonSoundings, false);
return !noWSBox;
}
let noWSBox = false;
let presetsAutoSet = false;
/** True after the user changes Wind/Lapse, Inset, Vertical, or Side Panel. Survives time scrubs. */
let userLockedPresets = false;
/** Unlock users sounding presets
* @returns {void} No value.
*/
function unlockUserSoundingPresets() {
userLockedPresets = false;
presetsAutoSet = false;
}
/** Chooses chart presets from the sounding characteristics.
* @param {*} Sounding Parameter for autoSetPresetsFromSounding.
* @returns {void} No value.
*/
function autoSetPresetsFromSounding(Sounding) {
if (userLockedPresets || presetsAutoSet) return;
presetsAutoSet = true;
// Always reset to defaults first
Presets['WindLapse'] = 'Default';
Presets['Inset'] = 'Default';
Presets['Vertical'] = 'Default';
SidePanelMode = 'Graph';
const Sound = Sounding.Sounding;
const sfcLayer = GetLayerByPres(Sound, "SFC");
const E_inf = EffInflowCalc(Sound, sfcLayer);
const sfcTemp = sfcLayer ? sfcLayer.T : null;
const sfcDew = sfcLayer ? sfcLayer.D : null;
const sfcRH = (sfcTemp !== null && sfcDew !== null && sfcTemp !== -9999 && sfcDew !== -9999)
? RelHum(sfcTemp, sfcDew)
: null;
const muParcel = LiftParcel(Sound, "MU", 767);
const layer1km = GetLayerByHght(Sound, 1000);
const layer1km_RH = RelHum(layer1km.T, layer1km.D);
const layer3km = GetLayerByHght(Sound, 3000);
const layer3km_theta = thetae(layer3km.P, layer3km.T, layer3km.D);
const sfcTheta = thetae(sfcLayer.P, sfcLayer.T, sfcLayer.D);
const layer1km_thetea = thetae(layer1km.P, layer1km.T, layer1km.D);
const muCape = muParcel ? muParcel.Cape : 0;
const isWinter = sfcTemp !== null && sfcTemp < 5;
const isFire = sfcRH !== null && sfcRH < 20;
let isAmbient = false;
if (sfcRH !== null && layer1km_RH !== null){
if (muCape <= 10 && sfcRH >= 80 && sfcTemp <= 20 && layer1km_RH < 30 && sfcLayer.WS <= 10){
//Foggy
isAmbient = true;
}
if (sfcTemp >= 35 && muCape <= 500 && sfcLayer.WS <= 15){
//Hot
isAmbient = true;
}
if (sfcTemp <= 0 && muCape <= 50 && sfcLayer.WS >= 1){
//Cold
isAmbient = true;
}
if (muCape == 0 && sfcTheta <= 320 && layer1km_thetea <= 320 && layer3km_theta && !isWinter && !isFire){
//Nothing
isAmbient = true;
}
}
if (isAmbient) {
Presets['WindLapse'] = 'Winter';
Presets['Inset'] = 'Ambient';
Presets['Vertical'] = 'Winter';
SidePanelMode = 'Winter';
} else if (isWinter){
Presets['WindLapse'] = 'Winter';
Presets['Inset'] = 'Winter';
Presets['Vertical'] = 'Winter';
SidePanelMode = 'Winter';
} else if (isFire) {
Presets['WindLapse'] = 'Fire';
Presets['Inset'] = 'Fire';
Presets['Vertical'] = 'Fire';
} else if (muCape > 500) {
if (isSoundingInTornadoClimoBounds(Sounding)){
SidePanelMode = 'Tornado';
} else {
SidePanelMode = 'Graph';
}
if (E_inf !== false){
if (E_inf.Bot+100 <= sfcLayer.P){
Presets['WindLapse'] = 'Elevated';
Presets['Inset'] = 'Default';
Presets['Vertical'] = 'Default';
}
}
}
if (Math.abs(sfcTheta - layer1km_thetea) < 5 && Math.abs(sfcTheta - layer3km_theta) < 10 && sfcRH <= 25 && muCape <= 100){
Presets['WindLapse'] = 'Dryline';
Presets['Inset'] = 'Default';
Presets['Vertical'] = 'Default';
}
autoPresets = {
WindLapse: Presets['WindLapse'],
Inset: Presets['Inset'],
Vertical: Presets['Vertical'],
SidePanel: SidePanelMode,
};
applyPresetOverrides(Sounding);
}
/** Presets the user pinned (null = follow the automatic choice for each sounding). */
let presetOverrides = { WindLapse: null, Inset: null, Vertical: null, SidePanel: null };
/** What autoSetPresetsFromSounding picked for the current sounding. */
let autoPresets = { WindLapse: 'Default', Inset: 'Default', Vertical: 'Default', SidePanel: 'Graph' };
/** Applies user overrides to the active chart presets.
* @param {*} Sounding Parameter for applyPresetOverrides.
* @returns {void} No value.
*/
function applyPresetOverrides(Sounding) {
for (const key of ['WindLapse', 'Inset', 'Vertical']) {
Presets[key] = presetOverrides[key] || autoPresets[key];
}
SidePanelMode = presetOverrides.SidePanel || autoPresets.SidePanel;
if (SidePanelMode === 'Tornado' && Sounding && Sounding.Location2 && !isSoundingInTornadoClimoBounds(Sounding)) {
SidePanelMode = autoPresets.SidePanel === 'Tornado' ? 'Graph' : autoPresets.SidePanel;
}
UpdatePresets(Presets);
applySidePanelVisibility();
emit("change");
}
/**
* Pin a preset, or pass "auto" to follow the per-sounding choice again.
* @param {'WindLapse'|'Inset'|'Vertical'|'SidePanel'} type
* @param {string} value
* @returns {void} No value.
*/
function setPreset(type, value) {
if (!(type in presetOverrides)) throw new Error(`SharpJS: unknown preset ${type}`);
presetOverrides[type] = value && value !== 'auto' ? value : null;
if (type === 'SidePanel') {
ChangeSidePanelMode(presetOverrides.SidePanel || autoPresets.SidePanel);
return;
}
Presets[type] = presetOverrides[type] || autoPresets[type];
UpdatePresets(Presets);
emit("change");
if (Snding && Snding.Sounding) {
drawVerticalBars(Snding, currentComparisonSoundings);
LoadWithWind(Snding, Info, ghost, currentComparisonSoundings);
LoadWithParcel(Snding, DP, Info, ghost, currentComparisonSoundings);
}
}
/** Draws skew tghost content.
* @param {*} Sounding Parameter for DrawSkewTGhost.
* @param {*} Soundings Parameter for DrawSkewTGhost.
* @returns {void} No value.
*/
function DrawSkewTGhost(Sounding, Soundings) {
const Canv = $("skewT");
if (!Canv) return;
const ctx = Canv.getContext("2d");
Canv.width = 800;//Canv.style.width;
Canv.height = 800;
DrawHodograph(Sounding, Soundings);
}
var DSnding = false;
var isBoxSounding = true;
/** Draws skew t content.
* @param {*} Sounding Parameter for DrawSkewT.
* @param {*} resetStart Parameter for DrawSkewT.
* @param {*} modify Parameter for DrawSkewT.
* @param {*} modify2 Parameter for DrawSkewT.
* @param {*} ghosted Parameter for DrawSkewT.
* @param {*} comparisonSoundings Parameter for DrawSkewT.
* @param {*} setmot Parameter for DrawSkewT.
* @param {*} vwpactive Parameter for DrawSkewT.
* @returns {void} No value.
*/
function DrawSkewT(Sounding, resetStart = false, modify = false, modify2 = false, ghosted = false, comparisonSoundings = [], setmot=false, vwpactive=false) {
if (vwpactive !== true) {
VWPActive = false;
}
if (typeof document === "undefined" || !$("skewT")) {
return;
}
setSkewTDomainsFromSounding(Sounding, comparisonSoundings);
Clean();
drawVerticalBars(Sounding, comparisonSoundings, vwpactive === true);
currentComparisonSoundings = comparisonSoundings;
const Canv = $("skewT");
const ctx = Canv.getContext("2d");
SetTitle(Sounding);
if (resetStart) {
userSelectedParcel = defaultParcel;
userSelectedMotion = defaultMotion;
let SMS = GetBM(Sounding.Sounding);
Motions = SMS;
Motions.RM.y *= -1;
Motions.LM.y *= -1;
Motions.MM.y *= -1;
Motions.US.y *= -1;
Motions.DS.y *= -1;
Motions.DTMR.y *= -1;
Motions.DTML.y *= -1;
CUMot = customMotionVector();
CSMot = SMS.RM;
userSetCustomLapse = false;
userSetCustomWind = false;
// Auto-presets run on a new sounding (new location / load). Do not re-run on
// forecast-hour / RAOB / dropsonde time changes (resetStart is false there),
// and never overwrite a user choice while they scrub.
if (!userLockedPresets) {
presetsAutoSet = false;
autoSetPresetsFromSounding(Sounding);
drawVerticalBars(Sounding, comparisonSoundings, vwpactive === true);
}
const graphCanvas = $("Values4");
const climoCanvas = $("Values6");
if (SidePanelMode === "Graph") {
if (graphCanvas) graphCanvas.classList.remove('skewthidden');
if (climoCanvas) climoCanvas.classList.add('skewthidden');
} else {
if (graphCanvas) graphCanvas.classList.add('skewthidden');
if (climoCanvas) climoCanvas.classList.remove('skewthidden');
}
}
if (DSnding == false || modify2) {
DSnding = JSON.parse(JSON.stringify(Sounding));
}
Set();
ctx.fillStyle = DarkMode == true && "rgba(0,0,0,1)" || "rgba(255,255,255,1)";
ctx.fillRect(0, 0, 1000, 1000);
Canv.width = 800;//Canv.style.width;
Canv.height = 800;
ctx.scale(SkewTScale, SkewTScale);
ctx.translate(SkewTPosition.x, SkewTPosition.y);
DrawSkewNumbers();
ctx.lineWidth = 1;
if (!VWPActive){
if (setmot !== false){
CSMot = setmot;
} else {
let SMS = GetBM(Sounding.Sounding);
Motions = SMS;
Motions.RM.y *= -1;
Motions.LM.y *= -1;
Motions.MM.y *= -1;
Motions.US.y *= -1;
Motions.DS.y *= -1;
Motions.DTMR.y *= -1;
Motions.DTML.y *= -1;
CUMot = customMotionVector();
CSMot = SMS.RM;
}
}
// Restore storm motion (on a new sounding this applies the default motion)
if (!VWPActive) {
if (userSelectedMotion === 'LM') {
CSMot = Motions.LM;
} else if (userSelectedMotion === 'MM') {
CSMot = Motions.MM;
} else if (userSelectedMotion === 'US') {
CSMot = Motions.US;
} else if (userSelectedMotion === 'DS') {
CSMot = Motions.DS;
} else if (userSelectedMotion === 'DTMR') {
CSMot = Motions.DTMR;
} else if (userSelectedMotion === 'DTML') {
CSMot = Motions.DTML;
} else if (userSelectedMotion === 'CM') {
CSMot = CUMot;
} else {
CSMot = Motions.RM;
}
}
// ECAPE inside LiftParcel reads the storm motion from the calc module; give it the one in use.
if (!VWPActive && CSMot && CSMot.x !== undefined) UpdateCSMotStorm(CSMot);
let SBParcel = LiftParcel(Sounding.Sounding, "SB");
let MLParcel = LiftParcel(Sounding.Sounding, "ML");
let MUParcel = LiftParcel(Sounding.Sounding, "MU", 767);
let CUParcel = LiftParcel(Sounding.Sounding, "CU", 900);
CUPrcl = CUParcel;
DP = MUParcel;
let Sound = Sounding.Sounding;
if (!userSetCustomLapse) {
CustomLapseLayer = {
Title: 'C: 950-850mb',
T: 950,
B: 850,
Lapse: LapseRatePres(Sound, 950, 850, LapseType),
}
} else {
// Recalculate lapse rate with same bounds but new sounding data (heights or pressures)
CustomLapseLayer.Lapse = CustomLapseLayer.Mode === "Hght"
? LapseRateHght(Sound, CustomLapseLayer.T, CustomLapseLayer.B, LapseType)
: LapseRatePres(Sound, CustomLapseLayer.T, CustomLapseLayer.B, LapseType);
}
let DCapeProf = GetDCape(Sounding.Sounding);
GetLapseBox(Sounding.Sounding);
let StartSlice = GetLayerByPres(Sound, "SFC");
rStart = StartSlice;
if (resetStart == true || relStart == false) {
// console.log("Rest");
relStart = JSON.parse(JSON.stringify(StartSlice));
}
let EndSlice = GetLayerByPres(Sound, "Last");
let EffInflow = false;
if (SBParcel.Path == false) {
let EffInflow = false;
} else {
EffInflow = EffInflowCalc(Sounding.Sounding, StartSlice);
}
let ESRH = 0;
// console.log(EffInflow);
if (EffInflow != false) {
ESRH = GetSRH(Sound, EffInflow.Bot, EffInflow.Top, "Pres");
ESRH = ESRH.SRHP + ESRH.SRHM;
}
let ME = 0;
if (EffInflow != false) {
ME = WindShearFindP(Sound, EffInflow.Bot, EffInflow.Top, true)
}
let hght0km = GetLayerByHght(Sound, "SFC");
// let hght05km = GetLayerByHght(Sound, 500);
// let hght1km = GetLayerByHght(Sound, 1000);
// let hght3km = GetLayerByHght(Sound, 3000);
// let hght6km = GetLayerByHght(Sound, 6000);
let hght2km = GetLayerByHght(Sound, 2000);
if (!userSetCustomWind) {
CustomWindLayer = {
Title: 'C: 0km-2km',
T: 0,
B: 2000,
SRH: GetSRH(Sound, hght0km.P, hght2km.P, "Pres"),
SHR: WindShearFind(Sound, "SFC", 2000),
MW: WindShearFind(Sound, "SFC", 2000, true),
EHI: 0,
}
CustomWindLayer.EHI = GetParams({ DP, SRHAmn: (CustomWindLayer.SRH.SRHP + CustomWindLayer.SRH.SRHM) }, "EHI")
} else {
// Recalculate with same bounds but new sounding data
const botLayer = GetLayerByPres(Sound, CustomWindLayer.T) || hght0km;
const topLayer = GetLayerByPres(Sound, CustomWindLayer.B) || hght2km;
CustomWindLayer.SRH = GetSRH(Sound, botLayer.P, topLayer.P, "Pres");
CustomWindLayer.SHR = WindShearFind(Sound, botLayer.H, topLayer.H);
CustomWindLayer.MW = WindShearFind(Sound, botLayer.H, topLayer.H, true);
CustomWindLayer.EHI = GetParams({ DP, SRHAmn: (CustomWindLayer.SRH.SRHP + CustomWindLayer.SRH.SRHM) }, "EHI")
}
CustomWindLayer.EHI = GetParams({ DP, SRHAmn: (CustomWindLayer.SRH.SRHP + CustomWindLayer.SRH.SRHM) }, "EHI")
ctx.lineWidth = 2;
ctx.strokeStyle = "rgba(255,255,255,0.5)";
ctx.fillStyle = "rgba(255,0,0,1)";
Info = { DCapeProf, EffInflow, SBParcel, MLParcel, MUParcel };
Snding = Sounding;
Snding.End = EndSlice;
let displayParcel;
if (userSelectedParcel === 'SB') {
displayParcel = Info.SBParcel;
} else if (userSelectedParcel === 'ML') {
displayParcel = Info.MLParcel;
} else if (userSelectedParcel === 'CU') {
displayParcel = CUPrcl;
} else {
displayParcel = MUParcel; // default MU
}
DP = displayParcel;
if (ghosted != false) {
DrawSkewTGhost(Sounding, ghosted);
ghost = ghosted;
LoadWithWind(Sounding, Info, true, comparisonSoundings);
ctx.lineWidth = 2;
ctx.strokeStyle = "rgba(255,255,255,0.5)";
ctx.fillStyle = "rgba(255,0,0,1)";
LoadWithParcel(Sounding, displayParcel, Info, true, comparisonSoundings);
} else {
LoadWithWind(Sounding, Info, false, comparisonSoundings);
ctx.lineWidth = 2;
ctx.strokeStyle = "rgba(255,255,255,0.5)";
ctx.fillStyle = "rgba(255,0,0,1)";
LoadWithParcel(Sounding, displayParcel, Info, false, comparisonSoundings);
}
ChangeStateSkewTSkew(false);
if (modify) {
ctx.fillStyle = "rgba(255,0,0,1)";
ctx.fillText("Custom Surface Data", 0.5 * Canv.width, 0.027 * Canv.height);
}
}
let HodoMode = true;
let Hodo2D = true;
let ChangeMod = false;
let M = "(Modified)";
/** Resets hodograph state.
* @returns {void} No value.
*/
function ResetHodograph() {
let NN = JSON.parse(JSON.stringify(DSnding));
Snding.Sounding.W = NN.Sounding.W;
for (let i = 0; i < Snding.Sounding.Slice.length; i++) {
let J = Snding.Sounding.Slice[i];
let J2 = NN.Sounding.Slice[i];
J[4] = J2[4];
J[5] = J2[5];
}
Slices = [];
const Canv = $("hodographOverHang3");
const ctx = Canv.getContext("2d");
ctx.clearRect(0, 0, 1000, 1000);
SetTitle(Snding);
ShowObjects();
let Mot = GetBM(Snding.Sounding);
Motions = Mot;
Motions.RM.y *= -1;
Motions.LM.y *= -1;
Motions.MM.y *= -1;
Motions.US.y *= -1;
Motions.DS.y *= -1;
Motions.DTMR.y *= -1;
Motions.DTML.y *= -1;
CSMot = Motions.RM;
DrawHodograph(Snding, false, currentComparisonSoundings);
DrawSkewT(Snding, false, false, false, false, currentComparisonSoundings);
}
/** Resets skew t state.
* @returns {void} No value.
*/
function ResetSkewT() {
let NN = JSON.parse(JSON.stringify(DSnding));
Snding.Sounding.T = NN.Sounding.T;
Snding.Sounding.D = NN.Sounding.D;
Snding.Sounding.TW = NN.Sounding.TW;
Snding.Sounding.DW = NN.Sounding.DW;
for (let i = 0; i < Snding.Sounding.Slice.length; i++) {
let J = Snding.Sounding.Slice[i];
let J2 = NN.Sounding.Slice[i];
J[2] = J2[2];
J[3] = J2[3];
J[6] = J2[6];
}
Slices = [];
const Canv = $("skewTOverHang2");
const ctx = Canv.getContext("2d");
ctx.clearRect(0, 0, 1000, 1000);
SetTitle(Snding);
ShowObjects();
DrawSkewT(Snding, false, false, false, false, currentComparisonSoundings);
}
/** Checks is numeric.
* @param {*} n Parameter for isNumeric.
* @returns {*} Result of the function.
*/
function isNumeric(n) {
return !isNaN(parseFloat(n)) && isFinite(n);
}
// 1. First, let's fix the ShowObjects function to ensure it doesn't overwrite event listeners
/** Shows the objects in the UI.
* @returns {void} No value.
*/
function ShowObjects() {
let tableBody = $("modifySkewTBody");
tableBody.innerHTML = "";
let sfcLayer = GetLayerByHght(Snding.Sounding, "SFC");
for (let i = 0; i < Snding.Sounding.Slice.length; i++) {
let dataLayer = ConvertToInfo(Snding.Sounding.Slice[i]);
let tableRow = document.createElement("tr");
// Add a class to highlight the layers currently selected by the user
if (Slices.some(slice => slice.Pos === i)) {
tableRow.classList.add("highlight-row");
}
// Define the columns with their properties
let columns = [
{
value: `${Math.round(10 * dataLayer.P) / 10}`,
suffix: " mb",
editable: false,
field: null
},
{
value: `${Math.round(10 * dataLayer.H) / 10}`,
suffix: " m",
editable: false,
field: null
},
{
value: dataLayer.T !== -9999 ? Math.round(100 * dataLayer.T) / 100 : "--",
suffix: "°C",
editable: true,
field: 'T'
},
{
value: dataLayer.D !== -9999 ? Math.round(100 * dataLayer.D) / 100 : "--",
suffix: "°C",
editable: true,
field: 'D'
},
{
value: dataLayer.WD !== -9999 ? Math.round(100 * dataLayer.WD) / 100 : "--",
suffix: "°",
editable: true,
field: 'WD'
},
{
value: dataLayer.WS !== -9999 ? Math.round(100 * dataLayer.WS) / 100 : "--",
suffix: " kts",
editable: true,
field: 'WS'
}
];
// Create table cells for each column
columns.forEach(column => {
let cell = document.createElement("td");
cell.textContent = column.value + column.suffix;
if (column.editable) {
cell.classList.add("editable");
cell.setAttribute("contenteditable", "true");
cell.dataset.layer = i;
cell.dataset.field = column.field;
cell.dataset.original = column.value;
cell.dataset.suffix = column.suffix;
// Add event listeners for editing
cell.addEventListener("focus", handleCellFocus);
cell.addEventListener("blur", handleCellBlur);
cell.addEventListener("keydown", handleCellKeydown);
} else {
cell.classList.add("non-editable");
}
tableRow.appendChild(cell);
});
tableBody.appendChild(tableRow);
}
// We'll call setupModalEvents here to ensure they're attached after populating the table
setupModalEvents();
}
// 2. Create a separate function to set up the modal event listeners
/** Sets or applies up modal events state.
* @returns {void} No value.
*/
function setupModalEvents() {
const closeBtn = $("closeModifySkewTModal");
// Remove existing listeners before adding new ones (to prevent duplicates)
if (closeBtn) {
closeBtn.removeEventListener("click", closeModifyModal);
closeBtn.addEventListener("click", function (e) {
closeModifyModal();
});
}
}
/** Turn off modify mode and reset state.
* @returns {void} No value.
*/
function TurnOff() {
ChangeMod = false;
// Clear per-chart state so late callbacks cannot redraw into removed canvases.
VWPActive = false;
Snding = {};
userLockedPresets = false;
presetsAutoSet = false;
}
// 3. Update the ChangeModify function to use our new setup
/** Sets or applies modify mode state.
* @param {*} on Parameter for setModifyMode.
* @returns {void} No value.
*/
function setModifyMode(on) {
setChangeModState(!!on);
let modifyModal = $("modifySkewTModal");
const skewTOverhang = $("skewTOverHang2");
if (skewTOverhang) {
const ctx = skewTOverhang.getContext("2d");
ctx.clearRect(0, 0, skewTOverhang.width, skewTOverhang.height);
}
if (ChangeMod) {
CursorActive = true;
setReadOutOnState(false);
setFrontActiveState(false);
const hodoThing = $("hodographOverHang3");
if (hodoThing) {
const ctx2 = hodoThing.getContext("2d");
ctx2.clearRect(0, 0, hodoThing.width, hodoThing.height);
}
if (modifyModal) modifyModal.style.display = "block";
Slices = [];
ShowObjects();
CursorReadout(false);
} else if (modifyModal) {
modifyModal.style.display = "none";
}
}
/** Handles handle cell focus events.
* @param {*} event Parameter for handleCellFocus.
* @returns {void} No value.
*/
function handleCellFocus(event) {
const cell = event.target;
// Remove the suffix when editing
cell.textContent = cell.dataset.original;
// Show the edit helper
const editHelper = $("editHelper");
editHelper.classList.add("active");
// Select all text in the cell
const selection = window.getSelection();
const range = document.createRange();
range.selectNodeContents(cell);
selection.removeAllRanges();
selection.addRange(range);
}
/** Handles handle cell blur events.
* @param {*} event Parameter for handleCellBlur.
* @returns {void} No value.
*/
function handleCellBlur(event) {
const cell = event.target;
const editHelper = $("editHelper");
// Add the suffix back when not editing
if (cell.textContent !== "") {
updateCellValue(cell);
} else {
cell.textContent = cell.dataset.original + cell.dataset.suffix;
}
// Hide the edit helper
editHelper.classList.remove("active");
}
// Modified handleCellKeydown function to apply changes immediately when Enter is pressed
/** Handles handle cell keydown events.
* @param {*} event Parameter for handleCellKeydown.
* @returns {void} No value.
*/
function handleCellKeydown(event) {
const cell = event.target;
if (event.key === "Enter") {
event.preventDefault();
// Update the cell value
const oldValue = parseFloat(cell.dataset.original);
let newValue = parseFloat(cell.textContent);
if (isNaN(newValue)) {
cell.textContent = cell.dataset.original + cell.dataset.suffix;
cell.blur();
return;
}
// Get the data layer
const layerIndex = parseInt(cell.dataset.layer);
const field = cell.dataset.field;
let dataLayer = ConvertToInfo(Snding.Sounding.Slice[layerIndex]);
const sfcLayer = GetLayerByPres(Snding.Sounding, "SFC");
// Apply constraints and update the cell visually
if (field === 'T') {
// Temperature can't be less than dewpoint
newValue = Math.max(newValue, dataLayer.D);
} else if (field === 'D') {
// Dewpoint can't be greater than temperature
newValue = Math.min(newValue, dataLayer.T);
} else if (field === 'WD') {
// Wind direction between 0 and 360
newValue = ((newValue % 360) + 360) % 360;
} else if (field === 'WS') {
// Wind speed can't be negative
newValue = Math.max(0, newValue);
}
// Update the cell display
cell.dataset.original = newValue.toString();
cell.textContent = newValue + cell.dataset.suffix;
// console.log(`Immediate update: ${field} at layer ${layerIndex} from ${oldValue} to ${newValue}`);
// IMPORTANT: Apply the change directly to the data structure
if (field === 'T') {
// Update temperature
Snding.Sounding.T[layerIndex] = newValue;
dataLayer.T = newValue;
if (Snding.Sounding.TW[layerIndex]) {
Snding.Sounding.TW[layerIndex].T = newValue;
}
// Update the slice
Snding.Sounding.Slice[layerIndex][2] = newValue;
// Recalculate virtual temp and wet bulb
dataLayer.VT = VirtTemp(dataLayer, sfcLayer.P, true, true);
dataLayer.WB = wetbulb(dataLayer.P, dataLayer.T, dataLayer.D);
Snding.Sounding.Slice[layerIndex][6] = dataLayer.WB; // Update wetbulb in slice
if (Snding.Sounding.TW[layerIndex]) {
Snding.Sounding.TW[layerIndex].VT = dataLayer.VT;
Snding.Sounding.TW[layerIndex].WB = dataLayer.WB;
}
} else if (field === 'D') {
// Update dewpoint
Snding.Sounding.D[layerIndex] = newValue;
dataLayer.D = newValue;
if (Snding.Sounding.DW[layerIndex]) {
Snding.Sounding.DW[layerIndex].D = newValue;
}
// Update the slice
Snding.Sounding.Slice[layerIndex][3] = newValue;
// Recalculate virtual temp and wet bulb
dataLayer.VT = VirtTemp(dataLayer, sfcLayer.P, true, true);
dataLayer.WB = wetbulb(dataLayer.P, dataLayer.T, dataLayer.D);
Snding.Sounding.Slice[layerIndex][6] = dataLayer.WB; // Update wetbulb in slice
if (Snding.Sounding.TW[layerIndex]) {
Snding.Sounding.TW[layerIndex].VT = dataLayer.VT;
Snding.Sounding.TW[layerIndex].WB = dataLayer.WB;
}
} else if (field === 'WD') {
// Update wind direction
Snding.Sounding.Slice[layerIndex][4] = newValue;
dataLayer.WD = newValue;
if (Snding.Sounding.W[layerIndex]) {
Snding.Sounding.W[layerIndex].WD = newValue;
}
} else if (field === 'WS') {
// Update wind speed
Snding.Sounding.Slice[layerIndex][5] = newValue;
dataLayer.WS = newValue;
if (Snding.Sounding.W[layerIndex]) {
Snding.Sounding.W[layerIndex].WS = newValue;
}
}
// Update the slice with the modified data
Snding.Sounding.Slice[layerIndex] = ConvertToRaw(dataLayer);
// Add "(Modified)" to the title if not already present
if (!Snding.Title.includes("(Modified)")) {
Snding.Title += " (Modified)";
SetTitle(Snding);
}
// Refresh all calculations
Info.SBParcel = LiftParcel(Snding.Sounding, "SB");
Info.MLParcel = LiftParcel(Snding.Sounding, "ML");
Info.MUParcel = LiftParcel(Snding.Sounding, "MU", 767);
Info.DCapeProf = GetDCape(Snding.Sounding);
// Recalculate storm motions
let BM = GetBM(Snding.Sounding);
Motions = BM;
Motions.RM.y *= -1;
Motions.LM.y *= -1;
Motions.MM.y *= -1;
Motions.US.y *= -1;
Motions.DS.y *= -1;
Motions.DTMR.y *= -1;
Motions.DTML.y *= -1;
// Only reset CSMot if it's the default right motion
if (CSMot === Motions.RM) {
CSMot = BM.RM;
}
// Redraw everything
DrawHodograph(Snding);
DrawSkewT(Snding, false, true, currentComparisonSoundings);
cell.blur();
} else if (event.key === "Escape") {
event.preventDefault();
cell.textContent = cell.dataset.original + cell.dataset.suffix;
cell.blur();
} else if (cell.dataset.field === 'T' || cell.dataset.field === 'D' ||
cell.dataset.field === 'WD' || cell.dataset.field === 'WS') {
// Only allow numbers, decimal point, minus sign, backspace, delete, and arrow keys
if (!/^[\d.-]$/.test(event.key) &&
event.key !== "Backspace" &&
event.key !== "Delete" &&
event.key !== "ArrowLeft" &&
event.key !== "ArrowRight" &&
event.key !== "Tab") {
event.preventDefault();
}
}
}
/** update skew-t modification cell values
* @param {*} cell Parameter for updateCellValue.
* @returns {void} No value.
*/
function updateCellValue(cell) {
const layerIndex = parseInt(cell.dataset.layer);
const field = cell.dataset.field;
const oldValue = parseFloat(cell.dataset.original);
let newValue = parseFloat(cell.textContent);
if (isNaN(newValue)) {
cell.textContent = cell.dataset.original + cell.dataset.suffix;
return;
}
// Get the data layer
let dataLayer = ConvertToInfo(Snding.Sounding.Slice[layerIndex]);
// Apply constraints based on field type
if (field === 'T') {
// Temperature can't be less than dewpoint
newValue = Math.max(newValue, dataLayer.D);
} else if (field === 'D') {
// Dewpoint can't be greater than temperature
newValue = Math.min(newValue, dataLayer.T);
} else if (field === 'WD') {
// Wind direction between 0 and 360
newValue = ((newValue % 360) + 360) % 360;
} else if (field === 'WS') {
// Wind speed can't be negative
newValue = Math.max(0, newValue);
}
// Check if the value actually changed
if (newValue !== oldValue) {
// Mark this cell as modified
cell.classList.add("modified");
// console.log(`Cell marked as modified: ${field} at layer ${layerIndex} from ${oldValue} to ${newValue}`);
} else {
cell.classList.remove("modified");
}
// Update the cell value and display
cell.dataset.original = newValue.toString();
cell.textContent = newValue + cell.dataset.suffix;
}
// 6. Simplify the closeModifyModal function
/** Turns off modify mode and resets state.
* @returns {void} No value.
*/
function closeModifyModal() {
setModifyMode(false);
emit("change");
}
var DisplayGraphMode = true;
/**
* Custom wind-shear layer. Nnn = "R" resets to 0-6 km, anything else loads `values`.
* @param {string} Nnn
* @param {{ bottom?: number|string, top?: number|string, mode?: 'Hght'|'Pres' }} [values] km AGL or mb
* @param {*} values Parameter for LoadNewShearLyr.
* @returns {void} No value.
*/
function LoadNewShearLyr(Nnn, values = {}) {
if (!Snding || !Snding.Sounding) return;
if (Nnn === 'R') {
userSetCustomWind = false;
} else {
userSetCustomWind = true;
}
try {
// Note based on UI labels:
// "ShearTop" ID = Bottom Level (Start)
// "ShearBot" ID = Top Level (End)
let StartInput = values.bottom ?? "";
let EndInput = values.top ?? "";
let Mode = values.mode || "Hght";
// --- RESET LOGIC (Default 0-6km Bulk Shear) ---
if (Nnn == "R") {
let Sfc = GetLayerByHght(Snding.Sounding, "SFC"); // Surface
// Find 6km AGL
let TargetHeight = Sfc.H + 6000;
let TopL = GetLayerByHght(Snding.Sounding, TargetHeight);
CustomWindLayer = {
Title: `C: 0km-6km`,
T: Sfc.P,
B: TopL.P,
SRH: GetSRH(Snding.Sounding, Sfc.P, TopL.P, "Pres"),
SHR: WindShearFind(Snding.Sounding, Sfc.H, TopL.H),
MW: WindShearFind(Snding.Sounding, Sfc.H, TopL.H, true),
EHI: 0
}
if (DP && CustomWindLayer.SRH) {
CustomWindLayer.EHI = GetParams({
DP,
SRHAmn: ((CustomWindLayer.SRH.SRHP || 0) + (CustomWindLayer.SRH.SRHM || 0))
}, "EHI");
}
LoadWithWind(Snding, Info, ghost, currentComparisonSoundings);
return;
}
// --- HEIGHT MODE (Strict KM) ---
if (Mode == "Hght") {
// 1. Parse inputs as Kilometers
let StartKM = parseFloat(StartInput);
let EndKM = parseFloat(EndInput);
// Defaults if empty
if (isNaN(StartKM)) StartKM = 0;
if (isNaN(EndKM)) EndKM = 6;
// 2. Get Surface Height in Meters
let SfcH = GetLayerByPres(Snding.Sounding, "SFC").H;
// 3. Convert KM input to Meters AGL, then add Surface Height to get MSL
// Example: Input "3" -> 3000m + SurfaceHeight
let StartMetersMSL = SfcH + (StartKM * 1000);
let EndMetersMSL = SfcH + (EndKM * 1000);
// 4. Find the closest actual data layers
let LayerStart = GetLayerByHght(Snding.Sounding, StartMetersMSL);
let LayerEnd = GetLayerByHght(Snding.Sounding, EndMetersMSL);
if (!LayerStart || !LayerEnd) {
emit("error", "Could not find data layers for these heights.");
return;
}
// 5. Create Layer Object with KM Title
CustomWindLayer = {
Title: `C: ${StartKM}km-${EndKM}km`,
T: LayerStart.P,
B: LayerEnd.P,
SRH: GetSRH(Snding.Sounding, LayerStart.P, LayerEnd.P, "Pres"),
SHR: WindShearFind(Snding.Sounding, LayerStart.H, LayerEnd.H),
MW: WindShearFind(Snding.Sounding, LayerStart.H, LayerEnd.H, true),
EHI: 0
}
if (DP && CustomWindLayer.SRH) {
let srhSum = (CustomWindLayer.SRH.SRHP || 0) + (CustomWindLayer.SRH.SRHM || 0);
CustomWindLayer.EHI = GetParams({ DP, SRHAmn: srhSum }, "EHI");
}
LoadWithWind(Snding, Info, ghost, currentComparisonSoundings);
// --- PRESSURE MODE (mb) ---
} else if (Mode == "Pres") {
// "Top" ID is actually the bottom pressure (higher number) in typical UI ordering,
// but usually users input Top Pressure (smaller number) and Bottom Pressure (larger number).
// Let's stick to the generic parsing and sort them.
let Val1 = parseFloat(StartInput);
let Val2 = parseFloat(EndInput);
let SfcP = Snding.Sounding.P[0];
if (isNaN(Val1)) Val1 = SfcP;
if (isNaN(Val2)) Val2 = Val1 - 100;
// Sort: P_Bottom is Higher Value (e.g. 1000), P_Top is Lower Value (e.g. 500)
let P_Bottom = Math.max(Val1, Val2);
let P_Top = Math.min(Val1, Val2);
// Clamp to surface
P_Bottom = Math.min(P_Bottom, SfcP);
let LayerBot = GetLayerByPres(Snding.Sounding, P_Bottom);
let LayerTop = GetLayerByPres(Snding.Sounding, P_Top);
CustomWindLayer = {
Title: `C: ${Math.round(LayerBot.P)}mb-${Math.round(LayerTop.P)}mb`,
T: LayerBot.P,
B: LayerTop.P,
SRH: GetSRH(Snding.Sounding, LayerBot.P, LayerTop.P, "Pres"),
SHR: WindShearFind(Snding.Sounding, LayerBot.H, LayerTop.H),
MW: WindShearFind(Snding.Sounding, LayerBot.H, LayerTop.H, true),
EHI: 0
}
if (DP && CustomWindLayer.SRH) {
let srhSum = (CustomWindLayer.SRH.SRHP || 0) + (CustomWindLayer.SRH.SRHM || 0);
CustomWindLayer.EHI = GetParams({ DP, SRHAmn: srhSum }, "EHI");
}
LoadWithWind(Snding, Info, ghost, currentComparisonSoundings);
}
} catch (err) {
console.error("Error in LoadNewShearLyr:", err);
}
}
let userSetCustomLapse = false;
let userSetCustomWind = false;
let DisplayStormMotions = false;
/** Show every storm motion estimate in the lapse-rate box. Toggles when omitted.
* @param {*} show Parameter for LoadStormMotions.
* @returns {*} Result of the function.
*/
function LoadStormMotions(show){
DisplayStormMotions = typeof show === 'boolean' ? show : !DisplayStormMotions;
if (Snding && Snding.Sounding) GetLapseBox(Snding.Sounding);
return DisplayStormMotions;
}
/**
* Custom lapse-rate layer. Nnn = "R" resets to 0-3 km, anything else loads `values`.
* @param {string} Nnn
* @param {{ bottom?: number|string, top?: number|string, mode?: 'Hght'|'Pres' }} [values] km AGL or mb
* @param {*} values Parameter for LoadNewLapseLyr.
* @returns {void} No value.
*/
function LoadNewLapseLyr(Nnn, values = {}) {
if (!Snding || !Snding.Sounding) return;
if (Nnn === 'R') {
userSetCustomLapse = false; // reset flag on reset
} else {
userSetCustomLapse = true; // user loaded a custom layer
}
try {
// Note based on UI labels:
// "LapseTop" ID = Bottom Level (Start)
// "LapseBot" ID = Top Level (End)
let StartInput = values.bottom ?? "";
let EndInput = values.top ?? "";
let Mode = values.mode || "Hght";
// --- RESET LOGIC (Default 0-3km) ---
if (Nnn == "R") {
let Sfc = GetLayerByHght(Snding.Sounding, "SFC");
let TargetHeight = Sfc.H + 3000; // 3km AGL
let TopL = GetLayerByHght(Snding.Sounding, TargetHeight);
CustomLapseLayer = {
Title: `C: 0km-3km`,
Mode: "Hght",
T: Sfc.H,
B: TopL.H,
Lapse: LapseRateHght(Snding.Sounding, Sfc.H, TopL.H, LapseType),
}
GetLapseBox(Snding.Sounding);
return;
}
// --- HEIGHT MODE (Strict KM) ---
if (Mode == "Hght") {
let StartKM = parseFloat(StartInput);
let EndKM = parseFloat(EndInput);
if (isNaN(StartKM)) StartKM = 0;
if (isNaN(EndKM)) EndKM = 3;
// Get Surface Height
let SfcH = GetLayerByPres(Snding.Sounding, "SFC").H;
// Convert KM input to Meters AGL, then add Surface Height to get MSL
let StartMetersMSL = SfcH + (StartKM * 1000);
let EndMetersMSL = SfcH + (EndKM * 1000);
// Find actual layers
let LayerStart = GetLayerByHght(Snding.Sounding, StartMetersMSL);
let LayerEnd = GetLayerByHght(Snding.Sounding, EndMetersMSL);
if (!LayerStart || !LayerEnd) {
emit("error", "Could not find data layers.");
return;
}
CustomLapseLayer = {
Title: `C: ${StartKM}km-${EndKM}km`,
Mode: "Hght",
T: LayerStart.H,
B: LayerEnd.H,
Lapse: LapseRateHght(Snding.Sounding, LayerStart.H, LayerEnd.H, LapseType),
}
GetLapseBox(Snding.Sounding);
// --- PRESSURE MODE (mb) ---
} else if (Mode == "Pres") {
let Val1 = parseFloat(StartInput);
let Val2 = parseFloat(EndInput);
let SfcP = Snding.Sounding.P[0];
if (isNaN(Val1)) Val1 = SfcP;
if (isNaN(Val2)) Val2 = Val1 - 100;
// Sort and Clamp
let P_Bottom = Math.max(Val1, Val2);
let P_Top = Math.min(Val1, Val2);
P_Bottom = Math.min(P_Bottom, SfcP);
let LayerBot = GetLayerByPres(Snding.Sounding, P_Bottom);
let LayerTop = GetLayerByPres(Snding.Sounding, P_Top);
CustomLapseLayer = {
Title: `C ${Math.round(LayerBot.P)}mb-${Math.round(LayerTop.P)}mb`,
Mode: "Pres",
T: LayerBot.P,
B: LayerTop.P,
Lapse: LapseRatePres(Snding.Sounding, LayerBot.P, LayerTop.P, LapseType),
}
GetLapseBox(Snding.Sounding);
}
} catch (err) {
console.error("Error in LoadNewLapseLyr:", err);
}
}
/** Returns snding data.
* @returns {*} Result of the function.
*/
function getSnding() {
return Snding;
}
/** Returns the visible comparison soundings.
* @returns {*} Result of the function.
*/
function getCurrentComparisonSoundings() {
return currentComparisonSoundings;
}
/** Last custom storm motion typed by the user (degrees from, knots). */
let customMotionInput = { direction: 0, speed: 10 };
/** Chart-space vector for the custom motion. Redraws rebuild it from the user's input so it survives.
* @returns {*} Result of the function.
*/
function customMotionVector() {
const Angg = ((parseFloat(customMotionInput.direction) % 360) + 360) % 360 || 0;
const Spdd = clamp(parseFloat(customMotionInput.speed) || 10, 0, 100);
const v = ConvertPolartoCart({ WD: Angg, WS: Spdd });
const snap = (n) => (Math.abs(n) < 1e-9 ? 0 : n);
return { x: snap(v.x), y: snap(-v.y) };
}
/**
* Select the storm motion used for storm-relative calculations.
* @param {'RM'|'LM'|'MM'|'US'|'DS'|'DTMR'|'DTML'|'CM'} N
* @param {boolean} [activateChanges=true] redraw afterwards
* @param {{ direction?: number, speed?: number }} [custom] for "CM": motion direction (deg) and speed (kt)
* @param {*} custom Parameter for LoadNewWind.
* @returns {void} No value.
*/
function LoadNewWind(N, activateChanges=true, custom=null) {
userSelectedMotion = N;
if (custom) {
customMotionInput = {
direction: custom.direction ?? customMotionInput.direction,
speed: custom.speed ?? customMotionInput.speed,
};
}
if (N == "RM") {
CSMot = Motions.RM;
} else if (N == "LM") {
CSMot = Motions.LM;
} else if (N == "MM") {
CSMot = Motions.MM;
} else if (N == "DTMR") {
CSMot = Motions.DTMR;
} else if (N == "DTML") {
CSMot = Motions.DTML;
} else if (N == "US") {
CSMot = Motions.US;
} else if (N == "DS") {
CSMot = Motions.DS;
} else if (N == "CM") {
CUMot = customMotionVector();
CSMot = CUMot;
}
emit("change");
if (activateChanges && Snding && Snding.Sounding){
const inVwp = VWPActive === true;
LoadWithWind(Snding, Info, ghost, currentComparisonSoundings);
DrawFront(false, true);
CursorReadout(false, true);
// VWP must use vwpactive=true so DrawSkewT does not clear VWPActive; otherwise layout
// reverts to full Skew-T and DrawBoxes can index past the last wind level (undefined .WS).
if (inVwp) {
DrawSkewT(Snding, false, false, false, false, currentComparisonSoundings, 'VWPSKIP', true);
} else {
DrawSkewT(Snding, false, false, false, false, currentComparisonSoundings, CSMot);
}
}
}
let DarkMode = true;
let ModifiedWind = false;
/** Convert a temperature in `mode` units ("F", "C" or "K") to Celsius.
* @param {*} N Parameter for Conv.
* @param {*} mode Parameter for Conv.
* @returns {*} Result of the function.
*/
function Conv(N, mode = "F") {
if (mode == "F") {
return FtoC(N);
} else if (mode == "C") {
return N;
} else if (mode == "K") {
return N - 273.15;
} else {
return N;
}
}
/** Returns a finite number or a fallback value.
* @param {*} v Parameter for finiteOr.
* @param {*} fallback Parameter for finiteOr.
* @returns {*} Result of the function.
*/
const finiteOr = (v, fallback) => {
const n = parseFloat(v);
return Number.isFinite(n) ? n : fallback;
};
/**
* Modify (N = "New") or restore (N = "Old") the surface level.
* @param {'New'|'Old'} N
* @param {'tmp'|'wnd'} t
* @param {object} [values]
* tmp: { temperature, dewpoint, unit: 'F'|'C'|'K' } (blank values keep the current surface)
* wnd: { direction, speed } (degrees, knots)
* @returns {void} No value.
*/
function LoadNewSfc(N, t, values = {}) {
if (!Snding || !Snding.Sounding) return;
if (t == "tmp") {
let mode = values.unit || "F";
if (N == "New") {
let TMPC = finiteOr(Conv(parseFloat(values.temperature), mode), rStart.T);
TMPC = clamp(TMPC, -78, 150);
let DWPC = finiteOr(Conv(parseFloat(values.dewpoint), mode), rStart.D);
DWPC = clamp(DWPC, -79, TMPC);
let VTemp = virtemp(rStart.P, TMPC, TMPC);
let WBulb = wetbulb(rStart.P, TMPC, DWPC);
let nSlice = rStart;
nSlice.VT = VTemp;
nSlice.T = TMPC;
nSlice.D = DWPC;
nSlice.WB = WBulb;
let Sound = Snding.Sounding;
Sound.D[0] = DWPC;
Sound.DW[0].D = DWPC;
Sound.TW[0].T = TMPC;
Sound.TW[0].VT = VTemp;
Sound.TW[0].WB = WBulb;
Sound.T[0] = TMPC;
Sound.Slice[0] = ConvertToRaw(nSlice);
DrawSkewT(Snding, false, true, false, false, currentComparisonSoundings);
} else if (N == "Old") {
let nSlice = relStart;
let TMPC = nSlice.T;
let DWPC = nSlice.D;
let WBulb = nSlice.WB;
let VTemp = nSlice.VT;
let Sound = Snding.Sounding;
Sound.D[0] = DWPC;
Sound.DW[0].D = DWPC;
Sound.TW[0].T = TMPC;
Sound.TW[0].VT = VTemp;
Sound.TW[0].WB = WBulb;
Sound.T[0] = TMPC;
Sound.Slice[0] = ConvertToRaw(nSlice);
DrawSkewT(Snding, false, false, false, false, currentComparisonSoundings);
}
} else if (t == "wnd") {
if (N == "New") {
let WSP = clamp(finiteOr(values.speed, 10), 0, 1000);
let WDI = clamp(finiteOr(values.direction, 180) % 360, 0, 360);
let nSlice = rStart;
nSlice.WD = WDI;
nSlice.WS = WSP;
let Sound = Snding.Sounding;
Sound.W[0].WD = WDI;
Sound.W[0].WS = WSP;
Sound.Slice[0] = ConvertToRaw(nSlice);
let SMS = GetBM(Snding.Sounding);
Motions = SMS;
Motions.RM.y *= -1;
Motions.LM.y *= -1;
Motions.MM.y *= -1;
Motions.US.y *= -1;
Motions.DS.y *= -1;
Motions.DTMR.y *= -1;
Motions.DTML.y *= -1;
ModifiedWind = true;
DrawSkewT(Snding, false, false, false, false, currentComparisonSoundings);
} else if (N == "Old") {
let nSlice = relStart;
let WSP = nSlice.WS;
let WDI = nSlice.WD;
let Sound = Snding.Sounding;
Sound.W[0].WD = WDI;
Sound.W[0].WS = WSP;
Sound.Slice[0] = ConvertToRaw(nSlice);
ModifiedWind = false;
let SMS = GetBM(Snding.Sounding);
Motions = SMS;
Motions.RM.y *= -1;
Motions.LM.y *= -1;
Motions.MM.y *= -1;
Motions.US.y *= -1;
Motions.DS.y *= -1;
Motions.DTMR.y *= -1;
Motions.DTML.y *= -1;
DrawSkewT(Snding, false, false, false, false, currentComparisonSoundings);
}
}
}
// Add these near the other global variables
let userSelectedParcel = 'MU'; // track what user picked
let userSelectedMotion = 'RM'; // track what user picked
/** Parcel / storm motion selected when a new sounding loads (settings: defaultParcel, defaultMotion). */
let defaultParcel = 'MU';
let defaultMotion = 'RM';
/** Last custom parcel starting pressure (mb). */
let customParcelPressure = 900;
/**
* Select the displayed parcel.
* @param {'SB'|'ML'|'MU'|'CU'} N
* @param {number} [pressure] for "CU": starting pressure in mb
* @returns {void} No value.
*/
function LoadNewParcel(N, pressure) {
userSelectedParcel = N;
if (pressure != null && !isNaN(parseFloat(pressure))) customParcelPressure = parseFloat(pressure);
emit("change");
if (!Snding || !Snding.Sounding) return;
if (N == "SB") {
DP = Info.SBParcel;
LoadWithParcel(Snding, DP, Info, ghost, currentComparisonSoundings);
} else if (N == "ML") {
DP = Info.MLParcel;
LoadWithParcel(Snding, DP, Info, ghost, currentComparisonSoundings);
} else if (N == "MU") {
DP = Info.MUParcel;
LoadWithParcel(Snding, DP, Info, ghost, currentComparisonSoundings);
} else if (N == "CU") {
let Pressure = customParcelPressure || 900;
let Max = GetLayerByPres(Snding.Sounding, "Last");
Pressure = clamp(Pressure, Max.P, Info.SBParcel.Start.P);
DP = LiftParcel(Snding.Sounding, "CU", Pressure);
CUPrcl = DP;
LoadWithParcel(Snding, DP, Info, ghost, currentComparisonSoundings);
}
LoadWithWind(Snding, Info, ghost, currentComparisonSoundings)
}
/** Sets or applies title state.
* @param {*} K Parameter for SetTitle.
* @returns {void} No value.
*/
function SetTitle(K) {
const Canv = $("skewTTitle");
if (!Canv) return;
const ctx = Canv.getContext("2d");
if (!ctx) return;
const dropsondeMode = isDropsondeSounding(K);
const bg = dropsondeMode ? (DarkMode ? "#0a0c0e" : "#f7f8fa") : "rgba(0,0,0,1)";
const fg = dropsondeMode ? (DarkMode ? "#e8eef2" : "#1a2228") : "rgba(255,255,255,1)";
const muted = DarkMode ? "#8ea3b3" : "#5a6b78";
Canv.width = 800;
Canv.height = dropsondeMode ? 54 : 30;
ctx.fillStyle = bg;
ctx.fillRect(0, 0, Canv.width, Canv.height);
const title = `${K.Title || ""}`;
const padX = 0.02 * Canv.width;
if (!dropsondeMode) {
ctx.font = `bold ${title.length > 35 ? Canv.width / 50 : Canv.width / 40}px ${globalFont}`;
ctx.fillStyle = fg;
ctx.textAlign = "left";
ctx.textBaseline = "alphabetic";
ctx.fillText(title, 0.025 * Canv.width, 22);
ctx.fillRect(25, 28, 795, 2);
return;
}
// Dropsonde: mission title on first line, SharpJS branding below
ctx.textBaseline = "middle";
ctx.textAlign = "left";
const titleY = 16;
const brandY = 38;
const parts = title.split(/\s*·\s*/);
const primary = (parts[0] || title).trim();
const secondary = parts.slice(1).join(" · ").trim();
ctx.font = `700 ${Canv.width / 38}px ${globalFont}`;
ctx.fillStyle = fg;
ctx.fillText(primary, padX, titleY);
let x = padX + ctx.measureText(primary).width;
if (secondary) {
ctx.font = `600 ${Canv.width / 42}px ${globalFont}`;
ctx.fillStyle = muted;
const sep = " · ";
ctx.fillText(sep, x, titleY);
x += ctx.measureText(sep).width;
ctx.fillText(secondary, x, titleY);
}
ctx.font = `600 ${Canv.width / 48}px ${globalFont}`;
ctx.fillStyle = muted;
ctx.fillText(brandLine(" · "), padX, brandY);
ctx.fillStyle = DarkMode ? "rgba(255,255,255,0.1)" : "rgba(0,0,0,0.1)";
ctx.fillRect(padX, Canv.height - 2, Canv.width - padX * 2, 1);
}
/**
* Draw a sounding.
* @param {string|object} text SHARPpy-style text, or an already parsed sounding (with processed=true)
* @param {boolean} [processed=false]
* @param {object} [opts]
* @param {boolean} [opts.reset=true] treat as a new sounding: re-run auto presets, reset parcel/motion
* choices to their defaults and remember it for "Reset SkewT/Hodograph"
* @param {Array} [opts.comparisons] comparison soundings ({ sounding, metadata }) to overlay
* @returns {void} No value.
*/
function Load(text, processed = false, opts = {}) {
let K = text;
if (!processed) {
K = Process(text);
}
const reset = opts.reset !== false;
const comparisons = Array.isArray(opts.comparisons) ? opts.comparisons : currentComparisonSoundings;
Clean()
SetTitle(K);
ghost = false;
if (reset || !DSnding) DSnding = JSON.parse(JSON.stringify(K));
DrawSkewT(K, reset, false, reset, false, comparisons);
ShowObjects();
const err = $("Error");
if (err) err.style.visibility = "hidden";
}
// 1. New function to just get the data and filename
/** Returns sounding text content data.
* @param {*} format Parameter for GetSoundingTextContent.
* @returns {*} Result of the function.
*/
function GetSoundingTextContent(format = SoundingExport) {
if (!Snding || !Snding.Sounding) return null;
return exportSounding(Snding, format, { savedBy: options.savedBy, filePrefix: options.filePrefix });
}
// 2. Modified original function (Kept for backward compatibility/browser fallback)
/** Downloads the current sounding.
* @returns {void} No value.
*/
function DownloadSounding() {
const data = GetSoundingTextContent();
if (!data) return;
let element = document.createElement('a');
element.setAttribute('href', 'data:text/plain;charset=utf-8,' + encodeURIComponent(data.text));
element.setAttribute('download', data.filename);
element.style.display = 'none';
document.body.appendChild(element);
element.click();
document.body.removeChild(element);
}
/** Averages multiple soundings.
* @param {*} Files Parameter for AverageAll.
* @returns {void} No value.
*/
function AverageAll(Files) {
let TF = [];
Files.forEach((N) => {
TF.push(Process(N[1]))//[N[0], Process(N[1])]);
});
let Avg = {
Sounding: {
T: [],
D: [],
W: [],
H: [],
P: [],
TW: [],
SRW: [],
DW: [],
Slice: [],
OMEGA: [],
},
Len: 0,
Location: false,
};
let Return = [];
let LowestP = 0;
let HighestP = 999;
TF.forEach((P) => {
LowestP = Math.max(LowestP, GetLayerByPres(P.Sounding, "SFC").P);
HighestP = Math.min(HighestP, GetLayerByPres(P.Sounding, "Last").P);
});
let Build = [];
for (let i = Math.min(1000, LowestP - 2); Math.max(100, HighestP) < i; i -= 10) {
let Slice = [0, 0, 0, 0, 0, 0];
let T = 0;
//p,h,t,d,wd,ws
TF.forEach((P) => {
let R = GetLayerByPres(P.Sounding, "SFC").P;
let R2 = GetLayerByPres(P.Sounding, "Last").P;
if (R <= i || i <= R2) {
;
} else {
T++;
let Lyr = GetLayerByPres(P.Sounding, i);
Slice[0] += Lyr.P;
Slice[1] += Lyr.H;
Slice[2] += Lyr.T;
Slice[3] += Lyr.D;
Slice[4] += Lyr.WD;
Slice[5] += Lyr.WS;
}
});
Slice[0] /= T;
Slice[1] /= T;
Slice[2] /= T;
Slice[3] /= T;
Slice[4] /= T;
Slice[5] /= T;
Build.push(Slice);
}
let InfoString = "";
let Sound = Snding.Sounding;
for (let i = 0; i < Build.length; i++) {
let P = Build[i]
InfoString += ` ${P[0]},\t${P[1]},\t${P[2]},\t${P[3]},\t${P[4]},\t${P[5]}\n`;
}
let text = `%TITLE%
AVERAGE OF ${TF.length} SOUNDINGS
LEVEL HGHT TEMP DWPT WDIR WSPD
-------------------------------------------------------------------
%RAW%
${InfoString}%END%`;
let RR = Process(text);
ghost = TF;
DSnding = JSON.parse(JSON.stringify(RR));
SetTitle(DSnding);
DrawSkewT(DSnding, true, false, false, TF, currentComparisonSoundings);
//DrawSkewT(RR, true, false, false, false, TF);
}
const GetColorComp = [
"rgb(0, 225, 255)",
"rgb(255, 0, 0)",
"rgb(246, 255, 0)",
"rgb(85, 255, 0)",
"rgb(170, 0, 255)",
"rgba(255, 255, 255, 1)",
]
/** Replace the comparison soundings ({ sounding, metadata } items) and redraw.
* @param {*} list Parameter for setComparisons.
* @returns {void} No value.
*/
function setComparisons(list) {
currentComparisonSoundings = Array.isArray(list) ? list : [];
redraw();
}
/** Remove every comparison sounding and redraw the main one.
* @returns {void} No value.
*/
function clearComparisons() {
currentComparisonSoundings = [];
if (DSnding && DSnding.Sounding) {
SetTitle(DSnding);
DrawSkewT(DSnding, false, false, false, false, []);
}
}
/**
* Overlay comparison soundings. Files: [[name, text], ...] (sparse arrays from older callers are fine).
* @param {*} Files Parameter for CompareSoundings.
* @returns {void} No value.
*/
function CompareSoundings(Files) {
let TF = [];
Files.forEach((N) => {
TF.push({
metadata:{
comparisonColor: 0,
date: 0,
fh: 0,
isVisible: true,
model: 'nam',
run: 12,
},
sounding: Process(N[1])
})//[N[0], Process(N[1])]);
});
for (let i = 0; i < TF.length; i++){
let P = TF[i];
P.metadata.comparisonColor = GetColorComp[i%GetColorComp.length];
P.metadata.date = "Cstm";
P.metadata.fh = 0;
P.metadata.model = 'Snding';
P.metadata.run = 12;
P.metadata.title = P.sounding.STitle;
P.metadata.title = P.metadata.title.replace("|", " ");
P.metadata.title = P.metadata.title.replace('Z|F-', "FH");
P.metadata.title = P.metadata.title.replace('FH', "z FH ").replace("|LOC-", " -AT: ").replace("N-", "N, -");
P.metadata.title = P.metadata.title.replace('VALID-', " ");
P.metadata.title = P.metadata.title.substring(0, Math.min(P.sounding.STitle.length, 45));
if (P.sounding.STitle.length > 45){
P.metadata.title = `${P.metadata.title}...`;
}
}
SetTitle(DSnding);
DrawSkewT(DSnding, true, false, false, false, TF);
//DrawSkewT(RR, true, false, false, false, TF);
}
/** Takes a screenshot of the current view.
* @param {*} del Parameter for Screenshot.
* @param {*} scale Parameter for Screenshot.
* @returns {*} Result of the function.
*/
function Screenshot(del = true, scale = 1) {
return new Promise((resolve, reject) => {
let ModifiedPanel = $("Values6");
if ($("Values6").classList.contains("skewthidden")) {
ModifiedPanel = $("Values4");
}
const shift = 150;
let ToLoad = [
[$("skewTTitle"), 0, 2 * scale, 38 * 800/30 * scale, 38 * scale],
[$("skewT"), 0, 40 * scale, (960 - shift) * scale, 720 * scale, true],
[$("verticalBar"), (985 - 100) * scale, 39 * scale, 214 * scale, 721 * scale],
[$("skewTNumbers"), 0, 760 * scale, (1050 - shift) * scale, 40 * scale],
[$("hodograph"), 1100 * scale, 0, 900 * scale, 800 * scale],
[$("Values1"), 0, 800 * scale, 825 * scale, 200 * scale],
[$("Values2"), 825 * scale, 800 * scale, 275 * scale, 200 * scale],
[$("Values3"), 0, 1000 * scale, 1100 * scale, 200 * scale],
[$("Values5"), 1100 * scale, 800 * scale, 540 * scale, 400 * scale],
[ModifiedPanel, 1640 * scale, 800 * scale, 360 * scale, 400 * scale],
];
let ToLoad2 = [
[$("barbs"), (942 - shift) * scale, 40 * scale, 100 * scale, 720 * scale],
];
if (VWPActive){
ToLoad = [
[$("skewTTitle"), 0, 2 * scale, 38 * 800/30 * scale, 38 * scale],
[$("hodograph"), 0, 40 * scale, 2000 * 0.60 * scale, 1200 * scale, true],
[$("Values5"), 2000 * 0.60 * scale, 0, 2000 * 0.4 * scale, 1200 * 0.36666 * scale],
[$("Values7"), 2000 * 0.60 * scale, 1200 * 0.36666 * scale, 2000 * 0.4 * scale, 1200 * 0.18333 * scale],
[$("Values8"), 2000 * 0.60 * scale, 1200 * 0.55 * scale, 2000 * 0.4 * scale, 1200 * 0.45 * scale],
];
ToLoad2 = [];
}
const downloadedImg = document.createElement("canvas");
const dImg = downloadedImg.getContext("2d");
downloadedImg.width = 2000 * scale;
downloadedImg.height = 1200 * scale;
dImg.fillStyle = "rgba(0,0,0,1)";
dImg.fillRect(0, 0, downloadedImg.width, downloadedImg.height);
// Convert a single canvas entry to a drawn image — all in parallel
/** Draws entry content.
* @param {*} img Parameter for drawEntry.
* @returns {*} Result of the function.
*/
const drawEntry = (img) => new Promise((res) => {
if (!img[0]) { res(); return; }
img[0].toBlob((blob) => {
if (!blob) { res(); return; }
const newImg = document.createElement("img");
const url = URL.createObjectURL(blob);
newImg.src = url;
newImg.onload = () => {
dImg.drawImage(newImg, img[1], img[2], img[3] || 0, img[4] || 0);
if (!["skewT", 'skewTNumbers', 'skewTTitle', 'verticalBar', 'barbs'].includes(img[0].dataset.sjs)) {
dImg.strokeStyle = "rgba(125,125,125,1)";
dImg.lineWidth = 3;
dImg.strokeRect(img[1], img[2], img[3] || 0, img[4] || 0);
}
if (["skewTTitle"].includes(img[0].dataset.sjs)){
dImg.fillStyle = "rgb(255, 255, 255)";
dImg.fillRect(25/800 * 1200 * scale, 28/30 * 40 * scale, 795/800 * 1200 * scale, 2/30 * 40 * scale);
}
URL.revokeObjectURL(url);
newImg.remove();
res();
};
newImg.onerror = () => {
console.error("Failed to load a canvas image for screenshotting.");
URL.revokeObjectURL(url);
res(); // don't reject — just skip
};
});
});
Promise.all(ToLoad.map(drawEntry))
.then(() => Promise.all(ToLoad2.map(drawEntry)))
.then(() => {
const finalImageDataUrl = downloadedImg.toDataURL("image/png");
if (!del) {
resolve(finalImageDataUrl);
} else {
const element = document.createElement('a');
element.setAttribute('href', finalImageDataUrl);
element.setAttribute('download', `${Snding.Title}_saved.png`);
element.style.display = 'none';
document.body.appendChild(element);
element.click();
document.body.removeChild(element);
downloadedImg.remove();
resolve(finalImageDataUrl);
}
})
.catch(reject);
});
}
/** Sets or applies state.
* @returns {void} No value.
*/
function Set() {
const c0 = $("skewTOverHang2");
c0.width = 800;//Canv.style.width;
c0.height = 800;
const c2 = $("skewTOverHang");
const ct2 = c2.getContext("2d");
c2.width = 800;//Canv.style.width;
c2.height = 800;
const Canv2 = $("hodographOverHang");
const ctx2 = Canv2.getContext("2d");
Canv2.width = 800;
Canv2.height = 800;
ctx2.scale(2, 2);
const Canv3 = $("hodographOverHang2");
const ctx3 = Canv2.getContext("2d");
Canv3.width = 800;
Canv3.height = 800;
ctx3.scale(2, 2);
const Canv4 = $("hodographOverHang3");
const ctx4 = Canv2.getContext("2d");
Canv4.width = 800;
Canv4.height = 800;
ctx4.scale(2, 2);
}
let Presets = {
"WindLapse": "Default",
"Inset": "Default",
"Vertical": "Default",
}
let SoundingExport = "Default";
/** Export text format: "Default" (SHARPpy), "SharpJS" (SHARPpy + omega) or "CM1".
* @param {*} format Parameter for setExportFormat.
* @returns {*} Result of the function.
*/
function setExportFormat(format) {
if (['Default', 'SharpJS', 'CM1'].includes(format)) SoundingExport = format;
emit("change");
return SoundingExport;
}
let VWPSounding = {};
/** Applies motion and sounding data from a VWP profile.
* @returns {void} No value.
*/
function applyVwpMotionsAndSndingFromVWPSounding() {
let SMS = GetBM(VWPSounding.Sounding);
Motions = SMS;
Motions.RM.y *= -1;
Motions.LM.y *= -1;
Motions.MM.y *= -1;
Motions.US.y *= -1;
Motions.DS.y *= -1;
Motions.DTMR.y *= -1;
Motions.DTML.y *= -1;
if (userSelectedMotion === 'LM') {
CSMot = Motions.LM;
} else if (userSelectedMotion === 'MM') {
CSMot = Motions.MM;
} else if (userSelectedMotion === 'US') {
CSMot = Motions.US;
} else if (userSelectedMotion === 'DS') {
CSMot = Motions.DS;
} else if (userSelectedMotion === 'DTMR') {
CSMot = Motions.DTMR;
} else if (userSelectedMotion === 'DTML') {
CSMot = Motions.DTML;
} else if (userSelectedMotion === 'CM') {
CSMot = CUMot;
} else {
CSMot = Motions.RM;
}
Snding = VWPSounding;
}
/**
* Apply a VWP sounding (built by io/vwp.js buildVwpSounding) to the live skew-T / hodograph.
* @param {object} stored Deep clone-safe plain object
* @returns {void} No value.
*/
function applyNexradVwpStoredSounding(stored) {
if (!stored || !stored.Sounding) return;
VWPActive = true;
VWPSounding = JSON.parse(JSON.stringify(stored));
applyVwpMotionsAndSndingFromVWPSounding();
DrawHodograph(VWPSounding, false, currentComparisonSoundings);
redrawSkewTFromVwp(VWPSounding);
}
/** Redraws the skew-T after applying a VWP sounding.
* @param {*} VWPSounding Parameter for redrawSkewTFromVwp.
* @param {*} options Parameter for redrawSkewTFromVwp.
* @returns {void} No value.
*/
function redrawSkewTFromVwp(VWPSounding, options = {}) {
// Canvases can be gone if the viewer was destroyed between a fetch and its redraw.
if (!$("skewT") || !$("skewTTitle")) {
return;
}
const skipMotionFix = options.skipMotionFix === true;
SetTitle(VWPSounding);
if (!skipMotionFix) {
if (isNaN(CSMot.x) && isNaN(CSMot.y)) {
userSelectedMotion = 'CM';
CSMot = CUMot;
LoadNewWind('CM', false);
}
UpdateCSMotStorm(CSMot);
}
VWPActive = true;
DrawSkewT(
VWPSounding,
false,
false,
false,
false,
currentComparisonSoundings,
'VWPSKIP',
true
);
VWPActive = true;
}
/** Returns the active ghost sounding.
* @returns {*} Result of the function.
*/
function getGhost() { return ghost; }
/** Returns the active display parcel.
* @returns {*} Result of the function.
*/
function getDP() { return DP; }
/** Returns the current chart analysis information.
* @returns {*} Result of the function.
*/
function getInfo() { return Info; }
const UNIT_SYSTEMS = ['default', 'metric', 'imperial'];
const PARCELS = ['SB', 'ML', 'MU'];
const MOTIONS = ['RM', 'LM', 'MM', 'US', 'DS', 'DTMR', 'DTML'];
/** Snapshot of every user-facing option. Settings persistence and the controls panel read this.
* @returns {*} Result of the function.
*/
function getState() {
return {
units: UNIT_SYSTEMS[useImperialUnits] || 'default',
skewTSkew: UserSkewNumber,
hodographNumbers: DisplayHodographNumbers,
hodographColorTable: HodographColorTable,
hodographScaleMode: HodographScaleMode,
hodographTrimWindProfile: HodographTrimWindProfile,
hodographMode: HodoMode ? 'Relative' : 'Storm',
readOut: ReadOutOn,
verticalBarsWindSpeed: !noWSBox,
showStormMotions: DisplayStormMotions,
presets: { WindLapse: Presets.WindLapse, Inset: Presets.Inset, Vertical: Presets.Vertical, SidePanel: SidePanelMode },
presetOverrides: Object.fromEntries(Object.entries(presetOverrides).map(([k, v]) => [k, v || 'auto'])),
autoPresets: { ...autoPresets },
efScale: currentEFScale,
parcel: userSelectedParcel,
motion: userSelectedMotion,
defaultParcel,
defaultMotion,
customParcelPressure,
customMotion: { ...customMotionInput },
exportFormat: SoundingExport,
frontActive: FrontActive,
modifyActive: ChangeMod,
tornadoClimoAvailable,
vwpActive: VWPActive,
hasSounding: !!(Snding && Snding.Sounding),
title: (Snding && Snding.Title) || '',
};
}
/** Redraw everything for the current sounding, keeping parcel / storm-motion choices.
* @returns {void} No value.
*/
function redraw() {
if (!Snding || !Snding.Sounding) return;
if (VWPActive) {
DrawSkewT(Snding, false, false, false, false, currentComparisonSoundings, 'VWPSKIP', true);
} else {
DrawSkewT(Snding, false, false, false, false, currentComparisonSoundings, CSMot && CSMot.x !== undefined ? CSMot : false);
}
CursorReadout(false, true, true);
}
/**
* Apply several options at once (same keys as getState()) and redraw once.
* Unknown keys and invalid values are ignored.
* @param {*} next Parameter for applyState.
* @param {*} options Parameter for applyState.
* @returns {*} Result of the function.
*/
function applyState(next = {}, { redraw: doRedraw = true } = {}) {
if (!next || typeof next !== 'object') return getState();
if (UNIT_SYSTEMS.includes(next.units)) setSoundingUnits(next.units);
setSoundingDesignState({
hodographNumbers: next.hodographNumbers,
hodographColorTable: next.hodographColorTable,
hodographScaleMode: next.hodographScaleMode,
hodographTrimWindProfile: next.hodographTrimWindProfile,
readOutOn: next.readOut,
skewTSkew: next.skewTSkew,
});
if (next.hodographMode === 'Relative' || next.hodographMode === 'Storm') HodoMode = next.hodographMode === 'Relative';
if (typeof next.verticalBarsWindSpeed === 'boolean') noWSBox = !next.verticalBarsWindSpeed;
if (typeof next.showStormMotions === 'boolean') DisplayStormMotions = next.showStormMotions;
if (next.efScale === 'EF2+' || next.efScale === 'EF0+') currentEFScale = next.efScale;
if (['Default', 'SharpJS', 'CM1'].includes(next.exportFormat)) SoundingExport = next.exportFormat;
if (PARCELS.includes(next.defaultParcel)) defaultParcel = next.defaultParcel;
if (MOTIONS.includes(next.defaultMotion)) defaultMotion = next.defaultMotion;
if (next.presetOverrides && typeof next.presetOverrides === 'object') {
for (const key of Object.keys(presetOverrides)) {
if (key in next.presetOverrides) {
const v = next.presetOverrides[key];
presetOverrides[key] = typeof v === 'string' && v !== 'auto' ? v : null;
}
}
applyPresetOverrides(Snding && Snding.Sounding ? Snding : null);
}
if (doRedraw) redraw();
emit('change');
return getState();
}
let listenersAttached = false;
/** The calc module keeps storm motion / presets in module state; point it at this chart first.
* @returns {void} No value.
*/
function syncCalcState() {
UpdateCSMotStorm(CSMot);
UpdatePresets(Presets);
}
/** Wraps a chart action with synchronized calculation state.
* @param {*} fn Parameter for withCalcState.
* @returns {*} Result of the function.
*/
const withCalcState = (fn) => function (...args) {
syncCalcState();
return fn.apply(this, args);
};
const handlers = {
keyup: withCalcState(onWindowKeyUp),
keydown: withCalcState(onWindowKeyDown),
mouseup: withCalcState(onWindowMouseUp),
};
/** Start listening for keyboard / mouse-up events on the window (modify mode, alt-drag).
* @returns {void} No value.
*/
function attach() {
if (listenersAttached || typeof window === 'undefined') return;
for (const [type, fn] of Object.entries(handlers)) window.addEventListener(type, fn);
listenersAttached = true;
}
/** Remove window listeners and drop per-chart state.
* @returns {void} No value.
*/
function detach() {
if (listenersAttached) {
for (const [type, fn] of Object.entries(handlers)) window.removeEventListener(type, fn);
listenersAttached = false;
}
TurnOff();
elementCache.clear();
}
// ---------------------------------------------------------------------------------------------
const api = {
// lifecycle
attach,
detach,
// data in / out
Load,
AverageAll,
CompareSoundings,
clearComparisons,
setComparisons,
applyNexradVwpStoredSounding,
setVwpActive,
SetMapImage,
GetSoundingTextContent,
DownloadSounding,
Screenshot,
// options
getState,
applyState,
redraw,
setSoundingUnits,
setSoundingDesignState,
setPreset,
setExportFormat,
ChangeSidePanelMode,
SetHodoMode,
SetEFScale,
ChangeStateSkewTSkew,
ChangeStateDynamicHodo,
ChangeStateHodographColorTable,
ChangeStateHodographNumbers,
setHodographTrimWindProfile,
ChangeWBox,
LoadStormMotions,
// analysis controls
LoadNewParcel,
LoadNewWind,
LoadNewSfc,
LoadNewLapseLyr,
LoadNewShearLyr,
ResetSkewT,
ResetHodograph,
// cursor tools
ChangeReadOut,
setReadOutOnState,
ChangeFront,
setFrontActiveState,
setModifyMode,
CursorReadout,
DrawFront,
// pointer events (bound by viewer/template.js)
SkewTMouseMoved,
SkewTMouseDown,
SkewTWheel,
Flip,
HodographMouseMoved,
HodographMouseDown,
HodographWheel,
Front,
// lower-level drawing, for hosts that compose their own layout
DrawSkewT,
DrawHodograph,
DrawInflowHodo,
DrawVWPInsets,
LoadWithParcel,
GenerateDropSondeLooks,
RemoteDataStores,
TurnOff,
unlockUserSoundingPresets,
getSnding,
getInfo,
getDP,
getGhost,
getCurrentComparisonSoundings,
};
// Every API call may change user-facing state; the host diffs getState() to find out.
for (const key of Object.keys(api)) {
if (key === 'getState') continue;
const fn = withCalcState(api[key]);
api[key] = function (...args) {
const result = fn.apply(this, args);
emit('change');
return result;
};
}
return api;
}