Vol. 07 · Dispatch2026-06-24

Rastreando 6.000 Starlinks no navegador com SGP4 e TypeScript

Como colocamos a ISS, o Hubble e a constelação Starlink inteira num globo WebGL a 60 FPS — pipelines de TLE, matemática ECI→ECEF, o que cachear onde, e o que NÃO fazer.

by MetaWorldOS Engineering
TypeScriptSatélitesWebGLThree.jsSGP4

TL;DR — Kepler basta para planetas. Satélites terrestres precisam de SGP4: um modelo analítico de perturbações da década de 1960 que a NASA ainda publica, alimentado por strings TLE atualizadas diariamente pelo NORAD. Colocamos a ISS, o Hubble, ~30 satélites nomeados e a constelação Starlink inteira (~6.000 satélites) num globo 3D ao vivo a 60 FPS usando satellite.js, ~500 linhas de cola, duas camadas de cache e um salto de coordenadas ECI→ECEF. Este é o passo-a-passo de engenharia.

Demo ao vivo: /universe — ligue a camada de Satélites + a camada Starlink. · decodificador interativo: /tools/tle


Sequência natural do nosso post sobre o sistema solar Kepleriano: Kepler é o modelo certo para planetas e luas, mas é o modelo errado para um satélite que orbita a Terra. Arrasto atmosférico, achatamento (J2), gravidade lunar/solar, pressão de radiação solar — cada uma dessas perturbações é pequena por órbita e acumula rápido o suficiente para que uma previsão Kepleriana pura da ISS desvie ~10 km em um único dia. Um satélite com cinco órbitas de vida no nosso renderer estaria visivelmente no lugar errado.

A correção é SGP4 — Simplified General Perturbations 4 — um propagador analítico de forma fechada desenvolvido para o catálogo de rastreamento do NORAD em 1970, refinado em 1980 (a especificação que ainda usamos) e com uma implementação de referência definitiva em C / C++ entregue por Vallado em 2006. Existem ports em TypeScript dessa referência; o mais bem mantido é satellite.js. Pesa 30 KB minificado e roda a propagação SGP4 em cerca de 100 microssegundos por satélite. Multiplique por 6.000 Starlinks e você fica em ~600 ms para um tick da constelação inteira — lento demais para uma chamada por frame, OK para uma chamada por segundo. Voltamos a isso mais para frente.


O formato TLE

Cada satélite tem um Two-Line Element set — duas linhas ASCII de 69 caracteres, assim para a ISS:

1 25544U 98067A   24181.71527778  .00012654  00000+0  22861-3 0  9991
2 25544  51.6406 109.5394 0001423 326.0921 156.7820 15.50157644457245

Não tente ler isso. Os campos são posicionais, de largura fixa, e sobrecarregados com pontos decimais e expoentes implícitos (22861-3 significa 0.00022861e-3). O trabalho do satellite.js é transformar essas duas linhas num SatRec que o SGP4 pode percorrer no tempo.

import * as satellite from "satellite.js";

const satrec = satellite.twoline2satrec(tle.line1, tle.line2);
// satrec agora é um propagador stateful para este satélite específico.

De onde vêm os TLEs? Celestrak é a fonte pública de facto — eles republicam o catálogo do NORAD a cada poucas horas, gratuitamente, sem chave de API, com um pedido educado para cachear e não martelar o servidor. Existem dois padrões de consulta:

  • Por satélite via NORAD ID: gp.php?CATNR=25544&FORMAT=tle — 144 bytes.
  • Por grupo: gp.php?GROUP=starlink&FORMAT=tle — ~1 MB de texto puro para todas as Starlinks.

Para satélites nomeados (ISS, Hubble, Tiangong, Landsat, BeiDou, GLONASS) batemos no endpoint individual com cache em memória de 24 h + cache no localStorage de 48 h. Para Starlink batemos no endpoint GROUP com cache de 12 h, porque 6.000 fetches individuais nos levariam ao rate-limit em segundos. Mais sobre isso adiante.


O pipeline: TLE → tela, em um diagrama

   strings TLE              (duas linhas ASCII de 69 chars do Celestrak)
        │
        ▼
   satellite.js: twoline2satrec()
        │
        ▼
   SatRec  ─────────►  cache por (norad_id, tleHash)
        │
        ▼
   SGP4 propagate(satrec, date)
        │
        ▼
   Posição ECI (km)         (Earth-Centered Inertial — referencial não-rotativo
        │                    onde a Terra gira por baixo de você)
        │
        ▼
   eciToEcf(eci, gmst)
        │
        ▼
   Posição ECEF (km)        (Earth-Centered Earth-Fixed — referencial rotativo
        │                    onde as estações de solo ficam paradas)
        │
        ▼
   Escala por KM_TO_SCENE = 0.8 / 6371
        │
        ▼
   Three.js Vector3 em unidades de cena, relativo ao centro da Terra
        │
        ▼
   + posição heliocêntrica da Terra (do nosso sistema solar Kepleriano)
        │
        ▼
   Coordenada final em mundo da cena

