- 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.
77 lines
5.2 KiB
TypeScript
77 lines
5.2 KiB
TypeScript
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>;
|
||
}
|