Copy this document into Rocket.new. Your live wind globe builds in one pass. Every file included. Zero guesswork.
Build a project called Atmosphere8K. A full-screen live global wind visualization globe. Follow every specification exactly. Do not add features not listed. Do not use libraries not listed.
USE: Next.js 15 App Router TypeScript · HTML5 Canvas (two stacked canvases) · D3.js geoOrthographic · TopoJSON world-atlas · Open-Meteo API · Netlify Blobs · Inline styles only
DO NOT USE: Three.js · WebGL · react-globe.gl · any globe library · Tailwind · GRIB2 parsers
{
"name": "atmosphere8k",
"version": "1.0.0",
"private": true,
"scripts": { "dev": "next dev", "build": "next build", "start": "next start" },
"dependencies": {
"next": "^15.0.0",
"react": "^18.3.0",
"react-dom": "^18.3.0",
"d3": "^7.9.0",
"d3-geo": "^3.1.1",
"topojson-client": "^3.1.0",
"@netlify/blobs": "^8.1.0"
},
"devDependencies": {
"@types/d3": "^7.4.3",
"@types/topojson-client": "^3.1.4",
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"typescript": "^5"
}
}*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body { width: 100%; height: 100%; overflow: hidden; background: #000a1a; }
canvas { display: block; }import type { Metadata, Viewport } from 'next';
import './globals.css';
export const metadata: Metadata = {
title: 'Atmosphere8K',
description: 'Live Wind Visualization',
};
export const viewport: Viewport = {
width: 'device-width', initialScale: 1, maximumScale: 1, userScalable: false,
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return <html lang="en"><body>{children}</body></html>;
}import Globe from '@/components/Globe';
export default function Page() { return <Globe />; }import { NextResponse } from 'next/server';
import { getStore } from '@netlify/blobs';
export const runtime = 'nodejs';
const NX = 72, NY = 37, DX = 5, DY = 5, LAT1 = 90, LON1 = 0;
const TTL = 6 * 60 * 60 * 1000;
let memCache: { ts: number; data: string } | null = null;
function buildGrid() {
const lats: number[] = [], lons: number[] = [];
for (let j = 0; j < NY; j++) {
for (let i = 0; i < NX; i++) {
lats.push(+(LAT1 - j * DY).toFixed(1));
const raw = LON1 + i * DX;
// CRITICAL: Open-Meteo rejects longitude above 180
lons.push(+(raw > 180 ? raw - 360 : raw).toFixed(1));
}
}
return { lats, lons };
}
function dirToUV(speed: number, dir: number): [number, number] {
const r = dir * (Math.PI / 180);
return [-speed * Math.sin(r), -speed * Math.cos(r)];
}
async function fetchBatch(lats: number[], lons: number[]) {
const url = 'https://api.open-meteo.com/v1/forecast' +
'?latitude=' + lats.join(',') +
'&longitude=' + lons.join(',') +
'¤t=wind_speed_10m,wind_direction_10m&wind_speed_unit=ms';
const res = await fetch(url, { signal: AbortSignal.timeout(30000) });
if (!res.ok) throw new Error('Open-Meteo ' + res.status);
const json = await res.json();
const locs = Array.isArray(json) ? json : [json];
return locs.map((l: any) => ({
speed: l?.current?.wind_speed_10m ?? 0,
dir: l?.current?.wind_direction_10m ?? 0,
}));
}
export async function GET() {
try {
if (memCache && Date.now() - memCache.ts < TTL)
return NextResponse.json(JSON.parse(memCache.data));
try {
const store = getStore('wind-data');
const cached = await store.getWithMetadata('wind-current');
if (cached?.metadata?.ts && Date.now() - Number(cached.metadata.ts) < TTL)
return NextResponse.json(JSON.parse(cached.data as string));
} catch {}
const { lats, lons } = buildGrid();
const mid = Math.ceil((NX * NY) / 2);
const r1 = await fetchBatch(lats.slice(0, mid), lons.slice(0, mid));
await new Promise(r => setTimeout(r, 5000));
const r2 = await fetchBatch(lats.slice(mid), lons.slice(mid));
const all = [...r1, ...r2];
const data = {
header: { la1: LAT1, lo1: LON1, la2: -LAT1, lo2: 355,
dx: DX, dy: DY, nx: NX, ny: NY },
uData: all.map(({ speed, dir }) => dirToUV(speed, dir)[0]),
vData: all.map(({ speed, dir }) => dirToUV(speed, dir)[1]),
};
const json = JSON.stringify(data);
memCache = { ts: Date.now(), data: json };
try {
const store = getStore('wind-data');
await store.set('wind-current', json, { metadata: { ts: Date.now() } });
} catch {}
return NextResponse.json(data);
} catch {
return NextResponse.json({ error: 'Wind data unavailable' }, { status: 503 });
}
}// Color themes — exact RGB stop values required
const COLOR_THEMES = {
classic: [[0,[17,110,187]],[3,[44,183,168]],[7,[76,196,123]],
[14,[170,213,57]],[21,[253,214,43]],[28,[240,124,26]],
[40,[212,55,21]],[63,[130,0,40]]],
ocean: [[0,[0,20,60]],[5,[0,60,120]],[12,[0,110,180]],
[20,[0,170,220]],[30,[50,210,240]],[63,[220,250,255]]],
fire: [[0,[10,0,30]],[2,[60,0,10]],[5,[150,20,0]],
[10,[210,70,0]],[18,[250,140,0]],[30,[255,210,0]],[63,[255,255,255]]],
neon: [[0,[0,0,50]],[4,[30,0,120]],[8,[0,50,220]],
[14,[0,200,255]],[20,[0,255,100]],[30,[200,255,0]],[63,[255,0,150]]],
aurora: [[0,[5,10,30]],[4,[0,60,80]],[8,[0,120,100]],
[14,[0,200,150]],[22,[100,230,200]],[35,[180,160,255]],[63,[255,200,255]]],
mono: [[0,[15,15,20]],[5,[55,58,68]],[12,[110,115,128]],
[20,[170,175,188]],[30,[210,215,228]],[63,[255,255,255]]],
};
function windColor(speed: number, theme: string): string {
const stops = COLOR_THEMES[theme] || COLOR_THEMES.classic;
if (speed <= 0) return `rgb(${stops[0][1].join(',')})`;
for (let i = 1; i < stops.length; i++) {
const [s0, c0] = stops[i-1], [s1, c1] = stops[i];
if (speed <= s1) {
const t = (speed - s0) / (s1 - s0);
return `rgb(${~~(c0[0]+t*(c1[0]-c0[0]))},${~~(c0[1]+t*(c1[1]-c0[1]))},${~~(c0[2]+t*(c1[2]-c0[2]))})`;
}
}
const last = stops[stops.length-1][1];
return `rgb(${last.join(',')})`;
}
function interpolateWind(wind: WindData, lon: number, lat: number) {
const { lo1, la1, dx, dy, nx, ny } = wind.header;
const i = ((lon - lo1 + 360) % 360) / dx;
const j = (la1 - lat) / dy;
if (j < 0 || j > ny - 1) return null;
const fi = Math.floor(i) % nx, ci = (fi + 1) % nx;
const fj = Math.min(Math.floor(j), ny - 2), cj = fj + 1;
const tx = i - Math.floor(i), ty = j - fj;
const idx = (r: number, c: number) => r * nx + c;
const lerp = (a: number[]) =>
(1-tx)*(1-ty)*a[idx(fj,fi)] + tx*(1-ty)*a[idx(fj,ci)] +
(1-tx)*ty*a[idx(cj,fi)] + tx*ty*a[idx(cj,ci)];
const u = lerp(wind.uData), v = lerp(wind.vData);
return isNaN(u)||isNaN(v) ? null : [u, v] as [number,number];
}bgCanvasRef stars/background, globeCanvasRef globe, particleCanvasRef wind + interactiond3.geoOrthographic().scale(Math.min(W,H)*0.44*zoom).translate([W/2,H/2]).rotate(rotation).clipAngle(90)N_PARTICLES=45000, MAX_AGE=100, EARTH_R=6371000, TIME_SCALE=1800, FADE_ALPHA=0.025, N_COLOR_BUCKETS=100globalCompositeOperation='destination-out'. Guard: if (r<=1) return. Clip to globe circle. Batch by speed bucket, one stroke() per bucketPromise.all([fetch('/world-110m.json'), fetch('/api/wind')]) then initParticles() and start tickcurl -o public/world-110m.json https://cdn.jsdelivr.net/npm/world-atlas@2/countries-110m.json
/api/wind route caches fetched wind data in a wind-data blob store for 6 hours.Built with Rocket.new · NOAA GFS wind data via Open-Meteo · D3.js geoOrthographic projection
ATMOSPHERE8K · LIVE WIND VISUALIZATION