From 2ee49cfd41fae18c8ed091a031c1638e420dadc3 Mon Sep 17 00:00:00 2001 From: 3dtours Date: Tue, 22 Sep 2026 17:11:12 +0700 Subject: [PATCH] web: keep the film strip seamless on a wide window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The marquee loops by translating the track by half its width, so the first half has to be at least as wide as the window. A short reel — seven stills, about 1780px — covered a 1440px window but not a 1920px or 2560px one: the strip ran out of frames before the loop restarted, and the band on the right stayed blank until the next pass drifted in. The track now measures one frame's pitch and repeats the reel until a half covers the window, re-measuring on resize. One still in the strip is repeated enough times to loop cleanly on its own; a reel already wide enough is left at a single copy, so nothing is duplicated without cause. --- docker/frontend/src/Landing.tsx | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/docker/frontend/src/Landing.tsx b/docker/frontend/src/Landing.tsx index 05a8a0c..4c3a37b 100644 --- a/docker/frontend/src/Landing.tsx +++ b/docker/frontend/src/Landing.tsx @@ -317,6 +317,27 @@ export function Landing() { [photos], ); + // The marquee loops by translating half the track, so one half has to be at + // least as wide as the window: a reel too short for the screen runs out of + // frames before the loop restarts, and the band on the right goes blank until + // the next pass arrives. Lay out one pass, then repeat the reel until a half + // covers the window — and again on resize, which changes what "covers" means. + const track = useRef(null); + const [reelCopies, setReelCopies] = useState(1); + useEffect(() => { + const el = track.current; + const fan = el?.querySelector('.lp-frame'); + if (!el || !fan || !reel.length) return; + const pitch = fan.getBoundingClientRect().width + (parseFloat(getComputedStyle(fan).marginRight) || 0); + if (pitch <= 0) return; + const view = () => el.parentElement?.clientWidth || window.innerWidth; + const fit = () => setReelCopies(Math.max(1, Math.ceil(view() / (reel.length * pitch)))); + fit(); + window.addEventListener('resize', fit); + return () => window.removeEventListener('resize', fit); + }, [reel]); + const loop = useMemo(() => Array.from({ length: reelCopies }, () => reel).flat(), [reel, reelCopies]); + // The three live sections, each cycling the stills the curator tagged for its // slot. Every set is drawn from a random start once per page load, so the // preview does not change under the visitor's eyes on every render; a slot @@ -643,8 +664,8 @@ export function Landing() {

{c({ en: 'Every still here is picked and placed by the studio — this reel is curated, not crowd-sourced.', vi: 'Mọi khung ảnh ở đây đều do studio chọn và đặt — dải phim này được tuyển chọn, không phải ai cũng tự thêm được.' })}

-
- {[...reel, ...reel].map((look, i) => { +
+ {[...loop, ...loop].map((look, i) => { const score = ratings[look.rateKey] ?? { avg: 0, n: 0, mine: 0 }; return (