A coisa toda cabe numa função. Aqui está o código de produção real, sem o tratamento de erros:

export function computeSatelliteEcefFromTle(
    tle: TLE,
    date: Date,
    cacheKey: string
): THREE.Vector3 {
    const satrec = getSatRecFromTLE(tle, cacheKey);

    // 1. SGP4: tempo → estado orbital em Earth-Centered Inertial (km).
    const { position: eciKm } = satellite.propagate(satrec, date);

    // 2. ECI → ECEF: rotaciona pelo ângulo de rotação atual da Terra (GMST).
    const gmst = getGreenwichSiderealTime(date);
    const ecefKm = satellite.eciToEcf(eciKm, gmst);

    // 3. km → unidades de cena.
    const x = ecefKm.x * KM_TO_SCENE;
    const y = ecefKm.y * KM_TO_SCENE;
    const z = ecefKm.z * KM_TO_SCENE;

    // 4. Remapeia eixos: o ECEF do satellite.js coloca X em Greenwich,
    //    Z no polo norte, Y a 90°L. Nossa cena coloca Y no polo norte. Troca.
    return new THREE.Vector3(x, z, y);
}

Doze linhas de lógica de negócio, seis linhas de comentário. O trabalho é escolher as chamadas certas, não escrever a matemática.


Por que ECI → ECEF importa

Esse é o pedaço que iniciantes sempre erram. Vou destrinchar.

O SGP4 produz saída em ECI — Earth-Centered Inertial. A origem do referencial é o centro da Terra e o eixo X aponta para o equinócio vernal (uma direção fixa no espaço, não na Terra). A Terra rotaciona dentro desse referencial uma vez por dia sideral.

Se você pegar a saída crua do SGP4 e desenhar por cima de uma Terra que também está rotacionando com o calendário, tanto a Terra quanto os satélites estão rotacionando. A ISS parece voar sobre Tóquio, Tóquio voa para fora dela, a ISS parece voar sobre Mumbai. Errado.

Você tem duas opções:

  1. Renderizar a Terra em ECI — apontando para o mesmo lado 24/7, com a textura (continentes) rotacionando por baixo. Os satélites ficam parados em relação ao referencial inercial.
  2. Renderizar a Terra em ECEF — Earth-Centered Earth-Fixed — textura grudada na superfície, estações de solo paradas. Converte a posição do satélite de ECI para ECEF a cada frame.

A opção (1) é errada para qualquer UX que envolva “onde a ISS está agora” — as trajetórias no solo ficam ilegíveis. A (2) é o que todo tracker de satélite de consumidor usa, e é o que usamos.

A conversão é uma rotação de matriz pelo ângulo do Greenwich Mean Sidereal Time:

const gmst = getGreenwichSiderealTime(date);   // radianos
const ecef = satellite.eciToEcf(eci, gmst);

O satellite.js faz a rotação. A única coisa que ele não faz é alinhar com a orientação do meridiano de Greenwich da sua textura da Terra — isso depende de como sua textura está mapeada. Empiricamente rotacionamos por um -π/2 extra para casar com a convenção típica de textura equirectangular da Terra (Greenwich no centro):

const LONGITUDE_OFFSET = -Math.PI / 2;   // rotaciona para alinhar com a textura

Se sua ISS aparece no Oceano Índico quando deveria estar sobre Houston, esse é o botão.


As duas camadas de cache

O SGP4 em si é rápido (~100 µs por satélite por chamada). As partes caras são buscar TLEs pela rede e construir o SatRec a partir das strings TLE. Cacheamos ambos agressivamente:

Camada 1: memoização do SatRec (por processo, em memória)

const satrecCache = new Map<string, satellite.SatRec>();
const tleHashCache = new Map<string, string>();

export function getSatRecFromTLE(tle: TLE, cacheKey: string): satellite.SatRec {
    const tleHash = `${tle.line1}|${tle.line2}`;
    if (tleHashCache.get(cacheKey) === tleHash && satrecCache.has(cacheKey)) {
        return satrecCache.get(cacheKey)!;
    }
    const satrec = satellite.twoline2satrec(tle.line1, tle.line2);
    satrecCache.set(cacheKey, satrec);
    tleHashCache.set(cacheKey, tleHash);
    return satrec;
}

