Tooling
This commit is contained in:
@@ -0,0 +1,768 @@
|
||||
// Wind simulation: pressure-driven seasonal wind with longitude-varying ITCZ.
|
||||
// Computes pressure fields and wind vectors for summer and winter seasons.
|
||||
|
||||
import { elevToHeightKm } from './color-map.js';
|
||||
import { smoothField, percentile } from './climate-util.js';
|
||||
|
||||
const DEG = Math.PI / 180;
|
||||
const RAD = 180 / Math.PI;
|
||||
|
||||
// ── Periodic cubic spline interpolation ──────────────────────────────────────
|
||||
|
||||
function buildPeriodicSpline(xs, ys) {
|
||||
// xs: sorted longitude samples (radians), ys: ITCZ latitude values
|
||||
// Returns spline data for evaluateSpline()
|
||||
const n = xs.length;
|
||||
const period = 2 * Math.PI;
|
||||
|
||||
// Build tridiagonal system for periodic natural cubic spline
|
||||
const h = new Float64Array(n);
|
||||
const alpha = new Float64Array(n);
|
||||
for (let i = 0; i < n; i++) {
|
||||
const next = (i + 1) % n;
|
||||
h[i] = (xs[next] - xs[i] + period) % period;
|
||||
if (h[i] === 0) h[i] = period / n;
|
||||
}
|
||||
for (let i = 0; i < n; i++) {
|
||||
const prev = (i - 1 + n) % n;
|
||||
const next = (i + 1) % n;
|
||||
alpha[i] = (3 / h[i]) * (ys[next] - ys[i]) - (3 / h[prev]) * (ys[i] - ys[prev]);
|
||||
}
|
||||
|
||||
// Solve with Thomas-like algorithm for periodic system
|
||||
// Simplified: use iterative relaxation (fast enough for n=72)
|
||||
const c = new Float64Array(n);
|
||||
for (let iter = 0; iter < 20; iter++) {
|
||||
for (let i = 0; i < n; i++) {
|
||||
const prev = (i - 1 + n) % n;
|
||||
const next = (i + 1) % n;
|
||||
c[i] = (alpha[i] - h[prev] * c[prev] - h[i] * c[next]) /
|
||||
(2 * (h[prev] + h[i]));
|
||||
}
|
||||
}
|
||||
|
||||
const b = new Float64Array(n);
|
||||
const d = new Float64Array(n);
|
||||
for (let i = 0; i < n; i++) {
|
||||
const next = (i + 1) % n;
|
||||
b[i] = (ys[next] - ys[i]) / h[i] - h[i] * (c[next] + 2 * c[i]) / 3;
|
||||
d[i] = (c[next] - c[i]) / (3 * h[i]);
|
||||
}
|
||||
|
||||
return { xs, ys, b, c, d, h, n, period };
|
||||
}
|
||||
|
||||
function evaluateSpline(spline, lon) {
|
||||
const { xs, ys, b, c, d, n, period } = spline;
|
||||
// Normalize lon to [xs[0], xs[0] + period)
|
||||
let t = ((lon - xs[0]) % period + period) % period + xs[0];
|
||||
|
||||
// Direct index calculation — segments are equally spaced
|
||||
const segStep = period / n;
|
||||
let seg = Math.floor((t - xs[0]) / segStep);
|
||||
if (seg < 0) seg = 0;
|
||||
else if (seg >= n) seg = n - 1;
|
||||
|
||||
const dx = t - xs[seg];
|
||||
return ys[seg] + b[seg] * dx + c[seg] * dx * dx + d[seg] * dx * dx * dx;
|
||||
}
|
||||
|
||||
// ── Smoothstep utility ───────────────────────────────────────────────────────
|
||||
|
||||
export function smoothstep(edge0, edge1, x) {
|
||||
if (edge0 === edge1) return x >= edge1 ? 1 : 0;
|
||||
const t = Math.max(0, Math.min(1, (x - edge0) / (edge1 - edge0)));
|
||||
return t * t * (3 - 2 * t);
|
||||
}
|
||||
|
||||
// ── ITCZ computation ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Build a spatial index binning regions by latitude/longitude for fast
|
||||
* geographic sampling. Returns a function landFracAndElev(lat, lon, radius)
|
||||
* that returns { landFrac, avgElev } by scanning nearby bins.
|
||||
*/
|
||||
function buildGeoIndex(r_lat, r_lon, r_sinLat, r_cosLat, r_elevation, r_isLand, numRegions) {
|
||||
const LAT_BINS = 36; // 5° each
|
||||
const LON_BINS = 72; // 5° each
|
||||
const numBins = LAT_BINS * LON_BINS;
|
||||
|
||||
// CSR (compressed sparse row) format: count regions per bin, then prefix-sum
|
||||
// Cache bin index per region to avoid recomputing in the fill pass
|
||||
const r_bin = new Uint32Array(numRegions);
|
||||
const binCount = new Uint32Array(numBins);
|
||||
for (let r = 0; r < numRegions; r++) {
|
||||
const latBin = Math.max(0, Math.min(LAT_BINS - 1,
|
||||
Math.floor((r_lat[r] + Math.PI / 2) / Math.PI * LAT_BINS)));
|
||||
const lonBin = Math.max(0, Math.min(LON_BINS - 1,
|
||||
Math.floor((r_lon[r] + Math.PI) / (2 * Math.PI) * LON_BINS)));
|
||||
const bin = latBin * LON_BINS + lonBin;
|
||||
r_bin[r] = bin;
|
||||
binCount[bin]++;
|
||||
}
|
||||
|
||||
const binOffset = new Uint32Array(numBins + 1);
|
||||
for (let i = 0; i < numBins; i++) {
|
||||
binOffset[i + 1] = binOffset[i] + binCount[i];
|
||||
}
|
||||
|
||||
const indices = new Uint32Array(numRegions);
|
||||
const fillPos = new Uint32Array(numBins);
|
||||
for (let r = 0; r < numRegions; r++) {
|
||||
const bin = r_bin[r];
|
||||
indices[binOffset[bin] + fillPos[bin]] = r;
|
||||
fillPos[bin]++;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sample land fraction and average elevation in a circular region.
|
||||
* @param {number} lat - center latitude (radians)
|
||||
* @param {number} lon - center longitude (radians)
|
||||
* @param {number} radius - great-circle radius (radians)
|
||||
*/
|
||||
return function sample(lat, lon, radius) {
|
||||
const latMin = lat - radius, latMax = lat + radius;
|
||||
const bMin = Math.max(0, Math.floor((latMin + Math.PI / 2) / Math.PI * LAT_BINS));
|
||||
const bMax = Math.min(LAT_BINS - 1, Math.floor((latMax + Math.PI / 2) / Math.PI * LAT_BINS));
|
||||
|
||||
// Longitude span widens near equator
|
||||
const cosLat = Math.cos(lat) || 0.01;
|
||||
const lonSpan = radius / cosLat;
|
||||
const lMin = Math.floor((lon - lonSpan + Math.PI) / (2 * Math.PI) * LON_BINS);
|
||||
const lMax = Math.floor((lon + lonSpan + Math.PI) / (2 * Math.PI) * LON_BINS);
|
||||
|
||||
let landCount = 0, totalCount = 0, elevSum = 0;
|
||||
const cosRadius = Math.cos(radius);
|
||||
const sinLat0 = Math.sin(lat), cosLat0 = Math.cos(lat);
|
||||
|
||||
for (let bi = bMin; bi <= bMax; bi++) {
|
||||
for (let li = lMin; li <= lMax; li++) {
|
||||
const lj = ((li % LON_BINS) + LON_BINS) % LON_BINS;
|
||||
const bin = bi * LON_BINS + lj;
|
||||
const start = binOffset[bin];
|
||||
const end = binOffset[bin + 1];
|
||||
for (let k = start; k < end; k++) {
|
||||
const r = indices[k];
|
||||
const sinLat1 = r_sinLat[r];
|
||||
const cosLat1 = r_cosLat[r];
|
||||
const dlon = r_lon[r] - lon;
|
||||
const cosDist = sinLat0 * sinLat1 + cosLat0 * cosLat1 * Math.cos(dlon);
|
||||
if (cosDist >= cosRadius) {
|
||||
totalCount++;
|
||||
if (r_isLand[r]) landCount++;
|
||||
elevSum += Math.max(0, r_elevation[r]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (totalCount === 0) return { landFrac: 0, avgElev: 0 };
|
||||
return { landFrac: landCount / totalCount, avgElev: elevSum / totalCount };
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute ITCZ latitude at sampled longitudes for a given season.
|
||||
* Uses a thermal equator search: scans latitudes from -30° to +30°,
|
||||
* computes a heating score at each, and picks the peak.
|
||||
*
|
||||
* Heating score combines:
|
||||
* - Solar insolation (cosine of latitude offset from subsolar point)
|
||||
* - Land thermal boost (land heats faster than ocean)
|
||||
* - Elevation boost (plateaus heat more intensely — thinner atmosphere)
|
||||
* - Cross-equatorial anchoring (winter-hemisphere land pulls ITCZ equatorward)
|
||||
*
|
||||
* @param {function} geoSample - from buildGeoIndex
|
||||
* @param {string} season - 'summer' (NH) or 'winter' (NH)
|
||||
* @param {number} tiltRad - axial tilt in radians
|
||||
* @returns {{ spline, lons: Float64Array, lats: Float64Array }}
|
||||
*/
|
||||
function computeITCZ(geoSample, season, tiltRad) {
|
||||
const NUM_LON = 72;
|
||||
// Two sampling radii: local (5°) for precise land detection, wide (30°) for continental scale
|
||||
const localRadius = 5 * DEG;
|
||||
const wideRadius = 30 * DEG;
|
||||
|
||||
// +1 = NH summer, -1 = SH summer (NH winter)
|
||||
const sign = season === 'summer' ? 1 : -1;
|
||||
|
||||
// Subsolar latitude: where the sun is directly overhead this season
|
||||
// Full tilt in summer hemisphere (e.g. +23.5° for NH summer)
|
||||
const subsolarLat = sign * tiltRad;
|
||||
|
||||
// Scan range: -30° to +30° in 2.5° steps
|
||||
const SCAN_MIN = -30;
|
||||
const SCAN_MAX = 30;
|
||||
const SCAN_STEP = 2.5;
|
||||
const numScans = Math.round((SCAN_MAX - SCAN_MIN) / SCAN_STEP) + 1;
|
||||
|
||||
const lons = new Float64Array(NUM_LON);
|
||||
const rawLats = new Float64Array(NUM_LON);
|
||||
|
||||
for (let i = 0; i < NUM_LON; i++) {
|
||||
const lon = -Math.PI + (i + 0.5) * (2 * Math.PI / NUM_LON);
|
||||
lons[i] = lon;
|
||||
|
||||
let bestScore = -Infinity;
|
||||
let bestLat = sign * 5 * DEG; // fallback
|
||||
|
||||
for (let si = 0; si < numScans; si++) {
|
||||
const latDeg = SCAN_MIN + si * SCAN_STEP;
|
||||
const lat = latDeg * DEG;
|
||||
const local = geoSample(lat, lon, localRadius);
|
||||
const wide = geoSample(lat, lon, wideRadius);
|
||||
|
||||
// (a) Solar insolation: peaks at subsolar latitude, broad Gaussian falloff.
|
||||
// σ = 25° gives a wide heating dome — the ITCZ doesn't track the
|
||||
// subsolar point 1:1, it lags and is damped by ocean thermal inertia.
|
||||
const dSolar = (lat - subsolarLat) * RAD; // degrees from subsolar
|
||||
const solarScore = Math.exp(-0.5 * (dSolar / 25) ** 2);
|
||||
|
||||
// (b) Land thermal boost: uses multi-scale sampling.
|
||||
// Only truly continental-scale landmasses pull the ITCZ significantly.
|
||||
// Islands, thin peninsulas, and coastlines near ocean register low at
|
||||
// the wide (30°) radius and get suppressed by the steep ramp.
|
||||
const localLand = local.landFrac;
|
||||
const wideLand = wide.landFrac;
|
||||
|
||||
// Also sample poleward of this latitude: a massive continent extending
|
||||
// poleward (like Asia beyond 20°N) creates an enormous heat reservoir
|
||||
// that pulls the ITCZ toward it even if the scan point itself is at
|
||||
// the continent's edge. Sample 15° poleward in the summer hemisphere.
|
||||
const polewardLat = lat + sign * 15 * DEG;
|
||||
const poleward = geoSample(polewardLat, lon, wideRadius);
|
||||
// Combined land signal: max of local-wide and poleward-wide.
|
||||
// Poleward land contributes at 70% strength (heat diffuses equatorward).
|
||||
const effectiveWideLand = Math.max(wideLand, poleward.landFrac * 0.7);
|
||||
|
||||
// Wide-scale land must exceed ~20% before any real pull kicks in.
|
||||
const continentalScale = smoothstep(0.20, 0.45, effectiveWideLand);
|
||||
// Square it so moderate land fractions still contribute little.
|
||||
const scaledLand = continentalScale * continentalScale;
|
||||
// Local land gate: require >25% local land fraction to activate.
|
||||
// At 5° radius (~560 km), ocean near thin islands stays well below this.
|
||||
const landGate = smoothstep(0.25, 0.55, localLand);
|
||||
// Strong max boost so massive continents pull ITCZ toward 25-30°
|
||||
const landBoost = landGate * scaledLand * 1.0;
|
||||
|
||||
// (c) Elevation boost: high plateaus heat more intensely
|
||||
// (thinner atmosphere, stronger surface insolation).
|
||||
// Also scaled by continental size — isolated volcanic peaks don't pull ITCZ.
|
||||
const elevKm = elevToHeightKm(Math.max(0, wide.avgElev));
|
||||
const elevBoost = Math.min(0.30, elevKm * 0.12) * scaledLand;
|
||||
|
||||
// (d) Cross-equatorial anchoring: if this latitude is in the
|
||||
// winter hemisphere but there's significant land, it anchors
|
||||
// the ITCZ closer to the equator (resists poleward migration).
|
||||
const isWinterHemi = (sign > 0 && latDeg < 0) || (sign < 0 && latDeg > 0);
|
||||
const anchorBoost = isWinterHemi ? landBoost * 0.4 : 0;
|
||||
|
||||
// (e) Ocean baseline: slight poleward bias in summer hemisphere
|
||||
// even over open ocean (~6-8° from equator on average).
|
||||
const isSummerHemi = !isWinterHemi;
|
||||
const oceanBias = isSummerHemi && localLand < 0.1
|
||||
? 0.08 * Math.exp(-0.5 * ((Math.abs(latDeg) - 7) / 5) ** 2)
|
||||
: 0;
|
||||
|
||||
const score = solarScore + landBoost + elevBoost + anchorBoost + oceanBias;
|
||||
|
||||
if (score > bestScore) {
|
||||
bestScore = score;
|
||||
bestLat = lat;
|
||||
}
|
||||
}
|
||||
|
||||
rawLats[i] = bestLat;
|
||||
}
|
||||
|
||||
// Pull extreme outliers toward the zonal mean before longitude smoothing.
|
||||
// The ITCZ is a planetary-scale feature — individual longitude columns
|
||||
// shouldn't deviate too far from the overall trend.
|
||||
const lats = new Float64Array(rawLats);
|
||||
const tmp = new Float64Array(NUM_LON);
|
||||
// Wide periodic moving average (kernel = 5 neighbors) for heavy smoothing,
|
||||
// then narrow (kernel = 3) for fine cleanup. More passes = smoother ITCZ.
|
||||
// Wide kernel: weights [0.1, 0.2, 0.4, 0.2, 0.1] over 5 neighbors
|
||||
for (let pass = 0; pass < 4; pass++) {
|
||||
for (let i = 0; i < NUM_LON; i++) {
|
||||
const p2 = (i - 2 + NUM_LON) % NUM_LON;
|
||||
const p1 = (i - 1 + NUM_LON) % NUM_LON;
|
||||
const n1 = (i + 1) % NUM_LON;
|
||||
const n2 = (i + 2) % NUM_LON;
|
||||
tmp[i] = 0.1 * lats[p2] + 0.2 * lats[p1] + 0.4 * lats[i] + 0.2 * lats[n1] + 0.1 * lats[n2];
|
||||
}
|
||||
lats.set(tmp);
|
||||
}
|
||||
// Narrow cleanup passes
|
||||
for (let pass = 0; pass < 3; pass++) {
|
||||
for (let i = 0; i < NUM_LON; i++) {
|
||||
const p = (i - 1 + NUM_LON) % NUM_LON;
|
||||
const n = (i + 1) % NUM_LON;
|
||||
tmp[i] = 0.25 * lats[p] + 0.5 * lats[i] + 0.25 * lats[n];
|
||||
}
|
||||
lats.set(tmp);
|
||||
}
|
||||
|
||||
// Clamp to ±30° (ITCZ never migrates beyond the tropics)
|
||||
for (let i = 0; i < NUM_LON; i++) {
|
||||
lats[i] = Math.max(-30 * DEG, Math.min(30 * DEG, lats[i]));
|
||||
}
|
||||
|
||||
const spline = buildPeriodicSpline(lons, lats);
|
||||
return { spline, lons, lats };
|
||||
}
|
||||
|
||||
// ── Pressure field ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Compute pressure at a single region.
|
||||
*/
|
||||
function regionPressure(lat, lon, itczSpline, season, landFrac, elevation, noiseFn, px, py, pz) {
|
||||
const itczLat = evaluateSpline(itczSpline, lon);
|
||||
const latDeg = lat * RAD;
|
||||
const seasonSign = season === 'summer' ? 1 : -1;
|
||||
|
||||
let p = 1013; // baseline hPa
|
||||
|
||||
// (a) ITCZ low — follows thermal equator
|
||||
const dItcz = (lat - itczLat) * RAD; // degrees from ITCZ
|
||||
p -= 15 * Math.exp(-0.5 * (dItcz / 8) ** 2);
|
||||
|
||||
// (b) Subtropical highs — shift with season, weaker over hot land
|
||||
const shiftDeg = seasonSign * 5;
|
||||
const nhSubHigh = 30 + shiftDeg;
|
||||
const shSubHigh = -(30 - shiftDeg);
|
||||
const highIntensity = 12 * (1 - 0.3 * landFrac);
|
||||
p += highIntensity * Math.exp(-0.5 * ((latDeg - nhSubHigh) / 10) ** 2);
|
||||
p += highIntensity * Math.exp(-0.5 * ((latDeg - shSubHigh) / 10) ** 2);
|
||||
|
||||
// (c) Subpolar lows
|
||||
p -= 10 * Math.exp(-0.5 * ((latDeg - 60) / 10) ** 2);
|
||||
p -= 10 * Math.exp(-0.5 * ((latDeg + 60) / 10) ** 2);
|
||||
|
||||
// (d) Polar highs
|
||||
p += 8 * Math.exp(-0.5 * ((latDeg - 85) / 8) ** 2);
|
||||
p += 8 * Math.exp(-0.5 * ((latDeg + 85) / 8) ** 2);
|
||||
|
||||
// (e) Land/sea thermal modifier
|
||||
// landFrac here is actually continentality (0 at coast → ~1 deep interior).
|
||||
// Only continental-scale landmasses produce meaningful thermal pressure:
|
||||
// small islands (continentality < 0.2) → 0, ramps to full at 0.5+.
|
||||
const continentalScale = smoothstep(0.2, 0.5, landFrac);
|
||||
if (continentalScale > 0.001) {
|
||||
// Continental thermal effect profile:
|
||||
// 0 at 0-15°, rises to ~0.75 at 30°, plateau ~1.0 at 45-60°, falls to ~0.5 at 75°, 0 at 90°
|
||||
const absLatDeg = Math.abs(lat) * RAD;
|
||||
const latFactor = absLatDeg < 15 ? 0
|
||||
: absLatDeg < 30 ? 0.75 * smoothstep(15, 30, absLatDeg)
|
||||
: absLatDeg < 45 ? 0.75 + 0.25 * smoothstep(30, 45, absLatDeg)
|
||||
: absLatDeg < 60 ? 1
|
||||
: absLatDeg < 90 ? smoothstep(90, 60, absLatDeg)
|
||||
: 0;
|
||||
const isSummerHemisphere = (seasonSign > 0 && lat > 0) || (seasonSign < 0 && lat < 0);
|
||||
if (isSummerHemisphere) {
|
||||
// Thermal low over hot continent
|
||||
p -= 10 * latFactor * continentalScale;
|
||||
} else {
|
||||
// Thermal high over cold continent (stronger — Siberian/Canadian highs)
|
||||
p += 14 * latFactor * continentalScale;
|
||||
}
|
||||
}
|
||||
|
||||
// (f) Elevation (barometric) — mild effect; real weather maps use
|
||||
// sea-level-reduced pressure so elevation doesn't dominate zonal bands
|
||||
p -= 3 * elevToHeightKm(Math.max(0, elevation));
|
||||
|
||||
// (g) Noise perturbation
|
||||
if (noiseFn) {
|
||||
p += noiseFn.fbm(px * 2, py * 2, pz * 2, 3) * 2;
|
||||
}
|
||||
|
||||
return p;
|
||||
}
|
||||
|
||||
|
||||
// ── Pressure gradient on mesh ────────────────────────────────────────────────
|
||||
|
||||
export function computeGradients(mesh, r_xyz, r_pressure,
|
||||
r_eastX, r_eastY, r_eastZ, r_northX, r_northY, r_northZ,
|
||||
r_gradE, r_gradN) {
|
||||
const { adjOffset, adjList, numRegions } = mesh;
|
||||
|
||||
for (let r = 0; r < numRegions; r++) {
|
||||
const px = r_xyz[3 * r], py = r_xyz[3 * r + 1], pz = r_xyz[3 * r + 2];
|
||||
const ex = r_eastX[r], ey = r_eastY[r], ez = r_eastZ[r];
|
||||
const nx = r_northX[r], ny = r_northY[r], nz = r_northZ[r];
|
||||
const pHere = r_pressure[r];
|
||||
|
||||
let sumEP = 0, sumEE = 0, sumNP = 0, sumNN = 0;
|
||||
const end = adjOffset[r + 1];
|
||||
|
||||
for (let ni = adjOffset[r]; ni < end; ni++) {
|
||||
const nb = adjList[ni];
|
||||
const dx = r_xyz[3 * nb] - px;
|
||||
const dy = r_xyz[3 * nb + 1] - py;
|
||||
const dz = r_xyz[3 * nb + 2] - pz;
|
||||
|
||||
const de = dx * ex + dy * ey + dz * ez;
|
||||
const dn = dx * nx + dy * ny + dz * nz;
|
||||
const dp = r_pressure[nb] - pHere;
|
||||
|
||||
sumEP += de * dp;
|
||||
sumEE += de * de;
|
||||
sumNP += dn * dp;
|
||||
sumNN += dn * dn;
|
||||
}
|
||||
|
||||
r_gradE[r] = sumEE > 1e-12 ? sumEP / sumEE : 0;
|
||||
r_gradN[r] = sumNN > 1e-12 ? sumNP / sumNN : 0;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Pressure gradient → wind ─────────────────────────────────────────────────
|
||||
|
||||
function pressureToWind(r_gradE, r_gradN, r_sinLat,
|
||||
r_windE, r_windN, r_windSpeed, numRegions) {
|
||||
const sin5 = Math.sin(5 * DEG);
|
||||
|
||||
for (let r = 0; r < numRegions; r++) {
|
||||
// PGF: from high to low = negative gradient
|
||||
const pgfE = -r_gradE[r];
|
||||
const pgfN = -r_gradN[r];
|
||||
|
||||
const sinLat = r_sinLat[r];
|
||||
const absSinLat = Math.abs(sinLat);
|
||||
|
||||
// Geostrophic deflection: 0° at equator → 70° at ≥5° latitude
|
||||
const geoAngle = 70 * DEG * smoothstep(0, sin5, absSinLat);
|
||||
|
||||
// Surface friction turns wind 20° back toward low pressure
|
||||
const frictionAngle = 20 * DEG;
|
||||
|
||||
// Net rotation: NH = clockwise (negative), SH = counterclockwise (positive)
|
||||
// The rotation matrix [cosθ,-sinθ; sinθ,cosθ] is counterclockwise for +θ,
|
||||
// so NH right-deflection needs negative angle, SH left-deflection needs positive.
|
||||
const sign = sinLat >= 0 ? -1 : 1;
|
||||
const totalAngle = sign * (geoAngle - frictionAngle);
|
||||
|
||||
const cosA = Math.cos(totalAngle);
|
||||
const sinA = Math.sin(totalAngle);
|
||||
|
||||
// Rotate PGF vector and apply friction speed reduction
|
||||
const we = (pgfE * cosA - pgfN * sinA) * 0.6;
|
||||
const wn = (pgfE * sinA + pgfN * cosA) * 0.6;
|
||||
|
||||
r_windE[r] = we;
|
||||
r_windN[r] = wn;
|
||||
r_windSpeed[r] = Math.sqrt(we * we + wn * wn);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Main entry point ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Compute seasonal pressure fields and wind vectors.
|
||||
*
|
||||
* @param {SphereMesh} mesh
|
||||
* @param {Float32Array} r_xyz - per-region 3D positions (3 * numRegions)
|
||||
* @param {Float32Array} r_elevation - per-region elevation
|
||||
* @param {Set} plateIsOcean - ocean plate seed set
|
||||
* @param {Int32Array} r_plate - per-region plate ID
|
||||
* @param {SimplexNoise} noise - seeded noise instance
|
||||
* @param {number} [axialTilt=23.5] - axial tilt in degrees
|
||||
* @returns {object} pressure and wind arrays for both seasons
|
||||
*/
|
||||
export function computeWind(mesh, r_xyz, r_elevation, plateIsOcean, r_plate, noise, axialTilt = 23.5) {
|
||||
const numRegions = mesh.numRegions;
|
||||
const avgEdgeKm = (Math.PI * 6371) / Math.sqrt(numRegions);
|
||||
const tiltRad = axialTilt * DEG;
|
||||
const timing = [];
|
||||
|
||||
// ── Step 0: Precompute per-region properties ──
|
||||
|
||||
let t0 = performance.now();
|
||||
|
||||
const r_lat = new Float32Array(numRegions);
|
||||
const r_lon = new Float32Array(numRegions);
|
||||
const r_sinLat = new Float32Array(numRegions);
|
||||
const r_cosLat = new Float32Array(numRegions);
|
||||
const r_isLand = new Uint8Array(numRegions);
|
||||
|
||||
// Tangent frame arrays
|
||||
const r_eastX = new Float32Array(numRegions);
|
||||
const r_eastY = new Float32Array(numRegions);
|
||||
const r_eastZ = new Float32Array(numRegions);
|
||||
const r_northX = new Float32Array(numRegions);
|
||||
const r_northY = new Float32Array(numRegions);
|
||||
const r_northZ = new Float32Array(numRegions);
|
||||
|
||||
for (let r = 0; r < numRegions; r++) {
|
||||
const x = r_xyz[3 * r], y = r_xyz[3 * r + 1], z = r_xyz[3 * r + 2];
|
||||
|
||||
// Y-up convention (matches map projection)
|
||||
r_lat[r] = Math.asin(Math.max(-1, Math.min(1, y)));
|
||||
r_lon[r] = Math.atan2(x, z);
|
||||
r_sinLat[r] = y;
|
||||
r_cosLat[r] = Math.sqrt(1 - y * y) || 0.01;
|
||||
r_isLand[r] = r_elevation[r] > 0 ? 1 : 0;
|
||||
|
||||
// East = normalize(Ŷ × P) = normalize(z, 0, -x)
|
||||
let ex = z, ey = 0, ez = -x;
|
||||
let elen = Math.sqrt(ex * ex + ez * ez);
|
||||
if (elen < 1e-10) { ex = 1; ez = 0; elen = 1; } // pole fallback
|
||||
ex /= elen; ez /= elen;
|
||||
|
||||
// North = P × East
|
||||
let nx = y * ez - z * ey;
|
||||
let ny = z * ex - x * ez;
|
||||
let nz = x * ey - y * ex;
|
||||
const nlen = Math.sqrt(nx * nx + ny * ny + nz * nz) || 1;
|
||||
nx /= nlen; ny /= nlen; nz /= nlen;
|
||||
|
||||
r_eastX[r] = ex; r_eastY[r] = ey; r_eastZ[r] = ez;
|
||||
r_northX[r] = nx; r_northY[r] = ny; r_northZ[r] = nz;
|
||||
}
|
||||
|
||||
timing.push({ stage: 'Wind: precompute lat/lon/tangent', ms: performance.now() - t0 });
|
||||
|
||||
// ── Step 1: Build geographic index + compute ITCZ ──
|
||||
|
||||
t0 = performance.now();
|
||||
const geoSample = buildGeoIndex(r_lat, r_lon, r_sinLat, r_cosLat, r_elevation, r_isLand, numRegions);
|
||||
const itczSummer = computeITCZ(geoSample, 'summer', tiltRad);
|
||||
const itczWinter = computeITCZ(geoSample, 'winter', tiltRad);
|
||||
timing.push({ stage: 'Wind: ITCZ computation', ms: performance.now() - t0 });
|
||||
|
||||
// ── Step 2–5: Compute pressure & wind for each season ──
|
||||
|
||||
const seasons = [
|
||||
{ name: 'summer', itcz: itczSummer },
|
||||
{ name: 'winter', itcz: itczWinter }
|
||||
];
|
||||
|
||||
const result = {};
|
||||
|
||||
// Precompute continentality via BFS coast distance.
|
||||
// Laplacian smoothing of binary r_isLand converges too fast — interior
|
||||
// cells hit 0.95+ within a few hundred km. Instead, compute actual
|
||||
// hop distance from coast through land, convert to km, and map with
|
||||
// smoothstep for a wide, tunable gradient.
|
||||
// 0 km (coast): cont ≈ 0.0
|
||||
// 500 km: cont ≈ 0.16
|
||||
// 1000 km: cont ≈ 0.50
|
||||
// 1500 km: cont ≈ 0.84
|
||||
// 2000 km+: cont ≈ 1.0
|
||||
// Ocean cells near coast get a small value (~0.05–0.15) via a few
|
||||
// smoothing passes, giving a natural land/sea thermal gradient.
|
||||
t0 = performance.now();
|
||||
const { adjOffset, adjList } = mesh;
|
||||
|
||||
// Find the main ocean: largest connected component of non-land cells.
|
||||
// Inland seas / small lakes don't count as "ocean" for continentality.
|
||||
const r_oceanLabel = new Int32Array(numRegions);
|
||||
r_oceanLabel.fill(-1);
|
||||
let mainOceanLabel = -1, mainOceanSize = 0;
|
||||
let nextLabel = 0;
|
||||
for (let r = 0; r < numRegions; r++) {
|
||||
if (r_isLand[r] || r_oceanLabel[r] >= 0) continue;
|
||||
const label = nextLabel++;
|
||||
let size = 0;
|
||||
const floodQueue = [r];
|
||||
r_oceanLabel[r] = label;
|
||||
let fHead = 0;
|
||||
while (fHead < floodQueue.length) {
|
||||
const cur = floodQueue[fHead++];
|
||||
size++;
|
||||
const end = adjOffset[cur + 1];
|
||||
for (let ni = adjOffset[cur]; ni < end; ni++) {
|
||||
const nb = adjList[ni];
|
||||
if (!r_isLand[nb] && r_oceanLabel[nb] === -1) {
|
||||
r_oceanLabel[nb] = label;
|
||||
floodQueue.push(nb);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (size > mainOceanSize) {
|
||||
mainOceanSize = size;
|
||||
mainOceanLabel = label;
|
||||
}
|
||||
}
|
||||
|
||||
// BFS coast distance through land, seeded only from main-ocean coastline
|
||||
const r_coastDist = new Int32Array(numRegions);
|
||||
r_coastDist.fill(-1);
|
||||
const bfsQueue = [];
|
||||
for (let r = 0; r < numRegions; r++) {
|
||||
if (!r_isLand[r]) continue;
|
||||
const end = adjOffset[r + 1];
|
||||
for (let ni = adjOffset[r]; ni < end; ni++) {
|
||||
const nb = adjList[ni];
|
||||
if (!r_isLand[nb] && r_oceanLabel[nb] === mainOceanLabel) {
|
||||
r_coastDist[r] = 0;
|
||||
bfsQueue.push(r);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
let head = 0;
|
||||
while (head < bfsQueue.length) {
|
||||
const r = bfsQueue[head++];
|
||||
const d = r_coastDist[r] + 1;
|
||||
const end = adjOffset[r + 1];
|
||||
for (let ni = adjOffset[r]; ni < end; ni++) {
|
||||
const nb = adjList[ni];
|
||||
if (r_isLand[nb] && r_coastDist[nb] === -1) {
|
||||
r_coastDist[nb] = d;
|
||||
bfsQueue.push(nb);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Map BFS distance to continentality [0, 1]
|
||||
const CONT_RANGE_KM = 2000; // distance at which cont reaches ~1.0
|
||||
const r_continentality = new Float32Array(numRegions);
|
||||
for (let r = 0; r < numRegions; r++) {
|
||||
if (r_isLand[r] && r_coastDist[r] >= 0) {
|
||||
const distKm = r_coastDist[r] * avgEdgeKm;
|
||||
r_continentality[r] = smoothstep(0, CONT_RANGE_KM, distKm);
|
||||
}
|
||||
// Ocean cells stay at 0; a few smooth passes below will bleed
|
||||
// small values onto nearshore ocean for thermal gradient.
|
||||
}
|
||||
// Light smoothing (~100 km) to soften BFS stepping artifacts and
|
||||
// bleed a small thermal signal onto nearshore ocean cells.
|
||||
const contSmoothPasses = Math.max(1, Math.round(100 / avgEdgeKm));
|
||||
smoothField(mesh, r_continentality, contSmoothPasses);
|
||||
|
||||
// Plate-based continentality: uses plate type (continental vs oceanic)
|
||||
// instead of actual land/ocean. Same BFS approach for wide gradient.
|
||||
const r_plateContinentality = new Float32Array(numRegions);
|
||||
// BFS through continental-plate cells
|
||||
const r_plateDist = new Int32Array(numRegions);
|
||||
r_plateDist.fill(-1);
|
||||
const plateBfsQueue = [];
|
||||
for (let r = 0; r < numRegions; r++) {
|
||||
if (plateIsOcean.has(r_plate[r])) continue; // skip oceanic plate cells
|
||||
const end = adjOffset[r + 1];
|
||||
for (let ni = adjOffset[r]; ni < end; ni++) {
|
||||
if (plateIsOcean.has(r_plate[adjList[ni]])) {
|
||||
r_plateDist[r] = 0;
|
||||
plateBfsQueue.push(r);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
head = 0;
|
||||
while (head < plateBfsQueue.length) {
|
||||
const r = plateBfsQueue[head++];
|
||||
const d = r_plateDist[r] + 1;
|
||||
const end = adjOffset[r + 1];
|
||||
for (let ni = adjOffset[r]; ni < end; ni++) {
|
||||
const nb = adjList[ni];
|
||||
if (!plateIsOcean.has(r_plate[nb]) && r_plateDist[nb] === -1) {
|
||||
r_plateDist[nb] = d;
|
||||
plateBfsQueue.push(nb);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (let r = 0; r < numRegions; r++) {
|
||||
if (!plateIsOcean.has(r_plate[r]) && r_plateDist[r] >= 0) {
|
||||
const distKm = r_plateDist[r] * avgEdgeKm;
|
||||
r_plateContinentality[r] = smoothstep(0, CONT_RANGE_KM, distKm);
|
||||
}
|
||||
}
|
||||
smoothField(mesh, r_plateContinentality, contSmoothPasses);
|
||||
timing.push({ stage: 'Wind: continentality BFS', ms: performance.now() - t0 });
|
||||
|
||||
// Shared gradient scratch arrays
|
||||
const r_gradE = new Float32Array(numRegions);
|
||||
const r_gradN = new Float32Array(numRegions);
|
||||
|
||||
// Smooth pressure field ~75 km (scale-invariant) — constant across seasons
|
||||
const pressSmoothPasses = Math.max(1, Math.round(75 / avgEdgeKm));
|
||||
|
||||
for (const { name, itcz } of seasons) {
|
||||
// Step 2: Pressure field
|
||||
t0 = performance.now();
|
||||
const r_pressure = new Float32Array(numRegions);
|
||||
|
||||
for (let r = 0; r < numRegions; r++) {
|
||||
r_pressure[r] = regionPressure(
|
||||
r_lat[r], r_lon[r], itcz.spline, name,
|
||||
r_continentality[r], r_elevation[r], noise,
|
||||
r_xyz[3 * r], r_xyz[3 * r + 1], r_xyz[3 * r + 2]
|
||||
);
|
||||
}
|
||||
smoothField(mesh, r_pressure, pressSmoothPasses);
|
||||
timing.push({ stage: `Wind: pressure field (${name})`, ms: performance.now() - t0 });
|
||||
|
||||
// Step 3: Gradient
|
||||
t0 = performance.now();
|
||||
r_gradE.fill(0);
|
||||
r_gradN.fill(0);
|
||||
computeGradients(mesh, r_xyz, r_pressure,
|
||||
r_eastX, r_eastY, r_eastZ, r_northX, r_northY, r_northZ,
|
||||
r_gradE, r_gradN);
|
||||
timing.push({ stage: `Wind: gradient (${name})`, ms: performance.now() - t0 });
|
||||
|
||||
// Step 4: Wind
|
||||
t0 = performance.now();
|
||||
const r_windE = new Float32Array(numRegions);
|
||||
const r_windN = new Float32Array(numRegions);
|
||||
const r_windSpeed = new Float32Array(numRegions);
|
||||
pressureToWind(r_gradE, r_gradN, r_sinLat,
|
||||
r_windE, r_windN, r_windSpeed, numRegions);
|
||||
|
||||
// Step 5: Normalize wind speed to 0-1
|
||||
const maxSpeed = percentile(r_windSpeed, 0.95);
|
||||
for (let r = 0; r < numRegions; r++) {
|
||||
r_windSpeed[r] = Math.min(1, r_windSpeed[r] / maxSpeed);
|
||||
}
|
||||
timing.push({ stage: `Wind: pressure→wind (${name})`, ms: performance.now() - t0 });
|
||||
|
||||
// Store pressure as deviation from 1013 for visualization (blue=low, red=high)
|
||||
const r_pressureDev = new Float32Array(numRegions);
|
||||
for (let r = 0; r < numRegions; r++) {
|
||||
r_pressureDev[r] = r_pressure[r] - 1013;
|
||||
}
|
||||
|
||||
const S = name === 'summer' ? 'Summer' : 'Winter';
|
||||
result[`r_pressure_${name}`] = r_pressureDev;
|
||||
result[`r_wind_east_${name}`] = r_windE;
|
||||
result[`r_wind_north_${name}`] = r_windN;
|
||||
result[`r_wind_speed_${name}`] = r_windSpeed;
|
||||
}
|
||||
|
||||
// Pre-evaluate ITCZ splines at 360 longitude points for visualization
|
||||
const ITCZ_SAMPLES = 360;
|
||||
const itczLons = new Float32Array(ITCZ_SAMPLES);
|
||||
const itczLatsSummer = new Float32Array(ITCZ_SAMPLES);
|
||||
const itczLatsWinter = new Float32Array(ITCZ_SAMPLES);
|
||||
for (let i = 0; i < ITCZ_SAMPLES; i++) {
|
||||
const lon = -Math.PI + (i + 0.5) * (2 * Math.PI / ITCZ_SAMPLES);
|
||||
itczLons[i] = lon;
|
||||
itczLatsSummer[i] = evaluateSpline(itczSummer.spline, lon);
|
||||
itczLatsWinter[i] = evaluateSpline(itczWinter.spline, lon);
|
||||
}
|
||||
result.itczLons = itczLons;
|
||||
result.itczLatsSummer = itczLatsSummer;
|
||||
result.itczLatsWinter = itczLatsWinter;
|
||||
|
||||
// Expose precomputed geographic data for downstream modules (ocean.js)
|
||||
result.r_lat = r_lat;
|
||||
result.r_lon = r_lon;
|
||||
result.r_sinLat = r_sinLat;
|
||||
result.r_isLand = r_isLand;
|
||||
result.r_continentality = r_continentality;
|
||||
result.r_coastDistLand = r_coastDist;
|
||||
result.r_plateContinentality = r_plateContinentality;
|
||||
result.r_eastX = r_eastX;
|
||||
result.r_eastY = r_eastY;
|
||||
result.r_eastZ = r_eastZ;
|
||||
result.r_northX = r_northX;
|
||||
result.r_northY = r_northY;
|
||||
result.r_northZ = r_northZ;
|
||||
|
||||
result._windTiming = timing;
|
||||
return result;
|
||||
}
|
||||
Reference in New Issue
Block a user