feat(dashboard): alarms filter + legend/palette sync + map polish

This commit is contained in:
htlee 2026-02-15 18:42:49 +09:00
부모 30e6e584ee
커밋 3497b8c7e2
12개의 변경된 파일1255개의 추가작업 그리고 516개의 파일을 삭제

파일 보기

@ -2,9 +2,9 @@
<html lang="ko">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>906척 실시간 조업 감시 — 선단 연관관계</title>
<title>WING 조업감시 데모</title>
</head>
<body>
<div id="root"></div>

파일 보기

@ -0,0 +1,21 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" width="128" height="128" role="img" aria-label="WING">
<defs>
<linearGradient id="g" x1="20" y1="20" x2="108" y2="108" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#60A5FA" />
<stop offset="1" stop-color="#2563EB" />
</linearGradient>
</defs>
<circle cx="64" cy="64" r="58" fill="#0F172A" stroke="#1E3A5F" stroke-width="8" />
<!-- Stylized "W" mark -->
<path
d="M28 38 L44 92 L64 54 L84 92 L100 38"
fill="none"
stroke="url(#g)"
stroke-width="14"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>

After

Width:  |  Height:  |  크기: 640 B

파일 보기

@ -465,6 +465,75 @@ body {
white-space: nowrap;
}
/* Alarm filter (dropdown) */
.alarm-filter {
position: relative;
}
.alarm-filter__summary {
list-style: none;
cursor: pointer;
padding: 2px 8px;
border-radius: 6px;
border: 1px solid var(--border);
background: rgba(30, 41, 59, 0.55);
color: var(--text);
font-size: 8px;
font-weight: 700;
letter-spacing: 0.4px;
user-select: none;
white-space: nowrap;
}
.alarm-filter__summary::-webkit-details-marker {
display: none;
}
.alarm-filter__menu {
position: absolute;
right: 0;
top: 22px;
z-index: 2000;
min-width: 170px;
padding: 6px;
border-radius: 10px;
border: 1px solid var(--border);
background: rgba(15, 23, 42, 0.98);
box-shadow: 0 16px 50px rgba(0, 0, 0, 0.55);
}
.alarm-filter__row {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 6px;
border-radius: 6px;
cursor: pointer;
font-size: 10px;
color: var(--text);
user-select: none;
}
.alarm-filter__row:hover {
background: rgba(59, 130, 246, 0.08);
}
.alarm-filter__row input {
cursor: pointer;
}
.alarm-filter__cnt {
margin-left: auto;
font-size: 9px;
color: var(--muted);
}
.alarm-filter__sep {
height: 1px;
background: rgba(30, 58, 95, 0.85);
margin: 4px 0;
}
/* Relation panel */
.rel-panel {
background: var(--card);

파일 보기

@ -301,14 +301,33 @@ export function computeLegacyAlarms(args: {
});
}
// Most recent first by timeLabel (approx), then by severity.
const sevScore = (s: "cr" | "hi") => (s === "cr" ? 2 : 1);
// Fixed category priority (independent of severity):
// 1) 수역 이탈 2) 쌍 이격 경고 3) 환적 의심 4) 휴어기 조업 의심 5) AIS 지연
// Within each category: most recent first (smaller N in "-N분" is more recent).
const kindPriority: Record<LegacyAlarm["kind"], number> = {
zone_violation: 0,
pair_separation: 1,
transshipment: 2,
closed_season: 3,
ais_stale: 4,
};
const parseAgeMin = (label: string) => {
if (label === "방금") return 0;
const m = /-(\\d+)분/.exec(label);
if (m) return Number(m[1]);
return Number.POSITIVE_INFINITY;
};
alarms.sort((a, b) => {
const av = sevScore(b.severity) - sevScore(a.severity);
if (av !== 0) return av;
const at = Number(a.timeLabel.replace(/[^0-9]/g, "")) || 0;
const bt = Number(b.timeLabel.replace(/[^0-9]/g, "")) || 0;
return at - bt;
const ak = kindPriority[a.kind] ?? 999;
const bk = kindPriority[b.kind] ?? 999;
if (ak !== bk) return ak - bk;
const am = parseAgeMin(a.timeLabel);
const bm = parseAgeMin(b.timeLabel);
if (am !== bm) return am - bm;
// Stable tie-break.
return a.text.localeCompare(b.text);
});
return alarms;

파일 보기