A chave do cache é o identificador estável do satélite (por exemplo, "iss"); a chave de invalidação é um hash das strings TLE. TLE novo → SatRec novo. Mesmo TLE → reuso instantâneo. Sempre faça hash do TLE, nunca confie só na chave do cache — TLEs são atualizados diariamente, e um SatRec velho significa previsões erradas.

Camada 2: persistência de TLE (por navegador, em localStorage)

Para satélites nomeados, o próprio TLE é cacheado por 24 h em memória e 48 h em localStorage. Para Starlink (um fetch cobre 6.000 satélites), 12 h. O cache é stale-while-revalidate — se expirou, servimos o dado velho imediatamente E disparamos um refresh assíncrono em background, então usuários nunca veem “carregando 6.000 satélites” numa visita de retorno.

async function getStarlinkTles(): Promise<StarlinkTleEntry[]> {
    const cached = readFromLocalStorage();
    const fresh = cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS;

    if (cached) {
        if (!fresh) refreshInBackground();  // fire-and-forget
        return cached.entries;               // serve velho imediatamente
    }
    return await fetchFresh();               // primeira visita, precisa esperar
}

12 h é o ponto doce para satélites em LEO: SGP4 com um TLE de 12 horas mantém o erro bem abaixo de 1 km, o que na nossa escala km-para-cena é sub-pixel. Os próprios TLEs do Celestrak normalmente têm idade de algumas horas a um dia; usar uma versão de 12 horas não é pior do que o que estações profissionais usam.


Escala: da ISS à Starlink

Para um satélite o pipeline é trivial. Para 6.000 é um problema de orçamento.

Um orçamento típico de render é 16,6 ms por frame a 60 FPS. SGP4 a ~100 µs/satélite dá 600 ms para a constelação Starlink inteira — 36 frames a 60 FPS. Não dá para propagar todo satélite todo frame.

O que fazemos:

  1. Tick a 1 Hz, não 60 Hz. A posição dos satélites não muda perceptivelmente em 16 ms — a ISS se move ~120 m nesse intervalo, bem abaixo de um pixel em zoom típico. Uma atualização por segundo é invisível ao olho e reduz o custo do SGP4 em 60×.
  2. Um único THREE.Points para a constelação inteira. Seis mil meshes individuais destruiriam o reconciler do React. Empacotamos as 6.000 posições num único Float32Array, atualizamos o buffer subjacente do atributo position in-place e setamos needsUpdate = true. Uma chamada de draw, uma atualização de buffer por segundo.
  3. Frustum cull na leitura, não na escrita. Não pule a propagação de satélites fora da tela — o custo de propagar é o mesmo, e você teria que re-propagar cada vez que a câmera se move. Apenas alimente as 6.000 posições e deixe a GPU recortar.

Resultado: ~10 ms de CPU por segundo para manter a constelação inteira atualizada. O resto do frame é seu.


O que deliberadamente NÃO fizemos

Lista honesta:

  • Modelos atmosféricos. SGP4 inclui um termo de arrasto simples, mas densidade atmosférica realista (que afeta significativamente órbitas em LEO durante tempestades solares) precisa de um modelo externo como NRLMSISE-00. Não rodamos. Para uma visualização, o erro resultante é invisível. Para previsão de colisão, seria tudo.
  • Manobras. Os satélites Starlink fazem queimas de evitamento de colisão regularmente. Os TLEs refletem o estado pós-queima quando a SpaceX publica os novos; entre atualizações mostraríamos a trajetória pré-queima. Aceitamos o desvio.
  • Espaço profundo (SDP4). Para satélites acima da altitude geoestacionária, o SGP4 passa o bastão para o SDP4 com perturbações lunares/solares. O satellite.js faz isso de forma transparente — usamos, mas não escrevemos.
  • Verificação de época do TLE. A boa prática é recusar propagar um TLE com mais de 14 dias de idade — o erro do SGP4 cresce não-linearmente além disso. Não impomos esse limite; confiamos na cadência de atualização do Celestrak. Uma implementação mais conservadora alertaria ou recusaria.

Quanto pesa

