Files
ridebus-board/src/RouteCloud.tsx
Zakhar Timoshenko f4ea47f2ae feat: add verification layout themes documentation and new components
- Created `verification-layout-themes.md` to document layout and theme verification results.
- Introduced `AlertCard` component for displaying alerts with appropriate icons and metadata.
- Added `RouteCloud` component to manage and display route information dynamically.
- Implemented tests for QR code generation and decoding to ensure functionality across multiple city and platform links.
2026-10-09 02:43:42 +03:00

77 lines
5.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<HTMLDivElement>(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<number, Departure>();
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 <article className="route-tile" key={key}>
<span className="route-watermark"><BusFront size={100} /></span><RouteBadge number={route.number} filled />
<div className="route-card-name"><FittedDestination text={route.name} rowHeight={carousel ? cardHeight : area.height / pageSize} />
<div className="route-card-next">{next ? <><time dateTime={next.scheduledAt}><NumericText value={next.scheduledTime} /></time>{localDate(Date.parse(next.scheduledAt)) > localDate(now) && <span>завтра</span>}</> : <span>Расписание уточняется</span>}
<span className="route-kind">{route.type === 'minibus' ? 'Маршрутка' : 'Автобус'}</span>
</div>
</div>
</article>;
};
return <div ref={element} className="route-cloud" data-carousel={carousel} data-horizontal={cardHeight < 150} style={{ '--lane-height': `${cardHeight}px`, '--route-card-width': `${cardWidth}px`, '--lane-gap': `${laneGap}px`, '--lane-edge': `${edge}px`, '--route-rows': pageSize } as CSSProperties}>
{carousel ? <>
<ul className="sr-only" aria-label="Все маршруты остановки">{routes.map(route => <li key={route.id}>№{route.number} · {route.name}</li>)}</ul>
{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 <div className="route-lane" key={lane} aria-hidden="true" style={{ '--lane-angle': `${lane % 2 ? .9 : -.9}deg` } as CSSProperties}>
<div className="route-track" style={{ animationDuration: `${duration}s`, animationDelay: `-${epoch % duration}s`, animationDirection: lane % 2 ? 'reverse' : 'normal', animationPlayState: paused ? 'paused' : 'running' }}>
{[0, 1].map(copy => <div className="route-group" key={copy}>{group.map((route, index) => tile(route, `${route.id}:${index}`))}</div>)}
</div>
</div>;
})}</> : <><div className="route-list">{routes.slice(pageStart, pageStart + pageSize).map((route, index) => tile(route, `position-${index}`))}</div>
{pages > 1 && <div className="route-list-pages" aria-label={`Страница маршрутов ${pageIndex + 1} из ${pages}`}><span>Через эту остановку</span><NumericText value={`${pageIndex + 1}/${pages}`} /></div>}
</>}
</div>;
}