@ -72,3 +72,19 @@ export type LegacyAlarm = {
text: string;
relatedMmsi: number[];
};
export const LEGACY_ALARM_KINDS: LegacyAlarmKind[] = [
"pair_separation",
"transshipment",
"closed_season",
"ais_stale",
"zone_violation",
];
export const LEGACY_ALARM_KIND_LABEL: Record<LegacyAlarmKind, string> = {
pair_separation: "쌍 이격 경고",
transshipment: "환적 의심",
closed_season: "휴어기 조업 의심",
ais_stale: "AIS 지연",
zone_violation: "수역 이탈",
};

파일 보기

@ -4,7 +4,8 @@ import { Map3DSettingsToggles } from "../../features/map3dSettings/Map3DSettings
import type { MapToggleState } from "../../features/mapToggles/MapToggles";
import { MapToggles } from "../../features/mapToggles/MapToggles";
import { TypeFilterGrid } from "../../features/typeFilter/TypeFilterGrid";
import type { DerivedLegacyVessel } from "../../features/legacyDashboard/model/types";
import type { DerivedLegacyVessel, LegacyAlarmKind } from "../../features/legacyDashboard/model/types";
import { LEGACY_ALARM_KIND_LABEL, LEGACY_ALARM_KINDS } from "../../features/legacyDashboard/model/types";
import { useLegacyVessels } from "../../entities/legacyVessel/api/useLegacyVessels";
import { buildLegacyVesselIndex } from "../../entities/legacyVessel/lib";
import type { LegacyVesselIndex } from "../../entities/legacyVessel/lib";
@ -117,6 +118,10 @@ export function DashboardPage() {
});
const [fleetRelationSortMode, setFleetRelationSortMode] = useState<FleetRelationSortMode>("count");
const [alarmKindEnabled, setAlarmKindEnabled] = useState<Record<LegacyAlarmKind, boolean>>(() => {
return Object.fromEntries(LEGACY_ALARM_KINDS.map((k) => [k, true])) as Record<LegacyAlarmKind, boolean>;
});
const [fleetFocus, setFleetFocus] = useState<{ id: string | number; center: [number, number]; zoom?: number } | undefined>(undefined);
const [settings, setSettings] = useState<Map3DSettings>({
@ -183,6 +188,26 @@ export function DashboardPage() {
const fcLinksAll = useMemo(() => computeFcLinks(legacyVesselsAll), [legacyVesselsAll]);
const alarms = useMemo(() => computeLegacyAlarms({ vessels: legacyVesselsAll, pairLinks: pairLinksAll, fcLinks: fcLinksAll }), [legacyVesselsAll, pairLinksAll, fcLinksAll]);
const alarmKindCounts = useMemo(() => {
const base = Object.fromEntries(LEGACY_ALARM_KINDS.map((k) => [k, 0])) as Record<LegacyAlarmKind, number>;
for (const a of alarms) {
base[a.kind] = (base[a.kind] ?? 0) + 1;
}
return base;
}, [alarms]);
const enabledAlarmKinds = useMemo(() => {
return LEGACY_ALARM_KINDS.filter((k) => alarmKindEnabled[k]);
}, [alarmKindEnabled]);
const allAlarmKindsEnabled = enabledAlarmKinds.length === LEGACY_ALARM_KINDS.length;
const filteredAlarms = useMemo(() => {
if (allAlarmKindsEnabled) return alarms;
const enabled = new Set(enabledAlarmKinds);
return alarms.filter((a) => enabled.has(a.kind));
}, [alarms, enabledAlarmKinds, allAlarmKindsEnabled]);
const pairLinksForMap = useMemo(() => computePairLinks(legacyVesselsFiltered), [legacyVesselsFiltered]);
const fcLinksForMap = useMemo(() => computeFcLinks(legacyVesselsFiltered), [legacyVesselsFiltered]);
const fleetCirclesForMap = useMemo(() => computeFleetCircles(legacyVesselsFiltered), [legacyVesselsFiltered]);
@ -253,6 +278,8 @@ export function DashboardPage() {
const enabledTypes = useMemo(() => VESSEL_TYPE_ORDER.filter((c) => typeEnabled[c]), [typeEnabled]);
const speedPanelType = (selectedLegacyVessel?.shipCode ?? (enabledTypes.length === 1 ? enabledTypes[0] : "PT")) as VesselTypeCode;
const enabledAlarmKindCount = enabledAlarmKinds.length;
const alarmFilterSummary = allAlarmKindsEnabled ? "전체" : `${enabledAlarmKindCount}/${LEGACY_ALARM_KINDS.length}`;
return (
<div className="app">
@ -339,7 +366,7 @@ export function DashboardPage() {
</div>
</div>
<div style={{ fontSize: 10, color: "var(--muted)" }}> Attribution() </div>
{/* Attribution (license) stays visible in the map UI; no need to repeat it here. */}
</div>
<div className="sb">
@ -421,9 +448,69 @@ export function DashboardPage() {
/>
</div>
<div className="sb" style={{ maxHeight: 130, overflowY: "auto" }}>
<div className="sb-t"> </div>
<AlarmsPanel alarms={alarms} onSelectMmsi={setSelectedMmsi} />
<div className="sb" style={{ maxHeight: 130, display: "flex", flexDirection: "column", overflow: "visible" }}>
<div className="sb-t sb-t-row" style={{ marginBottom: 6 }}>
<div>
{" "}
<span style={{ color: "var(--accent)", fontSize: 8 }}>
({filteredAlarms.length}/{alarms.length})
</span>
</div>
{LEGACY_ALARM_KINDS.length <= 3 ? (
<div style={{ display: "flex", gap: 6, alignItems: "center" }}>
{LEGACY_ALARM_KINDS.map((k) => (
<label key={k} style={{ display: "inline-flex", gap: 4, alignItems: "center", cursor: "pointer", userSelect: "none" }}>
<input
type="checkbox"
checked={!!alarmKindEnabled[k]}
onChange={() => setAlarmKindEnabled((prev) => ({ ...prev, [k]: !prev[k] }))}
/>
<span style={{ fontSize: 8, color: "var(--muted)", whiteSpace: "nowrap" }}>{LEGACY_ALARM_KIND_LABEL[k]}</span>
</label>
))}
</div>
) : (
<details className="alarm-filter">
<summary className="alarm-filter__summary" title="경고 종류 필터">
{alarmFilterSummary}
</summary>
<div className="alarm-filter__menu" role="menu" aria-label="alarm kind filter">
<label className="alarm-filter__row">
<input
type="checkbox"
checked={allAlarmKindsEnabled}
onChange={() =>
setAlarmKindEnabled((prev) => {
const allOn = LEGACY_ALARM_KINDS.every((k) => prev[k]);
const nextVal = allOn ? false : true;
return Object.fromEntries(LEGACY_ALARM_KINDS.map((k) => [k, nextVal])) as Record<LegacyAlarmKind, boolean>;
})
}
/>
<span className="alarm-filter__cnt">{alarms.length}</span>
</label>
<div className="alarm-filter__sep" />
{LEGACY_ALARM_KINDS.map((k) => (
<label key={k} className="alarm-filter__row">
<input
type="checkbox"
checked={!!alarmKindEnabled[k]}
onChange={() => setAlarmKindEnabled((prev) => ({ ...prev, [k]: !prev[k] }))}
/>
{LEGACY_ALARM_KIND_LABEL[k]}
<span className="alarm-filter__cnt">{alarmKindCounts[k] ?? 0}</span>
</label>
))}
</div>
</details>
)}
</div>
<div style={{ overflowY: "auto", minHeight: 0, flex: 1 }}>
<AlarmsPanel alarms={filteredAlarms} onSelectMmsi={setSelectedMmsi} />
</div>
</div>
{adminMode ? (

파일 보기

@ -0,0 +1,58 @@
export type Rgb = [number, number, number];
export type DeckRgba = [number, number, number, number];
export function rgbToHex(rgb: Rgb): string {
const toHex = (v: number) => {
const clamped = Math.max(0, Math.min(255, Math.round(v)));
return clamped.toString(16).padStart(2, "0");
};
return `#${toHex(rgb[0])}${toHex(rgb[1])}${toHex(rgb[2])}`;
}
export function rgba(rgb: Rgb, alpha01: number): string {
const a = Math.max(0, Math.min(1, alpha01));
return `rgba(${rgb[0]},${rgb[1]},${rgb[2]},${a})`;
}
export function deckRgba(rgb: Rgb, alpha255: number): DeckRgba {
const a = Math.max(0, Math.min(255, Math.round(alpha255)));
return [rgb[0], rgb[1], rgb[2], a];
}
// CN-permit (legacy) ship category colors. Used by both map layers and legend.
export const LEGACY_CODE_COLORS_RGB: Record<string, Rgb> = {
PT: [30, 64, 175], // #1e40af
"PT-S": [234, 88, 12], // #ea580c
GN: [16, 185, 129], // #10b981
OT: [139, 92, 246], // #8b5cf6
PS: [239, 68, 68], // #ef4444
FC: [245, 158, 11], // #f59e0b
C21: [236, 72, 153], // #ec4899
};
export const LEGACY_CODE_COLORS_HEX: Record<string, string> = Object.fromEntries(
Object.entries(LEGACY_CODE_COLORS_RGB).map(([k, rgb]) => [k, rgbToHex(rgb)]),
) as Record<string, string>;
// Non-target AIS ships should be visible but muted (speed encoded mainly via brightness).
export const OTHER_AIS_SPEED_RGB = {
fast: [148, 163, 184] as Rgb, // SOG >= 10
moving: [100, 116, 139] as Rgb, // 1 <= SOG < 10
stopped: [71, 85, 105] as Rgb, // SOG < 1
};
export const OTHER_AIS_SPEED_HEX = {
fast: rgbToHex(OTHER_AIS_SPEED_RGB.fast),
moving: rgbToHex(OTHER_AIS_SPEED_RGB.moving),
stopped: rgbToHex(OTHER_AIS_SPEED_RGB.stopped),
};
// Overlay palette: keep a cohesive "warm alert" family, but ensure each overlay type is distinguishable.
export const OVERLAY_RGB = {
pairNormal: [59, 130, 246] as Rgb, // blue-500
pairWarn: [251, 113, 133] as Rgb, // rose-400 (쌍 이격 경고)
fcTransfer: [249, 115, 22] as Rgb, // orange-500 (환적 연결)
fleetRange: [250, 204, 21] as Rgb, // yellow-400 (선단 범위)
suspicious: [239, 68, 68] as Rgb, // red-500
};

파일 보기

@ -1,13 +1,15 @@
import type { LegacyAlarm } from "../../features/legacyDashboard/model/types";
export function AlarmsPanel({ alarms, onSelectMmsi }: { alarms: LegacyAlarm[]; onSelectMmsi?: (mmsi: number) => void }) {
const shown = alarms.slice(0, 6);
if (alarms.length === 0) {
return <div style={{ fontSize: 11, color: "var(--muted)" }}>( )</div>;
}
return (
<div>
{shown.map((a, idx) => (
{alarms.map((a, idx) => (
<div
key={`${a.kind}-${idx}`}
key={`${a.kind}-${a.relatedMmsi.join("-")}-${a.timeLabel}-${idx}`}
className={`ai ${a.severity}`}
onClick={() => {
if (!onSelectMmsi) return;

파일 보기

@ -2,6 +2,7 @@ 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";
type Props = {
vessel: DerivedLegacyVessel;
@ -93,7 +94,7 @@ export function VesselInfoPanel({ vessel: v, allVessels, onClose, onSelectMmsi }
</div>
<div className="ir">
<span className="il"> </span>
<span className="iv" style={{ color: pairDist > 3 ? "#F59E0B" : "#22C55E" }}>
<span className="iv" style={{ color: pairDist > 3 ? rgbToHex(OVERLAY_RGB.pairWarn) : "#22C55E" }}>
{pairDist.toFixed(2)}NM {pairDist > 3 ? "⚠" : "✓"}
</span>
</div>

파일 보기

@ -1,4 +1,5 @@
import { ZONE_IDS, ZONE_META } from "../../entities/zone/model/meta";
import { LEGACY_CODE_COLORS_HEX, OTHER_AIS_SPEED_HEX, OVERLAY_RGB, rgbToHex, rgba } from "../../shared/lib/map/palette";
export function MapLegend() {
return (
@ -12,48 +13,56 @@ export function MapLegend() {
))}
<div className="lt" style={{ marginTop: 8 }}>
AIS ()
AIS ()
</div>
<div className="li">
<div className="ls" style={{ background: "#3B82F6", borderRadius: 999 }} />
<div className="ls" style={{ background: OTHER_AIS_SPEED_HEX.fast, borderRadius: 999 }} />
SOG 10 kt
</div>
<div className="li">
<div className="ls" style={{ background: "#22C55E", borderRadius: 999 }} />
<div className="ls" style={{ background: OTHER_AIS_SPEED_HEX.moving, borderRadius: 999 }} />
1 SOG &lt; 10 kt
</div>
<div className="li">
<div className="ls" style={{ background: "#64748B", borderRadius: 999 }} />
SOG &lt; 1 kt (or unknown)
<div className="ls" style={{ background: OTHER_AIS_SPEED_HEX.stopped, borderRadius: 999 }} />
SOG &lt; 1 kt
</div>
<div className="li">
<div className="ls" style={{ background: OTHER_AIS_SPEED_HEX.moving, opacity: 0.55, borderRadius: 999 }} />
SOG unknown
</div>
<div className="lt" style={{ marginTop: 8 }}>
CN Permit()
</div>
<div className="li">
<div className="ls" style={{ background: "#1E40AF", borderRadius: 999 }} />
<div className="ls" style={{ background: LEGACY_CODE_COLORS_HEX.PT, borderRadius: 999 }} />
PT (ring + )
</div>
<div className="li">
<div className="ls" style={{ background: "#EA580C", borderRadius: 999 }} />
<div className="ls" style={{ background: LEGACY_CODE_COLORS_HEX["PT-S"], borderRadius: 999 }} />
PT-S
</div>
<div className="li">
<div className="ls" style={{ background: "#10B981", borderRadius: 999 }} />
<div className="ls" style={{ background: LEGACY_CODE_COLORS_HEX.GN, borderRadius: 999 }} />
GN
</div>
<div className="li">
<div className="ls" style={{ background: "#8B5CF6", borderRadius: 999 }} />
<div className="ls" style={{ background: LEGACY_CODE_COLORS_HEX.OT, borderRadius: 999 }} />
OT 1
</div>
<div className="li">
<div className="ls" style={{ background: "#EF4444", borderRadius: 999 }} />
<div className="ls" style={{ background: LEGACY_CODE_COLORS_HEX.PS, borderRadius: 999 }} />
PS
</div>
<div className="li">
<div className="ls" style={{ background: "#F59E0B", borderRadius: 999 }} />
<div className="ls" style={{ background: LEGACY_CODE_COLORS_HEX.FC, borderRadius: 999 }} />
FC
</div>
<div className="li">
<div className="ls" style={{ background: LEGACY_CODE_COLORS_HEX.C21, borderRadius: 999 }} />
C21
</div>
<div className="lt" style={{ marginTop: 8 }}>
(3D)
@ -66,25 +75,29 @@ export function MapLegend() {
</div>
<div className="li">
<div style={{ width: 20, height: 2, background: "rgba(59,130,246,.35)", borderRadius: 1 }} />
<div style={{ width: 20, height: 2, background: rgba(OVERLAY_RGB.pairNormal, 0.35), borderRadius: 1 }} />
PTPT-S ()
</div>
<div className="li">
<div style={{ width: 14, height: 14, borderRadius: "50%", border: "1px solid rgba(59,130,246,.6)" }} />
<div style={{ width: 14, height: 14, borderRadius: "50%", border: `1px solid ${rgba(OVERLAY_RGB.pairNormal, 0.6)}` }} />
</div>
<div className="li">
<div style={{ width: 20, height: 2, background: "#F59E0B", borderRadius: 1 }} />
<div style={{ width: 20, height: 2, background: rgbToHex(OVERLAY_RGB.pairWarn), borderRadius: 1 }} />
(&gt;3NM)
</div>
<div className="li">
<div style={{ width: 20, height: 2, background: "#D97706", borderRadius: 1 }} />
<div style={{ width: 20, height: 2, background: rgbToHex(OVERLAY_RGB.fcTransfer), borderRadius: 1 }} />
FC (dashed)
</div>
<div className="li">
<div style={{ width: 14, height: 14, borderRadius: "50%", border: "1px solid rgba(245,158,11,.55)", opacity: 0.7 }} />
<div style={{ width: 14, height: 14, borderRadius: "50%", border: `1px solid ${rgba(OVERLAY_RGB.fleetRange, 0.75)}`, opacity: 0.8 }} />
</div>
<div className="li">
<div style={{ width: 20, height: 2, background: rgbToHex(OVERLAY_RGB.suspicious), borderRadius: 1 }} />
FC ()
</div>
</div>
);
}

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다. Load Diff

파일 보기

@ -2,6 +2,7 @@ import { useMemo, type MouseEvent } from "react";
import { 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, rgba } from "../../shared/lib/map/palette";
type FleetSortMode = "count" | "range";
@ -161,8 +162,8 @@ export function RelationsPanel({
<span
className="rel-dist"
style={{
background: warn ? "#F59E0B22" : "#22C55E22",
color: warn ? "#F59E0B" : "#22C55E",
background: warn ? rgba(OVERLAY_RGB.pairWarn, 0.13) : "#22C55E22",
color: warn ? rgbToHex(OVERLAY_RGB.pairWarn) : "#22C55E",
}}
>
{dist.toFixed(2)}NM
@ -197,7 +198,7 @@ export function RelationsPanel({
>
{fc.permitNo}
</span>
<span className="rel-dist" style={{ background: "#D9770622", color: "#D97706" }}>
<span className="rel-dist" style={{ background: rgba(OVERLAY_RGB.fcTransfer, 0.13), color: rgbToHex(OVERLAY_RGB.fcTransfer) }}>
{dist.toFixed(1)}NM
</span>
{isSameOwner ? (