/* Variant B — «Кинохроника» Видеофон Читы + цветовая засветка маршрута, фото-узлы. Точки распределены по полному внешнему кругу. Данные — window.ORBIT_ROUTES (из БД), подписи — window.ORBIT_I18N. */ // Seamless loop with ONE active video at a time. // We snapshot the last frame onto a canvas right before the seek-to-0, // then crossfade the canvas out as the video resumes from frame 0. // Only one decoder is ever running → far cheaper than dual-video crossfade. const SeamlessVideo = ({ src, poster, className }) => { const { useEffect, useRef } = React; const videoRef = useRef(null); const canvasRef = useRef(null); const fade = 0.7; useEffect(() => { const v = videoRef.current; const c = canvasRef.current; if (!v || !c) return; let handing = false; let timer = null; const onTime = () => { const dur = v.duration; if (!isFinite(dur) || dur <= 0 || handing) return; if (dur - v.currentTime <= 0.25) { handing = true; // 1. Snapshot the current frame. const w = v.videoWidth || 1280; const h = v.videoHeight || 720; if (c.width !== w) c.width = w; if (c.height !== h) c.height = h; const ctx = c.getContext("2d"); try { ctx.drawImage(v, 0, 0, w, h); } catch (_) {} // 2. Show the frozen frame instantly (cover the seek gap). c.style.transition = "none"; c.style.opacity = "1"; // 3. Seek + resume. try { v.currentTime = 0; } catch (_) {} v.play().catch(() => {}); // 4. After the video reports a new frame, fade the canvas out. const startFade = () => { requestAnimationFrame(() => { c.style.transition = `opacity ${fade}s linear`; c.style.opacity = "0"; }); }; if ("requestVideoFrameCallback" in v) { v.requestVideoFrameCallback(startFade); } else { v.addEventListener("seeked", startFade, { once: true }); } timer = setTimeout(() => { handing = false; }, fade * 1000 + 200); } }; v.addEventListener("timeupdate", onTime); return () => { v.removeEventListener("timeupdate", onTime); if (timer) clearTimeout(timer); }; }, []); return ( <> > ); }; const OrbitCinema = () => { const { useMemo, useState } = React; const routes = window.ORBIT_ROUTES || []; const I = window.ORBIT_I18N || {}; const t = (k, d) => (I[k] != null && I[k] !== "" ? I[k] : d); const { angle, activeId, setActiveId, hoverId, setHoverId, setTarget } = window.useOrbit({ speed: 0.15 }); // Выбранный объект (точка маршрута). Показывает справа карточку с аудиогидом. const [stop, setStop] = useState(null); // Клик по линии: орбита НЕ замирает — она продолжает непрерывно вращаться, а // веер точек активной линии вращается вместе с её узлом. Повторный клик — сброс. const selectLine = (id) => { setStop(null); setActiveId(id); setTarget(null); // держим орбиту в постоянном движении }; const reset = () => { setStop(null); setActiveId(null); setTarget(null); }; // Внешнее управление: интро-чипы линий вызывают window.orbitSelect(routeId), // чтобы открыть линию на орбите (с «перестройкой»). Держим selectLine в ref, // чтобы window.orbitSelect всегда вызывал актуальную версию и работал при // КАЖДОМ клике (а не только при первом), и НЕ удаляем его при размонтировании. const selectRef = React.useRef(null); selectRef.current = selectLine; React.useEffect(() => { window.orbitSelect = (rid) => { if (selectRef.current && routes.some((r) => r.id === rid)) selectRef.current(rid); }; // подхватываем «отложенный выбор», если чип кликнули до монтирования орбиты if (window.__pendingOrbit) { const p = window.__pendingOrbit; window.__pendingOrbit = null; window.orbitSelect(p); } }, []); // На мобиле карточку описания выносим ВНИЗ через портал в #orbit-card-slot // (вне scale-холста орбиты): орбита занимает всю ширину, а текст читаемый. const [isMobile, setIsMobile] = useState( typeof window !== "undefined" && window.matchMedia ? window.matchMedia("(max-width: 760px)").matches : false ); const [cardSlot, setCardSlot] = useState(null); React.useEffect(() => { setCardSlot(document.getElementById("orbit-card-slot")); if (!window.matchMedia) return; const mq = window.matchMedia("(max-width: 760px)"); const onChange = () => setIsMobile(mq.matches); onChange(); mq.addEventListener ? mq.addEventListener("change", onChange) : mq.addListener(onChange); return () => { mq.removeEventListener ? mq.removeEventListener("change", onChange) : mq.removeListener(onChange); }; }, []); const CX = 360; const CY = 372; const R_INNER = 170; const R_OUTER = 280; const LABEL_R = 44; // label offset OUTWARD from node center const N = routes.length; const total = String(N).padStart(2, "0"); const active = routes.find((r) => r.id === activeId); const activeIdx = routes.findIndex((r) => r.id === activeId); // Inner route nodes — always orbiting smoothly (no snap). const innerPositions = useMemo(() => { return routes.map((r, i) => { const a = (i / N) * 360 + angle - 90; const p = window.polar(a, R_INNER); const norm = Math.hypot(p.x, p.y) || 1; // Radial outward unit vector — used to anchor the label so it never flips return { ...r, angle: a, ...p, dirX: p.x / norm, dirY: p.y / norm }; }); }, [angle]); // Stops orbit WITH the active node — anchored to its live angle. const stops = useMemo(() => { if (!active) return []; const anchor = (activeIdx / N) * 360 + angle - 90; const count = active.stops.length; const arc = 220; const start = anchor - arc / 2; return active.stops.map((s, i) => { const tt = count === 1 ? 0.5 : i / (count - 1); const a = start + arc * tt; return { ...s, angle: a, ...window.polar(a, R_OUTER) }; }); }, [active, activeIdx, angle]); // Навигация стрелками: ◀▶ по маршрутам (когда открыта карточка линии) и по // объектам (точкам активного маршрута, когда открыта карточка объекта). С зацикливанием. const goRoute = (dir) => { if (!N) return; const cur = activeIdx < 0 ? 0 : activeIdx; selectLine(routes[(cur + dir + N) % N].id); }; const goStop = (dir) => { if (!active || !active.stops || !active.stops.length) return; const list = active.stops; let idx = stop ? list.findIndex((s) => s.id === stop.id) : 0; if (idx < 0) idx = 0; setStop(list[(idx + dir + list.length) % list.length]); }; // Те же переходы с клавиатуры ←/→ (если открыта карточка). React.useEffect(() => { const onKey = (e) => { if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return; const el = document.activeElement; if (el && /^(INPUT|TEXTAREA|SELECT)$/.test(el.tagName)) return; const dir = e.key === "ArrowRight" ? 1 : -1; if (stop) goStop(dir); else if (active) goRoute(dir); else return; e.preventDefault(); }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [stop, active, activeIdx]); // Пара кнопок-стрелок поверх фото карточки. const arrows = (onPrev, onNext, lbl) => (
{stop.desc}
: null} {stop.audio ? ({active.description}