A solar system
Item textures cropped to their disc, custom renderers by item kind (planets with a night side and rings), edges as orbits, a pulse on the Sun and an image background. Pluto’s face is on a host that sends no CORS header, so it keeps its plain disc and onTextureError reports it: see Images and CORS.
The frame below runs the example built from exactly these files. Start with source.ts. Every
example shares one small page shell (packages/core/examples/shared/ExampleShell.tsx): it keeps the focus in the
URL hash, builds a breadcrumb from onSceneChange, and shows the selected item from onNodeSelect.
import { createSceneSource, type ExplorerEdge, type ExplorerNode, type ExplorerScene, type SceneSource } from "@data-xplorer/core";import { bodyById, PLANETS, SUN, type Body } from "./data";import { FACE_URLS } from "./textures";
/** * A star system as radial scenes: the Sun at the centre with its planets round it, each planet a * scene of its own with its moons. Every body is drawn with a same-origin texture (US071), except * Pluto, whose face is on an origin the canvas may not read. */type PlanetId = string;
/** * Pluto's face: an image on another origin that sends no CORS header. `github.com/<user>.png` * redirects to the avatar without one, so a page can show it in an `<img>` but a canvas may not * draw it. Pluto keeps its plain disc, and `onTextureError` reports the URL once. */export const NO_CORS_FACE = "https://github.com/SirKettle.png";
/** Where a body's face is: one of `textures/` (same origin), or the no-CORS image. */export function faceUrl(face: string): string { return face === "no-cors" ? NO_CORS_FACE : (FACE_URLS[face] ?? FACE_URLS.moon!);}
function bodyNode(body: Body, drillable: boolean): ExplorerNode<PlanetId> { return { id: body.id, label: body.name, sublabel: `${body.radiusKm.toLocaleString("en")} km`, weight: body.radiusKm, marks: [`radius ${body.radiusKm.toLocaleString("en")} km`, ...(body.moons.length ? [`${body.moons.length} moons shown`] : [])], href: `#${body.id}`, drillsInto: drillable && body.moons.length > 0 ? body.id : null, shape: "disc", texture: body.face ? { url: faceUrl(body.face) } : null, // A planet is drawn by the example's own renderer (planet-renderers.ts); the Sun and the // moons keep the built-in disc and texture. kind: body.kind ?? null, effects: body === SUN ? ["pulse"] : [], };}
function orbits(centre: Body, around: Body[]): ExplorerEdge[] { return around.map((body) => ({ id: `${body.id}-orbits-${centre.id}`, sourceId: body.id, targetId: centre.id, kind: "orbits" }));}
function buildScene(focus: PlanetId | null): ExplorerScene<PlanetId> { const centre = focus === null ? SUN : bodyById.get(focus)!; const around = focus === null ? PLANETS : centre.moons; return { id: centre.id, heading: focus === null ? "The Solar System" : centre.name, focusId: centre.id, // radial: the centre body in the middle, what orbits it around clusters: [], nodes: [bodyNode(centre, false), ...around.map((body) => bodyNode(body, focus === null))], edges: orbits(centre, around), };}
export function solarSystemSource(): SceneSource<PlanetId> { return createSceneSource<PlanetId>({ load: buildScene, // A planet's scene sits inside its own disc in the Sun's. parentOf: (planet) => ({ kind: "anchored", focus: null, anchorId: planet }), });}
export const decodePlanet = (hash: string): PlanetId | null => (PLANETS.some((p) => p.id === hash) ? hash : null);import { useState } from "react";import { createRoot } from "react-dom/client";import { ExampleShell } from "../shared/ExampleShell";import { bodyById } from "./data";import { decodePlanet, solarSystemSource } from "./source";import starsUrl from "./stars.svg";
const source = solarSystemSource();
function SolarSystem() { const [failed, setFailed] = useState<string[]>([]);
return ( <ExampleShell title="A solar system" intro={ <p> The Sun with its planets round it; enter a planet to see its moons. Every body is drawn with a <code>texture</code> from this page's own origin, cropped to its disc once it is big enough on screen. Pluto's comes from a second origin that sends no CORS header, so the canvas may not use it: Pluto keeps its plain disc, and <code>onTextureError</code>{" "} reports it once, below. The Sun pulses; the orbits are edges. The planets are drawn by a custom renderer, loaded lazily: a night side, and rings for Saturn that shimmer (held still under reduced motion). </p> } source={source} encode={(planet) => planet} decode={decodePlanet} trailOf={(planet) => (planet === null ? ["The Solar System"] : ["The Solar System", bodyById.get(planet)!.name])} background={{ color: "#03040a", image: { url: starsUrl, fit: "tile", alpha: 0.8 }, grid: false }} edges={{ visible: true, style: { dash: [2, 6], alpha: 0.35, width: 1 } }} textures={{ minDiameterPx: 32 }} renderers={() => import("./planet-renderers")} events={{ onTextureError: ({ url }) => setFailed((urls) => [...urls, url]) }} footer={ failed.length > 0 && ( <section className="example__events" aria-label="Texture errors"> <p className="example__camera"> onTextureError: {failed.join(", ")} could not be drawn on the canvas (no CORS header). </p> </section> ) } /> );}
createRoot(document.getElementById("root")!).render(<SolarSystem />);import { Container, Graphics, Sprite, Texture } from "pixi.js";import { defineRenderers, type ItemFrame } from "@data-xplorer/core/pixi";
/** * The solar-system example's custom renderers (US071 Step 7): a planet with a night side, and a * ringed planet whose rings pass behind and in front of it. This module and PixiJS are loaded * lazily, through `renderers={() => import("./planet-renderers")}`; nothing on the page imports * them directly. * * Every view is drawn at a design radius of 50 and scaled to the item, so the drawing code never * deals with zoom. The light comes from the upper left. Saturn's rings shimmer (`animated: true`), * which is the one thing here that asks for frames; under reduced motion they hold still. */
const R = 50;/** The texture is cropped to a disc of radius 124 in a 256 px square (the package's crop). */const TEXTURE_SCALE = R / 124;/** Below this on screen, a planet is a plain tinted disc: the detail would not read. */const DETAIL_PX = 28;const LIGHT_ANGLE = (-135 * Math.PI) / 180; // from the upper left
/** The dark side: the half facing away from the light, bounded by an elliptical terminator. */function nightSide(): Graphics { const shade = new Graphics(); const points: number[] = []; for (let i = 0; i <= 24; i++) { const t = -Math.PI / 2 + (Math.PI * i) / 24; points.push(Math.cos(t) * R, Math.sin(t) * R); // the lit-away limb } // The terminator, bowed towards the night. It shares its two ends with the limb, so they are // not repeated: a repeated vertex made the triangulation degenerate and blacked out the canvas. for (let i = 23; i >= 1; i--) { const t = -Math.PI / 2 + (Math.PI * i) / 24; points.push(Math.cos(t) * R * 0.25, Math.sin(t) * R); } shade.poly(points).fill({ color: 0x000000, alpha: 0.55 }); shade.rotation = LIGHT_ANGLE + Math.PI; // face away from the light return shade;}
/** Half a ring ellipse: `front` is the half nearer the viewer, drawn over the planet. */function ringHalf(front: boolean): Graphics { const ring = new Graphics(); const from = front ? 0 : Math.PI; for (const [radius, alpha, width] of [ [1.65, 0.75, 7], [1.95, 0.45, 5], ] as const) { const points: number[] = []; for (let i = 0; i <= 32; i++) { const t = from + (Math.PI * i) / 32; points.push(Math.cos(t) * R * radius, Math.sin(t) * R * radius * 0.3); } ring.moveTo(points[0]!, points[1]!); for (let i = 2; i < points.length; i += 2) ring.lineTo(points[i]!, points[i + 1]!); ring.stroke({ color: 0xe8d3a2, alpha, width }); } ring.rotation = -0.35; return ring;}
interface PlanetView extends Container { body: Graphics; face: Sprite; shade: Graphics; back?: Graphics; front?: Graphics;}
function createPlanet(ringed: boolean): PlanetView { const view = new Container() as PlanetView; view.body = new Graphics().circle(0, 0, R).fill(0xffffff); view.face = new Sprite(Texture.EMPTY); view.face.anchor.set(0.5); view.face.scale.set(TEXTURE_SCALE); view.shade = nightSide(); if (ringed) { view.back = ringHalf(false); view.front = ringHalf(true); view.addChild(view.back); } view.addChild(view.body, view.face, view.shade); if (view.front) view.addChild(view.front); return view;}
/** A slow shimmer of the rings: a 6 s breath of their brightness. */const SHIMMER_MS = 6000;
function updatePlanet(view: PlanetView, frame: ItemFrame) { view.scale.set(frame.size / (R * 2)); view.body.tint = frame.tint ?? 0x888888; view.face.texture = frame.texture ?? Texture.EMPTY; view.face.visible = frame.texture !== null; const detailed = frame.screenSize >= DETAIL_PX; view.shade.visible = detailed; if (view.back) view.back.visible = detailed; if (view.front) view.front.visible = detailed; if (view.back && view.front) { const level = frame.reducedMotion ? 0.5 : 0.5 - 0.5 * Math.cos((2 * Math.PI * frame.timeMs) / SHIMMER_MS); view.back.alpha = 0.7 + 0.3 * level; view.front.alpha = 0.7 + 0.3 * level; }}
export default defineRenderers({ planet: { create: () => createPlanet(false), update: (view: PlanetView, _item, frame) => updatePlanet(view, frame), destroy: (view: PlanetView) => view.destroy({ children: true }), }, "ringed-planet": { create: () => createPlanet(true), update: (view: PlanetView, _item, frame) => updatePlanet(view, frame), destroy: (view: PlanetView) => view.destroy({ children: true }), animated: true, },});/** * The Sun, its planets, one dwarf planet and some of their moons. Radii are mean radii in km, * rounded; they are what each body's size is drawn from, and the viewer can read them. */export interface Body { /** Ordered, so the radial ring (sorted by id) goes round in orbital order. */ id: string; name: string; radiusKm: number; /** A face in `textures/`, or `null` for a body without one. */ face: string | null; /** The custom renderer that draws it, if any: `planet`, or `ringed-planet` for Saturn. */ kind?: "planet" | "ringed-planet"; moons: Body[];}
const moon = (id: string, name: string, radiusKm: number, face: string | null = "moon"): Body => ({ id, name, radiusKm, face, moons: [] });
export const SUN: Body = { id: "sun", name: "Sun", radiusKm: 696_340, face: "sun", moons: [] };
export const PLANETS: Body[] = [ { id: "p1-mercury", name: "Mercury", radiusKm: 2_440, face: "mercury", kind: "planet", moons: [] }, { id: "p2-venus", name: "Venus", radiusKm: 6_052, face: "venus", kind: "planet", moons: [] }, { id: "p3-earth", name: "Earth", radiusKm: 6_371, face: "earth", kind: "planet", moons: [moon("m1-moon", "Moon", 1_737)] }, { id: "p4-mars", name: "Mars", radiusKm: 3_390, face: "mars", kind: "planet", moons: [moon("m1-phobos", "Phobos", 11), moon("m2-deimos", "Deimos", 6)] }, { id: "p5-jupiter", name: "Jupiter", radiusKm: 69_911, face: "jupiter", kind: "planet", moons: [moon("m1-io", "Io", 1_822, "io"), moon("m2-europa", "Europa", 1_561, "europa"), moon("m3-ganymede", "Ganymede", 2_634), moon("m4-callisto", "Callisto", 2_410)], }, { id: "p6-saturn", name: "Saturn", radiusKm: 58_232, face: "saturn", kind: "ringed-planet", moons: [moon("m1-enceladus", "Enceladus", 252), moon("m2-rhea", "Rhea", 764), moon("m3-titan", "Titan", 2_575, "titan")], }, { id: "p7-uranus", name: "Uranus", radiusKm: 25_362, face: "uranus", kind: "planet", moons: [moon("m1-miranda", "Miranda", 236), moon("m2-titania", "Titania", 789), moon("m3-oberon", "Oberon", 761)] }, { id: "p8-neptune", name: "Neptune", radiusKm: 24_622, face: "neptune", kind: "planet", moons: [moon("m1-triton", "Triton", 1_353)] }, // Its face is served from a second origin with no CORS header: the canvas may not use it. { id: "p9-pluto", name: "Pluto", radiusKm: 1_188, face: "no-cors", kind: "planet", moons: [moon("m1-charon", "Charon", 606)] },];
export const bodyById = new Map<string, Body>([[SUN.id, SUN], ...PLANETS.map((p) => [p.id, p] as const)]);// Imported, so the bundler gives each a URL that works wherever the examples are served (the dev// server, or the docs site under a sub-path). Same origin, so the canvas may draw them.import earth from "./earth.svg";import europa from "./europa.svg";import io from "./io.svg";import jupiter from "./jupiter.svg";import mars from "./mars.svg";import mercury from "./mercury.svg";import moon from "./moon.svg";import neptune from "./neptune.svg";import saturn from "./saturn.svg";import sun from "./sun.svg";import titan from "./titan.svg";import uranus from "./uranus.svg";import venus from "./venus.svg";
export const FACE_URLS: Record<string, string> = { earth, europa, io, jupiter, mars, mercury, moon, neptune, saturn, sun, titan, uranus, venus };