// weather-vis.jsx — atmospheric visualizations & chart primitives
// All exports attached to window at the bottom.
// ─────────────────────────────────────────────────────────────────────
// Atmospheric background — three layered SVGs that compose the "weather
// you can feel" effect: isobars (pressure curves), wind flow field,
// precipitation/condensation overlay.
// ─────────────────────────────────────────────────────────────────────
function IsobarField({ palette, opacity = 0.5, seed = 1 }) {
// Concentric blobby isobars in the palette's accent line color.
const cx1 = 30 + seed * 7, cy1 = 35;
const cx2 = 75, cy2 = 70 - seed * 5;
const ring = (i, base) => {
const r = 12 + i * 7;
return `M ${base.cx - r} ${base.cy} ` +
`C ${base.cx - r} ${base.cy - r * 0.8}, ${base.cx + r} ${base.cy - r}, ${base.cx + r} ${base.cy} ` +
`C ${base.cx + r} ${base.cy + r * 0.7}, ${base.cx - r} ${base.cy + r * 0.9}, ${base.cx - r} ${base.cy}`;
};
return (
);
}
function WindField({ palette, direction = 268, intensity = 4.1, opacity = 0.55 }) {
// Streamlines flowing in `direction` (meteorological — direction wind
// comes from; we render the flow going TO direction+180).
const flowDir = ((direction + 180) % 360) * Math.PI / 180;
const dx = Math.cos(flowDir - Math.PI / 2);
const dy = Math.sin(flowDir - Math.PI / 2);
const lines = [];
const density = Math.min(28, 14 + intensity);
for (let i = 0; i < density; i++) {
const t = i / density;
// start points scattered across an oblique band
const sx = -10 + t * 120 + Math.sin(i * 7.3) * 8;
const sy = -10 + Math.cos(i * 3.1) * 110 + t * 30;
// 3 control points along the flow vector with slight curl
const pts = [];
let x = sx, y = sy;
for (let k = 0; k < 5; k++) {
pts.push([x, y]);
const wob = Math.sin((x + y + i) * 0.07) * 4;
x += dx * 30 + wob * dy * 0.3;
y += dy * 30 - wob * dx * 0.3;
}
const path = "M " + pts.map(p => p.join(" ")).join(" L ");
lines.push(
);
}
return (
);
}
function PrecipOverlay({ palette, intensity = 0.3, mood = "mist" }) {
// Mist = soft horizontal washes. Rain = vertical streaks. Storm = streaks + flashes.
if (mood === "mist" || mood === "overcast") {
return (
);
}
if (mood === "rain" || mood === "storm") {
const drops = [];
for (let i = 0; i < 60; i++) {
const x = (i * 17) % 100;
const y = (i * 23) % 100;
const len = 3 + (i % 4);
drops.push(
);
}
return (
);
}
return null;
}
function AtmosphericBackdrop({ palette, weathercode = 55, hour = 8, windDir = 268, windSpd = 4 }) {
const mood = palette.mood;
return (
{/* film grain via SVG noise */}
);
}
// ─────────────────────────────────────────────────────────────────────
// Sun arc — sunrise/sunset arc with current sun position dot.
// ─────────────────────────────────────────────────────────────────────
function SunArc({ palette, sunrise, sunset, now, width = 320, height = 70 }) {
const t0 = new Date(sunrise + ":00").getTime();
const t1 = new Date(sunset + ":00").getTime();
const tn = new Date(now + ":00").getTime();
const p = Math.max(0, Math.min(1, (tn - t0) / (t1 - t0)));
const w = width, h = height;
// half-ellipse arc
const cx = w / 2, cy = h * 0.95, rx = w * 0.42, ry = h * 0.78;
const ang = Math.PI * (1 - p); // 0..PI from left (sunrise) to right (sunset)
const sx = cx - rx * Math.cos(ang);
const sy = cy - ry * Math.sin(ang);
return (
);
}
// ─────────────────────────────────────────────────────────────────────
// Moon glyph — pure SVG given phase fraction 0..1.
// ─────────────────────────────────────────────────────────────────────
function MoonGlyph({ phase, size = 26, color = "#fff", bg = "#000" }) {
// phase 0=new 0.5=full
const r = size / 2;
const lit = phase; // simple cos blend
// x offset for terminator ellipse
const k = Math.cos(phase * 2 * Math.PI);
return (
);
}
// ─────────────────────────────────────────────────────────────────────
// SeriesChart — multi-variable hourly sparkline.
// ─────────────────────────────────────────────────────────────────────
function Sparkline({ values, width, height, color, fill, strokeWidth = 1.5, smooth = true, minPad = 0.1 }) {
const min = Math.min(...values), max = Math.max(...values);
const range = Math.max(max - min, 0.001);
const pad = range * minPad;
const lo = min - pad, hi = max + pad, span = hi - lo;
const xs = (i) => (i / (values.length - 1)) * width;
const ys = (v) => height - ((v - lo) / span) * height;
if (!smooth) {
const d = values.map((v, i) => `${i === 0 ? 'M' : 'L'} ${xs(i)} ${ys(v)}`).join(' ');
return ;
}
// Catmull-Rom -> cubic
const pts = values.map((v, i) => [xs(i), ys(v)]);
let d = `M ${pts[0][0]} ${pts[0][1]}`;
for (let i = 0; i < pts.length - 1; i++) {
const p0 = pts[Math.max(0, i - 1)];
const p1 = pts[i];
const p2 = pts[i + 1];
const p3 = pts[Math.min(pts.length - 1, i + 2)];
const c1x = p1[0] + (p2[0] - p0[0]) / 6;
const c1y = p1[1] + (p2[1] - p0[1]) / 6;
const c2x = p2[0] - (p3[0] - p1[0]) / 6;
const c2y = p2[1] - (p3[1] - p1[1]) / 6;
d += ` C ${c1x} ${c1y} ${c2x} ${c2y} ${p2[0]} ${p2[1]}`;
}
return (
<>
{fill && (
)}
>
);
}
// Bar series — for precipitation
function Bars({ values, width, height, color, max }) {
const top = max ?? Math.max(...values, 1);
const bw = width / values.length;
return values.map((v, i) => {
const h = (v / top) * height;
return ;
});
}
// ─────────────────────────────────────────────────────────────────────
// Wind compass — rose with cardinals + current dir/speed.
// ─────────────────────────────────────────────────────────────────────
function WindCompass({ direction = 268, speed = 4.1, gusts = 10.1, palette, size = 140, lang = "pt" }) {
const r = size / 2;
const c = size / 2;
const ang = (direction - 90) * Math.PI / 180;
const ax = c + (r - 18) * Math.cos(ang);
const ay = c + (r - 18) * Math.sin(ang);
const card = lang === "pt" ? ["N", "L", "S", "O"] : ["N", "E", "S", "W"];
return (
);
}
// ─────────────────────────────────────────────────────────────────────
// Tide curve — synthesized sinusoid for a port near the city.
// ─────────────────────────────────────────────────────────────────────
function TideCurve({ palette, width = 340, height = 110, lang = "pt" }) {
const N = 96;
const data = Array.from({ length: N }, (_, i) => {
const t = (i / N) * Math.PI * 4;
return 0.5 + 0.4 * Math.sin(t + 0.6) + 0.05 * Math.sin(t * 3.2);
});
const xs = (i) => (i / (N - 1)) * width;
const ys = (v) => height - 10 - v * (height - 25);
let d = `M ${xs(0)} ${ys(data[0])}`;
for (let i = 1; i < N; i++) d += ` L ${xs(i)} ${ys(data[i])}`;
const nowI = Math.floor(N * (8.5 / 24));
return (
);
}
// Air-quality ring (AQI 0-200) — concentric meter.
function AQIRing({ value = 38, palette, size = 110, lang = "pt" }) {
const max = 300;
const pct = Math.min(1, value / max);
const r = size / 2 - 8;
const c = size / 2;
const circ = 2 * Math.PI * r;
const color =
value < 50 ? "#5e9c6a" : value < 100 ? "#c5a23a" : value < 150 ? "#c97c3a" : "#a8463a";
const label =
value < 50 ? (lang === "pt" ? "Bom" : "Good")
: value < 100 ? (lang === "pt" ? "Moderado" : "Moderate")
: value < 150 ? (lang === "pt" ? "Sensíveis" : "Sensitive")
: (lang === "pt" ? "Ruim" : "Unhealthy");
return (
);
}
Object.assign(window, {
IsobarField, WindField, PrecipOverlay, AtmosphericBackdrop,
SunArc, MoonGlyph, Sparkline, Bars, WindCompass, TideCurve, AQIRing,
});