Files
gods-eye-view/src/layers/wind/fields.js
T
German KhokhlovandClaude Opus 5.5 3129e531e6 feat(i18n): translate the whole interface
Mark every UI string the unmarked-text check reported (534) plus text it
cannot see (helpers, joined labels, runtime-built panels), and translate
the result to Spanish and Russian: 1,679 messages each.

- Kept on purpose, with the reason where the text lives: provider and
  product names, place and camera names, codes and units, dB values, the HUD
  terminal readouts, demo shot titles that saved projects look up by title,
  and text for AI tools and the voice model.
- Modules shared with the server or AI tools stay free of the i18n import
  (local msg); the wind reading and voice session stay English and are
  translated where the page shows them.
- npm test now runs the unmarked-text check.
- Fixes on the way: a duplicated radio privacy paragraph, the contact panel
  toggle saying Expand while open, the CSS filter mistaking & for CSS.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 19:24:42 +04:00

172 lines
5.3 KiB
JavaScript

import { normalizeLongitude, sampleWind } from './model.js';
/** Fixed physical scales shared by imagery and the field legend. */
export const WIND_FIELDS = Object.freeze({
speed: Object.freeze({
label: 'Wind speed', // i18n-ignore: internal field name; the shown label comes from index.js
units: 'm/s',
min: 0,
max: 30,
stops: ['#133a67', '#2876a3', '#66c9d1', '#c8dfac', '#f1cf79', '#ee9761'],
}),
temperature: Object.freeze({
label: '2 m air temperature', // i18n-ignore: internal field name; the shown label comes from index.js
units: '°C',
min: -40,
max: 50,
// Fixed 10°C anchors keep model comparisons honest while making everyday
// temperature gradients distinguishable from the underlying basemap.
stops: [
'#31235b',
'#403b92',
'#315fc1',
'#268fce',
'#31bdd0',
'#8ad4aa',
'#f0d255',
'#ee8a34',
'#d74638',
'#9f274d',
],
}),
pressure: Object.freeze({
label: 'Mean sea-level pressure', // i18n-ignore: internal field name; the shown label comes from index.js
units: 'hPa',
min: 960,
max: 1050,
stops: ['#705ca0', '#6985b4', '#a5c3cd', '#d8c99a', '#dbad61'],
}),
});
function palette(stops) {
const rgb = stops.map((color) =>
color.match(/\w\w/g).map((value) => parseInt(value, 16)),
);
return Array.from({ length: 128 }, (_, i) => {
const p = (i / 127) * (rgb.length - 1);
const a = Math.min(rgb.length - 2, Math.floor(p));
return rgb[a].map((value, channel) =>
Math.round(value + (rgb[a + 1][channel] - value) * (p - a)),
);
});
}
const PALETTES = Object.fromEntries(
Object.entries(WIND_FIELDS).map(([kind, spec]) => [
kind,
palette(spec.stops),
]),
);
const WIND_COLORS = PALETTES.speed.map((rgb) => `rgb(${rgb.join(',')})`);
const clamp = (x, low, high) => Math.max(low, Math.min(high, x));
/** No parsing, arrays or CSS interpolation in the particle loop. */
export function windTrailColor(speed) {
return WIND_COLORS[
Math.round((clamp(Number.isFinite(speed) ? speed : 0, 0, 30) / 30) * 127)
];
}
/** Decay is a function of elapsed time, independent of refresh rate. */
export function trailEraseAlpha(seconds, halfLife = 0.65) {
return 1 - Math.pow(0.5, Math.max(0, seconds) / halfLife);
}
/** Scalar units are already normalized by the source; do not convert twice. */
export function sampleScalar(
snapshot,
lon,
lat,
kind = snapshot?.scalar?.kind ?? 'speed',
) {
const grid = snapshot?.grid ?? snapshot;
if (!grid || !Number.isFinite(lon) || !Number.isFinite(lat)) return null;
if (kind === 'speed') {
const wind = sampleWind(
grid === snapshot ? grid : { ...grid, u: snapshot.u, v: snapshot.v },
lon,
lat,
);
const speed = Math.hypot(wind.u, wind.v);
return Number.isFinite(speed) ? speed : null;
}
const scalar = snapshot.scalar;
if (
!WIND_FIELDS[kind] ||
scalar?.kind !== kind ||
scalar.units !== WIND_FIELDS[kind].units ||
!scalar.values
)
return null;
const { nx, ny, lo1, la1, dx, dy } = grid;
const x = ((normalizeLongitude(lon - lo1) + 360) % 360) / dx;
const y = clamp((la1 - lat) / dy, 0, ny - 1);
const x0 = Math.floor(x);
const y0 = Math.floor(y);
const tx = x - x0;
const ty = y - y0;
const col = (n) => ((n % nx) + nx) % nx;
const y1 = Math.min(y0 + 1, ny - 1);
const a = scalar.values[y0 * nx + col(x0)];
const b = scalar.values[y0 * nx + col(x0 + 1)];
const c = scalar.values[y1 * nx + col(x0)];
const d = scalar.values[y1 * nx + col(x0 + 1)];
const value =
(a * (1 - tx) + b * tx) * (1 - ty) + (c * (1 - tx) + d * tx) * ty;
return Number.isFinite(value) ? value : null;
}
/** One bounded geographic texture per installed field, never per render frame. */
export function createFieldRaster(snapshot, kind, width = 360, height = 181) {
const spec = WIND_FIELDS[kind];
if (
!spec ||
!snapshot ||
!Number.isInteger(width) ||
!Number.isInteger(height) ||
width < 2 ||
height < 2 ||
width > 720 ||
height > 362
)
return null;
if (
kind !== 'speed' &&
(snapshot.scalar?.kind !== kind || snapshot.scalar.units !== spec.units)
)
return null;
const rgba = new Uint8ClampedArray(width * height * 4);
const flat = snapshot.grid
? {
...snapshot.grid,
u: snapshot.u,
v: snapshot.v,
scalar: snapshot.scalar,
}
: snapshot;
let finiteCount = 0;
for (let y = 0; y < height; y++) {
const lat = 90 - ((y + 0.5) / height) * 180;
for (let x = 0; x < width; x++) {
// Pixel centers cover -180..180; the source's 0-degree origin is not an image origin.
const lon = -180 + ((x + 0.5) / width) * 360;
const value = sampleScalar(flat, lon, lat, kind);
if (value === null) continue;
const t = clamp((value - spec.min) / (spec.max - spec.min), 0, 1);
const rgb = PALETTES[kind][Math.round(t * 127)];
const offset = (y * width + x) * 4;
rgba[offset] = rgb[0];
rgba[offset + 1] = rgb[1];
rgba[offset + 2] = rgb[2];
const alpha =
kind === 'speed'
? 0.22 + 0.43 * Math.sqrt(t)
: kind === 'pressure'
? 0.24 + 0.3 * Math.min(1, Math.abs(value - 1013) / 40)
: 0.95;
rgba[offset + 3] = Math.round(255 * alpha);
finiteCount++;
}
}
return finiteCount ? { width, height, rgba } : null;
}