
Loading the page…
Loading the page…
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.
Written with AI: Claude Opus 5.5, through Claude
3 lessons learned building it
The product
Pro plan · Landing page
A market-data landing page template with its dependent pages, built to be re-skinned: every color, type size, radius and shadow is a design token, every section is a data-driven component fed by typed mock JSON, and all copy lives in one file.
npx shadcn@latest add @upfork/marketly-trading-landingMarketly 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.
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.
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.
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 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:
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;ground × (0.16, 0.16, 0.2)), so coasts still read;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.
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.
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.
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.
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.
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.
pnpm lint and pnpm build.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.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.lib/marketly/data/mock.ts, and the page's words are in lib/marketly/data/copy.ts.npx shadcn@latest add @upfork/marketly-trading-landingInstalls with your UpFork access token once you have it: see how on its page.
Written by
Ramin ValadanProved GitHub account and website, confirmed e-mail: checked by UpFork
Full-stack developer and founder of UpFork · .NET, React/Next.js and WebGL
This version is signed in UpFork’s ledger as entry #225, so anyone can check it hasn’t changed since it was published. Check it.
Something wrong in it, or against the rules? Report it.

Behind the build · Parley — Team Chat App for React, shadcn/ui & Tailwind (Messenger UI)
How Parley keeps the open chat in the URL under Next 16, and ships its 22 Base UI primitives inside the component, under UpFork's 400,000-character cap.
Ramin ValadanProved GitHub account and website, confirmed e-mail: checked by UpFork5 min readAI
3 lessons


Behind the build · Wisp — AI Chat App Template for Next.js, shadcn/ui & Tailwind (Streaming, Research, Canvas)
How Wisp keeps a 520-message chat scrolling with Shiki in a Web Worker, persists to IndexedDB without crashing next start, and eight traps with blur, scrollbars and code blocks.
Ramin ValadanProved GitHub account and website, confirmed e-mail: checked by UpFork5 min readAI
8 lessons


Behind the build · Sofreh — Restaurant POS, Kitchen Display & Back Office Template for Next.js (English & Persian)
How Sofreh keeps a POS, kitchen screen and order board in sync across tabs with no server, builds 120 days of data in a Web Worker, and seven traps in Next.js, React 19, pnpm and testing.
Ramin ValadanProved GitHub account and website, confirmed e-mail: checked by UpFork6 min readAI
7 lessons
earth_clouds.webpthe 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 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.
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.
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 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.
the build's probes of both pages (the computed ::before and the stylesheet's bytes); Flag.tsx lines 10–23.
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 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.
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.
/*
* 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); // 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); 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>
}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;
}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>
);
}
Comments
0 comments
No comments yet. Questions, what you built with it, what could be better: its developer reads them.
Sign in to comment.
Sign in to comment