gc-wing/apps/web/src/widgets/info/VesselInfoPanel.tsx

168 lines
6.6 KiB
TypeScript
Raw Normal View 히스토리

2026-02-15 11:22:38 +09:00
import { ZONE_META } from "../../entities/zone/model/meta";
import { SPEED_MAX, VESSEL_TYPES } from "../../entities/vessel/model/meta";
import type { DerivedLegacyVessel } from "../../features/legacyDashboard/model/types";
import { haversineNm } from "../../shared/lib/geo/haversineNm";
import { OVERLAY_RGB, rgbToHex } from "../../shared/lib/map/palette";
2026-02-15 11:22:38 +09:00
type Props = {
vessel: DerivedLegacyVessel;
allVessels: DerivedLegacyVessel[];
onClose: () => void;
onSelectMmsi?: (mmsi: number) => void;
};
export function VesselInfoPanel({ vessel: v, allVessels, onClose, onSelectMmsi }: Props) {
const t = VESSEL_TYPES[v.shipCode];
const ownerLabel = v.ownerCn || v.ownerRoman || v.ownerKey || "-";
const primary = t.speedProfile.filter((s) => s.primary);
const inRange = v.sog !== null && primary.length ? primary.some((s) => v.sog! >= s.range[0] && v.sog! <= s.range[1]) : false;
const pair = v.pairPermitNo ? allVessels.find((v2) => v2.permitNo === v.pairPermitNo) ?? null : null;
const pairDist = pair ? haversineNm(v.lat, v.lon, pair.lat, pair.lon) : null;
const month = new Date().getMonth();
const zone = v.zoneId ? ZONE_META[v.zoneId] : null;
const allowed = v.zoneId ? t.allowedZones.includes(v.zoneId) : null;
return (
<div className="map-info" role="dialog" aria-label="vessel info">
<button className="close-btn" onClick={onClose} aria-label="close">
</button>
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 8 }}>
<span style={{ fontSize: 20 }}>{t.icon}</span>
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 16, fontWeight: 900, color: t.color, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
{v.permitNo}
</div>
<div style={{ fontSize: 10, color: "var(--muted)", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
{v.shipCode} · {t.name} · {v.name}
</div>
</div>
</div>
<div className="ir">
<span className="il"></span>
<span className="iv" style={{ color: inRange ? "#22C55E" : "var(--muted)" }}>
{v.sog !== null ? v.sog.toFixed(1) : "?"}kt {inRange ? "(조업범위)" : "(범위외)"}
</span>
</div>
<div className="ir">
<span className="il"></span>
<span className="iv">{v.state.label}</span>
</div>
<div className="ir">
<span className="il"></span>
<span className="iv" style={{ color: zone?.color ?? "var(--text)" }}>
{zone ? `${zone.label} ${allowed === null ? "" : allowed ? "✓허가" : "⚠이탈"}` : "-"}
</span>
</div>
<div className="ir">
<span className="il"></span>
<span className="iv">
{v.lat.toFixed(3)}°N {v.lon.toFixed(3)}°E
</span>
</div>
<div className="ir">
<span className="il">MMSI</span>
<span className="iv">{v.mmsi}</span>
</div>
<div className="ir">
<span className="il">CallSign</span>
<span className="iv">{v.callsign || "-"}</span>
</div>
<div className="ir">
<span className="il">Msg TS</span>
<span className="iv">{v.messageTimestamp || "-"}</span>
</div>
<div className="ir">
<span className="il"></span>
<span className="iv" style={{ maxWidth: 160, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }} title={ownerLabel}>
{ownerLabel}
</span>
</div>
{pair && pairDist !== null ? (
<>
<div className="ir">
<span className="il"> </span>
<span className="iv" style={{ color: VESSEL_TYPES[pair.shipCode].color, cursor: onSelectMmsi ? "pointer" : undefined }} onClick={() => onSelectMmsi?.(pair.mmsi)}>
{pair.permitNo} ({pair.shipCode})
</span>
</div>
<div className="ir">
<span className="il"> </span>
<span className="iv" style={{ color: pairDist > 3 ? rgbToHex(OVERLAY_RGB.pairWarn) : "#22C55E" }}>
2026-02-15 11:22:38 +09:00
{pairDist.toFixed(2)}NM {pairDist > 3 ? "⚠" : "✓"}
</span>
</div>
</>
) : null}
<div style={{ marginTop: 8 }}>
<div style={{ fontSize: 8, fontWeight: 700, color: "var(--muted)", marginBottom: 2 }}> vs </div>
<div style={{ position: "relative", height: 14, background: "var(--bg)", borderRadius: 3, overflow: "visible" }}>
{t.speedProfile.map((s) => {
const left = (s.range[0] / SPEED_MAX) * 100;
const width = ((s.range[1] - s.range[0]) / SPEED_MAX) * 100;
return (
<div
key={`${s.label}-${s.range[0]}`}
style={{
position: "absolute",
top: s.primary ? 0 : 2,
height: s.primary ? 14 : 8,
left: `${left}%`,
width: `${width}%`,
background: s.color,
borderRadius: 2,
opacity: s.primary ? 0.9 : 0.4,
}}
/>
);
})}
<div
style={{
position: "absolute",
top: -2,
left: `${Math.min(((v.sog ?? 0) / SPEED_MAX) * 100, 100)}%`,
width: 2,
height: 18,
background: "#FFF",
borderRadius: 1,
boxShadow: "0 0 4px #FFF",
}}
/>
</div>
<div style={{ display: "flex", justifyContent: "space-between", marginTop: 1 }}>
{[0, 5, 10, 15].map((k) => (
<span key={k} style={{ fontSize: 6, color: "rgba(255,255,255,.2)" }}>
{k}
</span>
))}
</div>
</div>
<div style={{ marginTop: 6 }}>
<div style={{ fontSize: 8, fontWeight: 700, color: "var(--muted)", marginBottom: 2 }}> </div>
<div className="month-row">
{t.monthlyIntensity.map((val, i) => {
const cur = i === month;
const bg = val === 0 ? "#EF444433" : `${t.color}${Math.round(val * 200).toString(16).padStart(2, "0")}`;
const border = cur ? "1.5px solid #FFF" : "none";
const color = val === 0 ? "#EF4444" : cur ? "#FFF" : "transparent";
const text = val === 0 ? "✗" : cur ? "◉" : "";
return (
<div key={i} className="month-cell" style={{ background: bg, border, color }}>
{text}
</div>
);
})}
</div>
</div>
</div>
);
}