This commit is contained in:
Rainer Leit
2026-09-25 17:02:24 +03:00
parent cc43ed8dc8
commit 9597629951
2149 changed files with 460234 additions and 1770 deletions
+768
View File
@@ -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;
}