113 lines
4.0 KiB
TypeScript
113 lines
4.0 KiB
TypeScript
import { useMemo, useState } from "react";
|
|
import { TextInput } from "@wing/ui";
|
|
import { VESSEL_TYPES } from "../../entities/vessel/model/meta";
|
|
import type { DerivedLegacyVessel } from "../../features/legacyDashboard/model/types";
|
|
import type { MouseEvent } from "react";
|
|
|
|
type Props = {
|
|
vessels: DerivedLegacyVessel[];
|
|
selectedMmsi: number | null;
|
|
highlightedMmsiSet?: number[];
|
|
onToggleHighlightMmsi: (mmsi: number) => void;
|
|
onSelectMmsi: (mmsi: number) => void;
|
|
onHoverMmsi?: (mmsi: number) => void;
|
|
onClearHover?: () => void;
|
|
};
|
|
|
|
function isFiniteNumber(x: unknown): x is number {
|
|
return typeof x === "number" && Number.isFinite(x);
|
|
}
|
|
|
|
const STRIP_RE = /[\s\-.,_]/g;
|
|
|
|
function normalize(s: string): string {
|
|
return s.replace(STRIP_RE, "").toLowerCase();
|
|
}
|
|
|
|
function matchesQuery(v: DerivedLegacyVessel, nq: string): boolean {
|
|
if (normalize(v.permitNo).includes(nq)) return true;
|
|
if (normalize(v.name).includes(nq)) return true;
|
|
if (v.legacy.shipNameRoman && normalize(v.legacy.shipNameRoman).includes(nq)) return true;
|
|
if (v.legacy.shipNameCn && normalize(v.legacy.shipNameCn).includes(nq)) return true;
|
|
if (v.legacy.callSign && normalize(v.legacy.callSign).includes(nq)) return true;
|
|
if (normalize(String(v.mmsi)).includes(nq)) return true;
|
|
return false;
|
|
}
|
|
|
|
export function VesselList({
|
|
vessels,
|
|
selectedMmsi,
|
|
highlightedMmsiSet = [],
|
|
onToggleHighlightMmsi,
|
|
onSelectMmsi,
|
|
onHoverMmsi,
|
|
onClearHover,
|
|
}: Props) {
|
|
const [searchQuery, setSearchQuery] = useState("");
|
|
|
|
const handlePrimaryAction = (e: MouseEvent, mmsi: number) => {
|
|
if (e.shiftKey || e.ctrlKey || e.metaKey) {
|
|
onToggleHighlightMmsi(mmsi);
|
|
return;
|
|
}
|
|
onSelectMmsi(mmsi);
|
|
};
|
|
|
|
const sorted = useMemo(() => {
|
|
const nq = searchQuery.length >= 2 ? normalize(searchQuery) : "";
|
|
const filtered = nq ? vessels.filter((v) => matchesQuery(v, nq)) : vessels;
|
|
return filtered.slice().sort((a, b) => (isFiniteNumber(b.sog) ? b.sog : -1) - (isFiniteNumber(a.sog) ? a.sog : -1));
|
|
}, [vessels, searchQuery]);
|
|
|
|
return (
|
|
<div className="flex min-h-0 flex-1 flex-col">
|
|
<div className="mb-1.5 shrink-0 px-1">
|
|
<TextInput
|
|
placeholder="검색: 등록번호 / 선박명 / MMSI"
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
className="h-5 w-full py-0 text-[9px]"
|
|
/>
|
|
</div>
|
|
<div className="vlist">
|
|
{sorted.map((v) => {
|
|
const meta = VESSEL_TYPES[v.shipCode];
|
|
const primarySegs = meta.speedProfile.filter((s) => s.primary);
|
|
const inRange =
|
|
v.sog !== null && primarySegs.length ? primarySegs.some((s) => v.sog! >= s.range[0] && v.sog! <= s.range[1]) : false;
|
|
|
|
const sc = v.state.isFishing ? "#22C55E" : (v.sog ?? 0) > 3 ? "#3B82F6" : "#64748B";
|
|
const speedColor = inRange ? "#22C55E" : (v.sog ?? 0) > 5 ? "#3B82F6" : "var(--muted)";
|
|
const hasPair = v.pairPermitNo ? "⛓" : "";
|
|
const sel = selectedMmsi === v.mmsi;
|
|
const hl = highlightedMmsiSet.includes(v.mmsi);
|
|
|
|
return (
|
|
<div
|
|
key={v.mmsi}
|
|
className={`vi ${sel ? "sel" : ""} ${hl ? "hl" : ""}`}
|
|
onClick={(e) => handlePrimaryAction(e, v.mmsi)}
|
|
onMouseEnter={() => onHoverMmsi?.(v.mmsi)}
|
|
onMouseLeave={() => onClearHover?.()}
|
|
title={v.name}
|
|
>
|
|
<div className="dot" style={{ background: meta.color, boxShadow: v.state.isFishing ? `0 0 3px ${meta.color}` : undefined }} />
|
|
<div className="nm">
|
|
{hasPair}
|
|
{v.permitNo}
|
|
</div>
|
|
<div className="sp" style={{ color: speedColor }}>
|
|
{v.sog !== null ? v.sog.toFixed(1) : "?"}kt
|
|
</div>
|
|
<div className="st" style={{ background: `${sc}22`, color: sc }}>
|
|
{v.state.label}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
{sorted.length === 0 ? <div style={{ fontSize: 11, color: "var(--muted)" }}>(표시할 대상 선박 없음)</div> : null}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|