Módulo Linhas Nota
satellitePosition.ts (o pipeline acima) 188 TLE → ECEF, com cache
tleService.ts (fetch + cache por satélite) 306 Endpoint Celestrak por NORAD
starlinkService.ts (fetch da constelação) 207 Endpoint GROUP + cache SWR
ISS.tsx (o componente 3D + telemetria) 281 Mesh + ground track + footprint
Hubble.tsx, Satellite.tsx genérico ~200 Mesma forma, modelo diferente
siderealTime.ts (helper GMST) ~50 Uma função de matemática

Adicione os arquivos de dados (TLEs, o catálogo) e a cola do React, e a camada de satélite inteira fica em ~1.500 linhas em cima do satellite.js. O trabalho pesado — SGP4, ECI/ECEF, a matemática de propagação — vive na dependência. Nosso trabalho é encanamento, cache e respeito à largura de banda do Celestrak.


Lições para o seu eu do passado

  1. Não propague a 60 Hz. Satélites em velocidade orbital se movem menos que um pixel por frame em zoom típico. Atualizações de 1 Hz são visualmente idênticas e 60× mais baratas.
  2. Cacheie SatRecs pelo hash do TLE, não pelo nome. Um SatRec velho é invisível até sua visualização ficar absurdamente errada. Hashear as linhas do TLE é uma linha; te poupa do tipo errado de sessão de debug.
  3. Stale-while-revalidate é a forma certa para TLEs. Usuários com conexão lenta não devem encarar uma cena em branco. Sirva o último fetch, atualize em background, substitua silenciosamente. SGP4 com um TLE de meio dia ainda fica dentro de 1 km.
  4. Converta ECI → ECEF ou suas trajetórias no solo vão mentir. Se sua textura tem Greenwich no centro e seus satélites estão rotacionando com a Terra, você está fazendo errado. Escolha um referencial, mantenha-o.
  5. Não faça fan-fetch de 6.000 satélites. O endpoint GROUP do Celestrak existe por uma razão. Use. As multas de rate-limit dos seus usuários vão te agradecer.
  6. A resposta é satellite.js. Não porte SGP4 você mesmo, a menos que goste de implementar Fortran dos anos 80. A biblioteca TypeScript é madura, rápida, e é a mesma matemática que o NORAD usa.

Experimente

A constelação ao vivo vive em /universe — ligue a camada de Satélites (ISS, Hubble, Tiangong + ~30 entradas do catálogo) e a camada Starlink (todos os ~6.000 satélites ativos, com fetch GROUP e cache SWR). A variante de Céu de Aniversário (/universe/birthday) reusa a mesma camada de satélite em qualquer instante do tempo — digite uma data, o TimeManager pausa, a camada de satélite é redesenhada a partir do mesmo pipeline TLE naquele exato instante.

Se você está construindo um tracker de satélite e tem dúvidas — sobre a invalidação de cache, a conversão para o referencial ECEF, a draw call da Starlink, ou a política de época do TLE — entre em contato pelo Contact.

— Read Next —

Recommended Dispatches

More engineering deep-dives into 3D rendering, physics simulation, and game architecture

Three.js

Zero art assets — building every polygon, texture, and shader in code

Pokémon of the Forest ships no GLB files, no PNG textures, no baked normal maps. Every creature is marching-cubes-meshed from Wyvill metaballs at boot; every leaf is a 2D-canvas Bézier fill baked into a CanvasTexture; every terrain patch is an analytic heightfield sampled onto a PlaneGeometry; every skin material is a MeshPhysicalMaterial with a custom subsurface-wrap term injected via onBeforeCompile. This post walks the seven techniques that let a browser game with ~24k lines of TypeScript render Pallet Town without downloading a single texture.

Read dispatch
Three.js

How Infinitown fakes an infinite city with 81 chunks and mod 9

The infinite-scrolling town in our Infinitown gamecenter port isn't procedurally generated — it's a Möbius carpet. A 9×9 pool of pre-built chunks maps onto a 9×9 grid of fixed container slots through modulo arithmetic, and camera drags rebind slots to different pool entries instead of spawning new geometry. This post walks the four moving parts (pool, containers, mapping, drag event) and explains why the whole system holds together with zero allocations at runtime.

Read dispatch
IndexedDB

When signed URLs break your browser cache, put the bytes in IndexedDB

Signed OSS URLs rotate every request, so the disk cache never matches. Sound Race redownloaded 13 GLBs on every visit until we sat an IndexedDB layer in front of GLTFLoader. Here's the code, why Cache Storage doesn't help, and one invalidation edge case we're leaving as tech debt.

Read dispatch

Receba um email quando publicarmos um novo artigo — análises técnicas, ~uma vez por mês.