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.
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:
- 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.
- 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:
- 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×.
- Um único
THREE.Pointspara a constelação inteira. Seis mil meshes individuais destruiriam o reconciler do React. Empacotamos as 6.000 posições num únicoFloat32Array, atualizamos o buffer subjacente do atributopositionin-place e setamosneedsUpdate = true. Uma chamada de draw, uma atualização de buffer por segundo. - 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.jsfaz 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
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.