# Marketly: a trading landing page with a live 3D Earth in three.js, and three rendering traps

> How Marketly's three.js Earth turns towns on at dusk, draws price charts between lit cities, and three rendering traps found while building it.

Behind the build, by [Ramin Valadan](https://upfork.dev/developers/ramin-valadan), about [Marketly – Trading platform landing page](https://upfork.dev/products/marketly-trading-landing) (Landing page, comes with the Pro plan). Published 2026-10-26. 8 min read. Written with AI: Claude Opus 5.5, through Claude.

Marketly is a landing page template for a market-data and trading platform, with eight more pages (pricing, markets, ideas, brokers, features, news, sign-in and a 404), built on Next.js, Tailwind CSS and shadcn/ui. Its hero is a live 3D Earth in three.js and React Three Fiber, seen from orbit at dusk: the ground runs down the frame into the night, its towns light up as they cross the terminator, and price charts rise from one lit town and land on another.

[Live preview of Marketly – Trading platform landing page](https://upfork.dev/products/marketly-trading-landing)

This article is for anyone drawing a planet, a globe or any lit sphere in three.js who wants it to look like footage rather than a model. It explains how the Earth is set up and lit, then three rendering traps: one in the clouds, one in a CSS flag, and one in how the screenshots were taken.

## A camera in orbit, a sun behind the planet

The planet has radius 1 and sits at `(0, −1.25, 0)`; the camera rests a quarter of a radius above its surface and looks down at the horizon, so the limb is a wide, gently curved line across the lower part of the frame. The sun is behind the planet, just under the horizon, so the upper part of the visible ground is still in the evening light and the rest is night.

The spin is what makes it read as an evening seen from orbit. The planet turns about its own axis, laid across the frame with north to the right, with the camera over 37°N looking west: the ground comes over the horizon in daylight, runs down the frame, crosses the terminator, and passes into the night.

`components/marketly/hero/EarthScene.tsx`, lines 27–38:

```tsx
/*
 * The spin. The planet turns about its own axis, which lies across the frame with north to the right: the camera
 * hangs over 37°N looking west, the way an evening is seen from orbit. The ground comes over the horizon in daylight,
 * runs down the frame, crosses the terminator, and its towns light up as it passes into the night. Along 37°N the
 * frame sweeps the Mediterranean, Europe, the United States, Japan, China, India and the Middle East.
 */
const LATITUDE = THREE.MathUtils.degToRad(37);
const POLE = new THREE.Quaternion().setFromUnitVectors(new THREE.Vector3(0, 1, 0), new THREE.Vector3(Math.cos(LATITUDE), Math.sin(LATITUDE), 0));
/** Radians a second about the axis: a town crosses the frame from the horizon to the bottom edge in about 12 s. */
const SPIN_SPEED = 0.024;
/** Where the spin starts: the Middle East under the camera, the Mediterranean ahead in the evening light. */
const SPIN_START = THREE.MathUtils.degToRad(120);
```

At `SPIN_SPEED` 0.024 radians a second a town takes about 12 s to cross the frame, and a whole turn takes about 4 minutes 22 seconds (2π ÷ 0.024 s). The spin is a running angle added up each frame, so a re-render can never put the planet back where it started.

## The ground: evening light, earthshine, towns at dusk

The ground is a `ShaderMaterial` of its own, driven by one number per pixel, `dayness`, the dot product of the surface normal and the sun's direction:

- the evening light is `smoothstep(−0.03, 0.16, dayness)`, a soft ramp from the terminator toward the horizon, and the day texture shows through it under a blue veil of air;
- the night side keeps a faint cool earthshine, the ground at about 16% (`ground × (0.16, 0.16, 0.2)`), so coasts still read;
- the towns come on with `nightfall = smoothstep(0.03, −0.05, dayness)`, but not all at once.

The night texture is divided into a 1440 × 720 grid of cells, and each cell has its own moment in the dusk: a hash of the cell gives it an onset between 0 and 0.75 of the nightfall, and it fades in over the next 0.12. A cell's value is blended with its three neighbours by a smoothstep weight, as a texture is filtered, so towns come on one by one without square edges. Where one texel of the night map covers several pixels (measured with `fwidth`), its glow is broken into points of different brightness by smoothed noise, so a close town is a scatter of lights rather than a soft blob.

`components/marketly/hero/EarthScene.tsx`, lines 336–378:

```tsx
      // Whether a cell's towns are on: each cell has its own moment in the dusk.
      float cellOn(vec2 cell, float nightfall) {
        float onset = hash(cell) * 0.75;
        return smoothstep(onset, onset + 0.12, nightfall);
      }

      // Cells blended into their neighbours, so towns come on one by one without square edges.
      float townsOn(vec2 p, float nightfall) {
        vec2 i = floor(p - 0.5);
        vec2 f = fract(p - 0.5);
        vec2 u = f * f * (3.0 - 2.0 * f);
        return mix(
          mix(cellOn(i, nightfall), cellOn(i + vec2(1.0, 0.0), nightfall), u.x),
          mix(cellOn(i + vec2(0.0, 1.0), nightfall), cellOn(i + vec2(1.0, 1.0), nightfall), u.x),
          u.y
        );
      }

      void main() {
        float dayness = dot(normalize(vWorldNormal), sunDir);
        vec3 ground = texture2D(dayMap, vUv).rgb;
        vec3 lights = texture2D(nightMap, vUv).rgb;

        // Evening light from a sun low behind the horizon, and a faint cool earthshine on the night side.
        float light = smoothstep(-0.03, 0.16, dayness);
        vec3 evening = ground * (0.22 + 1.0 * light) * light;
        // Seen through the evening air: cooler, under a blue veil.
        evening = evening * vec3(0.7, 0.84, 1.05) + vec3(0.02, 0.05, 0.12) * light;
        vec3 earthshine = ground * vec3(0.16, 0.16, 0.2);
        vec3 color = mix(earthshine, evening, light);

        // Town by town: each cell of the map switches on at its own moment as the ground goes from dusk to night.
        float nightfall = smoothstep(0.03, -0.05, dayness);
        float on = townsOn(vUv * vec2(1440.0, 720.0), nightfall);
        // Where a texel covers several pixels, break its glow into points of different brightness.
        vec2 fine = vUv * vec2(8192.0, 4096.0);
        float spread = length(fwidth(fine));
        float grain = smoothNoise(fine + 17.0);
        float sparkle = mix(1.0, 0.25 + 1.6 * grain * grain, clamp(1.4 - spread * 0.6, 0.0, 1.0));
        float level = dot(lights, vec3(0.35, 0.45, 0.2));
        vec3 glow = vec3(1.0, 0.7, 0.38) * pow(level, 1.5) * 3.8 * sparkle * on;

        gl_FragColor = vec4(color + glow, 1.0);
```

## The clouds

Above the ground is a second sphere for the clouds, turning a touch faster than the planet. The first version gave it a standard material with the cloud texture as its `alphaMap`.

> **Lesson: three.js alphaMap reads a texture's green channel, not its alpha: a cloud map with clouds in alpha became a haze** (My own mistake; learned by the AI (Claude Opus 5.5); three@0.186)
> **I assumed:** `<meshStandardMaterial alphaMap={clouds} transparent />` would show the clouds where the cloud texture has them, with this texture (three.js's example cloud map, shipped here as `earth_clouds.webp`).
> **What happened:** the cloud layer was a uniform haze over the planet. An `alphaMap` is sampled from the texture's green channel, but this texture keeps its clouds in the alpha channel. Measured on the shipped file, the green channel is above half on 73% of its texels, while the alpha channel, the real cloud cover, is above a quarter on 14%.
> **The fix:** the clouds have a `ShaderMaterial` of their own that reads `.a` as the cover, lights it with the same sun direction as the ground, and all but removes it on the night side (`cover × (0.05 + 0.5 × light)`), so clouds never dim the towns.
> **Evidence:** commit c67ab3b ("The clouds read their alpha channel (they had been a uniform haze)"); the earlier code's `alphaMap={clouds}` (commit 54de615); EarthScene.tsx lines 399–411; the channels of public/textures/earth_clouds.webp, measured.

`components/marketly/hero/EarthScene.tsx`, lines 399–411:

```tsx
    fragmentShader: /* glsl */ `
      uniform sampler2D cloudMap;
      uniform vec3 sunDir;
      varying vec2 vUv;
      varying vec3 vWorldNormal;
      void main() {
        float cover = texture2D(cloudMap, vUv).a;
        float light = smoothstep(-0.06, 0.28, dot(normalize(vWorldNormal), sunDir));
        vec3 color = mix(vec3(0.02, 0.025, 0.04), vec3(0.8, 0.8, 0.82), light);
        gl_FragColor = vec4(color, cover * (0.05 + 0.5 * light));
        #include <tonemapping_fragment>
        #include <colorspace_fragment>
      }
```

## Price charts between lit towns

The lines start and end where the night texture is bright, so they always join two towns. When the scene starts, `brightSpots` draws the night map onto a 1024 × 512 canvas, keeps the brightest texel of every 6 × 6 cell if its luminance is over 70 of 255, and turns each into a point on the sphere, with the same latitude and longitude mapping three.js's `SphereGeometry` gives an equirectangular texture.

For a new line, `pickArc` takes two of those towns that are on the night side, well inside the planet's disc in the frame and high enough in it to stay there while the planet turns, 0.07 to 0.26 radians apart (about 4° to 15° of the planet), clear of the line already showing, and lying across the frame rather than pointing at the camera. It runs from the farther town to the nearer one, so the climb goes gently into the distance and the landing is the steep drop, the way a chart falls back onto its last price. `priceArc` then builds the chart: its ground track is the great circle between the two towns, and its height above the ground is the price.

`components/marketly/hero/EarthScene.tsx`, lines 747–766:

```tsx
function priceArc(a: THREE.Vector3, b: THREE.Vector3, seed: number): THREE.Vector3[] {
  const random = mulberry32(seed * 31 + 7);
  const steps = 120;
  const angle = a.angleTo(b);
  const axis = new THREE.Vector3().crossVectors(a, b).normalize();
  const height = THREE.MathUtils.clamp(angle * 0.38, 0.022, 0.06);
  const q = new THREE.Quaternion();
  const points: THREE.Vector3[] = [];
  let wobble = 0;
  for (let i = 0; i <= steps; i++) {
    const t = i / steps;
    wobble = wobble * 0.82 + (random() - 0.5) * 0.16 + (random() > 0.93 ? (random() - 0.5) * 0.35 : 0);
    const trend = Math.pow(Math.min(t / 0.86, 1), 1.1);
    const land = t < 0.86 ? 1 : 1 - Math.pow((t - 0.86) / 0.14, 1.2);
    const level = THREE.MathUtils.clamp(trend * (1 + wobble), 0, 1.25) * land;
    const ground = a.clone().applyQuaternion(q.setFromAxisAngle(axis, angle * t)).normalize();
    points.push(ground.multiplyScalar(1.0015 + height * level));
  }
  return points;
}
```

The height is `clamp(angle × 0.38, 0.022, 0.06)` radii, so a longer line stands taller; the level follows a rising trend with a jagged, decaying wobble and the odd sharp move, and comes down in the last 14% of the way. Two lines show at a time, each drawn in over 2.8 s, held for 2.6 s and faded over 0.9 s, three widths of the same line stacked for the glow, and the town it lands on flares up.

## A flag that is a line of text

The rest of the page is ordinary React and CSS, with one trap. In the listed copy, the flags of flag-icons' CSS classes became round flags of their own (square SVGs in `public/flags/`, clipped to a circle), and the economic calendar's rows no longer matched the original.

> **Lesson: flag-icons fills each flag's ::before with a non-breaking space, not a space: a stand-in flag needs that line box too** (Not in the docs; learned by the AI (Claude Opus 5.5); flag-icons@7.5)
> **I assumed:** flag-icons' `.fi:before { content: " " }` was a plain space, so a stand-in flag with a plain space in its `::before` (Tailwind's `before:content-['_']`) would sit in the row the same way.
> **What happened:** compared with the original, the preview's calendar row was 3 px taller. The real flag's computed `::before` showed `content: " "` with a 20px line height, and the bytes of that space in flag-icons' stylesheet were `C2 A0`: a non-breaking space. A non-breaking space doesn't collapse, so it gives the flag a line box and a text baseline to sit on; a plain space collapses away and gives neither.
> **The fix:** the stand-in flag renders a U+00A0 itself, with its line height equal to its size ("a line of text, so the flag sits on the baseline the way a letter does"). The calendar rows then matched the original to the pixel.
> **Evidence:** the build's probes of both pages (the computed `::before` and the stylesheet's bytes); Flag.tsx lines 10–23.

`components/marketly/ui/Flag.tsx`, lines 10–23:

```tsx
export function Flag({ country, size = 36, className, title }: { country: string; size?: number; className?: string; title?: string }) {
  return (
    <span
      role={title ? "img" : undefined}
      aria-label={title}
      aria-hidden={title ? undefined : true}
      className={cn("relative inline-block shrink-0 rounded-full bg-cover bg-center", className)}
      style={{ width: size, height: size, lineHeight: `${size}px`, backgroundImage: `url("${flagSrc(country)}")` }}
    >
      {/* a line of text, so the flag sits on the baseline the way a letter does */}
      {"\u00a0"}
    </span>
  );
}
```

## Taking the screenshots

The gallery pictures and every comparison with the reference were shot by script in Chromium, and the hero is a pinned stage the page scrolls through.

> **Lesson: An instant scrollTo before a Playwright shot of a scroll-pinned WebGL page left a stale frame: scroll with the wheel** (The tools and the machine; learned by the AI (Claude Opus 5.5); playwright-core@1.55)
> **I assumed:** jumping to each section with `window.scrollTo` and then taking the screenshot would show that section as a visitor sees it.
> **What happened:** on the landing page, an instant jump could leave a stale frame in the shot: the upper half black and the header in the middle of the screen. The economy section's picture had to be shot again.
> **The fix:** the scripts scroll only with `page.mouse.wheel`, in steps of a few hundred pixels with a wait after each, and shoot once the page has arrived, as a visitor's own scrolling would.
> **Evidence:** the build's screenshot scripts, run with `playwright-core@1.55` installed beside them (not a dependency of the template), and the re-shoot's own comment: "an instant jump there left a stale black frame"; the build notes.

## Checked

- `pnpm lint` and `pnpm build`.
- UpFork's importer and preview builder on the listed copy: 95 files, 6 full-page demos, 365,251 characters, and a 2.1 MB preview page. Shot at the same scroll positions as the original template, the preview matched it except where it moves: the turning Earth, a chart line mid-draw, and next/image's resampling.
- The economic calendar's rows, after the flag fix: the same height as the original, to the pixel.

## Make it yours

- `SPIN_SPEED` and `SPIN_START` set how fast the planet turns and what is under the camera at the start; `ARC_SLOTS`, `ARC_COLORS` and the draw, hold and fade times set the price lines.
- The scene falls back to a still (`EarthFallbackVideo`) without WebGL or when `navigator.deviceMemory` is under 4, renders one still frame with reduced motion, and skips the bloom pass below 768 px or above a pixel ratio of 2.
- `EarthSceneLazy` loads the three.js code as its own chunk and mounts it after the browser's first idle moment, so the headline and the rest of the page come first.
- The market data is mock data in `lib/marketly/data/mock.ts`, and the page's words are in `lib/marketly/data/copy.ts`.

```bash
npx shadcn@latest add @upfork/marketly-trading-landing
```

---

On UpFork: https://upfork.dev/articles/marketly-a-trading-landing-page-with-a-live-3d-earth-in-three-js-and-three-r
