import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import type { CSSProperties } from 'react'; import type { Departure, Route } from './types'; import { BusFront } from './Icons'; import { RouteBadge } from './RouteBadge'; import { FittedDestination } from './FittedDestination'; import { NumericText } from './NumericText'; import { localDate } from './lib/timetable.mjs'; type Props = { routes: Route[]; departures: Departure[]; now: number; paused: boolean; wide: boolean }; export function RouteCloud({ routes, departures, now, paused, wide }: Props) { const element = useRef(null); const [epoch] = useState(() => Date.now() / 1000); const [page, setPage] = useState(() => Math.floor(Date.now() / 6500)); const [area, setArea] = useState({ width: 300, height: 300 }); const carousel = wide && area.width >= 650; // Opposite inclines need extra clearance at the edges of a wide display. const laneGap = Math.max(36, Math.ceil(area.width * .016) + 24); const edge = Math.ceil(area.width * .008) + 8; const lanes = Math.min(3, Math.max(2, Math.floor((area.height + laneGap) / (155 + laneGap))), Math.max(1, routes.length)); const cardHeight = Math.max(1, (area.height - (lanes - 1) * laneGap) / lanes); const cardWidth = cardHeight < 150 ? 420 : Math.max(300, Math.min(380, area.width * .32)); const capacity = Math.min(Math.max(1, routes.length), Math.max(1, Math.floor(area.height / 128))); const pages = Math.max(1, Math.ceil(routes.length / capacity)); const pageIndex = page % pages; const baseSize = Math.floor(routes.length / pages), extra = routes.length % pages; const pageSize = Math.max(1, baseSize + (pageIndex < extra ? 1 : 0)); const pageStart = pageIndex * baseSize + Math.min(pageIndex, extra); const nextByRoute = useMemo(() => { const result = new Map(); for (const departure of departures) if (!result.has(departure.routeId)) result.set(departure.routeId, departure); return result; }, [departures]); useLayoutEffect(() => { if (!element.current) return; const observer = new ResizeObserver(entries => { const { width, height } = entries[0].contentRect; if (width <= 0 || height <= 0) return; setArea(previous => Math.abs(previous.width - width) < .5 && Math.abs(previous.height - height) < .5 ? previous : { width, height }); }); observer.observe(element.current); return () => observer.disconnect(); }, []); useEffect(() => { if (carousel || paused || pages < 2) return; const timer = setInterval(() => setPage(value => value + 1), 6500); return () => clearInterval(timer); }, [carousel, paused, pages]); const tile = (route: Route, key: string) => { const next = nextByRoute.get(route.id); return
{next ? <>{localDate(Date.parse(next.scheduledAt)) > localDate(now) && завтра} : Расписание уточняется} {route.type === 'minibus' ? 'Маршрутка' : 'Автобус'}
; }; return
{carousel ? <>
    {routes.map(route =>
  • №{route.number} · {route.name}
  • )}
{Array.from({ length: lanes }, (_, lane) => { const items = routes.filter((_, index) => index % lanes === lane); const group = items.length ? Array.from({ length: Math.max(1, Math.ceil(4 / items.length)) }, () => items).flat() : []; const duration = Math.max(12, group.length * (cardWidth + 16) / 24); return ; })} : <>
{routes.slice(pageStart, pageStart + pageSize).map((route, index) => tile(route, `position-${index}`))}
{pages > 1 &&
Через эту остановку
} }
; }