What it is
A bilingual (Ukrainian and English) 3D redesign of the Sea4U tour page "Georgian cuisine and the islands of Greece", an eight-day catamaran trip around the Saronic Islands. The page is one scroll voyage: a sticky 3D map of the gulf (Attica and Athens in the north, then Aegina, Moni, the Methana peninsula with its steaming volcano, Poros, Hydra and the Peloponnese) where a catamaran sails the dotted route from Alimos marina and stops at each day's island while a glass card with the day's real programme and photo slides in. The light follows the programme from golden hour on day one to dinner at dusk on Hydra and morning light on departure day. After the voyage: why this route (five photo cards that tilt toward the pointer and drift with the scroll), what is on board, the two captains, and a booking request form. A UA / EN switch in the top bar swaps every string and is remembered.
Facts (days, places, times, meals, what is included, crew, contacts) and the photos come from the Sea4U tour page at sea4u.org. Price and dates are not published there, so the page says so and shows none. The 3D map, boat and route are illustrative and not to scale.
Engineering decisions
- Plain Three.js without React Three Fiber: the scene is one imperative module (
world.ts) with a frame(state) call, and React renders only the HTML around it. - Everything in the canvas is generated in code, with no model files and no textures: the terrain is one heightfield mesh from a pure
heightAt(x, z) function (data.ts) with vertex colours for beach, scrub, pine forest and rock; the islands are placed from real positions and compressed into one map with a diorama scale (islands are about 2 km per unit, the boat and houses are much bigger than true scale so they read). The same heightAt drives the shoreline of the sea. - The sea is one shader plane: four summed sine waves for the normal (no vertex displacement), Fresnel mixing with the sky colour, a sun glint, and a shallow-water tint and foam line read from a 128-px proximity texture baked once from the heightfield (land mask, blurred). Waves fade out with distance to avoid shimmer. The sky is a shader sphere with a gradient, a sun disc, a dusk glow and night stars.
- Time of day is one
sunState(hour) function; it feeds the sun and moon lights, hemisphere light, fog, sky, sea colours, house and temple window glow, the boat's cabin light and the clouds. Each day has an hour, interpolated along the scroll. - The boat follows one Catmull-Rom curve per leg between stops. Position, heading and wake are all pure functions of the story position (
boatAt(s)), so scrubbing back and forth never leaves state behind: the wake is a row of foam points sampled along the curve just behind the boat. The boat holds still near each day and sails in the middle of the scroll between two days. Unit tests check that every stop and every leg of the route stays on water. - Villages are instanced boxes with pyramid roofs placed on the terrain near their harbours and turned downhill; Athens is flat-roofed blocks along the coast; pines and cypresses are instanced cones; two temples (the Parthenon on its rock, the Temple of Aphaia on Aegina) are columns, plinths and a roof; small moored sailboats sit in the bays; steam rises over Methana from a point shader; clouds are sprites. All repeated parts are instanced, so the whole map is a few dozen draw calls.
- The story is one scrubbed GSAP tween on a progress value that the render loop reads, never React state. The camera orbits the boat with a per-day distance, tilt and side, blends from a high overview of the gulf in the intro, and pulls back for the last day; on wide screens the map shifts sideways so the subject is never behind the card. Stop labels are HTML positioned from projected 3D points; the stop the boat rests at is highlighted.
- Cards enter as one timeline each (card rise, masked word reveal, list items, photo zoom) and play back out when scrolled past. Photo cards tilt toward a mouse pointer with a CSS variable transform and a moving sheen; feature photos drift with a scrubbed parallax. Dragging the map turns the camera around the boat and it settles back at the next chapter.
- Copy for both languages lives in
data.ts next to the route and is unit tested for matching lists. The chosen language is stored in localStorage and defaults to English; the experiment root carries lang. - Detail pass (2026-10-14): the catamaran gained hull windows, railings with stanchions, a netted trampoline, mainsail battens, a waving Greek flag, fenders, bimini, helm, stern steps and davits. The islands gained varied cliff colours (limestone, ochre, red, dark volcanic rock on Methana), pale beaches, terrace bands, harbour quays and piers at every stop, lighthouses on the capes with lamps that glow at night, windmills with turning sails on Hydra, Poros and Aegina, blue-domed chapels and a monastery high on Hydra, a bell tower with a dome and cross in each harbour village, olive groves in rows and dry-stone terrace walls. Houses now have doors and windows that light up at night, blue shutters on Hydra and Poros, flat terrace roofs, chimneys and some pastel walls. Scene life: channel buoys at each harbour mouth, yachts sailing slow loops, a ferry shuttling on the Aegina lane and gulls circling the boat. Every kind is one instanced mesh, and the low tier places fewer of each (no gulls or shutters).
- Calm sailing: each chapter is 160 lvh with a sticky card, the scrub smoothing is 2.2 s,
hold() is a smootherstep over the middle 72 % of a chapter, and the bob, roll, sails, sea waves, wake pulse and beacon pulse all run slower. - Clouds wrap without popping: each sprite has its own material that fades at the wrap edges and when the camera is within about 6 to 12 units; they float higher and drift more gently.
- The booking form builds a
mailto: link with the request filled in; the page itself sends nothing.
Performance notes
Three.js loads only on this page. The high tier animates the sea continuously while the stage is on screen and the tab is visible; the low tier (phones, 4 cores or fewer, or ?quality=low) draws on demand only, uses a coarser terrain grid, fewer houses, trees, boats and clouds, no antialiasing and a pixel ratio of 1.25. The first screen starts dimmed under a gradient overlay that fades out over the first 85 % of the intro scroll (and back when scrolling up); its opacity is written from the render loop only when it changes. While the intro is on screen the canvas renders at 0.75 of its pixel ratio and idle frames are capped at about 30 fps; per-frame colour and vector allocations were removed from frame(). Photos load lazily and are decoded off the main thread. Everything created is disposed on unmount, and a lost WebGL context shows the poster.
Reduced motion
The story jumps to the final state of each chapter behind a 0.15 s canvas fade instead of sailing; the sea, wake, steam and clouds are still; cards, words and sections appear without movement; the loader skin's waves, boat, sails, gulls, windmill and lighthouse stand still; photo cards do not tilt and nothing drifts with the scroll.