bffb8e47b7
При наложении в крайней точке хват попадал на верхний ползунок to, который на правом краю заперт fromIndex и не уходит влево, а нижний from был недоступен. Теперь при fromIndex === toIndex управляемый ползунок выбирается по направлению движения (влево → from, вправо → to). Чинит и «Сутки» (SlotDaySelector), и «Витки» (MapPassSelector).
141 lines
5.2 KiB
TypeScript
141 lines
5.2 KiB
TypeScript
import { useRef } from "react";
|
||
import type { OrbitPassInfo } from "../tgu-map-2d/geometry/passes";
|
||
|
||
type MapPassSelectorProps = {
|
||
passes: OrbitPassInfo[];
|
||
fromIndex: number;
|
||
toIndex: number;
|
||
onFromChange: (idx: number) => void;
|
||
onToChange: (idx: number) => void;
|
||
};
|
||
|
||
function localHM(ms: number): string {
|
||
const d = new Date(ms);
|
||
return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
|
||
}
|
||
|
||
function localDM(ms: number): string {
|
||
const d = new Date(ms);
|
||
return `${String(d.getDate()).padStart(2, "0")}.${String(d.getMonth() + 1).padStart(2, "0")}`;
|
||
}
|
||
|
||
export function MapPassSelector({ passes, fromIndex, toIndex, onFromChange, onToChange }: MapPassSelectorProps) {
|
||
const trackRef = useRef<HTMLDivElement>(null);
|
||
const n = passes.length;
|
||
if (n === 0) return null;
|
||
|
||
const fromPass = passes[Math.max(0, Math.min(fromIndex - 1, n - 1))];
|
||
const toPass = passes[Math.max(0, Math.min(toIndex - 1, n - 1))];
|
||
|
||
const multiDay = localDM(passes[0].startMs) !== localDM(passes[n - 1].startMs);
|
||
const isFullRange = fromIndex === 1 && toIndex === n;
|
||
const fromRev = fromPass.revolution ?? fromIndex;
|
||
const toRev = toPass.revolution ?? toIndex;
|
||
|
||
const pct = (idx: number): number => (n <= 1 ? 0 : ((idx - 1) / (n - 1)) * 100);
|
||
const fromPct = pct(fromIndex);
|
||
const toPct = pct(toIndex);
|
||
|
||
const indexAt = (clientX: number): number => {
|
||
const rect = trackRef.current?.getBoundingClientRect();
|
||
if (!rect || n <= 1) return 1;
|
||
return Math.round(Math.max(0, Math.min(1, (clientX - rect.left) / rect.width)) * (n - 1)) + 1;
|
||
};
|
||
|
||
// Какой ползунок реально тянем. При наложении (fromIndex === toIndex) хват попадает на верхний
|
||
// (to), но на правом краю он заперт fromIndex и не может уйти влево — поэтому при наложении
|
||
// выбираем ползунок по направлению движения: влево → from (открыть диапазон), вправо → to.
|
||
const dragRef = useRef<"from" | "to" | null>(null);
|
||
|
||
const makeHandlers = (which: "from" | "to") => ({
|
||
onPointerDown(e: React.PointerEvent<HTMLDivElement>) {
|
||
e.stopPropagation();
|
||
dragRef.current = which;
|
||
(e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId);
|
||
},
|
||
onPointerMove(e: React.PointerEvent<HTMLDivElement>) {
|
||
if (!(e.currentTarget as HTMLDivElement).hasPointerCapture(e.pointerId)) return;
|
||
const idx = indexAt(e.clientX);
|
||
let active = dragRef.current;
|
||
if (fromIndex === toIndex) {
|
||
if (idx < fromIndex) active = "from";
|
||
else if (idx > toIndex) active = "to";
|
||
dragRef.current = active;
|
||
}
|
||
if (active === "from") {
|
||
onFromChange(Math.max(1, Math.min(idx, toIndex)));
|
||
} else {
|
||
onToChange(Math.min(n, Math.max(idx, fromIndex)));
|
||
}
|
||
},
|
||
onPointerUp(e: React.PointerEvent<HTMLDivElement>) {
|
||
dragRef.current = null;
|
||
(e.currentTarget as HTMLDivElement).releasePointerCapture(e.pointerId);
|
||
},
|
||
});
|
||
|
||
const onTrackPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
|
||
const idx = indexAt(e.clientX);
|
||
if (Math.abs(idx - fromIndex) <= Math.abs(idx - toIndex)) {
|
||
onFromChange(Math.max(1, Math.min(idx, toIndex)));
|
||
} else {
|
||
onToChange(Math.min(n, Math.max(idx, fromIndex)));
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className="map-pass-selector">
|
||
<div className="map-pass-selector__header">
|
||
<span className="map-pass-selector__title">Витки</span>
|
||
<span className="map-pass-selector__nums">{fromRev}–{toRev}</span>
|
||
<span className="map-pass-selector__times">
|
||
{multiDay ? `${localDM(fromPass.startMs)} ` : ""}{localHM(fromPass.startMs)}
|
||
{" – "}
|
||
{multiDay ? `${localDM(toPass.startMs)} ` : ""}{localHM(toPass.startMs)}
|
||
</span>
|
||
{!isFullRange && (
|
||
<button
|
||
type="button"
|
||
className="map-pass-selector__reset"
|
||
title="Все витки"
|
||
onClick={() => { onFromChange(1); onToChange(n); }}
|
||
>
|
||
Все
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
<div className="map-pass-selector__body" onPointerDown={onTrackPointerDown}>
|
||
{/* Track line */}
|
||
<div className="map-pass-selector__track" ref={trackRef}>
|
||
<div
|
||
className="map-pass-selector__fill"
|
||
style={{ left: `${fromPct}%`, width: `${toPct - fromPct}%` }}
|
||
/>
|
||
{/* Tick marks */}
|
||
{passes.map((p) => (
|
||
<div
|
||
key={p.index}
|
||
className={`map-pass-selector__tick${p.index >= fromIndex && p.index <= toIndex ? " is-active" : ""}`}
|
||
style={{ left: `${pct(p.index)}%` }}
|
||
/>
|
||
))}
|
||
{/* From handle */}
|
||
<div
|
||
className="map-pass-selector__thumb"
|
||
style={{ left: `${fromPct}%` }}
|
||
{...makeHandlers("from")}
|
||
/>
|
||
{/* To handle */}
|
||
<div
|
||
className="map-pass-selector__thumb"
|
||
style={{ left: `${toPct}%` }}
|
||
{...makeHandlers("to")}
|
||
/>
|
||
</div>
|
||
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|