chore: initial commit

This commit is contained in:
htlee 2026-02-15 11:22:38 +09:00
커밋 e69ace4434
69개의 변경된 파일10161개의 추가작업 그리고 0개의 파일을 삭제

17
.gitignore vendored Normal file
파일 보기

@ -0,0 +1,17 @@
node_modules
**/node_modules
dist
**/dist
.idea
.vscode
.DS_Store
.env
.env.*
*.log
*.tsbuildinfo

60
README.md Normal file
파일 보기

@ -0,0 +1,60 @@
# WING Fleet Dashboard (Prototype to Project)
This repo started as a single HTML prototype and is being refactored into a structured React + TypeScript project.
## Layout
- `legacy/`: original single-file HTML prototype (reference)
- `data/raw/zones/`: raw GeoJSON zone polygons as received
- `apps/web/`: React + TS frontend (MapLibre GL JS + Deck.gl, single WebGL context)
- `apps/api/`: optional API server (proxy/stub)
- `scripts/`: data preparation scripts (e.g. zone reprojection)
## What Works Now (GIS Demo)
- Live AIS polling (initial 10 minutes, then every 1 minute / last 2 minutes incremental) with MMSI upsert store:
- frontend hook: `apps/web/src/features/aisPolling/useAisTargetPolling.ts`
- API proxy (CORS safe): `apps/api/src/index.ts` (`GET /api/ais-target/search?minutes=...&bbox=...`)
- MapLibre basemap:
- default (no MapTiler key): local raster style + OpenSeaMap seamark overlay: `apps/web/public/map/styles/osm-seamark.json`
- optional (recommended): MapTiler `dataviz-dark` + Ocean (bathymetry) vector overlays, plus OpenSeaMap seamark raster overlay
- Deck.gl layers rendered into MapLibre WebGL context (single context via `MapboxOverlay`):
- ships `IconLayer`, zones `GeoJsonLayer`, optional 3D density `HexagonLayer`
- map widget: `apps/web/src/widgets/map3d/Map3D.tsx`
- Zone polygons converted to WGS84 GeoJSON:
- input: `data/raw/zones/*.json`
- build: `pnpm prepare:data` -> `apps/web/public/data/zones/zones.wgs84.geojson`
- Legacy CN-permit overlay (name/callsign/mmsi match; ring + color by 업종):
- legacy sources: `legacy/*.xlsx`, `legacy/*.jsx`
- build: `pnpm prepare:data` -> `apps/web/public/data/legacy/chinese-permitted.v1.json`
## Quick Start
1. `pnpm install`
2. `pnpm prepare:data`
3. `pnpm dev:web`
4. (optional) `pnpm dev:api` # only needed when you start adding DB-backed/local APIs
API server (optional): `pnpm dev:api` (defaults to `http://127.0.0.1:5174`)
Ports (dev):
- Web (Vite): `http://127.0.0.1:5175` (default, strict). Change via `WEB_PORT=....`
- API (Fastify): `http://127.0.0.1:5174` (default). Change via `PORT=....` and match `API_PORT=....` for the web proxy.
Vite proxy (dev):
- `/snp-api/*` -> `http://211.208.115.83:8041` (override via `VITE_SNP_API_TARGET=...`)
## MapTiler (Optional, Bathymetry Vector Map)
To enable a dark-mode basemap + bathymetry (MapTiler Ocean tiles):
1. Create a free MapTiler Cloud account and get an API key
2. Set env vars for the web app:
```bash
# enables MapTiler style + ocean bathymetry layers in MapLibre
VITE_MAPTILER_KEY="YOUR_KEY"
# optional: change basemap style id (default: dataviz-dark)
VITE_MAPTILER_BASE_MAP_ID="dataviz-dark"
```

21
apps/api/package.json Normal file
파일 보기

@ -0,0 +1,21 @@
{
"name": "@wing/api",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc -p tsconfig.json",
"start": "node dist/index.js"
},
"dependencies": {
"@fastify/cors": "^11.1.0",
"fastify": "^5.6.1"
},
"devDependencies": {
"@types/node": "^24.10.1",
"tsx": "^4.20.5",
"typescript": "~5.9.3"
}
}

158
apps/api/src/index.ts Normal file
파일 보기

@ -0,0 +1,158 @@
import cors from "@fastify/cors";
import Fastify from "fastify";
import fs from "node:fs/promises";
import path from "node:path";
const app = Fastify({ logger: true });
await app.register(cors, { origin: true });
app.setErrorHandler((err, req, reply) => {
req.log.error({ err }, "Unhandled error");
if (reply.sent) return;
const statusCode =
typeof (err as { statusCode?: unknown }).statusCode === "number" ? (err as { statusCode: number }).statusCode : 500;
reply.code(statusCode).send({
success: false,
message: err instanceof Error ? err.message : String(err),
data: [],
errorCode: "INTERNAL",
});
});
app.get("/health", async () => ({ ok: true }));
const AIS_UPSTREAM_BASE = "http://211.208.115.83:8041";
const AIS_UPSTREAM_PATH = "/snp-api/api/ais-target/search";
app.get<{
Querystring: {
minutes?: string;
bbox?: string;
};
}>("/api/ais-target/search", async (req, reply) => {
const minutesRaw = req.query.minutes ?? "60";
const minutes = Number(minutesRaw);
if (!Number.isFinite(minutes) || minutes <= 0 || minutes > 60 * 24) {
return reply.code(400).send({ success: false, message: "invalid minutes", data: [], errorCode: "BAD_REQUEST" });
}
const bboxRaw = req.query.bbox;
const bbox = parseBbox(bboxRaw);
if (bboxRaw && !bbox) {
return reply.code(400).send({ success: false, message: "invalid bbox", data: [], errorCode: "BAD_REQUEST" });
}
const u = new URL(AIS_UPSTREAM_PATH, AIS_UPSTREAM_BASE);
u.searchParams.set("minutes", String(minutes));
const controller = new AbortController();
const timeoutMs = 20_000;
const timeout = setTimeout(() => controller.abort(), timeoutMs);
try {
const res = await fetch(u, { signal: controller.signal, headers: { accept: "application/json" } });
const txt = await res.text();
if (!res.ok) {
req.log.warn({ status: res.status, body: txt.slice(0, 2000) }, "AIS upstream error");
return reply.code(502).send({ success: false, message: "upstream error", data: [], errorCode: "UPSTREAM" });
}
// Apply optional bbox filtering server-side to reduce payload to the browser.
let json: { data?: unknown; message?: string };
try {
json = JSON.parse(txt) as { data?: unknown; message?: string };
} catch (e) {
req.log.warn({ err: e, body: txt.slice(0, 2000) }, "AIS upstream returned invalid JSON");
return reply
.code(502)
.send({ success: false, message: "upstream invalid json", data: [], errorCode: "UPSTREAM_INVALID_JSON" });
}
if (!json || typeof json !== "object") {
req.log.warn({ body: txt.slice(0, 2000) }, "AIS upstream returned non-object JSON");
return reply
.code(502)
.send({ success: false, message: "upstream invalid payload", data: [], errorCode: "UPSTREAM_INVALID_PAYLOAD" });
}
const rows = Array.isArray(json.data) ? (json.data as unknown[]) : [];
const filtered = bbox
? rows.filter((r) => {
if (!r || typeof r !== "object") return false;
const lat = (r as { lat?: unknown }).lat;
const lon = (r as { lon?: unknown }).lon;
if (typeof lat !== "number" || typeof lon !== "number") return false;
return lon >= bbox.lonMin && lon <= bbox.lonMax && lat >= bbox.latMin && lat <= bbox.latMax;
})
: rows;
if (bbox) {
json.message = `${json.message ?? ""} (bbox: ${filtered.length}/${rows.length})`.trim();
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(json as any).data = filtered;
reply.type("application/json").send(json);
} catch (e) {
const name = e instanceof Error ? e.name : "";
const isTimeout = name === "AbortError";
req.log.warn({ err: e, url: u.toString() }, "AIS proxy request failed");
return reply.code(isTimeout ? 504 : 502).send({
success: false,
message: isTimeout ? `upstream timeout (${timeoutMs}ms)` : "upstream fetch failed",
data: [],
errorCode: isTimeout ? "UPSTREAM_TIMEOUT" : "UPSTREAM_FETCH_FAILED",
});
} finally {
clearTimeout(timeout);
}
});
function parseBbox(raw: string | undefined) {
if (!raw) return null;
const parts = raw
.split(",")
.map((s) => s.trim())
.filter(Boolean);
if (parts.length !== 4) return null;
const [lonMin, latMin, lonMax, latMax] = parts.map((p) => Number(p));
const ok =
Number.isFinite(lonMin) &&
Number.isFinite(latMin) &&
Number.isFinite(lonMax) &&
Number.isFinite(latMax) &&
lonMin >= -180 &&
lonMax <= 180 &&
latMin >= -90 &&
latMax <= 90 &&
lonMin < lonMax &&
latMin < latMax;
if (!ok) return null;
return { lonMin, latMin, lonMax, latMax };
}
app.get("/zones", async (_req, reply) => {
const zonesPath = path.resolve(
process.cwd(),
"..",
"web",
"public",
"data",
"zones",
"zones.wgs84.geojson",
);
const txt = await fs.readFile(zonesPath, "utf-8");
reply.type("application/json").send(JSON.parse(txt));
});
app.get("/vessels", async () => {
return {
source: "todo",
vessels: [],
};
});
const port = Number(process.env.PORT || 5174);
const host = process.env.HOST || "127.0.0.1";
await app.listen({ port, host });

14
apps/api/tsconfig.json Normal file
파일 보기

@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"skipLibCheck": true,
"types": ["node"]
},
"include": ["src/**/*.ts"]
}

24
apps/web/.gitignore vendored Normal file
파일 보기

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

73
apps/web/README.md Normal file
파일 보기

@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```

23
apps/web/eslint.config.js Normal file
파일 보기

@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

13
apps/web/index.html Normal file
파일 보기

@ -0,0 +1,13 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>906척 실시간 조업 감시 — 선단 연관관계</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

35
apps/web/package.json Normal file
파일 보기

@ -0,0 +1,35 @@
{
"name": "@wing/web",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@deck.gl/aggregation-layers": "^9.2.7",
"@deck.gl/core": "^9.2.7",
"@deck.gl/layers": "^9.2.7",
"@deck.gl/mapbox": "^9.2.7",
"maplibre-gl": "^5.18.0",
"react": "^19.2.0",
"react-dom": "^19.2.0"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/node": "^24.10.1",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.48.0",
"vite": "^7.3.1"
}
}

파일 보기

@ -0,0 +1,21 @@
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128">
<!-- Monochrome top-down ship silhouette (white + transparent) for Deck.gl IconLayer with mask:true -->
<path
d="M64 6
C78 12 90 26 96 42
L109 72
C111 77 109 83 104 86
L86 98
L86 114
C86 121 81 124 74 124
L54 124
C47 124 42 121 42 114
L42 98
L24 86
C19 83 17 77 19 72
L32 42
C38 26 50 12 64 6 Z"
fill="#ffffff"
/>
<path d="M52 54 H76 V74 H52 Z" fill="#ffffff" />
</svg>

After

Width:  |  Height:  |  크기: 565 B

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

파일 보기

@ -0,0 +1,30 @@
{
"version": 8,
"name": "CARTO Dark (Legacy)",
"sources": {
"carto-dark": {
"type": "raster",
"tiles": [
"https://a.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png",
"https://b.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png",
"https://c.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png",
"https://d.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png"
],
"tileSize": 256,
"attribution": "© OpenStreetMap contributors © CARTO"
}
},
"layers": [
{
"id": "background",
"type": "background",
"paint": { "background-color": "#0b1020" }
},
{
"id": "carto-dark",
"type": "raster",
"source": "carto-dark",
"paint": { "raster-opacity": 1 }
}
]
}

파일 보기

@ -0,0 +1,28 @@
{
"version": 8,
"name": "OSM Raster + OpenSeaMap",
"sources": {
"osm": {
"type": "raster",
"tiles": ["https://tile.openstreetmap.org/{z}/{x}/{y}.png"],
"tileSize": 256,
"attribution": "© OpenStreetMap contributors"
},
"seamark": {
"type": "raster",
"tiles": ["https://tiles.openseamap.org/seamark/{z}/{x}/{y}.png"],
"tileSize": 256,
"attribution": "© OpenSeaMap contributors"
}
},
"layers": [
{ "id": "osm", "type": "raster", "source": "osm" },
{
"id": "seamark",
"type": "raster",
"source": "seamark",
"paint": { "raster-opacity": 0.85 }
}
]
}

1
apps/web/public/vite.svg Normal file
파일 보기

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  크기: 1.5 KiB

6
apps/web/src/app/App.tsx Normal file
파일 보기

@ -0,0 +1,6 @@
import { DashboardPage } from "../pages/dashboard/DashboardPage";
export default function App() {
return <DashboardPage />;
}

675
apps/web/src/app/styles.css Normal file
파일 보기

@ -0,0 +1,675 @@
@import url("https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;600;700;800;900&display=swap");
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
--bg: #020617;
--panel: #0f172a;
--card: #1e293b;
--border: #1e3a5f;
--text: #e2e8f0;
--muted: #64748b;
--accent: #3b82f6;
--crit: #ef4444;
--high: #f59e0b;
}
html,
body {
height: 100%;
}
body {
font-family: "Noto Sans KR", sans-serif;
background: var(--bg);
color: var(--text);
overflow: hidden;
}
#root {
height: 100%;
}
.app {
display: grid;
grid-template-columns: 310px 1fr;
grid-template-rows: 44px 1fr;
height: 100vh;
}
.topbar {
grid-column: 1/-1;
background: var(--panel);
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
padding: 0 14px;
gap: 10px;
z-index: 1000;
}
.topbar .logo {
font-size: 14px;
font-weight: 800;
display: flex;
align-items: center;
gap: 6px;
white-space: nowrap;
}
.topbar .logo span {
color: var(--accent);
}
.topbar .stats {
display: flex;
gap: 14px;
margin-left: auto;
flex-wrap: wrap;
justify-content: flex-end;
}
.topbar .stat {
font-size: 10px;
color: var(--muted);
display: flex;
align-items: center;
gap: 4px;
}
.topbar .stat b {
color: var(--text);
font-size: 12px;
}
.topbar .time {
font-size: 10px;
color: var(--accent);
font-weight: 600;
margin-left: 10px;
white-space: nowrap;
}
.sidebar {
background: var(--panel);
border-right: 1px solid var(--border);
overflow-y: auto;
display: flex;
flex-direction: column;
}
.map-area {
position: relative;
}
.sb {
padding: 10px 12px;
border-bottom: 1px solid var(--border);
}
.sb-t {
font-size: 9px;
font-weight: 700;
color: var(--muted);
letter-spacing: 1.5px;
text-transform: uppercase;
margin-bottom: 6px;
}
/* Type grid */
.tg {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 3px;
}
.tb {
background: var(--card);
border: 1px solid transparent;
border-radius: 5px;
padding: 4px;
cursor: pointer;
text-align: center;
transition: all 0.15s;
user-select: none;
}
.tb:hover {
border-color: var(--border);
}
.tb.on {
border-color: var(--accent);
background: rgba(59, 130, 246, 0.1);
}
.tb .c {
font-size: 11px;
font-weight: 800;
}
.tb .n {
font-size: 8px;
color: var(--muted);
}
/* Speed bar */
.sbar {
position: relative;
height: 24px;
background: var(--bg);
border-radius: 5px;
overflow: hidden;
margin: 4px 0;
}
.sseg {
position: absolute;
border-radius: 3px;
display: flex;
align-items: center;
justify-content: center;
font-size: 7px;
color: #fff;
font-weight: 600;
}
.sseg.p {
height: 24px;
top: 0;
border: 1.5px solid rgba(255, 255, 255, 0.25);
box-shadow: 0 0 6px rgba(0, 0, 0, 0.3);
}
.sseg:not(.p) {
height: 16px;
top: 4px;
opacity: 0.6;
}
/* Vessel list */
.vlist {
flex: 1;
min-height: 0;
overflow-y: auto;
}
.vi {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 6px;
border-radius: 3px;
cursor: pointer;
font-size: 10px;
transition: background 0.1s;
user-select: none;
}
.vi:hover {
background: var(--card);
}
.vi .dot {
width: 7px;
height: 7px;
border-radius: 50%;
flex-shrink: 0;
}
.vi .nm {
flex: 1;
font-weight: 500;
}
.vi .sp {
font-weight: 700;
font-size: 9px;
}
.vi .st {
font-size: 7px;
padding: 1px 3px;
border-radius: 2px;
font-weight: 600;
}
/* AIS target list */
.ais-q {
flex: 1;
font-size: 10px;
padding: 6px 8px;
border-radius: 6px;
border: 1px solid var(--border);
background: rgba(30, 41, 59, 0.75);
color: var(--text);
outline: none;
}
.ais-q::placeholder {
color: rgba(100, 116, 139, 0.9);
}
.ais-mode {
display: flex;
border: 1px solid var(--border);
border-radius: 6px;
overflow: hidden;
}
.ais-mode-btn {
font-size: 9px;
padding: 0 8px;
height: 28px;
border: none;
background: var(--card);
color: var(--muted);
cursor: pointer;
}
.ais-mode-btn.on {
background: rgba(59, 130, 246, 0.18);
color: var(--text);
}
.ais-list {
flex: 1;
min-height: 0;
overflow-y: auto;
}
.ais-row {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 6px;
border-radius: 6px;
cursor: pointer;
user-select: none;
transition: background 0.12s, border-color 0.12s;
border: 1px solid transparent;
}
.ais-row:hover {
background: rgba(30, 41, 59, 0.6);
border-color: rgba(30, 58, 95, 0.8);
}
.ais-row.sel {
background: rgba(59, 130, 246, 0.14);
border-color: rgba(59, 130, 246, 0.55);
}
.ais-dot {
width: 7px;
height: 7px;
border-radius: 50%;
flex-shrink: 0;
}
.ais-nm {
flex: 1;
min-width: 0;
}
.ais-nm1 {
font-size: 10px;
font-weight: 700;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ais-nm2 {
font-size: 9px;
color: var(--muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ais-right {
text-align: right;
flex-shrink: 0;
}
.ais-badges {
display: flex;
gap: 4px;
justify-content: flex-end;
margin-bottom: 2px;
flex-wrap: wrap;
}
.ais-badge {
font-size: 8px;
padding: 1px 5px;
border-radius: 5px;
border: 1px solid rgba(255, 255, 255, 0.08);
font-weight: 800;
letter-spacing: 0.2px;
color: #fff;
background: rgba(100, 116, 139, 0.22);
}
.ais-badge.pn {
color: var(--muted);
background: rgba(30, 41, 59, 0.55);
border-color: rgba(30, 58, 95, 0.9);
font-weight: 700;
}
.ais-badge.PT {
background: rgba(30, 64, 175, 0.28);
border-color: rgba(30, 64, 175, 0.7);
}
.ais-badge.PT-S {
background: rgba(234, 88, 12, 0.22);
border-color: rgba(234, 88, 12, 0.7);
}
.ais-badge.GN {
background: rgba(16, 185, 129, 0.22);
border-color: rgba(16, 185, 129, 0.7);
}
.ais-badge.OT {
background: rgba(139, 92, 246, 0.22);
border-color: rgba(139, 92, 246, 0.7);
}
.ais-badge.PS {
background: rgba(239, 68, 68, 0.22);
border-color: rgba(239, 68, 68, 0.7);
}
.ais-badge.FC {
background: rgba(245, 158, 11, 0.22);
border-color: rgba(245, 158, 11, 0.7);
}
.ais-sp {
font-size: 10px;
font-weight: 800;
}
.ais-ts {
font-size: 9px;
color: rgba(100, 116, 139, 0.9);
}
/* Alarm */
.ai {
display: flex;
gap: 6px;
padding: 4px 6px;
border-radius: 3px;
margin-bottom: 2px;
font-size: 9px;
border-left: 3px solid;
}
.ai.cr {
border-color: var(--crit);
background: rgba(239, 68, 68, 0.07);
}
.ai.hi {
border-color: var(--high);
background: rgba(245, 158, 11, 0.05);
}
.ai .at {
color: var(--muted);
font-size: 8px;
white-space: nowrap;
}
/* Relation panel */
.rel-panel {
background: var(--card);
border-radius: 6px;
padding: 8px;
margin-top: 4px;
}
.rel-header {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 6px;
}
.rel-badge {
font-size: 9px;
padding: 1px 5px;
border-radius: 3px;
font-weight: 700;
}
.rel-line {
display: flex;
align-items: center;
gap: 4px;
font-size: 10px;
padding: 2px 0;
}
.rel-line .dot {
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
}
.rel-link {
width: 20px;
display: flex;
align-items: center;
justify-content: center;
color: var(--muted);
font-size: 10px;
}
.rel-dist {
font-size: 8px;
padding: 1px 4px;
border-radius: 2px;
font-weight: 600;
}
/* Fleet network */
.fleet-card {
background: rgba(30, 41, 59, 0.8);
border: 1px solid var(--border);
border-radius: 6px;
padding: 8px;
margin-bottom: 4px;
}
.fleet-owner {
font-size: 10px;
font-weight: 700;
color: var(--accent);
margin-bottom: 4px;
}
.fleet-vessel {
display: flex;
align-items: center;
gap: 4px;
font-size: 9px;
padding: 1px 0;
}
/* Toggles */
.tog {
display: flex;
gap: 3px;
flex-wrap: wrap;
margin-bottom: 6px;
}
.tog-btn {
font-size: 8px;
padding: 2px 6px;
border-radius: 3px;
border: 1px solid var(--border);
background: var(--card);
color: var(--muted);
cursor: pointer;
transition: all 0.15s;
user-select: none;
}
.tog-btn.on {
background: var(--accent);
color: #fff;
border-color: var(--accent);
}
/* Map panels */
.map-legend {
position: absolute;
/* Keep attribution visible (bottom-right) for licensing/compliance. */
bottom: 44px;
right: 12px;
z-index: 800;
background: rgba(15, 23, 42, 0.92);
backdrop-filter: blur(8px);
border: 1px solid var(--border);
border-radius: 8px;
padding: 10px;
font-size: 9px;
min-width: 180px;
}
.map-legend .lt {
font-size: 8px;
font-weight: 700;
color: var(--muted);
margin-bottom: 4px;
letter-spacing: 1px;
}
.map-legend .li {
display: flex;
align-items: center;
gap: 5px;
margin-bottom: 2px;
}
.map-legend .ls {
width: 12px;
height: 12px;
border-radius: 3px;
flex-shrink: 0;
}
.map-info {
position: absolute;
top: 12px;
right: 12px;
z-index: 800;
background: rgba(15, 23, 42, 0.95);
backdrop-filter: blur(8px);
border: 1px solid var(--border);
border-radius: 8px;
padding: 12px;
width: 270px;
}
.map-info .ir {
display: flex;
justify-content: space-between;
font-size: 10px;
padding: 2px 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.03);
}
.map-info .il {
color: var(--muted);
}
.map-info .iv {
font-weight: 600;
}
.close-btn {
position: absolute;
top: 6px;
right: 8px;
background: none;
border: none;
color: var(--muted);
cursor: pointer;
font-size: 13px;
}
.month-row {
display: flex;
gap: 1px;
}
.month-cell {
flex: 1;
height: 12px;
border-radius: 2px;
display: flex;
align-items: center;
justify-content: center;
font-size: 6px;
font-weight: 600;
}
::-webkit-scrollbar {
width: 3px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 2px;
}
.maplibregl-ctrl-group {
border: 1px solid var(--border) !important;
background: rgba(15, 23, 42, 0.92) !important;
backdrop-filter: blur(8px);
}
.maplibregl-ctrl-group button {
background: transparent !important;
}
.maplibregl-ctrl-group button + button {
border-top: 1px solid var(--border) !important;
}
.maplibregl-ctrl-group button span {
filter: invert(1);
opacity: 0.9;
}
.maplibregl-ctrl-attrib {
font-size: 10px !important;
background: rgba(15, 23, 42, 0.75) !important;
color: var(--text) !important;
border: 1px solid var(--border) !important;
border-radius: 8px;
}
@media (max-width: 920px) {
.app {
grid-template-columns: 1fr;
grid-template-rows: 44px 1fr;
}
.sidebar {
display: none;
}
}

파일 보기

@ -0,0 +1,38 @@
import type { AisTargetSearchResponse } from "../model/types";
export type SearchAisTargetsParams = {
minutes: number;
bbox?: string;
};
export async function searchAisTargets(params: SearchAisTargetsParams, signal?: AbortSignal) {
// Same convention as the "dark" project:
// - dev: default to Vite proxy base `/snp-api`
// - prod/other: can be overridden via `VITE_API_URL` (e.g. `http://host:8041/snp-api`)
const base = (import.meta.env.VITE_API_URL || "/snp-api").replace(/\/$/, "");
const u = new URL(`${base}/api/ais-target/search`, window.location.origin);
u.searchParams.set("minutes", String(params.minutes));
if (params.bbox) u.searchParams.set("bbox", params.bbox);
const res = await fetch(u, { signal, headers: { accept: "application/json" } });
const txt = await res.text();
let json: unknown = null;
try {
json = JSON.parse(txt);
} catch {
// ignore
}
if (!res.ok) {
const msg =
json && typeof json === "object" && typeof (json as { message?: unknown }).message === "string"
? (json as { message: string }).message
: txt.slice(0, 200) || res.statusText;
throw new Error(`AIS target API failed: ${res.status} ${msg}`);
}
if (!json || typeof json !== "object") throw new Error("AIS target API returned invalid payload");
const parsed = json as AisTargetSearchResponse;
if (!parsed.success) throw new Error(parsed.message || "AIS target API returned success=false");
return parsed;
}

파일 보기

@ -0,0 +1,31 @@
export type AisTarget = {
mmsi: number;
imo: number;
name: string;
callsign: string;
vesselType: string;
lat: number;
lon: number;
heading: number;
sog: number;
cog: number;
rot: number;
length: number;
width: number;
draught: number;
destination: string;
eta: string;
status: string;
messageTimestamp: string;
receivedDate: string;
source: string;
classType: string;
};
export type AisTargetSearchResponse = {
success: boolean;
message: string;
data: AisTarget[];
errorCode: string | null;
};

파일 보기

@ -0,0 +1,33 @@
import { useEffect, useState } from "react";
import type { LegacyVesselDataset } from "../model/types";
export function useLegacyVessels(url = "/data/legacy/chinese-permitted.v1.json") {
const [data, setData] = useState<LegacyVesselDataset | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
async function run() {
try {
setError(null);
const res = await fetch(url);
if (!res.ok) throw new Error(`Failed to load legacy vessels: ${res.status} ${res.statusText}`);
const json = (await res.json()) as LegacyVesselDataset;
if (cancelled) return;
setData(json);
} catch (e) {
if (cancelled) return;
setError(e instanceof Error ? e.message : String(e));
}
}
void run();
return () => {
cancelled = true;
};
}, [url]);
return { data, error };
}

파일 보기

@ -0,0 +1,75 @@
import { normalizeShipName } from "./normalizeShipName";
import type { LegacyVesselInfo } from "../model/types";
export type LegacyVesselIndex = {
byName: Map<string, LegacyVesselInfo>;
byMmsi: Map<number, LegacyVesselInfo>;
};
function score(v: LegacyVesselInfo) {
// Prefer records that were merged from more sources.
return Number(!!v.sources.permittedList) + Number(!!v.sources.checklist) + Number(!!v.sources.fleet906);
}
export function buildLegacyVesselIndex(vessels: LegacyVesselInfo[]): LegacyVesselIndex {
const byName = new Map<string, LegacyVesselInfo>();
const byMmsi = new Map<number, LegacyVesselInfo>();
for (const v of vessels) {
const keys = new Set<string>();
if (v.shipNameRoman) keys.add(normalizeShipName(v.shipNameRoman));
if (v.shipNameCn) keys.add(normalizeShipName(v.shipNameCn));
if (v.callSign) keys.add(normalizeShipName(v.callSign));
for (const k of keys) {
if (!k) continue;
const prev = byName.get(k);
if (!prev) {
byName.set(k, v);
continue;
}
if (score(v) > score(prev)) byName.set(k, v);
}
for (const m of v.mmsiList || []) {
if (!Number.isFinite(m)) continue;
const prev = byMmsi.get(m);
if (!prev) {
byMmsi.set(m, v);
continue;
}
if (score(v) > score(prev)) byMmsi.set(m, v);
}
}
return { byName, byMmsi };
}
export type LegacyMatchable = {
mmsi?: number;
name?: string;
callsign?: string;
};
export function matchLegacyVessel(t: LegacyMatchable, idx: LegacyVesselIndex): LegacyVesselInfo | null {
const mmsi = t.mmsi;
if (typeof mmsi === "number") {
const hit = idx.byMmsi.get(mmsi);
if (hit) return hit;
}
const nameKey = t.name ? normalizeShipName(t.name) : "";
if (nameKey) {
const hit = idx.byName.get(nameKey);
if (hit) return hit;
}
const csKey = t.callsign ? normalizeShipName(t.callsign) : "";
if (csKey) {
const hit = idx.byName.get(csKey);
if (hit) return hit;
}
return null;
}

파일 보기

@ -0,0 +1,5 @@
export function normalizeShipName(x: string) {
// User requirement: case-insensitive + whitespace removed.
return (x || "").toLowerCase().replace(/\s+/g, "");
}

파일 보기

@ -0,0 +1,35 @@
export type LegacyVesselSources = {
permittedList: boolean;
checklist: boolean;
fleet906: boolean;
};
export type LegacyVesselInfo = {
permitNo: string;
shipNameRoman: string;
shipNameCn: string | null;
shipCode: string; // PT | PT-S | GN | OT | PS | FC | ...
ton: number | null;
callSign: string;
mmsiList: number[];
workSeaArea: string;
workTerm1: string;
workTerm2: string;
quota: string;
ownerCn: string | null;
ownerRoman: string | null;
pairPermitNo: string | null;
pairShipNameCn: string | null;
checklistSheet: string | null;
sources: LegacyVesselSources;
};
export type LegacyVesselDataset = {
generatedAt: string;
counts: Record<string, number>;
vessels: LegacyVesselInfo[];
};

파일 보기

@ -0,0 +1,34 @@
import type { Vessel, VesselTypeCode } from "../model/types";
export function isTrawl(code: VesselTypeCode) {
return code === "PT" || code === "PT-S";
}
export function filterVesselsForMap(vessels: readonly Vessel[], selectedType: VesselTypeCode | null) {
if (!selectedType) return vessels;
return vessels.filter((v) => {
if (v.code === selectedType) return true;
// PT and PT-S should be shown together
if (selectedType === "PT" && v.code === "PT-S") return true;
if (selectedType === "PT-S" && v.code === "PT") return true;
// FC interacts with trawl; show trawl when FC selected and FC when trawl selected
if (selectedType === "FC" && (v.code === "PT" || v.code === "PT-S")) return true;
if ((selectedType === "PT" || selectedType === "PT-S") && v.code === "FC") return true;
return false;
});
}
export function filterVesselsForList(vessels: readonly Vessel[], selectedType: VesselTypeCode | null) {
if (!selectedType) return vessels;
return vessels.filter((v) => {
if (v.code === selectedType) return true;
if (selectedType === "PT" && v.code === "PT-S") return true;
if (selectedType === "PT-S" && v.code === "PT") return true;
return false;
});
}

파일 보기

@ -0,0 +1,147 @@
import type { ZoneId } from "../../zone/model/meta";
import type { VesselTypeCode } from "./types";
export const SPEED_MAX = 15;
export const MONTH_LABELS = [
"1월",
"2월",
"3월",
"4월",
"5월",
"6월",
"7월",
"8월",
"9월",
"10월",
"11월",
"12월",
] as const;
export interface SpeedSegment {
label: string;
range: [number, number];
typicalSpeed: number;
color: string;
primary?: boolean;
}
export interface VesselTypeMeta {
code: VesselTypeCode;
name: string;
count: number;
allowedZones: ZoneId[];
color: string;
icon: string;
speedProfile: SpeedSegment[];
monthlyIntensity: number[]; // 12 months (0 = closed)
trajectory: string;
}
export const VESSEL_TYPE_ORDER: VesselTypeCode[] = ["PT", "PT-S", "GN", "OT", "PS", "FC"];
export const VESSEL_TYPES: Record<VesselTypeCode, VesselTypeMeta> = {
PT: {
code: "PT",
name: "2척식 저인망(본선)",
count: 323,
allowedZones: ["2", "3"],
color: "#2563EB",
icon: "⛵",
speedProfile: [
{ label: "정박", range: [0, 0.5], typicalSpeed: 0.3, color: "#64748B" },
{ label: "투양망", range: [1, 2.5], typicalSpeed: 1.5, color: "#F59E0B" },
{ label: "예인조업", range: [2.5, 4.5], typicalSpeed: 3.3, color: "#2563EB", primary: true },
{ label: "저속", range: [4.5, 7], typicalSpeed: 5.5, color: "#8B5CF6" },
{ label: "고속", range: [7, 15], typicalSpeed: 8.3, color: "#475569" },
],
monthlyIntensity: [0.5, 0.5, 0.7, 0.8, 0.7, 0.1, 0, 0, 0.8, 1, 0.9, 0.6],
trajectory: "직선예인(쌍동기화)",
},
"PT-S": {
code: "PT-S",
name: "2척식 저인망(부속선)",
count: 323,
allowedZones: ["2", "3"],
color: "#3B82F6",
icon: "⛵",
speedProfile: [
{ label: "정박", range: [0, 0.5], typicalSpeed: 0.3, color: "#64748B" },
{ label: "보조", range: [1, 2.5], typicalSpeed: 1.5, color: "#F59E0B" },
{ label: "동기예인", range: [2.5, 4.5], typicalSpeed: 3.3, color: "#3B82F6", primary: true },
{ label: "추종", range: [4.5, 7], typicalSpeed: 5.5, color: "#8B5CF6" },
{ label: "고속", range: [7, 15], typicalSpeed: 8.3, color: "#475569" },
],
monthlyIntensity: [0.5, 0.5, 0.7, 0.8, 0.7, 0.1, 0, 0, 0.8, 1, 0.9, 0.6],
trajectory: "본선거울상",
},
GN: {
code: "GN",
name: "유망",
count: 200,
allowedZones: ["2", "3", "4"],
color: "#059669",
icon: "🪢",
speedProfile: [
{ label: "정박", range: [0, 0.3], typicalSpeed: 0.2, color: "#64748B" },
{ label: "표류", range: [0.5, 2], typicalSpeed: 1, color: "#059669", primary: true },
{ label: "양망", range: [1, 3], typicalSpeed: 2, color: "#F59E0B" },
{ label: "투망", range: [2, 4], typicalSpeed: 3, color: "#EF4444" },
{ label: "항해", range: [5, 15], typicalSpeed: 7.5, color: "#475569" },
],
monthlyIntensity: [0.6, 0.5, 0.7, 0.7, 0.6, 0.3, 0.2, 0.3, 0.9, 1, 0.9, 0.7],
trajectory: "투망→표류→양망",
},
OT: {
code: "OT",
name: "1척식 저인망",
count: 13,
allowedZones: ["2", "3"],
color: "#7C3AED",
icon: "🚢",
speedProfile: [
{ label: "정박", range: [0, 0.5], typicalSpeed: 0.3, color: "#64748B" },
{ label: "투양망", range: [1, 2], typicalSpeed: 1.5, color: "#F59E0B" },
{ label: "단독예인", range: [2.5, 5], typicalSpeed: 3.5, color: "#7C3AED", primary: true },
{ label: "항해", range: [5, 15], typicalSpeed: 8, color: "#475569" },
],
monthlyIntensity: [0.5, 0.5, 0.7, 0.7, 0.6, 0.1, 0, 0, 0.8, 1, 0.8, 0.5],
trajectory: "단독레이스트랙",
},
PS: {
code: "PS",
name: "위망/채낚기",
count: 16,
allowedZones: ["1", "2", "3", "4"],
color: "#DC2626",
icon: "🦑",
speedProfile: [
{ label: "정박", range: [0, 0.3], typicalSpeed: 0.2, color: "#64748B" },
{ label: "위망", range: [0.3, 1.5], typicalSpeed: 0.5, color: "#DC2626", primary: true },
{ label: "채낚기", range: [0.3, 2], typicalSpeed: 0.8, color: "#F97316", primary: true },
{ label: "투양", range: [1.5, 3], typicalSpeed: 2, color: "#F59E0B" },
{ label: "항해", range: [5, 15], typicalSpeed: 8, color: "#475569" },
],
monthlyIntensity: [0.6, 0.7, 0.8, 0.7, 0.4, 0.3, 0.5, 0.7, 0.9, 1, 0.8, 0.6],
trajectory: "점군집/야간표류",
},
FC: {
code: "FC",
name: "운반선",
count: 31,
allowedZones: ["1", "2", "3", "4"],
color: "#D97706",
icon: "🚛",
speedProfile: [
{ label: "정박", range: [0, 0.3], typicalSpeed: 0.2, color: "#64748B" },
{ label: "환적", range: [0.5, 2], typicalSpeed: 1, color: "#D97706", primary: true },
{ label: "저속", range: [3, 6], typicalSpeed: 4.5, color: "#8B5CF6" },
{ label: "고속", range: [6, 15], typicalSpeed: 9, color: "#475569" },
],
monthlyIntensity: [0.4, 0.4, 0.6, 0.7, 0.5, 0.2, 0.1, 0.1, 0.8, 1, 0.9, 0.5],
trajectory: "허브스포크순회",
},
};
export const VESSEL_TOTAL_TARGET = Object.values(VESSEL_TYPES).reduce((sum, t) => sum + t.count, 0);

파일 보기

@ -0,0 +1,278 @@
import type { ZoneId } from "../../zone/model/meta";
import { haversineNm } from "../../../shared/lib/geo/haversineNm";
import { VESSEL_TYPES } from "./meta";
import type { FleetOwner, FleetState, TrawlPair, Vessel, VesselTypeCode } from "./types";
const SURNAMES = ["张", "王", "李", "刘", "陈", "杨", "黄", "赵", "周", "吴", "徐", "孙", "马", "朱", "胡", "郭", "林", "何", "高", "罗"];
const REGIONS = ["荣成", "石岛", "烟台", "威海", "日照", "青岛", "连云港", "舟山", "象山", "大连"];
const ZONE_BOUNDS: Record<ZoneId, { lat: [number, number]; lon: [number, number] }> = {
"1": { lon: [128.85, 131.70], lat: [36.16, 38.25] },
"2": { lon: [126.00, 128.90], lat: [32.18, 34.35] },
"3": { lon: [124.12, 126.06], lat: [33.13, 35.00] },
"4": { lon: [124.33, 125.85], lat: [35.00, 37.00] },
};
function rnd(a: number, b: number) {
return a + Math.random() * (b - a);
}
function pick<T>(arr: readonly T[]) {
return arr[Math.floor(Math.random() * arr.length)];
}
function randomPointInZone(zone: ZoneId) {
const b = ZONE_BOUNDS[zone];
// Small margin to avoid sitting exactly on the edge.
const lat = rnd(b.lat[0] + 0.05, b.lat[1] - 0.05);
const lon = rnd(b.lon[0] + 0.05, b.lon[1] - 0.05);
return { lat, lon };
}
function makePermit(id: number, suffix: "A" | "B") {
return `C21-${10000 + id}${suffix}`;
}
export function createMockFleetState(): FleetState {
const vessels: Vessel[] = [];
const owners: FleetOwner[] = [];
const ptPairs: TrawlPair[] = [];
let vid = 1;
// PT pairs: PT count == PT-S count, treat as "pair count".
for (let i = 0; i < VESSEL_TYPES.PT.count; i++) {
const owner = `${pick(SURNAMES)}${pick(SURNAMES)}${pick(["渔业", "海产", "水产", "船务"])}${pick(["有限公司", "合作社", ""])}`;
const region = pick(REGIONS);
const zone = pick<ZoneId>(["2", "3"]);
const { lat, lon } = randomPointInZone(zone);
const isFishing = Math.random() < 0.55;
const sp = isFishing ? rnd(2.5, 4.5) : rnd(6, 11);
const crs = rnd(0, 360);
const pairDist = isFishing ? rnd(0.2, 1.2) : rnd(0, 0.3); // NM (rough)
const pairAngle = rnd(0, 360);
const lat2 = lat + (pairDist / 60) * Math.cos((pairAngle * Math.PI) / 180);
const lon2 = lon + ((pairDist / 60) * Math.sin((pairAngle * Math.PI) / 180)) / Math.cos((lat * Math.PI) / 180);
const permitBase = vid;
const ptId = vid++;
const ptsId = vid++;
const pt: Vessel = {
id: ptId,
permit: makePermit(permitBase, "A"),
code: "PT",
color: VESSEL_TYPES.PT.color,
lat,
lon,
speed: Number(sp.toFixed(1)),
course: Number(crs.toFixed(0)),
state: isFishing ? "조업중" : sp < 1 ? "정박" : "항해중",
zone,
isFishing,
owner,
region,
pairId: null,
pairDistNm: Number(pairDist.toFixed(2)),
nearVesselIds: [],
};
const pts: Vessel = {
id: ptsId,
permit: makePermit(permitBase, "B"),
code: "PT-S",
color: VESSEL_TYPES["PT-S"].color,
lat: Number(lat2.toFixed(4)),
lon: Number(lon2.toFixed(4)),
speed: Number((sp + rnd(-0.3, 0.3)).toFixed(1)),
course: Number((crs + rnd(-10, 10)).toFixed(0)),
state: isFishing ? "조업중" : sp < 1 ? "정박" : "항해중",
zone,
isFishing,
owner,
region,
pairId: null,
pairDistNm: pt.pairDistNm,
nearVesselIds: [],
};
pt.pairId = pts.id;
pts.pairId = pt.id;
vessels.push(pt, pts);
ptPairs.push({ mainId: pt.id, subId: pts.id, owner, region });
owners.push({ name: owner, region, vessels: [pt.id, pts.id], type: "trawl" });
}
// GN vessels
for (let i = 0; i < VESSEL_TYPES.GN.count; i++) {
const attachToOwner = Math.random() < 0.3 ? owners[Math.floor(Math.random() * owners.length)] : null;
const owner = attachToOwner ? attachToOwner.name : `${pick(SURNAMES)}${pick(SURNAMES)}${pick(["渔业", "水产"])}有限公司`;
const region = attachToOwner ? attachToOwner.region : pick(REGIONS);
const zone = pick<ZoneId>(["2", "3", "4"]);
const { lat, lon } = randomPointInZone(zone);
const isFishing = Math.random() < 0.5;
const sp = isFishing ? rnd(0.5, 2) : rnd(5, 10);
const id = vid++;
const v: Vessel = {
id,
permit: makePermit(id, "A"),
code: "GN",
color: VESSEL_TYPES.GN.color,
lat,
lon,
speed: Number(sp.toFixed(1)),
course: Number(rnd(0, 360).toFixed(0)),
state: isFishing ? pick(["표류", "투망", "양망"]) : sp < 1 ? "정박" : "항해중",
zone,
isFishing,
owner,
region,
pairId: null,
pairDistNm: null,
nearVesselIds: [],
};
vessels.push(v);
if (attachToOwner) attachToOwner.vessels.push(v.id);
else owners.push({ name: owner, region, vessels: [v.id], type: "gn" });
}
// OT
for (let i = 0; i < VESSEL_TYPES.OT.count; i++) {
const owner = `${pick(SURNAMES)}${pick(SURNAMES)}远洋渔业`;
const region = pick(REGIONS);
const zone = pick<ZoneId>(["2", "3"]);
const { lat, lon } = randomPointInZone(zone);
const isFishing = Math.random() < 0.5;
const sp = isFishing ? rnd(2.5, 5) : rnd(5, 10);
const id = vid++;
const v: Vessel = {
id,
permit: makePermit(id, "A"),
code: "OT",
color: VESSEL_TYPES.OT.color,
lat,
lon,
speed: Number(sp.toFixed(1)),
course: Number(rnd(0, 360).toFixed(0)),
state: isFishing ? "조업중" : "항해중",
zone,
isFishing,
owner,
region,
pairId: null,
pairDistNm: null,
nearVesselIds: [],
};
vessels.push(v);
owners.push({ name: owner, region, vessels: [v.id], type: "ot" });
}
// PS
for (let i = 0; i < VESSEL_TYPES.PS.count; i++) {
const owner = `${pick(SURNAMES)}${pick(SURNAMES)}水产`;
const region = pick(REGIONS);
const zone = pick<ZoneId>(["1", "2", "3", "4"]);
const { lat, lon } = randomPointInZone(zone);
const isFishing = Math.random() < 0.5;
const sp = isFishing ? rnd(0.3, 1.5) : rnd(5, 9);
const id = vid++;
const v: Vessel = {
id,
permit: makePermit(id, "A"),
code: "PS",
color: VESSEL_TYPES.PS.color,
lat,
lon,
speed: Number(sp.toFixed(1)),
course: Number(rnd(0, 360).toFixed(0)),
state: isFishing ? pick(["위망", "채낚기"]) : "항해중",
zone,
isFishing,
owner,
region,
pairId: null,
pairDistNm: null,
nearVesselIds: [],
};
vessels.push(v);
owners.push({ name: owner, region, vessels: [v.id], type: "ps" });
}
// FC — assigned to trawl owners (positioned near PT)
const trawlOwners = owners.filter((o) => o.type === "trawl");
for (let i = 0; i < VESSEL_TYPES.FC.count; i++) {
const oi = i < trawlOwners.length ? trawlOwners[i] : pick(trawlOwners);
const refId = oi.vessels.find((id) => vessels[id - 1]?.code === "PT") ?? oi.vessels[0];
const ref = vessels[refId - 1];
const zone = pick<ZoneId>(["2", "3"]);
const lat = ref.lat + rnd(-0.2, 0.2);
const lon = ref.lon + rnd(-0.2, 0.2);
const isNear = Math.random() < 0.4;
const sp = isNear ? rnd(0.5, 1.5) : rnd(5, 9);
const nearVesselIds = isNear ? oi.vessels.filter((id) => vessels[id - 1]?.code !== "FC").slice(0, 2) : [];
const v: Vessel = {
id: vid,
permit: makePermit(vid, "A"),
code: "FC",
color: VESSEL_TYPES.FC.color,
lat,
lon,
speed: Number(sp.toFixed(1)),
course: Number(rnd(0, 360).toFixed(0)),
state: isNear ? "환적" : "항해중",
zone,
isFishing: isNear, // kept from prototype: treat "환적" as fishing-like activity
owner: oi.name,
region: oi.region,
pairId: null,
pairDistNm: null,
nearVesselIds,
};
vid += 1;
vessels.push(v);
oi.vessels.push(v.id);
}
// Ensure initial pair distances are consistent with actual coordinates.
for (const p of ptPairs) {
const a = vessels[p.mainId - 1];
const b = vessels[p.subId - 1];
const d = haversineNm(a.lat, a.lon, b.lat, b.lon);
a.pairDistNm = d;
b.pairDistNm = d;
}
return { vessels, owners, ptPairs };
}
export function tickMockFleetState(state: FleetState) {
for (const v of state.vessels) {
v.lat += (0.5 - Math.random()) * 0.003;
v.lon += (0.5 - Math.random()) * 0.003;
v.speed = Math.max(0, Number((v.speed + (0.5 - Math.random()) * 0.4).toFixed(1)));
v.course = Number(((v.course + (0.5 - Math.random()) * 6 + 360) % 360).toFixed(0));
}
for (const p of state.ptPairs) {
const a = state.vessels[p.mainId - 1];
const b = state.vessels[p.subId - 1];
const d = haversineNm(a.lat, a.lon, b.lat, b.lon);
a.pairDistNm = d;
b.pairDistNm = d;
}
}
export function isVesselCode(code: string): code is VesselTypeCode {
return code === "PT" || code === "PT-S" || code === "GN" || code === "OT" || code === "PS" || code === "FC";
}

파일 보기

@ -0,0 +1,48 @@
import type { ZoneId } from "../../zone/model/meta";
export type VesselTypeCode = "PT" | "PT-S" | "GN" | "OT" | "PS" | "FC";
export interface Vessel {
id: number;
permit: string;
code: VesselTypeCode;
color: string;
lat: number;
lon: number;
speed: number;
course: number;
state: string;
zone: ZoneId;
isFishing: boolean;
owner: string;
region: string;
pairId: number | null;
pairDistNm: number | null;
nearVesselIds: number[];
}
export interface TrawlPair {
mainId: number;
subId: number;
owner: string;
region: string;
}
export interface FleetOwner {
name: string;
region: string;
vessels: number[]; // vessel ids
type: "trawl" | "gn" | "ot" | "ps";
}
export interface FleetState {
vessels: Vessel[];
owners: FleetOwner[];
ptPairs: TrawlPair[];
}

파일 보기

@ -0,0 +1,37 @@
import { useEffect, useState } from "react";
export type ZonesGeoJson = GeoJSON.FeatureCollection<
GeoJSON.MultiPolygon,
GeoJSON.GeoJsonProperties
>;
export function useZones(url = "/data/zones/zones.wgs84.geojson") {
const [data, setData] = useState<ZonesGeoJson | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
async function run() {
try {
setError(null);
const res = await fetch(url);
if (!res.ok) throw new Error(`Failed to load zones: ${res.status} ${res.statusText}`);
const json = (await res.json()) as ZonesGeoJson;
if (cancelled) return;
setData(json);
} catch (e) {
if (cancelled) return;
setError(e instanceof Error ? e.message : String(e));
}
}
void run();
return () => {
cancelled = true;
};
}, [url]);
return { data, error };
}

파일 보기

@ -0,0 +1,11 @@
export type ZoneId = "1" | "2" | "3" | "4";
export const ZONE_IDS: ZoneId[] = ["1", "2", "3", "4"];
export const ZONE_META: Record<ZoneId, { label: string; name: string; color: string }> = {
"1": { label: "", name: "수역I(동해)", color: "#3B82F6" },
"2": { label: "Ⅱ", name: "수역II(제주남방)", color: "#22C55E" },
"3": { label: "Ⅲ", name: "수역III(서해남부)", color: "#F59E0B" },
"4": { label: "Ⅳ", name: "수역IV(서해중간)", color: "#A855F7" },
};

파일 보기

@ -0,0 +1,210 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { searchAisTargets } from "../../entities/aisTarget/api/searchAisTargets";
import type { AisTarget } from "../../entities/aisTarget/model/types";
export type AisPollingStatus = "idle" | "loading" | "ready" | "error";
export type AisPollingSnapshot = {
status: AisPollingStatus;
error: string | null;
lastFetchAt: string | null;
lastFetchMinutes: number | null;
lastMessage: string | null;
total: number;
lastUpserted: number;
lastInserted: number;
lastDeleted: number;
};
export type AisPollingOptions = {
initialMinutes?: number;
incrementalMinutes?: number;
intervalMs?: number;
retentionMinutes?: number;
bbox?: string;
enabled?: boolean;
};
function upsertByMmsi(store: Map<number, AisTarget>, rows: AisTarget[]) {
let inserted = 0;
let upserted = 0;
for (const r of rows) {
if (!r || typeof r.mmsi !== "number") continue;
const prev = store.get(r.mmsi);
if (!prev) {
store.set(r.mmsi, r);
inserted += 1;
upserted += 1;
continue;
}
// Prefer newer records if the upstream ever returns stale items.
const prevTs = prev.messageTimestamp ?? "";
const nextTs = r.messageTimestamp ?? "";
if (nextTs && prevTs && nextTs < prevTs) continue;
store.set(r.mmsi, r);
upserted += 1;
}
return { inserted, upserted };
}
function parseBbox(raw: string | undefined) {
if (!raw) return null;
const parts = raw
.split(",")
.map((s) => s.trim())
.filter(Boolean);
if (parts.length !== 4) return null;
const [lonMin, latMin, lonMax, latMax] = parts.map((p) => Number(p));
const ok =
Number.isFinite(lonMin) &&
Number.isFinite(latMin) &&
Number.isFinite(lonMax) &&
Number.isFinite(latMax) &&
lonMin >= -180 &&
lonMax <= 180 &&
latMin >= -90 &&
latMax <= 90 &&
lonMin < lonMax &&
latMin < latMax;
if (!ok) return null;
return { lonMin, latMin, lonMax, latMax };
}
function pruneStore(store: Map<number, AisTarget>, retentionMinutes: number, bboxRaw: string | undefined) {
const cutoffMs = Date.now() - retentionMinutes * 60_000;
const bbox = parseBbox(bboxRaw);
let deleted = 0;
for (const [mmsi, t] of store.entries()) {
const ts = Date.parse(t.messageTimestamp || "");
if (Number.isFinite(ts) && ts < cutoffMs) {
store.delete(mmsi);
deleted += 1;
continue;
}
if (bbox) {
const lat = t.lat;
const lon = t.lon;
if (typeof lat !== "number" || typeof lon !== "number") {
store.delete(mmsi);
deleted += 1;
continue;
}
if (lon < bbox.lonMin || lon > bbox.lonMax || lat < bbox.latMin || lat > bbox.latMax) {
store.delete(mmsi);
deleted += 1;
continue;
}
}
}
return deleted;
}
export function useAisTargetPolling(opts: AisPollingOptions = {}) {
const initialMinutes = opts.initialMinutes ?? 60;
const incrementalMinutes = opts.incrementalMinutes ?? 1;
const intervalMs = opts.intervalMs ?? 60_000;
const retentionMinutes = opts.retentionMinutes ?? initialMinutes;
const enabled = opts.enabled ?? true;
const bbox = opts.bbox;
const storeRef = useRef<Map<number, AisTarget>>(new Map());
const inFlightRef = useRef(false);
const [rev, setRev] = useState(0);
const [snapshot, setSnapshot] = useState<AisPollingSnapshot>({
status: "idle",
error: null,
lastFetchAt: null,
lastFetchMinutes: null,
lastMessage: null,
total: 0,
lastUpserted: 0,
lastInserted: 0,
lastDeleted: 0,
});
useEffect(() => {
if (!enabled) return;
let cancelled = false;
const controller = new AbortController();
async function run(minutes: number) {
if (inFlightRef.current) return;
inFlightRef.current = true;
try {
setSnapshot((s) => ({ ...s, status: s.status === "idle" ? "loading" : s.status, error: null }));
const res = await searchAisTargets({ minutes, bbox }, controller.signal);
if (cancelled) return;
const { inserted, upserted } = upsertByMmsi(storeRef.current, res.data);
const deleted = pruneStore(storeRef.current, retentionMinutes, bbox);
const total = storeRef.current.size;
const lastFetchAt = new Date().toISOString();
setSnapshot({
status: "ready",
error: null,
lastFetchAt,
lastFetchMinutes: minutes,
lastMessage: res.message,
total,
lastUpserted: upserted,
lastInserted: inserted,
lastDeleted: deleted,
});
setRev((r) => r + 1);
} catch (e) {
if (cancelled) return;
setSnapshot((s) => ({
...s,
status: "error",
error: e instanceof Error ? e.message : String(e),
}));
} finally {
inFlightRef.current = false;
}
}
// Reset store when polling config changes (bbox, retention, etc).
storeRef.current = new Map();
setSnapshot({
status: "loading",
error: null,
lastFetchAt: null,
lastFetchMinutes: null,
lastMessage: null,
total: 0,
lastUpserted: 0,
lastInserted: 0,
lastDeleted: 0,
});
setRev((r) => r + 1);
void run(initialMinutes);
const id = window.setInterval(() => void run(incrementalMinutes), intervalMs);
return () => {
cancelled = true;
controller.abort();
window.clearInterval(id);
};
}, [initialMinutes, incrementalMinutes, intervalMs, retentionMinutes, bbox, enabled]);
const targets = useMemo(() => {
// `rev` is a version counter so we recompute the array snapshot when the store changes.
void rev;
return Array.from(storeRef.current.values());
}, [rev]);
return { targets, snapshot };
}

파일 보기

@ -0,0 +1,310 @@
import type { AisTarget } from "../../../entities/aisTarget/model/types";
import type { LegacyVesselIndex } from "../../../entities/legacyVessel/lib";
import { matchLegacyVessel } from "../../../entities/legacyVessel/lib";
import type { LegacyVesselInfo } from "../../../entities/legacyVessel/model/types";
import type { ZonesGeoJson } from "../../../entities/zone/api/useZones";
import type { ZoneId } from "../../../entities/zone/model/meta";
import { ZONE_IDS } from "../../../entities/zone/model/meta";
import { VESSEL_TYPES } from "../../../entities/vessel/model/meta";
import type { VesselTypeCode } from "../../../entities/vessel/model/types";
import { haversineNm } from "../../../shared/lib/geo/haversineNm";
import { pointInMultiPolygon } from "../../../shared/lib/geo/pointInPolygon";
import type { DerivedLegacyVessel, DerivedVesselState, FcLink, FleetCircle, LegacyAlarm, PairLink } from "./types";
function isFiniteNumber(x: unknown): x is number {
return typeof x === "number" && Number.isFinite(x);
}
export function deriveVesselState(shipCode: VesselTypeCode, sogRaw: unknown): DerivedVesselState {
const sog = isFiniteNumber(sogRaw) ? sogRaw : null;
if (sog === null) return { label: "미상", isFishing: false, isTransit: false };
if (sog < 0.5) return { label: "정지", isFishing: false, isTransit: false };
const meta = VESSEL_TYPES[shipCode];
const inPrimary = meta.speedProfile.some((s) => s.primary && sog >= s.range[0] && sog <= s.range[1]);
if (inPrimary) return { label: "조업", isFishing: true, isTransit: false };
if (sog >= 5) return { label: "항해", isFishing: false, isTransit: true };
return { label: "저속", isFishing: false, isTransit: false };
}
export function buildLegacyHitMap(targets: AisTarget[], legacyIndex: LegacyVesselIndex | null): Map<number, LegacyVesselInfo> {
const hits = new Map<number, LegacyVesselInfo>();
if (!legacyIndex) return hits;
for (const t of targets) {
if (typeof t.mmsi !== "number") continue;
const hit = matchLegacyVessel(t, legacyIndex);
if (!hit) continue;
hits.set(t.mmsi, hit);
}
return hits;
}
export function findZoneId(lon: number, lat: number, zones: ZonesGeoJson | null): ZoneId | null {
if (!zones) return null;
for (const f of zones.features) {
const zoneId = (f.properties as { zoneId?: string } | undefined)?.zoneId as ZoneId | undefined;
if (!zoneId) continue;
if (!ZONE_IDS.includes(zoneId)) continue;
const geom = f.geometry;
if (!geom || geom.type !== "MultiPolygon") continue;
if (pointInMultiPolygon(lon, lat, geom.coordinates as unknown as [number, number][][][])) return zoneId;
}
return null;
}
export function deriveLegacyVessels(args: {
targets: AisTarget[];
legacyHits: Map<number, LegacyVesselInfo>;
zones: ZonesGeoJson | null;
}): DerivedLegacyVessel[] {
const out: DerivedLegacyVessel[] = [];
for (const t of args.targets) {
if (typeof t.mmsi !== "number") continue;
const legacy = args.legacyHits.get(t.mmsi);
if (!legacy) continue;
const lat = isFiniteNumber(t.lat) ? t.lat : null;
const lon = isFiniteNumber(t.lon) ? t.lon : null;
if (lat === null || lon === null) continue;
const code = legacy.shipCode as VesselTypeCode;
if (!code || !(code in VESSEL_TYPES)) continue;
const ownerKey = (legacy.ownerRoman || legacy.ownerCn || "").trim() || null;
out.push({
mmsi: t.mmsi,
name: (t.name || "").trim() || legacy.shipNameCn || legacy.shipNameRoman || "(no name)",
callsign: (t.callsign || "").trim() || null,
lat,
lon,
sog: isFiniteNumber(t.sog) ? t.sog : null,
cog: isFiniteNumber(t.cog) ? t.cog : null,
heading: isFiniteNumber(t.heading) ? t.heading : null,
messageTimestamp: t.messageTimestamp ?? null,
receivedDate: t.receivedDate ?? null,
ais: t,
legacy,
shipCode: code,
permitNo: legacy.permitNo,
ownerKey,
ownerCn: legacy.ownerCn ?? null,
ownerRoman: legacy.ownerRoman ?? null,
workSeaArea: legacy.workSeaArea ?? null,
pairPermitNo: legacy.pairPermitNo ?? null,
zoneId: findZoneId(lon, lat, args.zones),
state: deriveVesselState(code, t.sog),
});
}
return out;
}
export function filterByShipCode(vessels: DerivedLegacyVessel[], selected: VesselTypeCode | null): DerivedLegacyVessel[] {
if (!selected) return vessels;
if (selected === "PT" || selected === "PT-S") return vessels.filter((v) => v.shipCode === "PT" || v.shipCode === "PT-S");
return vessels.filter((v) => v.shipCode === selected);
}
export function filterByShipCodes(vessels: DerivedLegacyVessel[], enabled: Record<VesselTypeCode, boolean>): DerivedLegacyVessel[] {
return vessels.filter((v) => enabled[v.shipCode]);
}
export function computeCountsByType(vessels: DerivedLegacyVessel[]) {
const counts: Record<VesselTypeCode, number> = { PT: 0, "PT-S": 0, GN: 0, OT: 0, PS: 0, FC: 0 };
for (const v of vessels) counts[v.shipCode] += 1;
return counts;
}
export function computePairLinks(vessels: DerivedLegacyVessel[]): PairLink[] {
const byPermit = new Map<string, DerivedLegacyVessel>();
for (const v of vessels) byPermit.set(v.permitNo, v);
const seen = new Set<string>();
const links: PairLink[] = [];
for (const v of vessels) {
if (!v.pairPermitNo) continue;
const pair = byPermit.get(v.pairPermitNo);
if (!pair) continue;
const a = v.mmsi < pair.mmsi ? v : pair;
const b = v.mmsi < pair.mmsi ? pair : v;
const key = `${a.mmsi}-${b.mmsi}`;
if (seen.has(key)) continue;
seen.add(key);
const d = haversineNm(a.lat, a.lon, b.lat, b.lon);
links.push({
aMmsi: a.mmsi,
bMmsi: b.mmsi,
from: [a.lon, a.lat],
to: [b.lon, b.lat],
distanceNm: d,
warn: d > 3,
});
}
return links;
}
export function computeFcLinks(vessels: DerivedLegacyVessel[]): FcLink[] {
const others = vessels.filter((v) => v.shipCode !== "FC");
const fcs = vessels.filter((v) => v.shipCode === "FC");
const links: FcLink[] = [];
for (const fc of fcs) {
let best: DerivedLegacyVessel | null = null;
let bestD = Infinity;
for (const o of others) {
const d = haversineNm(fc.lat, fc.lon, o.lat, o.lon);
if (d < bestD) {
bestD = d;
best = o;
}
}
if (!best || !Number.isFinite(bestD)) continue;
if (bestD > 5) continue;
links.push({
fcMmsi: fc.mmsi,
otherMmsi: best.mmsi,
from: [fc.lon, fc.lat],
to: [best.lon, best.lat],
distanceNm: bestD,
suspicious: bestD < 0.5,
});
}
return links;
}
export function computeFleetCircles(vessels: DerivedLegacyVessel[]): FleetCircle[] {
const groups = new Map<string, DerivedLegacyVessel[]>();
for (const v of vessels) {
if (!v.ownerKey) continue;
const list = groups.get(v.ownerKey);
if (list) list.push(v);
else groups.set(v.ownerKey, [v]);
}
const out: FleetCircle[] = [];
for (const [ownerKey, vs] of groups.entries()) {
if (vs.length < 3) continue;
const lon = vs.reduce((sum, v) => sum + v.lon, 0) / vs.length;
const lat = vs.reduce((sum, v) => sum + v.lat, 0) / vs.length;
let radiusNm = 0;
for (const v of vs) radiusNm = Math.max(radiusNm, haversineNm(lat, lon, v.lat, v.lon));
out.push({
ownerKey,
center: [lon, lat],
radiusNm: Math.max(0.2, radiusNm),
count: vs.length,
vesselMmsis: vs.map((v) => v.mmsi),
});
}
// Show largest fleets first.
out.sort((a, b) => b.count - a.count);
return out.slice(0, 30);
}
function fmtAgoLabel(nowMs: number, iso: string | null): string {
if (!iso) return "-";
const t = Date.parse(iso);
if (!Number.isFinite(t)) return "-";
const diffMin = Math.max(0, Math.round((nowMs - t) / 60_000));
if (diffMin <= 0) return "방금";
return `-${diffMin}`;
}
export function computeLegacyAlarms(args: {
vessels: DerivedLegacyVessel[];
pairLinks: PairLink[];
fcLinks: FcLink[];
now?: Date;
}): LegacyAlarm[] {
const nowMs = (args.now ?? new Date()).getTime();
const month = new Date(nowMs).getMonth(); // 0-11
const alarms: LegacyAlarm[] = [];
for (const p of args.pairLinks) {
if (!p.warn) continue;
const a = args.vessels.find((v) => v.mmsi === p.aMmsi);
const b = args.vessels.find((v) => v.mmsi === p.bMmsi);
const ts = a?.messageTimestamp ?? b?.messageTimestamp ?? null;
alarms.push({
severity: "hi",
kind: "pair_separation",
timeLabel: fmtAgoLabel(nowMs, ts),
text: `${a?.permitNo ?? p.aMmsi}${b?.permitNo ?? p.bMmsi} 쌍분리 ${p.distanceNm.toFixed(1)}NM`,
relatedMmsi: [p.aMmsi, p.bMmsi],
});
}
for (const l of args.fcLinks) {
if (!l.suspicious) continue;
const fc = args.vessels.find((v) => v.mmsi === l.fcMmsi);
const o = args.vessels.find((v) => v.mmsi === l.otherMmsi);
const ts = fc?.messageTimestamp ?? o?.messageTimestamp ?? null;
alarms.push({
severity: "hi",
kind: "transshipment",
timeLabel: fmtAgoLabel(nowMs, ts),
text: `${fc?.permitNo ?? l.fcMmsi}${o?.permitNo ?? l.otherMmsi} 환적의심 ${l.distanceNm.toFixed(2)}NM`,
relatedMmsi: [l.fcMmsi, l.otherMmsi],
});
}
for (const v of args.vessels) {
const meta = VESSEL_TYPES[v.shipCode];
if (meta.monthlyIntensity[month] !== 0) continue;
if (!v.state.isFishing) continue;
alarms.push({
severity: "cr",
kind: "closed_season",
timeLabel: fmtAgoLabel(nowMs, v.messageTimestamp),
text: `${v.permitNo} [${v.shipCode}] 휴어기 조업 의심 ${v.sog ?? "?"}kt`,
relatedMmsi: [v.mmsi],
});
}
// AIS stale
for (const v of args.vessels) {
const ts = Date.parse(v.messageTimestamp || "");
if (!Number.isFinite(ts)) continue;
const diffMin = (nowMs - ts) / 60_000;
if (diffMin < 45) continue;
alarms.push({
severity: "cr",
kind: "ais_stale",
timeLabel: fmtAgoLabel(nowMs, v.messageTimestamp),
text: `${v.permitNo} [${v.shipCode}] AIS 지연 ${Math.round(diffMin)}`,
relatedMmsi: [v.mmsi],
});
}
// Zone violations (only when we can detect zone).
for (const v of args.vessels) {
if (!v.zoneId) continue;
const allowed = VESSEL_TYPES[v.shipCode].allowedZones;
if (allowed.includes(v.zoneId)) continue;
alarms.push({
severity: "hi",
kind: "zone_violation",
timeLabel: fmtAgoLabel(nowMs, v.messageTimestamp),
text: `${v.permitNo} [${v.shipCode}] 수역 이탈 (${v.zoneId})`,
relatedMmsi: [v.mmsi],
});
}
// Most recent first by timeLabel (approx), then by severity.
const sevScore = (s: "cr" | "hi") => (s === "cr" ? 2 : 1);
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;
});
return alarms;
}

파일 보기

@ -0,0 +1,74 @@
import type { AisTarget } from "../../../entities/aisTarget/model/types";
import type { LegacyVesselInfo } from "../../../entities/legacyVessel/model/types";
import type { ZoneId } from "../../../entities/zone/model/meta";
import type { VesselTypeCode } from "../../../entities/vessel/model/types";
export type DerivedVesselState = {
label: string;
isFishing: boolean;
isTransit: boolean;
};
export type DerivedLegacyVessel = {
mmsi: number;
name: string;
callsign: string | null;
lat: number;
lon: number;
sog: number | null;
cog: number | null;
heading: number | null;
messageTimestamp: string | null;
receivedDate: string | null;
ais: AisTarget;
legacy: LegacyVesselInfo;
shipCode: VesselTypeCode;
permitNo: string;
ownerKey: string | null;
ownerCn: string | null;
ownerRoman: string | null;
workSeaArea: string | null;
pairPermitNo: string | null;
zoneId: ZoneId | null;
state: DerivedVesselState;
};
export type PairLink = {
aMmsi: number;
bMmsi: number;
from: [number, number]; // [lon, lat]
to: [number, number];
distanceNm: number;
warn: boolean;
};
export type FcLink = {
fcMmsi: number;
otherMmsi: number;
from: [number, number];
to: [number, number];
distanceNm: number;
suspicious: boolean;
};
export type FleetCircle = {
ownerKey: string;
center: [number, number];
radiusNm: number;
count: number;
vesselMmsis: number[];
};
export type LegacyAlarmKind = "pair_separation" | "transshipment" | "closed_season" | "ais_stale" | "zone_violation";
export type LegacyAlarm = {
severity: "cr" | "hi";
kind: LegacyAlarmKind;
timeLabel: string;
text: string;
relatedMmsi: number[];
};

파일 보기

@ -0,0 +1,24 @@
import type { Map3DSettings } from "../../widgets/map3d/Map3D";
type Props = {
value: Map3DSettings;
onToggle: (key: keyof Map3DSettings) => void;
};
export function Map3DSettingsToggles({ value, onToggle }: Props) {
const items: Array<{ id: keyof Map3DSettings; label: string }> = [
{ id: "showShips", label: "선박(Deck)" },
{ id: "showDensity", label: "밀도(3D)" },
{ id: "showSeamark", label: "OpenSeaMap" },
];
return (
<div className="tog">
{items.map((t) => (
<div key={t.id} className={`tog-btn ${value[t.id] ? "on" : ""}`} onClick={() => onToggle(t.id)}>
{t.label}
</div>
))}
</div>
);
}

파일 보기

@ -0,0 +1,32 @@
export type MapToggleState = {
pairLines: boolean;
pairRange: boolean;
fcLines: boolean;
zones: boolean;
fleetCircles: boolean;
};
type Props = {
value: MapToggleState;
onToggle: (key: keyof MapToggleState) => void;
};
export function MapToggles({ value, onToggle }: Props) {
const items: Array<{ id: keyof MapToggleState; label: string }> = [
{ id: "pairLines", label: "쌍 연결선" },
{ id: "pairRange", label: "쌍 연결범위" },
{ id: "fcLines", label: "환적 연결선" },
{ id: "zones", label: "수역 표시" },
{ id: "fleetCircles", label: "선단 범위" },
];
return (
<div className="tog">
{items.map((t) => (
<div key={t.id} className={`tog-btn ${value[t.id] ? "on" : ""}`} onClick={() => onToggle(t.id)}>
{t.label}
</div>
))}
</div>
);
}

파일 보기

@ -0,0 +1,37 @@
import { VESSEL_TYPE_ORDER, VESSEL_TYPES } from "../../entities/vessel/model/meta";
import type { VesselTypeCode } from "../../entities/vessel/model/types";
type Props = {
enabled: Record<VesselTypeCode, boolean>;
totalCount: number;
countsByType: Record<VesselTypeCode, number>;
onToggle: (type: VesselTypeCode) => void;
onToggleAll: () => void;
};
export function TypeFilterGrid({ enabled, totalCount, countsByType, onToggle, onToggleAll }: Props) {
const allOn = VESSEL_TYPE_ORDER.every((c) => enabled[c]);
return (
<div className="tg">
<div className={`tb ${allOn ? "on" : ""}`} onClick={onToggleAll} style={{ gridColumn: "1/-1" }}>
<div className="c" style={{ color: "var(--accent)" }}>
</div>
<div className="n">{totalCount}</div>
</div>
{VESSEL_TYPE_ORDER.map((code) => {
const t = VESSEL_TYPES[code];
const cnt = countsByType[code] ?? 0;
return (
<div key={code} className={`tb ${enabled[code] ? "on" : ""}`} onClick={() => onToggle(code)}>
<div className="c" style={{ color: t.color }}>
{code}
</div>
<div className="n">{cnt}</div>
</div>
);
})}
</div>
);
}

8
apps/web/src/main.tsx Normal file
파일 보기

@ -0,0 +1,8 @@
import { createRoot } from "react-dom/client";
import "maplibre-gl/dist/maplibre-gl.css";
import App from "./app/App";
import "./app/styles.css";
createRoot(document.getElementById("root")!).render(
<App />,
)

파일 보기

@ -0,0 +1,490 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useAisTargetPolling } from "../../features/aisPolling/useAisTargetPolling";
import { Map3DSettingsToggles } from "../../features/map3dSettings/Map3DSettingsToggles";
import type { MapToggleState } from "../../features/mapToggles/MapToggles";
import { MapToggles } from "../../features/mapToggles/MapToggles";
import { TypeFilterGrid } from "../../features/typeFilter/TypeFilterGrid";
import { useLegacyVessels } from "../../entities/legacyVessel/api/useLegacyVessels";
import { buildLegacyVesselIndex } from "../../entities/legacyVessel/lib";
import type { LegacyVesselIndex } from "../../entities/legacyVessel/lib";
import type { LegacyVesselDataset } from "../../entities/legacyVessel/model/types";
import { useZones } from "../../entities/zone/api/useZones";
import type { VesselTypeCode } from "../../entities/vessel/model/types";
import { AisInfoPanel } from "../../widgets/aisInfo/AisInfoPanel";
import { AisTargetList } from "../../widgets/aisTargetList/AisTargetList";
import { AlarmsPanel } from "../../widgets/alarms/AlarmsPanel";
import { MapLegend } from "../../widgets/legend/MapLegend";
import { Map3D, type BaseMapId, type Map3DSettings, type MapProjectionId } from "../../widgets/map3d/Map3D";
import { RelationsPanel } from "../../widgets/relations/RelationsPanel";
import { SpeedProfilePanel } from "../../widgets/speed/SpeedProfilePanel";
import { Topbar } from "../../widgets/topbar/Topbar";
import { VesselInfoPanel } from "../../widgets/info/VesselInfoPanel";
import { VesselList } from "../../widgets/vesselList/VesselList";
import {
buildLegacyHitMap,
computeCountsByType,
computeFcLinks,
computeFleetCircles,
computeLegacyAlarms,
computePairLinks,
deriveLegacyVessels,
filterByShipCodes,
} from "../../features/legacyDashboard/model/derive";
import { VESSEL_TYPE_ORDER } from "../../entities/vessel/model/meta";
const AIS_API_BASE = (import.meta.env.VITE_API_URL || "/snp-api").replace(/\/$/, "");
function fmtLocal(iso: string | null) {
if (!iso) return "-";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
return d.toLocaleString("ko-KR", { hour12: false });
}
type Bbox = [number, number, number, number]; // [lonMin, latMin, lonMax, latMax]
function inBbox(lon: number, lat: number, bbox: Bbox) {
const [lonMin, latMin, lonMax, latMax] = bbox;
if (lat < latMin || lat > latMax) return false;
if (lonMin <= lonMax) return lon >= lonMin && lon <= lonMax;
return lon >= lonMin || lon <= lonMax;
}
function fmtBbox(b: Bbox | null) {
if (!b) return "-";
return `${b[0].toFixed(4)},${b[1].toFixed(4)},${b[2].toFixed(4)},${b[3].toFixed(4)}`;
}
function useLegacyIndex(data: LegacyVesselDataset | null): LegacyVesselIndex | null {
return useMemo(() => (data ? buildLegacyVesselIndex(data.vessels) : null), [data]);
}
export function DashboardPage() {
const { data: zones, error: zonesError } = useZones();
const { data: legacyData, error: legacyError } = useLegacyVessels();
const legacyIndex = useLegacyIndex(legacyData);
const [viewBbox, setViewBbox] = useState<Bbox | null>(null);
const [useViewportFilter, setUseViewportFilter] = useState(false);
const [useApiBbox, setUseApiBbox] = useState(false);
const [apiBbox, setApiBbox] = useState<string | undefined>(undefined);
const { targets, snapshot } = useAisTargetPolling({
initialMinutes: 60,
incrementalMinutes: 2,
intervalMs: 60_000,
retentionMinutes: 90,
bbox: useApiBbox ? apiBbox : undefined,
});
const [selectedMmsi, setSelectedMmsi] = useState<number | null>(null);
const [typeEnabled, setTypeEnabled] = useState<Record<VesselTypeCode, boolean>>({
PT: true,
"PT-S": true,
GN: true,
OT: true,
PS: true,
FC: true,
});
const [showTargets, setShowTargets] = useState(true);
const [showOthers, setShowOthers] = useState(false);
const [baseMap, setBaseMap] = useState<BaseMapId>("enhanced");
const [projection, setProjection] = useState<MapProjectionId>("mercator");
const [overlays, setOverlays] = useState<MapToggleState>({
pairLines: true,
pairRange: false,
fcLines: true,
zones: true,
fleetCircles: true,
});
const [settings, setSettings] = useState<Map3DSettings>({
showShips: true,
showDensity: false,
showSeamark: false,
});
const [clock, setClock] = useState(() => new Date().toLocaleString("ko-KR", { hour12: false }));
useEffect(() => {
const id = window.setInterval(() => setClock(new Date().toLocaleString("ko-KR", { hour12: false })), 1000);
return () => window.clearInterval(id);
}, []);
// Secret admin toggle: 7 clicks within 900ms on the logo.
const [adminMode, setAdminMode] = useState(false);
const clicksRef = useRef<number[]>([]);
const onLogoClick = () => {
const now = Date.now();
clicksRef.current = clicksRef.current.filter((t) => now - t < 900);
clicksRef.current.push(now);
if (clicksRef.current.length >= 7) {
clicksRef.current = [];
setAdminMode((v) => !v);
}
};
const targetsInScope = useMemo(() => {
if (!useViewportFilter || !viewBbox) return targets;
return targets.filter((t) => typeof t.lon === "number" && typeof t.lat === "number" && inBbox(t.lon, t.lat, viewBbox));
}, [targets, useViewportFilter, viewBbox]);
const legacyHits = useMemo(() => buildLegacyHitMap(targetsInScope, legacyIndex), [targetsInScope, legacyIndex]);
const legacyVesselsAll = useMemo(() => deriveLegacyVessels({ targets: targetsInScope, legacyHits, zones }), [targetsInScope, legacyHits, zones]);
const legacyCounts = useMemo(() => computeCountsByType(legacyVesselsAll), [legacyVesselsAll]);
const legacyVesselsFiltered = useMemo(() => {
if (!showTargets) return [];
return filterByShipCodes(legacyVesselsAll, typeEnabled);
}, [legacyVesselsAll, showTargets, typeEnabled]);
const legacyMmsiForMap = useMemo(() => new Set(legacyVesselsFiltered.map((v) => v.mmsi)), [legacyVesselsFiltered]);
const targetsForMap = useMemo(() => {
const out = [];
for (const t of targetsInScope) {
const mmsi = t.mmsi;
if (typeof mmsi !== "number") continue;
const isLegacy = legacyHits.has(mmsi);
if (isLegacy) {
if (!showTargets) continue;
if (!legacyMmsiForMap.has(mmsi)) continue;
out.push(t);
continue;
}
if (showOthers) out.push(t);
}
return out;
}, [targetsInScope, legacyHits, showTargets, showOthers, legacyMmsiForMap]);
const pairLinksAll = useMemo(() => computePairLinks(legacyVesselsAll), [legacyVesselsAll]);
const fcLinksAll = useMemo(() => computeFcLinks(legacyVesselsAll), [legacyVesselsAll]);
const alarms = useMemo(() => computeLegacyAlarms({ vessels: legacyVesselsAll, pairLinks: pairLinksAll, fcLinks: fcLinksAll }), [legacyVesselsAll, pairLinksAll, fcLinksAll]);
const pairLinksForMap = useMemo(() => computePairLinks(legacyVesselsFiltered), [legacyVesselsFiltered]);
const fcLinksForMap = useMemo(() => computeFcLinks(legacyVesselsFiltered), [legacyVesselsFiltered]);
const fleetCirclesForMap = useMemo(() => computeFleetCircles(legacyVesselsFiltered), [legacyVesselsFiltered]);
const selectedLegacyVessel = useMemo(() => {
if (!selectedMmsi) return null;
return legacyVesselsAll.find((v) => v.mmsi === selectedMmsi) ?? null;
}, [legacyVesselsAll, selectedMmsi]);
const selectedTarget = useMemo(() => {
if (!selectedMmsi) return null;
return targetsInScope.find((t) => t.mmsi === selectedMmsi) ?? null;
}, [targetsInScope, selectedMmsi]);
const selectedLegacyInfo = useMemo(() => {
if (!selectedMmsi) return null;
return legacyHits.get(selectedMmsi) ?? null;
}, [selectedMmsi, legacyHits]);
const fishingCount = legacyVesselsAll.filter((v) => v.state.isFishing).length;
const transitCount = legacyVesselsAll.filter((v) => v.state.isTransit).length;
const enabledTypes = useMemo(() => VESSEL_TYPE_ORDER.filter((c) => typeEnabled[c]), [typeEnabled]);
const speedPanelType = (selectedLegacyVessel?.shipCode ?? (enabledTypes.length === 1 ? enabledTypes[0] : "PT")) as VesselTypeCode;
return (
<div className="app">
<Topbar
total={legacyVesselsAll.length}
fishing={fishingCount}
transit={transitCount}
pairLinks={pairLinksAll.length}
alarms={alarms.length}
pollingStatus={snapshot.status}
lastFetchMinutes={snapshot.lastFetchMinutes}
clock={clock}
adminMode={adminMode}
onLogoClick={onLogoClick}
/>
<div className="sidebar">
<div className="sb">
<div className="sb-t"> </div>
<div className="tog">
<div
className={`tog-btn ${showTargets ? "on" : ""}`}
onClick={() => {
setShowTargets((v) => {
const next = !v;
if (!next) setSelectedMmsi((m) => (legacyHits.has(m ?? -1) ? null : m));
return next;
});
}}
title="레거시(CN permit) 대상 선박 표시"
>
</div>
<div className={`tog-btn ${showOthers ? "on" : ""}`} onClick={() => setShowOthers((v) => !v)} title="대상 외 AIS 선박 표시">
AIS
</div>
</div>
<TypeFilterGrid
enabled={typeEnabled}
totalCount={legacyVesselsAll.length}
countsByType={legacyCounts}
onToggle={(code) => {
// When hiding the currently selected legacy vessel's type, clear selection.
if (selectedLegacyVessel && selectedLegacyVessel.shipCode === code && typeEnabled[code]) setSelectedMmsi(null);
setTypeEnabled((prev) => ({ ...prev, [code]: !prev[code] }));
}}
onToggleAll={() => {
const allOn = VESSEL_TYPE_ORDER.every((c) => typeEnabled[c]);
const nextVal = !allOn; // any-off -> true, all-on -> false
if (!nextVal && selectedLegacyVessel) setSelectedMmsi(null);
setTypeEnabled({
PT: nextVal,
"PT-S": nextVal,
GN: nextVal,
OT: nextVal,
PS: nextVal,
FC: nextVal,
});
}}
/>
</div>
<div className="sb">
<div className="sb-t"> </div>
<MapToggles value={overlays} onToggle={(k) => setOverlays((prev) => ({ ...prev, [k]: !prev[k] }))} />
<div style={{ fontSize: 9, fontWeight: 700, color: "var(--muted)", letterSpacing: 1.5, marginTop: 8, marginBottom: 6 }}>
</div>
<div className="tog" style={{ flexWrap: "nowrap", alignItems: "center" }}>
<div className={`tog-btn ${baseMap === "enhanced" ? "on" : ""}`} onClick={() => setBaseMap("enhanced")} title="현재 지도(해저지형/3D 표현 포함)">
</div>
<div className={`tog-btn ${baseMap === "legacy" ? "on" : ""}`} onClick={() => setBaseMap("legacy")} title="레거시 대시보드(Carto Dark) 베이스맵">
</div>
<div style={{ flex: 1 }} />
<div
className={`tog-btn ${projection === "globe" ? "on" : ""}`}
onClick={() => setProjection((p) => (p === "globe" ? "mercator" : "globe"))}
title="지구본(globe) 투영: 드래그로 회전, 휠로 확대/축소"
>
</div>
</div>
<div style={{ fontSize: 10, color: "var(--muted)" }}> Attribution() </div>
</div>
<div className="sb">
<div className="sb-t"> </div>
<SpeedProfilePanel selectedType={speedPanelType} />
</div>
<div className="sb" style={{ maxHeight: 260, display: "flex", flexDirection: "column", overflow: "hidden" }}>
<div className="sb-t">
{" "}
<span style={{ color: "var(--accent)", fontSize: 8 }}>
{selectedLegacyVessel ? `${selectedLegacyVessel.permitNo} 연관` : "(선박 클릭 시 표시)"}
</span>
</div>
<div style={{ overflowY: "auto", minHeight: 0 }}>
<RelationsPanel
selectedVessel={selectedLegacyVessel}
vessels={legacyVesselsAll}
fleetVessels={legacyVesselsFiltered}
onSelectMmsi={setSelectedMmsi}
/>
</div>
</div>
<div className="sb" style={{ flex: 1, minHeight: 0, display: "flex", flexDirection: "column" }}>
<div className="sb-t">
{" "}
<span style={{ color: "var(--accent)", fontSize: 8 }}>
({legacyVesselsFiltered.length})
</span>
</div>
<VesselList vessels={legacyVesselsFiltered} selectedMmsi={selectedMmsi} onSelectMmsi={setSelectedMmsi} />
</div>
<div className="sb" style={{ maxHeight: 130, overflowY: "auto" }}>
<div className="sb-t"> </div>
<AlarmsPanel alarms={alarms} onSelectMmsi={setSelectedMmsi} />
</div>
{adminMode ? (
<>
<div className="sb">
<div className="sb-t">ADMIN · AIS Target Polling</div>
<div style={{ fontSize: 11, lineHeight: 1.45 }}>
<div style={{ color: "var(--muted)", fontSize: 10 }}></div>
<div style={{ wordBreak: "break-all" }}>{AIS_API_BASE}/api/ais-target/search</div>
<div style={{ marginTop: 6, color: "var(--muted)", fontSize: 10 }}></div>
<div>
<b style={{ color: snapshot.status === "ready" ? "#22C55E" : snapshot.status === "error" ? "#EF4444" : "#F59E0B" }}>
{snapshot.status.toUpperCase()}
</b>
{snapshot.error ? <span style={{ marginLeft: 6, color: "#EF4444" }}>{snapshot.error}</span> : null}
</div>
<div style={{ marginTop: 6, color: "var(--muted)", fontSize: 10 }}> fetch</div>
<div>
{fmtLocal(snapshot.lastFetchAt)}{" "}
<span style={{ color: "var(--muted)", fontSize: 10 }}>
({snapshot.lastFetchMinutes ?? "-"}m, inserted {snapshot.lastInserted}, upserted {snapshot.lastUpserted})
</span>
</div>
<div style={{ marginTop: 6, color: "var(--muted)", fontSize: 10 }}></div>
<div style={{ color: "var(--text)", fontSize: 10 }}>{snapshot.lastMessage ?? "-"}</div>
</div>
</div>
<div className="sb">
<div className="sb-t">ADMIN · Legacy (CN Permit)</div>
{legacyError ? (
<div style={{ fontSize: 11, color: "#EF4444" }}>legacy load error: {legacyError}</div>
) : (
<div style={{ fontSize: 11, lineHeight: 1.45 }}>
<div style={{ color: "var(--muted)", fontSize: 10 }}></div>
<div style={{ wordBreak: "break-all", fontSize: 10 }}>/data/legacy/chinese-permitted.v1.json</div>
<div style={{ marginTop: 6, color: "var(--muted)", fontSize: 10 }}>( scope)</div>
<div>
<b style={{ color: "#F59E0B" }}>{legacyVesselsAll.length}</b>{" "}
<span style={{ color: "var(--muted)", fontSize: 10 }}>/ {targetsInScope.length}</span>
</div>
<div style={{ marginTop: 6, color: "var(--muted)", fontSize: 10 }}></div>
<div style={{ fontSize: 10, color: "var(--text)" }}>{legacyData?.generatedAt ? fmtLocal(legacyData.generatedAt) : "loading..."}</div>
</div>
)}
</div>
<div className="sb">
<div className="sb-t">ADMIN · Viewport / BBox</div>
<div style={{ fontSize: 11, lineHeight: 1.45 }}>
<div style={{ color: "var(--muted)", fontSize: 10 }}> View BBox</div>
<div style={{ wordBreak: "break-all", fontSize: 10 }}>{fmtBbox(viewBbox)}</div>
<div style={{ marginTop: 8, display: "flex", gap: 6, flexWrap: "wrap" }}>
<button
onClick={() => setUseViewportFilter((v) => !v)}
style={{
fontSize: 10,
padding: "4px 8px",
borderRadius: 6,
border: "1px solid var(--border)",
background: useViewportFilter ? "rgba(59,130,246,.18)" : "var(--card)",
color: "var(--text)",
cursor: "pointer",
}}
title="지도/리스트에 현재 화면 영역 내 선박만 표시(클라이언트 필터)"
>
Viewport filter {useViewportFilter ? "ON" : "OFF"}
</button>
<button
onClick={() => {
if (!viewBbox) return;
setUseApiBbox((v) => {
const next = !v;
if (next && viewBbox) setApiBbox(fmtBbox(viewBbox));
if (!next) setApiBbox(undefined);
return next;
});
}}
style={{
fontSize: 10,
padding: "4px 8px",
borderRadius: 6,
border: "1px solid var(--border)",
background: useApiBbox ? "rgba(245,158,11,.14)" : "var(--card)",
color: viewBbox ? "var(--text)" : "var(--muted)",
cursor: viewBbox ? "pointer" : "not-allowed",
}}
title="서버에서 bbox로 필터링해서 내려받기(페이로드 감소). 켜는 순간 현재 view bbox로 고정됨."
disabled={!viewBbox}
>
API bbox {useApiBbox ? "ON" : "OFF"}
</button>
<button
onClick={() => {
if (!viewBbox) return;
setApiBbox(fmtBbox(viewBbox));
setUseApiBbox(true);
}}
style={{
fontSize: 10,
padding: "4px 8px",
borderRadius: 6,
border: "1px solid var(--border)",
background: "var(--card)",
color: viewBbox ? "var(--text)" : "var(--muted)",
cursor: viewBbox ? "pointer" : "not-allowed",
}}
disabled={!viewBbox}
title="현재 view bbox로 API bbox를 갱신"
>
bbox=viewport
</button>
</div>
<div style={{ marginTop: 8, color: "var(--muted)", fontSize: 10 }}>
: <b style={{ color: "var(--text)" }}>{targetsInScope.length}</b> / :{" "}
<b style={{ color: "var(--text)" }}>{snapshot.total}</b>
</div>
</div>
</div>
<div className="sb">
<div className="sb-t">ADMIN · Map (Extras)</div>
<Map3DSettingsToggles value={settings} onToggle={(k) => setSettings((prev) => ({ ...prev, [k]: !prev[k] }))} />
<div style={{ fontSize: 10, color: "var(--muted)", marginTop: 6 }}> WebGL 컨텍스트: MapboxOverlay(interleaved)</div>
</div>
<div className="sb" style={{ flex: 1, minHeight: 0, display: "flex", flexDirection: "column" }}>
<div className="sb-t">ADMIN · AIS Targets (All)</div>
<AisTargetList
targets={targetsInScope}
selectedMmsi={selectedMmsi}
onSelectMmsi={setSelectedMmsi}
legacyIndex={legacyIndex}
/>
</div>
<div className="sb" style={{ maxHeight: 130, overflowY: "auto" }}>
<div className="sb-t">ADMIN · </div>
{zonesError ? (
<div style={{ fontSize: 11, color: "#EF4444" }}>zones load error: {zonesError}</div>
) : (
<div style={{ fontSize: 11, color: "var(--muted)" }}>
{zones ? `loaded (${zones.features.length} features)` : "loading..."}
</div>
)}
</div>
</>
) : null}
</div>
<div className="map-area">
<Map3D
targets={targetsForMap}
zones={zones}
selectedMmsi={selectedMmsi}
settings={settings}
baseMap={baseMap}
projection={projection}
overlays={overlays}
onSelectMmsi={setSelectedMmsi}
onViewBboxChange={setViewBbox}
legacyHits={legacyHits}
pairLinks={pairLinksForMap}
fcLinks={fcLinksForMap}
fleetCircles={fleetCirclesForMap}
/>
<MapLegend />
{selectedLegacyVessel ? (
<VesselInfoPanel vessel={selectedLegacyVessel} allVessels={legacyVesselsAll} onClose={() => setSelectedMmsi(null)} onSelectMmsi={setSelectedMmsi} />
) : selectedTarget ? (
<AisInfoPanel target={selectedTarget} legacy={selectedLegacyInfo} onClose={() => setSelectedMmsi(null)} />
) : null}
</div>
</div>
);
}

파일 보기

@ -0,0 +1,7 @@
export function hexToRgb(hex: string): [number, number, number] {
const m = /^#?([0-9a-fA-F]{6})$/.exec(hex.trim());
if (!m) return [255, 255, 255];
const n = parseInt(m[1], 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}

파일 보기

@ -0,0 +1,12 @@
export function haversineNm(lat1: number, lon1: number, lat2: number, lon2: number) {
const R = 3440.065; // nautical miles
const dLat = ((lat2 - lat1) * Math.PI) / 180;
const dLon = ((lon2 - lon1) * Math.PI) / 180;
const a =
Math.sin(dLat / 2) ** 2 +
Math.cos((lat1 * Math.PI) / 180) *
Math.cos((lat2 * Math.PI) / 180) *
Math.sin(dLon / 2) ** 2;
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}

파일 보기

@ -0,0 +1,38 @@
type Coord = [number, number]; // [lon, lat]
function pointInRing(lon: number, lat: number, ring: Coord[]): boolean {
// Ray-casting algorithm. Assumes ring is closed or open; works either way.
let inside = false;
const n = ring.length;
if (n < 3) return false;
for (let i = 0, j = n - 1; i < n; j = i++) {
const xi = ring[i][0];
const yi = ring[i][1];
const xj = ring[j][0];
const yj = ring[j][1];
const intersect = yi > lat !== yj > lat && lon < ((xj - xi) * (lat - yi)) / (yj - yi + 0.0) + xi;
if (intersect) inside = !inside;
}
return inside;
}
export function pointInPolygon(lon: number, lat: number, polygon: Coord[][]): boolean {
// polygon: [outerRing, ...holes]
if (!polygon.length) return false;
if (!pointInRing(lon, lat, polygon[0])) return false;
for (let i = 1; i < polygon.length; i++) {
if (pointInRing(lon, lat, polygon[i])) return false;
}
return true;
}
export function pointInMultiPolygon(lon: number, lat: number, multiPolygon: Coord[][][]): boolean {
for (const poly of multiPolygon) {
if (pointInPolygon(lon, lat, poly)) return true;
}
return false;
}

파일 보기

@ -0,0 +1,22 @@
function unsupported(name: string): never {
throw new Error(`${name} is not available in the browser build`);
}
export function spawn() {
return unsupported("child_process.spawn");
}
export function fork() {
return unsupported("child_process.fork");
}
export function exec() {
return unsupported("child_process.exec");
}
export function execFile() {
return unsupported("child_process.execFile");
}
export default { spawn, fork, exec, execFile };

파일 보기

@ -0,0 +1,96 @@
import type { AisTarget } from "../../entities/aisTarget/model/types";
import type { LegacyVesselInfo } from "../../entities/legacyVessel/model/types";
type Props = {
target: AisTarget;
legacy?: LegacyVesselInfo | null;
onClose: () => void;
};
export function AisInfoPanel({ target: t, legacy, onClose }: Props) {
const name = (t.name || "").trim() || "(no name)";
return (
<div className="map-info">
<button className="close-btn" onClick={onClose} aria-label="close">
</button>
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 8 }}>
<div>
<div style={{ fontSize: 16, fontWeight: 900, color: "var(--accent)" }}>{name}</div>
<div style={{ fontSize: 10, color: "var(--muted)" }}>
MMSI {t.mmsi} · {t.vesselType || "Unknown"} · Class {t.classType || "?"}
</div>
</div>
</div>
{legacy ? (
<div style={{ marginBottom: 8, padding: "8px 8px", borderRadius: 8, border: "1px solid rgba(245,158,11,.35)", background: "rgba(245,158,11,.06)" }}>
<div style={{ fontSize: 10, fontWeight: 900, color: "#F59E0B", marginBottom: 4 }}>CN Permit Match</div>
<div style={{ fontSize: 10, color: "var(--text)", lineHeight: 1.5 }}>
<div>
<span style={{ color: "var(--muted)" }}></span> <b>{legacy.shipCode}</b> ·{" "}
<span style={{ color: "var(--muted)" }}></span> <b>{legacy.permitNo}</b>
</div>
<div>
<span style={{ color: "var(--muted)" }}></span> <b>{legacy.workSeaArea || "-"}</b> ·{" "}
<span style={{ color: "var(--muted)" }}></span> <b>{legacy.ton ?? "-"}</b>
</div>
{legacy.ownerCn || legacy.ownerRoman ? (
<div>
<span style={{ color: "var(--muted)" }}></span>{" "}
<b>{legacy.ownerCn || legacy.ownerRoman}</b>
{legacy.ownerCn && legacy.ownerRoman ? <span style={{ color: "var(--muted)" }}> ({legacy.ownerRoman})</span> : null}
</div>
) : null}
{legacy.pairPermitNo ? (
<div>
<span style={{ color: "var(--muted)" }}></span> <b>{legacy.pairPermitNo}</b>
{legacy.pairShipNameCn ? <span style={{ color: "var(--muted)" }}> · {legacy.pairShipNameCn}</span> : null}
</div>
) : null}
</div>
</div>
) : null}
<div className="ir">
<span className="il">SOG</span>
<span className="iv">{t.sog ?? "?"} kt</span>
</div>
<div className="ir">
<span className="il">COG</span>
<span className="iv">{t.cog ?? "?"}°</span>
</div>
<div className="ir">
<span className="il">Heading</span>
<span className="iv">{t.heading ?? "?"}°</span>
</div>
<div className="ir">
<span className="il">Status</span>
<span className="iv">{t.status || "N/A"}</span>
</div>
<div className="ir">
<span className="il">Position</span>
<span className="iv">
{Number.isFinite(t.lat) ? t.lat.toFixed(5) : "?"}, {Number.isFinite(t.lon) ? t.lon.toFixed(5) : "?"}
</span>
</div>
<div className="ir">
<span className="il">Dest</span>
<span className="iv">{(t.destination || "").trim() || "-"}</span>
</div>
<div className="ir">
<span className="il">ETA</span>
<span className="iv">{t.eta || "-"}</span>
</div>
<div className="ir">
<span className="il">Msg TS</span>
<span className="iv">{t.messageTimestamp || "-"}</span>
</div>
<div className="ir">
<span className="il">Received</span>
<span className="iv">{t.receivedDate || "-"}</span>
</div>
</div>
);
}

파일 보기

@ -0,0 +1,133 @@
import { useMemo, useState } from "react";
import type { AisTarget } from "../../entities/aisTarget/model/types";
import type { LegacyVesselIndex } from "../../entities/legacyVessel/lib";
import { matchLegacyVessel } from "../../entities/legacyVessel/lib";
type SortMode = "recent" | "speed";
type Props = {
targets: AisTarget[];
selectedMmsi: number | null;
onSelectMmsi: (mmsi: number) => void;
legacyIndex?: LegacyVesselIndex | null;
};
function isFiniteNumber(x: unknown): x is number {
return typeof x === "number" && Number.isFinite(x);
}
function getSpeedColor(sog: unknown) {
if (!isFiniteNumber(sog)) return "#64748B";
if (sog >= 10) return "#3B82F6";
if (sog >= 1) return "#22C55E";
return "#64748B";
}
function fmtLocalTime(iso: string | null | undefined) {
if (!iso) return "";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return String(iso);
return d.toLocaleTimeString("ko-KR", { hour12: false });
}
export function AisTargetList({ targets, selectedMmsi, onSelectMmsi, legacyIndex }: Props) {
const [q, setQ] = useState("");
const [mode, setMode] = useState<SortMode>("recent");
const rows = useMemo(() => {
const query = q.trim();
let out = targets;
if (query) {
const qLower = query.toLowerCase();
const isDigits = /^[0-9]+$/.test(query);
out = targets.filter((t) => {
const name = (t.name || "").trim();
const callsign = (t.callsign || "").trim();
const mmsi = t.mmsi;
if (isDigits) return String(mmsi).includes(query);
return name.toLowerCase().includes(qLower) || callsign.toLowerCase().includes(qLower) || String(mmsi).includes(query);
});
}
const sorted = out.slice().sort((a, b) => {
if (mode === "speed") {
const as = isFiniteNumber(a.sog) ? a.sog : -1;
const bs = isFiniteNumber(b.sog) ? b.sog : -1;
if (bs !== as) return bs - as;
}
const at = Date.parse(a.messageTimestamp || "");
const bt = Date.parse(b.messageTimestamp || "");
const ats = Number.isFinite(at) ? at : 0;
const bts = Number.isFinite(bt) ? bt : 0;
if (bts !== ats) return bts - ats;
return (a.mmsi ?? 0) - (b.mmsi ?? 0);
});
return sorted.slice(0, 200);
}, [targets, q, mode]);
return (
<div style={{ display: "flex", flexDirection: "column", height: "100%", minHeight: 0 }}>
<div style={{ display: "flex", gap: 6, marginBottom: 6 }}>
<input
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="검색: MMSI / 선박명 / CallSign"
className="ais-q"
/>
<div className="ais-mode">
<button className={`ais-mode-btn ${mode === "recent" ? "on" : ""}`} onClick={() => setMode("recent")}>
</button>
<button className={`ais-mode-btn ${mode === "speed" ? "on" : ""}`} onClick={() => setMode("speed")}>
</button>
</div>
</div>
<div className="ais-list">
{rows.map((t) => {
const name = (t.name || "").trim() || "(no name)";
const sel = selectedMmsi && t.mmsi === selectedMmsi;
const sp = isFiniteNumber(t.sog) ? t.sog.toFixed(1) : "?";
const sc = getSpeedColor(t.sog);
const ts = fmtLocalTime(t.messageTimestamp);
const legacy = legacyIndex ? matchLegacyVessel(t, legacyIndex) : null;
const legacyCode = legacy?.shipCode || "";
return (
<div key={t.mmsi} className={`ais-row ${sel ? "sel" : ""}`} onClick={() => onSelectMmsi(t.mmsi)} title={t.vesselType || ""}>
<div className="ais-dot" style={{ background: sc }} />
<div className="ais-nm">
<div className="ais-nm1">{name}</div>
<div className="ais-nm2">
MMSI <b>{t.mmsi}</b>
{t.callsign ? <span style={{ marginLeft: 6, opacity: 0.8 }}>{t.callsign}</span> : null}
</div>
</div>
<div className="ais-right">
{legacy ? (
<div className="ais-badges">
<span className={`ais-badge ${legacyCode}`}>
CN {legacyCode}
</span>
<span className="ais-badge pn">{legacy.permitNo}</span>
</div>
) : null}
<div className="ais-sp" style={{ color: legacy ? "var(--text)" : sc }}>{sp}kt</div>
<div className="ais-ts">{ts}</div>
</div>
</div>
);
})}
{rows.length === 0 ? <div style={{ fontSize: 11, color: "var(--muted)" }}>( )</div> : null}
</div>
</div>
);
}

파일 보기

@ -0,0 +1,26 @@
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);
return (
<div>
{shown.map((a, idx) => (
<div
key={`${a.kind}-${idx}`}
className={`ai ${a.severity}`}
onClick={() => {
if (!onSelectMmsi) return;
const m = a.relatedMmsi[0];
if (typeof m === "number") onSelectMmsi(m);
}}
style={{ cursor: onSelectMmsi ? "pointer" : undefined }}
title={onSelectMmsi ? "클릭: 관련 선박 선택" : undefined}
>
<span className="at">{a.timeLabel}</span>
<span style={{ flex: 1 }}>{a.text}</span>
</div>
))}
</div>
);
}

파일 보기

@ -0,0 +1,166 @@
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";
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 ? "#F59E0B" : "#22C55E" }}>
{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>
);
}

파일 보기

@ -0,0 +1,90 @@
import { ZONE_IDS, ZONE_META } from "../../entities/zone/model/meta";
export function MapLegend() {
return (
<div className="map-legend">
<div className="lt"></div>
{ZONE_IDS.map((z) => (
<div key={z} className="li">
<div className="ls" style={{ background: `${ZONE_META[z].color}33`, border: `1px solid ${ZONE_META[z].color}` }} />
{ZONE_META[z].name}
</div>
))}
<div className="lt" style={{ marginTop: 8 }}>
AIS ()
</div>
<div className="li">
<div className="ls" style={{ background: "#3B82F6", borderRadius: 999 }} />
SOG 10 kt
</div>
<div className="li">
<div className="ls" style={{ background: "#22C55E", 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>
<div className="lt" style={{ marginTop: 8 }}>
CN Permit()
</div>
<div className="li">
<div className="ls" style={{ background: "#1E40AF", borderRadius: 999 }} />
PT (ring + )
</div>
<div className="li">
<div className="ls" style={{ background: "#EA580C", borderRadius: 999 }} />
PT-S
</div>
<div className="li">
<div className="ls" style={{ background: "#10B981", borderRadius: 999 }} />
GN
</div>
<div className="li">
<div className="ls" style={{ background: "#8B5CF6", borderRadius: 999 }} />
OT 1
</div>
<div className="li">
<div className="ls" style={{ background: "#EF4444", borderRadius: 999 }} />
PS
</div>
<div className="li">
<div className="ls" style={{ background: "#F59E0B", borderRadius: 999 }} />
FC
</div>
<div className="lt" style={{ marginTop: 8 }}>
(3D)
</div>
<div className="li" style={{ color: "var(--muted)" }}>
Hexagon: 화면 AIS
</div>
<div className="lt" style={{ marginTop: 8 }}>
</div>
<div className="li">
<div style={{ width: 20, height: 2, background: "rgba(59,130,246,.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>
<div className="li">
<div style={{ width: 20, height: 2, background: "#F59E0B", borderRadius: 1 }} />
(&gt;3NM)
</div>
<div className="li">
<div style={{ width: 20, height: 2, background: "#D97706", 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>
</div>
);
}

파일 보기

@ -0,0 +1,988 @@
import { HexagonLayer } from "@deck.gl/aggregation-layers";
import { IconLayer, GeoJsonLayer, LineLayer, ScatterplotLayer } from "@deck.gl/layers";
import { MapboxOverlay } from "@deck.gl/mapbox";
import { MapView, _GlobeView as GlobeView, type PickingInfo } from "@deck.gl/core";
import maplibregl, {
type LayerSpecification,
type RasterDEMSourceSpecification,
type StyleSpecification,
type VectorSourceSpecification,
} from "maplibre-gl";
import { useEffect, useMemo, useRef } from "react";
import type { AisTarget } from "../../entities/aisTarget/model/types";
import type { LegacyVesselInfo } from "../../entities/legacyVessel/model/types";
import type { ZonesGeoJson } from "../../entities/zone/api/useZones";
import type { ZoneId } from "../../entities/zone/model/meta";
import { ZONE_META } from "../../entities/zone/model/meta";
import type { MapToggleState } from "../../features/mapToggles/MapToggles";
import type { FcLink, FleetCircle, PairLink } from "../../features/legacyDashboard/model/types";
import { hexToRgb } from "../../shared/lib/color/hexToRgb";
export type Map3DSettings = {
showSeamark: boolean;
showShips: boolean;
showDensity: boolean;
};
export type BaseMapId = "enhanced" | "legacy";
export type MapProjectionId = "mercator" | "globe";
type Props = {
targets: AisTarget[];
zones: ZonesGeoJson | null;
selectedMmsi: number | null;
settings: Map3DSettings;
baseMap: BaseMapId;
projection: MapProjectionId;
overlays: MapToggleState;
onSelectMmsi: (mmsi: number | null) => void;
onViewBboxChange?: (bbox: [number, number, number, number]) => void;
legacyHits?: Map<number, LegacyVesselInfo> | null;
pairLinks?: PairLink[];
fcLinks?: FcLink[];
fleetCircles?: FleetCircle[];
};
const SHIP_ICON_MAPPING = {
ship: {
x: 0,
y: 0,
width: 128,
height: 128,
anchorX: 64,
anchorY: 64,
mask: true,
},
} as const;
function isFiniteNumber(x: unknown): x is number {
return typeof x === "number" && Number.isFinite(x);
}
const LEGACY_CODE_COLORS: Record<string, [number, number, number]> = {
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
};
const DEPTH_DISABLED_PARAMS = {
// In MapLibre+Deck (interleaved), the map often leaves a depth buffer populated.
// For 2D overlays like zones/icons/halos we want stable painter's-order rendering
// to avoid z-fighting flicker when layers overlap at (or near) the same z.
depthCompare: "always",
depthWriteEnabled: false,
} as const;
const GLOBE_OVERLAY_PARAMS = {
// In globe mode we want depth-testing against the globe so features on the far side don't draw through.
// Still disable depth writes so our overlays don't interfere with each other.
depthCompare: "less-equal",
depthWriteEnabled: false,
} as const;
function getMapTilerKey(): string | null {
const k = import.meta.env.VITE_MAPTILER_KEY;
if (typeof k !== "string") return null;
const v = k.trim();
return v ? v : null;
}
function ensureSeamarkOverlay(map: maplibregl.Map, beforeLayerId?: string) {
const srcId = "seamark";
const layerId = "seamark";
if (!map.getSource(srcId)) {
map.addSource(srcId, {
type: "raster",
tiles: ["https://tiles.openseamap.org/seamark/{z}/{x}/{y}.png"],
tileSize: 256,
attribution: "© OpenSeaMap contributors",
});
}
if (!map.getLayer(layerId)) {
const layer: LayerSpecification = {
id: layerId,
type: "raster",
source: srcId,
paint: { "raster-opacity": 0.85 },
} as unknown as LayerSpecification;
// By default, MapLibre adds new layers to the top.
// For readability we want seamarks above bathymetry fill, but below bathymetry lines/labels.
const before = beforeLayerId && map.getLayer(beforeLayerId) ? beforeLayerId : undefined;
map.addLayer(layer, before);
}
}
function injectOceanBathymetryLayers(style: StyleSpecification, maptilerKey: string) {
const oceanSourceId = "maptiler-ocean";
const terrainSourceId = "maptiler-terrain";
if (!style.sources) style.sources = {} as StyleSpecification["sources"];
if (!style.layers) style.layers = [];
if (!style.sources[oceanSourceId]) {
style.sources[oceanSourceId] = {
type: "vector",
url: `https://api.maptiler.com/tiles/ocean/tiles.json?key=${encodeURIComponent(maptilerKey)}`,
} satisfies VectorSourceSpecification as unknown as StyleSpecification["sources"][string];
}
if (!style.sources[terrainSourceId]) {
style.sources[terrainSourceId] = {
type: "raster-dem",
url: `https://api.maptiler.com/tiles/terrain-rgb/tiles.json?key=${encodeURIComponent(maptilerKey)}`,
tileSize: 512,
encoding: "mapbox",
} satisfies RasterDEMSourceSpecification as unknown as StyleSpecification["sources"][string];
}
const depth = ["to-number", ["get", "depth"]] as unknown as number[];
const depthLabel = ["concat", ["to-string", ["*", depth, -1]], "m"] as unknown as string[];
const bathyFillColor = [
"interpolate",
["linear"],
depth,
-11000,
"#00040b",
-8000,
"#010610",
-6000,
"#020816",
-4000,
"#030c1c",
-2000,
"#041022",
-1000,
"#051529",
-500,
"#061a30",
-200,
"#071f36",
-100,
"#08263d",
-50,
"#092c44",
-20,
"#0a334b",
0,
"#0b3a53",
] as const;
const bathyHillshade: LayerSpecification = {
id: "bathymetry-hillshade",
type: "hillshade",
source: terrainSourceId,
paint: {
"hillshade-illumination-anchor": "viewport",
"hillshade-illumination-direction": 315,
"hillshade-illumination-altitude": 45,
"hillshade-exaggeration": ["interpolate", ["linear"], ["zoom"], 0, 0.15, 6, 0.25, 10, 0.32],
// Dark-mode tuned shading. Alpha is baked into the colors.
"hillshade-shadow-color": "rgba(0,0,0,0.45)",
"hillshade-highlight-color": "rgba(255,255,255,0.18)",
"hillshade-accent-color": "rgba(255,255,255,0.06)",
},
} as unknown as LayerSpecification;
const bathyFill: LayerSpecification = {
id: "bathymetry-fill",
type: "fill",
source: oceanSourceId,
"source-layer": "contour",
paint: {
// Dark-mode friendly palette (shallow = slightly brighter; deep = near-black).
"fill-color": bathyFillColor,
"fill-opacity": ["interpolate", ["linear"], ["zoom"], 0, 0.9, 6, 0.86, 10, 0.78],
},
} as unknown as LayerSpecification;
const bathyExtrusion: LayerSpecification = {
id: "bathymetry-extrusion",
type: "fill-extrusion",
source: oceanSourceId,
"source-layer": "contour",
minzoom: 6,
paint: {
"fill-extrusion-color": bathyFillColor,
// MapLibre fill-extrusion cannot go below 0m, so we exaggerate the "relative seabed height"
// (shallow areas higher, deep areas lower) to create a stepped relief.
"fill-extrusion-base": 0,
// NOTE: `zoom` can only appear as the input to a top-level `step`/`interpolate`.
"fill-extrusion-height": [
"interpolate",
["linear"],
["zoom"],
6,
["*", ["+", depth, 12000], 0.002], // depth is negative; -> range [0..12000]
10,
["*", ["+", depth, 12000], 0.01],
],
"fill-extrusion-opacity": ["interpolate", ["linear"], ["zoom"], 6, 0.0, 7, 0.25, 10, 0.55],
"fill-extrusion-vertical-gradient": true,
},
} as unknown as LayerSpecification;
const bathyBandBorders: LayerSpecification = {
id: "bathymetry-borders",
type: "line",
source: oceanSourceId,
"source-layer": "contour",
minzoom: 4,
paint: {
"line-color": "rgba(255,255,255,0.06)",
"line-opacity": ["interpolate", ["linear"], ["zoom"], 4, 0.12, 8, 0.18, 12, 0.22],
"line-blur": ["interpolate", ["linear"], ["zoom"], 4, 0.8, 10, 0.2],
"line-width": ["interpolate", ["linear"], ["zoom"], 4, 0.2, 8, 0.35, 12, 0.6],
},
} as unknown as LayerSpecification;
const bathyLinesMinor: LayerSpecification = {
id: "bathymetry-lines",
type: "line",
source: oceanSourceId,
"source-layer": "contour_line",
minzoom: 8,
paint: {
"line-color": [
"interpolate",
["linear"],
depth,
-11000,
"rgba(255,255,255,0.04)",
-6000,
"rgba(255,255,255,0.05)",
-2000,
"rgba(255,255,255,0.07)",
0,
"rgba(255,255,255,0.10)",
],
"line-opacity": ["interpolate", ["linear"], ["zoom"], 8, 0.18, 10, 0.22, 12, 0.28],
"line-blur": ["interpolate", ["linear"], ["zoom"], 8, 0.8, 11, 0.3],
"line-width": ["interpolate", ["linear"], ["zoom"], 8, 0.35, 10, 0.55, 12, 0.85],
},
} as unknown as LayerSpecification;
const majorDepths = [-50, -100, -200, -500, -1000, -2000, -4000, -6000, -8000, -9500];
const bathyLinesMajor: LayerSpecification = {
id: "bathymetry-lines-major",
type: "line",
source: oceanSourceId,
"source-layer": "contour_line",
minzoom: 8,
// Use legacy filter syntax here (not expression "in"), so we can pass multiple values.
filter: ["in", "depth", ...majorDepths] as unknown as unknown[],
paint: {
"line-color": "rgba(255,255,255,0.16)",
"line-opacity": ["interpolate", ["linear"], ["zoom"], 8, 0.22, 10, 0.28, 12, 0.34],
"line-blur": ["interpolate", ["linear"], ["zoom"], 8, 0.4, 11, 0.2],
"line-width": ["interpolate", ["linear"], ["zoom"], 8, 0.6, 10, 0.95, 12, 1.3],
},
} as unknown as LayerSpecification;
const bathyBandBordersMajor: LayerSpecification = {
id: "bathymetry-borders-major",
type: "line",
source: oceanSourceId,
"source-layer": "contour",
minzoom: 4,
// Use legacy filter syntax here (not expression "in"), so we can pass multiple values.
filter: ["in", "depth", ...majorDepths] as unknown as unknown[],
paint: {
"line-color": "rgba(255,255,255,0.14)",
"line-opacity": ["interpolate", ["linear"], ["zoom"], 4, 0.14, 8, 0.2, 12, 0.26],
"line-blur": ["interpolate", ["linear"], ["zoom"], 4, 0.3, 10, 0.15],
"line-width": ["interpolate", ["linear"], ["zoom"], 4, 0.35, 8, 0.55, 12, 0.85],
},
} as unknown as LayerSpecification;
const bathyLabels: LayerSpecification = {
id: "bathymetry-labels",
type: "symbol",
source: oceanSourceId,
"source-layer": "contour_line",
minzoom: 10,
// Use legacy filter syntax here (not expression "in"), so we can pass multiple values.
filter: ["in", "depth", ...majorDepths] as unknown as unknown[],
layout: {
"symbol-placement": "line",
"text-field": depthLabel,
"text-font": ["Noto Sans Regular", "Open Sans Regular"],
"text-size": ["interpolate", ["linear"], ["zoom"], 10, 10, 12, 12],
"text-allow-overlap": false,
"text-padding": 2,
"text-rotation-alignment": "map",
},
paint: {
"text-color": "rgba(226,232,240,0.72)",
"text-halo-color": "rgba(2,6,23,0.82)",
"text-halo-width": 1.0,
"text-halo-blur": 0.6,
},
} as unknown as LayerSpecification;
const landformLabels: LayerSpecification = {
id: "bathymetry-landforms",
type: "symbol",
source: oceanSourceId,
"source-layer": "landform",
minzoom: 8,
filter: ["has", "name"] as unknown as unknown[],
layout: {
"text-field": ["get", "name"] as unknown as unknown[],
"text-font": ["Noto Sans Italic", "Noto Sans Regular", "Open Sans Italic", "Open Sans Regular"],
"text-size": ["interpolate", ["linear"], ["zoom"], 8, 11, 10, 12, 12, 13],
"text-allow-overlap": false,
"text-anchor": "center",
"text-offset": [0, 0.0],
},
paint: {
"text-color": "rgba(148,163,184,0.70)",
"text-halo-color": "rgba(2,6,23,0.85)",
"text-halo-width": 1.0,
"text-halo-blur": 0.7,
},
} as unknown as LayerSpecification;
// Insert before the first symbol layer (keep labels on top), otherwise append.
const layers = style.layers as LayerSpecification[];
const symbolIndex = layers.findIndex((l) => l.type === "symbol");
const insertAt = symbolIndex >= 0 ? symbolIndex : layers.length;
const toInsert = [
bathyFill,
bathyHillshade,
bathyExtrusion,
bathyBandBorders,
bathyBandBordersMajor,
bathyLinesMinor,
bathyLinesMajor,
bathyLabels,
landformLabels,
].filter(
(l) => !layers.some((x) => x.id === l.id),
);
if (toInsert.length > 0) layers.splice(insertAt, 0, ...toInsert);
}
async function resolveInitialMapStyle(signal: AbortSignal): Promise<string | StyleSpecification> {
const key = getMapTilerKey();
if (!key) return "/map/styles/osm-seamark.json";
const baseMapId = (import.meta.env.VITE_MAPTILER_BASE_MAP_ID || "dataviz-dark").trim();
const styleUrl = `https://api.maptiler.com/maps/${encodeURIComponent(baseMapId)}/style.json?key=${encodeURIComponent(key)}`;
const res = await fetch(styleUrl, { signal, headers: { accept: "application/json" } });
if (!res.ok) throw new Error(`MapTiler style fetch failed: ${res.status} ${res.statusText}`);
const json = (await res.json()) as StyleSpecification;
injectOceanBathymetryLayers(json, key);
return json;
}
async function resolveMapStyle(baseMap: BaseMapId, signal: AbortSignal): Promise<string | StyleSpecification> {
if (baseMap === "legacy") return "/map/styles/carto-dark.json";
return resolveInitialMapStyle(signal);
}
function getShipColor(
t: AisTarget,
selectedMmsi: number | null,
legacyShipCode: string | null,
): [number, number, number, number] {
if (selectedMmsi && t.mmsi === selectedMmsi) return [255, 255, 255, 255];
if (legacyShipCode) {
const rgb = LEGACY_CODE_COLORS[legacyShipCode];
if (rgb) return [rgb[0], rgb[1], rgb[2], 235];
return [245, 158, 11, 235];
}
if (!isFiniteNumber(t.sog)) return [100, 116, 139, 160];
if (t.sog >= 10) return [59, 130, 246, 220];
if (t.sog >= 1) return [34, 197, 94, 210];
return [100, 116, 139, 160];
}
type DashSeg = { from: [number, number]; to: [number, number]; suspicious: boolean };
function dashifyLine(from: [number, number], to: [number, number], suspicious: boolean): DashSeg[] {
// Simple dashed effect: split into segments and render every other one.
const segs: DashSeg[] = [];
const steps = 14;
for (let i = 0; i < steps; i++) {
if (i % 2 === 1) continue;
const a0 = i / steps;
const a1 = (i + 1) / steps;
const lon0 = from[0] + (to[0] - from[0]) * a0;
const lat0 = from[1] + (to[1] - from[1]) * a0;
const lon1 = from[0] + (to[0] - from[0]) * a1;
const lat1 = from[1] + (to[1] - from[1]) * a1;
segs.push({ from: [lon0, lat0], to: [lon1, lat1], suspicious });
}
return segs;
}
type PairRangeCircle = {
center: [number, number]; // [lon, lat]
radiusNm: number;
warn: boolean;
};
const DECK_VIEW_ID = "mapbox";
function getDeckView(projection: MapProjectionId) {
return projection === "globe" ? new GlobeView({ id: DECK_VIEW_ID }) : new MapView({ id: DECK_VIEW_ID });
}
export function Map3D({
targets,
zones,
selectedMmsi,
settings,
baseMap,
projection,
overlays,
onSelectMmsi,
onViewBboxChange,
legacyHits,
pairLinks,
fcLinks,
fleetCircles,
}: Props) {
const containerRef = useRef<HTMLDivElement | null>(null);
const mapRef = useRef<maplibregl.Map | null>(null);
const overlayRef = useRef<MapboxOverlay | null>(null);
const showSeamarkRef = useRef(settings.showSeamark);
const baseMapRef = useRef<BaseMapId>(baseMap);
const projectionRef = useRef<MapProjectionId>(projection);
useEffect(() => {
showSeamarkRef.current = settings.showSeamark;
}, [settings.showSeamark]);
useEffect(() => {
baseMapRef.current = baseMap;
}, [baseMap]);
useEffect(() => {
projectionRef.current = projection;
}, [projection]);
// Init MapLibre + Deck.gl (single WebGL context via MapboxOverlay)
useEffect(() => {
if (!containerRef.current || mapRef.current) return;
let map: maplibregl.Map | null = null;
let overlay: MapboxOverlay | null = null;
let cancelled = false;
const controller = new AbortController();
(async () => {
let style: string | StyleSpecification = "/map/styles/osm-seamark.json";
try {
style = await resolveMapStyle(baseMapRef.current, controller.signal);
} catch (e) {
// Don't block the app if MapTiler isn't configured yet.
// This is expected in early dev environments without `VITE_MAPTILER_KEY`.
console.warn("Map style init failed, falling back to local raster style:", e);
style = "/map/styles/osm-seamark.json";
}
if (cancelled || !containerRef.current) return;
map = new maplibregl.Map({
container: containerRef.current,
style,
center: [126.5, 34.2],
zoom: 7,
pitch: 45,
bearing: 0,
maxPitch: 85,
dragRotate: true,
pitchWithRotate: true,
touchPitch: true,
scrollZoom: { around: "center" },
});
map.addControl(new maplibregl.NavigationControl({ showZoom: true, showCompass: true }), "top-left");
map.addControl(new maplibregl.ScaleControl({ maxWidth: 120, unit: "metric" }), "bottom-left");
// NOTE: `MapboxOverlayProps`'s TS typing pins `DeckProps` generics to `null`,
// which makes `views` type `null`. Runtime supports `views`; cast to keep TS happy.
overlay = new MapboxOverlay({ interleaved: true, layers: [], views: getDeckView(projectionRef.current) } as unknown as never);
map.addControl(overlay);
mapRef.current = map;
overlayRef.current = overlay;
function applyProjection() {
if (!map) return;
const next = projectionRef.current;
if (next === "mercator") return;
try {
map.setProjection({ type: next });
// Globe mode renders a single world; copies can look odd and aren't needed for KR region.
map.setRenderWorldCopies(next !== "globe");
} catch (e) {
console.warn("Projection apply failed:", e);
}
}
// Ensure the seamark raster overlay exists even when using MapTiler vector styles.
map.on("style.load", () => {
applyProjection();
if (!showSeamarkRef.current) return;
try {
ensureSeamarkOverlay(map!, "bathymetry-lines");
} catch {
// ignore (style not ready / already has it)
}
});
// Send initial bbox and update on move end (useful for lists / debug)
const emitBbox = () => {
const cb = onViewBboxChange;
if (!cb || !map) return;
const b = map.getBounds();
cb([b.getWest(), b.getSouth(), b.getEast(), b.getNorth()]);
};
map.on("load", emitBbox);
map.on("moveend", emitBbox);
function applySeamarkOpacity() {
if (!map) return;
const opacity = settings.showSeamark ? 0.85 : 0;
try {
map.setPaintProperty("seamark", "raster-opacity", opacity);
} catch {
// style not ready yet
}
}
map.once("load", () => {
if (showSeamarkRef.current) {
try {
ensureSeamarkOverlay(map!, "bathymetry-lines");
} catch {
// ignore
}
applySeamarkOpacity();
}
});
})();
return () => {
cancelled = true;
controller.abort();
if (map) {
map.remove();
map = null;
}
if (overlay) {
overlay.finalize();
overlay = null;
}
overlayRef.current = null;
mapRef.current = null;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Projection toggle (mercator <-> globe)
useEffect(() => {
const map = mapRef.current;
if (!map) return;
let cancelled = false;
// Recreate the Deck overlay so the Deck instance uses the correct View type
// (MapView vs GlobeView) and stays in sync with MapLibre.
try {
const old = overlayRef.current;
if (old) map.removeControl(old);
} catch {
// ignore
}
try {
const next = new MapboxOverlay({ interleaved: true, layers: [], views: getDeckView(projection) } as unknown as never);
map.addControl(next);
overlayRef.current = next;
} catch (e) {
console.warn("Deck overlay re-create failed:", e);
}
const applyProjection = () => {
if (cancelled) return;
try {
map.setProjection({ type: projection });
map.setRenderWorldCopies(projection !== "globe");
} catch (e) {
console.warn("Projection switch failed:", e);
}
};
if (map.isStyleLoaded()) applyProjection();
else map.once("style.load", applyProjection);
return () => {
cancelled = true;
try {
map.off("style.load", applyProjection);
} catch {
// ignore
}
};
}, [projection]);
// Base map toggle
useEffect(() => {
const map = mapRef.current;
if (!map) return;
let cancelled = false;
const controller = new AbortController();
(async () => {
try {
const style = await resolveMapStyle(baseMap, controller.signal);
if (cancelled) return;
// Disable style diff to avoid warnings with custom layers (Deck MapboxOverlay) and
// to ensure a clean rebuild when switching between very different styles.
map.setStyle(style, { diff: false });
} catch (e) {
if (cancelled) return;
console.warn("Base map switch failed:", e);
}
})();
return () => {
cancelled = true;
controller.abort();
};
}, [baseMap]);
// seamark toggle
useEffect(() => {
const map = mapRef.current;
if (!map) return;
if (settings.showSeamark) {
try {
ensureSeamarkOverlay(map, "bathymetry-lines");
map.setPaintProperty("seamark", "raster-opacity", 0.85);
} catch {
// ignore until style is ready
}
return;
}
// If seamark is off, remove the layer+source to avoid unnecessary network tile requests.
try {
if (map.getLayer("seamark")) map.removeLayer("seamark");
} catch {
// ignore
}
try {
if (map.getSource("seamark")) map.removeSource("seamark");
} catch {
// ignore
}
}, [settings.showSeamark]);
const shipData = useMemo(() => {
return targets.filter((t) => isFiniteNumber(t.lat) && isFiniteNumber(t.lon));
}, [targets]);
const legacyTargets = useMemo(() => {
if (!legacyHits) return [];
return shipData.filter((t) => legacyHits.has(t.mmsi));
}, [shipData, legacyHits]);
const fcDashed = useMemo(() => {
const segs: DashSeg[] = [];
for (const l of fcLinks || []) segs.push(...dashifyLine(l.from, l.to, l.suspicious));
return segs;
}, [fcLinks]);
const pairRanges = useMemo(() => {
const out: PairRangeCircle[] = [];
for (const p of pairLinks || []) {
const center: [number, number] = [(p.from[0] + p.to[0]) / 2, (p.from[1] + p.to[1]) / 2];
out.push({ center, radiusNm: Math.max(0.05, p.distanceNm / 2), warn: p.warn });
}
return out;
}, [pairLinks]);
// When the selected MMSI changes due to external UI (e.g., list click), fly to it.
useEffect(() => {
const map = mapRef.current;
if (!map) return;
if (!selectedMmsi) return;
const t = shipData.find((x) => x.mmsi === selectedMmsi);
if (!t) return;
map.easeTo({ center: [t.lon, t.lat], zoom: Math.max(map.getZoom(), 10), duration: 600 });
}, [selectedMmsi, shipData]);
// Update Deck.gl layers
useEffect(() => {
const overlay = overlayRef.current;
const map = mapRef.current;
if (!overlay || !map) return;
const overlayParams = projection === "globe" ? GLOBE_OVERLAY_PARAMS : DEPTH_DISABLED_PARAMS;
const layers = [];
if (settings.showDensity) {
layers.push(
new HexagonLayer<AisTarget>({
id: "density",
data: shipData,
pickable: true,
extruded: true,
radius: 2500,
elevationScale: 35,
coverage: 0.92,
opacity: 0.35,
getPosition: (d) => [d.lon, d.lat],
}),
);
}
if (overlays.zones && zones) {
layers.push(
new GeoJsonLayer({
id: "zones",
data: zones,
pickable: true,
// Avoid z-fighting flicker with other layers in the shared MapLibre depth buffer.
parameters: overlayParams,
stroked: true,
filled: true,
getFillColor: (f) => {
const zoneId = (f.properties as { zoneId?: string } | undefined)?.zoneId as ZoneId | undefined;
const col = zoneId ? ZONE_META[zoneId]?.color : "#3B82F6";
const [r, g, b] = hexToRgb(col);
return [r, g, b, 22];
},
getLineColor: (f) => {
const zoneId = (f.properties as { zoneId?: string } | undefined)?.zoneId as ZoneId | undefined;
const col = zoneId ? ZONE_META[zoneId]?.color : "#3B82F6";
const [r, g, b] = hexToRgb(col);
return [r, g, b, 200];
},
lineWidthMinPixels: 1,
lineWidthMaxPixels: 2,
getLineWidth: 1,
}),
);
}
if (overlays.fleetCircles && (fleetCircles?.length ?? 0) > 0) {
layers.push(
new ScatterplotLayer<FleetCircle>({
id: "fleet-circles",
data: fleetCircles,
pickable: false,
billboard: projection === "globe",
parameters: overlayParams,
filled: false,
stroked: true,
radiusUnits: "meters",
getRadius: (d) => d.radiusNm * 1852,
lineWidthUnits: "pixels",
getLineWidth: 1,
getLineColor: () => [245, 158, 11, 140],
getPosition: (d) => d.center,
}),
);
}
if (overlays.pairRange && pairRanges.length > 0) {
layers.push(
new ScatterplotLayer<PairRangeCircle>({
id: "pair-range",
data: pairRanges,
pickable: false,
billboard: projection === "globe",
parameters: overlayParams,
filled: false,
stroked: true,
radiusUnits: "meters",
getRadius: (d) => d.radiusNm * 1852,
radiusMinPixels: 10,
lineWidthUnits: "pixels",
getLineWidth: () => 1,
getLineColor: (d) => (d.warn ? [245, 158, 11, 170] : [59, 130, 246, 110]),
getPosition: (d) => d.center,
}),
);
}
if (overlays.pairLines && (pairLinks?.length ?? 0) > 0) {
layers.push(
new LineLayer<PairLink>({
id: "pair-lines",
data: pairLinks,
pickable: false,
parameters: overlayParams,
getSourcePosition: (d) => d.from,
getTargetPosition: (d) => d.to,
getColor: (d) => (d.warn ? [245, 158, 11, 220] : [59, 130, 246, 85]),
getWidth: (d) => (d.warn ? 2.2 : 1.4),
widthUnits: "pixels",
}),
);
}
if (overlays.fcLines && fcDashed.length > 0) {
layers.push(
new LineLayer<DashSeg>({
id: "fc-lines",
data: fcDashed,
pickable: false,
parameters: overlayParams,
getSourcePosition: (d) => d.from,
getTargetPosition: (d) => d.to,
getColor: (d) => (d.suspicious ? [239, 68, 68, 220] : [217, 119, 6, 200]),
getWidth: () => 1.3,
widthUnits: "pixels",
}),
);
}
if (settings.showShips && legacyTargets.length > 0) {
layers.push(
new ScatterplotLayer<AisTarget>({
id: "legacy-halo",
data: legacyTargets,
pickable: false,
billboard: projection === "globe",
// This ring is most prone to z-fighting, so force it into pure painter's-order rendering.
parameters: overlayParams,
filled: false,
stroked: true,
radiusUnits: "pixels",
getRadius: (d) => (selectedMmsi && d.mmsi === selectedMmsi ? 22 : 16),
lineWidthUnits: "pixels",
getLineWidth: 2,
getLineColor: (d) => {
const l = legacyHits?.get(d.mmsi);
const rgb = l ? LEGACY_CODE_COLORS[l.shipCode] : null;
if (!rgb) return [245, 158, 11, 200];
return [rgb[0], rgb[1], rgb[2], 200];
},
getPosition: (d) => [d.lon, d.lat],
updateTriggers: {
getRadius: [selectedMmsi],
getLineColor: [legacyHits],
},
}),
);
}
if (settings.showShips) {
layers.push(
new IconLayer<AisTarget>({
id: "ships",
data: shipData,
pickable: true,
// Mercator: keep icons horizontal on the sea surface when view is pitched/rotated.
// Globe: billboard to keep the icon visible and glued to the globe.
billboard: projection === "globe",
parameters: projection === "globe" ? ({ ...overlayParams, cullMode: "none" } as const) : overlayParams,
iconAtlas: "/assets/ship.svg",
iconMapping: SHIP_ICON_MAPPING,
getIcon: () => "ship",
getPosition: (d) => [d.lon, d.lat],
getAngle: (d) => (isFiniteNumber(d.cog) ? d.cog : 0),
sizeUnits: "pixels",
getSize: (d) => (selectedMmsi && d.mmsi === selectedMmsi ? 34 : 22),
getColor: (d) => getShipColor(d, selectedMmsi, legacyHits?.get(d.mmsi)?.shipCode ?? null),
alphaCutoff: 0.05,
updateTriggers: {
getSize: [selectedMmsi],
getColor: [selectedMmsi, legacyHits],
},
}),
);
}
overlay.setProps({
layers,
getTooltip: (info: PickingInfo) => {
if (!info.object) return null;
if (info.layer && info.layer.id === "density") {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const o: any = info.object;
const n = Array.isArray(o?.points) ? o.points.length : 0;
return { text: `AIS density: ${n}` };
}
// zones
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const obj: any = info.object;
if (typeof obj.mmsi === "number") {
const t = obj as AisTarget;
const name = (t.name || "").trim() || "(no name)";
const legacy = legacyHits?.get(t.mmsi);
const legacyHtml = legacy
? `<div style="margin-top: 6px; padding-top: 6px; border-top: 1px solid rgba(255,255,255,.08)">
<div><b>CN Permit</b> · <b>${legacy.shipCode}</b> · ${legacy.permitNo}</div>
</div>`
: "";
return {
html: `<div style="font-family: system-ui; font-size: 12px;">
<div style="font-weight: 700; margin-bottom: 4px;">${name}</div>
<div>MMSI: <b>${t.mmsi}</b> · ${t.vesselType || "Unknown"}</div>
<div>SOG: <b>${t.sog ?? "?"}</b> kt · COG: <b>${t.cog ?? "?"}</b>°</div>
<div style="opacity:.8">${t.status || ""}</div>
<div style="opacity:.7; font-size: 11px; margin-top: 4px;">${t.messageTimestamp || ""}</div>
${legacyHtml}
</div>`,
};
}
const p = obj.properties as { zoneName?: string; zoneLabel?: string } | undefined;
const label = p?.zoneName ?? p?.zoneLabel;
if (label) return { text: label };
return null;
},
onClick: (info: PickingInfo) => {
if (!info.object) {
onSelectMmsi(null);
return;
}
if (info.layer && info.layer.id === "density") return;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const obj: any = info.object;
if (typeof obj.mmsi === "number") {
const t = obj as AisTarget;
onSelectMmsi(t.mmsi);
map.easeTo({ center: [t.lon, t.lat], zoom: Math.max(map.getZoom(), 10), duration: 600 });
}
},
});
}, [
projection,
shipData,
zones,
selectedMmsi,
overlays.zones,
settings.showShips,
settings.showDensity,
onSelectMmsi,
legacyHits,
legacyTargets,
overlays.pairLines,
overlays.pairRange,
overlays.fcLines,
overlays.fleetCircles,
pairLinks,
pairRanges,
fcDashed,
fleetCircles,
]);
return <div ref={containerRef} style={{ width: "100%", height: "100%" }} />;
}

파일 보기

@ -0,0 +1,270 @@
import { VESSEL_TYPES } from "../../entities/vessel/model/meta";
import type { DerivedLegacyVessel } from "../../features/legacyDashboard/model/types";
import { haversineNm } from "../../shared/lib/geo/haversineNm";
type Props = {
selectedVessel: DerivedLegacyVessel | null;
vessels: DerivedLegacyVessel[];
fleetVessels: DerivedLegacyVessel[];
onSelectMmsi: (mmsi: number) => void;
};
export function RelationsPanel({ selectedVessel, vessels, fleetVessels, onSelectMmsi }: Props) {
if (selectedVessel) {
const v = selectedVessel;
const meta = VESSEL_TYPES[v.shipCode];
const ownerLabel = v.ownerCn || v.ownerRoman || v.ownerKey || "-";
const sameOwner = v.ownerKey ? vessels.filter((v2) => v2.ownerKey === v.ownerKey && v2.mmsi !== v.mmsi) : [];
const pair = v.pairPermitNo ? vessels.find((v2) => v2.permitNo === v.pairPermitNo) ?? null : null;
const fcNearby = vessels.filter((fc) => fc.shipCode === "FC" && fc.mmsi !== v.mmsi && haversineNm(fc.lat, fc.lon, v.lat, v.lon) < 5);
return (
<div className="rel-panel">
<div className="rel-header">
<span style={{ fontSize: 14 }}>{meta.icon}</span>
<span style={{ fontSize: 11, fontWeight: 800, color: meta.color }}>{v.permitNo}</span>
<span className="rel-badge" style={{ background: `${meta.color}22`, color: meta.color }}>
{v.shipCode}
</span>
</div>
<div style={{ fontSize: 9, color: "var(--muted)", marginBottom: 6 }}>
:{" "}
<b
style={{
color: "var(--text)",
display: "inline-block",
maxWidth: 230,
verticalAlign: "bottom",
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
title={ownerLabel}
>
{ownerLabel}
</b>
</div>
{pair ? (
<>
<div style={{ fontSize: 8, fontWeight: 700, color: "var(--muted)", margin: "6px 0 3px", letterSpacing: 1 }}>
</div>
{(() => {
const dist = haversineNm(v.lat, v.lon, pair.lat, pair.lon);
const warn = dist > 3;
const pairMeta = VESSEL_TYPES[pair.shipCode];
return (
<>
<div className="rel-line">
<div className="dot" style={{ background: meta.color }} />
<span style={{ fontSize: 9, fontWeight: 600, cursor: "pointer" }} onClick={() => onSelectMmsi(v.mmsi)}>
{v.permitNo}
</span>
<div className="rel-link">{warn ? "⚠" : "⟷"}</div>
<div className="dot" style={{ background: pairMeta.color }} />
<span style={{ fontSize: 9, fontWeight: 600, cursor: "pointer" }} onClick={() => onSelectMmsi(pair.mmsi)}>
{pair.permitNo}
</span>
<span
className="rel-dist"
style={{
background: warn ? "#F59E0B22" : "#22C55E22",
color: warn ? "#F59E0B" : "#22C55E",
}}
>
{dist.toFixed(2)}NM
</span>
</div>
<div style={{ fontSize: 8, color: "var(--muted)", marginLeft: 10 }}>
범위: 0.3~1.0NM | {warn ? "⚠ 이격 경고" : "✓ 정상 동기화"}
</div>
</>
);
})()}
</>
) : null}
{fcNearby.length && v.shipCode !== "FC" ? (
<>
<div style={{ fontSize: 8, fontWeight: 700, color: "var(--muted)", margin: "6px 0 3px", letterSpacing: 1 }}>
🚛
</div>
{fcNearby.slice(0, 6).map((fc) => {
const dist = haversineNm(v.lat, v.lon, fc.lat, fc.lon);
const isSameOwner = !!v.ownerKey && v.ownerKey === fc.ownerKey;
const warn = dist < 0.5;
return (
<div key={fc.mmsi} className="rel-line">
<div className="dot" style={{ background: VESSEL_TYPES.FC.color }} />
<span style={{ fontSize: 9, fontWeight: 600, cursor: "pointer" }} onClick={() => onSelectMmsi(fc.mmsi)}>
{fc.permitNo}
</span>
<span className="rel-dist" style={{ background: "#D9770622", color: "#D97706" }}>
{dist.toFixed(1)}NM
</span>
{isSameOwner ? (
<span
style={{
fontSize: 7,
background: "#F59E0B22",
color: "#F59E0B",
padding: "1px 3px",
borderRadius: 2,
}}
>
</span>
) : null}
{warn ? (
<span
style={{
fontSize: 7,
background: "#EF444422",
color: "#EF4444",
padding: "1px 3px",
borderRadius: 2,
}}
>
</span>
) : null}
</div>
);
})}
</>
) : null}
{sameOwner.length ? (
<>
<div style={{ fontSize: 8, fontWeight: 700, color: "var(--muted)", margin: "6px 0 3px", letterSpacing: 1 }}>
🏢 ({sameOwner.length + 1})
</div>
{sameOwner.slice(0, 8).map((sv) => {
const m = VESSEL_TYPES[sv.shipCode];
return (
<div key={sv.mmsi} className="fleet-vessel" onClick={() => onSelectMmsi(sv.mmsi)} style={{ cursor: "pointer" }}>
<div className="dot" style={{ background: m.color }} />
<span style={{ color: m.color, fontWeight: 600 }}>{sv.shipCode}</span> {sv.permitNo}
<span style={{ color: "var(--muted)" }}>
{sv.sog ?? "?"}kt {sv.state.label}
</span>
</div>
);
})}
{sameOwner.length > 8 ? <div style={{ fontSize: 8, color: "var(--muted)" }}>... +{sameOwner.length - 8}</div> : null}
</>
) : null}
</div>
);
}
// No vessel selected: show top fleets
if (fleetVessels.length === 0) {
return <div style={{ fontSize: 11, color: "var(--muted)" }}>( )</div>;
}
const group = new Map<string, DerivedLegacyVessel[]>();
for (const v of fleetVessels) {
if (!v.ownerKey) continue;
const list = group.get(v.ownerKey);
if (list) list.push(v);
else group.set(v.ownerKey, [v]);
}
const topFleets = Array.from(group.entries())
.map(([ownerKey, vs]) => ({ ownerKey, vs }))
.filter((x) => x.vs.length >= 3)
.sort((a, b) => b.vs.length - a.vs.length)
.slice(0, 5);
if (topFleets.length === 0) {
return <div style={{ fontSize: 11, color: "var(--muted)" }}>( (3 ) )</div>;
}
return (
<div>
{topFleets.map(({ ownerKey, vs }) => {
const displayOwner = vs.find((v) => v.ownerCn)?.ownerCn || vs.find((v) => v.ownerRoman)?.ownerRoman || ownerKey;
const displayTitle = ownerKey && displayOwner !== ownerKey ? `${displayOwner} (${ownerKey})` : displayOwner;
const codes: Record<string, number> = {};
for (const v of vs) codes[v.shipCode] = (codes[v.shipCode] ?? 0) + 1;
return (
<div key={ownerKey} className="fleet-card">
<div className="fleet-owner">
🏢{" "}
<span
style={{
display: "inline-block",
maxWidth: 240,
verticalAlign: "bottom",
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
title={displayTitle}
>
{displayOwner}
</span>{" "}
<span style={{ fontSize: 8, color: "var(--muted)" }}>{vs.length}</span>
</div>
<div style={{ display: "flex", gap: 4, flexWrap: "wrap", marginBottom: 3 }}>
{Object.entries(codes).map(([c, n]) => {
const meta = VESSEL_TYPES[c as keyof typeof VESSEL_TYPES];
return (
<span
key={c}
style={{
fontSize: 8,
background: `${meta.color}22`,
color: meta.color,
padding: "1px 4px",
borderRadius: 2,
fontWeight: 600,
}}
>
{c}×{n}
</span>
);
})}
</div>
<div style={{ display: "flex", alignItems: "center", gap: 2, flexWrap: "wrap" }}>
{vs.slice(0, 18).map((v) => {
const m = VESSEL_TYPES[v.shipCode];
const text = v.shipCode === "FC" ? "F" : v.shipCode === "PT" ? "M" : v.shipCode === "PT-S" ? "S" : v.shipCode[0];
return (
<span key={v.mmsi} style={{ display: "inline-flex", alignItems: "center", gap: 2 }}>
<div
onClick={() => onSelectMmsi(v.mmsi)}
style={{
cursor: "pointer",
width: 16,
height: 16,
borderRadius: v.shipCode === "FC" ? 2 : "50%",
background: m.color,
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: 6,
color: "#fff",
border: "1px solid rgba(255,255,255,.2)",
}}
title={`${v.permitNo} ${v.shipCode} ${v.sog ?? "?"}kt`}
>
{text}
</div>
{v.pairPermitNo && (v.shipCode === "PT" || v.shipCode === "PT-S") ? <span style={{ fontSize: 8, color: "var(--muted)" }}></span> : null}
</span>
);
})}
{vs.length > 18 ? <span style={{ fontSize: 8, color: "var(--muted)" }}>+{vs.length - 18}</span> : null}
</div>
</div>
);
})}
</div>
);
}

파일 보기

@ -0,0 +1,62 @@
import { ZONE_META } from "../../entities/zone/model/meta";
import { SPEED_MAX, VESSEL_TYPES } from "../../entities/vessel/model/meta";
import type { VesselTypeCode } from "../../entities/vessel/model/types";
type Props = {
selectedType: VesselTypeCode | null;
};
export function SpeedProfilePanel({ selectedType }: Props) {
const code = selectedType ?? "PT";
const t = VESSEL_TYPES[code];
const segs = t.speedProfile.map((s) => {
const left = (s.range[0] / SPEED_MAX) * 100;
const width = ((s.range[1] - s.range[0]) / SPEED_MAX) * 100;
const showText = width > 8;
const text = showText ? `${s.label}${s.primary ? ` ${s.typicalSpeed}kt` : ""}` : "";
return (
<div
key={`${s.label}-${s.range[0]}`}
className={`sseg ${s.primary ? "p" : ""}`}
style={{ left: `${left}%`, width: `${width}%`, background: s.color }}
>
{text}
</div>
);
});
const primary = t.speedProfile.filter((s) => s.primary);
return (
<div>
<div style={{ fontSize: 10, fontWeight: 700, color: t.color, marginBottom: 3 }}>
{t.icon} {code} {t.name}
</div>
<div className="sbar">{segs}</div>
<div style={{ display: "flex", justifyContent: "space-between" }}>
{[0, 3, 5, 7, 10, 15].map((k) => (
<span key={k} style={{ fontSize: 7, color: "rgba(255,255,255,.2)" }}>
{k}
</span>
))}
</div>
<div style={{ marginTop: 2 }}>
{primary.map((s) => (
<span key={s.label} style={{ fontSize: 8, color: s.color, marginRight: 8 }}>
{s.label} {s.range[0]}~{s.range[1]}kt
</span>
))}
</div>
<div style={{ fontSize: 9, color: "var(--muted)", marginTop: 3 }}>
: <b style={{ color: "var(--text)" }}>{t.trajectory}</b> | :{" "}
{t.allowedZones.map((z) => (
<span key={z} style={{ color: ZONE_META[z].color, marginRight: 6 }}>
{ZONE_META[z].label}
</span>
))}
</div>
</div>
);
}

파일 보기

@ -0,0 +1,52 @@
type Props = {
total: number;
fishing: number;
transit: number;
pairLinks: number;
alarms: number;
pollingStatus: "idle" | "loading" | "ready" | "error";
lastFetchMinutes: number | null;
clock: string;
adminMode?: boolean;
onLogoClick?: () => void;
};
export function Topbar({ total, fishing, transit, pairLinks, alarms, pollingStatus, lastFetchMinutes, clock, adminMode, onLogoClick }: Props) {
const statusColor =
pollingStatus === "ready" ? "#22C55E" : pollingStatus === "loading" ? "#F59E0B" : pollingStatus === "error" ? "#EF4444" : "var(--muted)";
return (
<div className="topbar">
<div className="logo" onClick={onLogoClick} style={{ cursor: onLogoClick ? "pointer" : undefined }} title={adminMode ? "ADMIN" : undefined}>
🛰 <span>WING</span> · {adminMode ? <span style={{ fontSize: 10, color: "#F59E0B" }}>(ADMIN)</span> : null}
</div>
<div className="stats">
<div className="stat">
DATA <b style={{ color: "#22C55E" }}>API</b>
</div>
<div className="stat">
POLL{" "}
<b style={{ color: statusColor }}>
{pollingStatus.toUpperCase()}
{lastFetchMinutes ? `(${lastFetchMinutes}m)` : ""}
</b>
</div>
<div className="stat">
<b>{total}</b>
</div>
<div className="stat">
<b style={{ color: "#22C55E" }}>{fishing}</b>
</div>
<div className="stat">
<b style={{ color: "#3B82F6" }}>{transit}</b>
</div>
<div className="stat">
<b style={{ color: "#F59E0B" }}>{pairLinks}</b>
</div>
<div className="stat">
<b style={{ color: "#EF4444" }}>{alarms}</b>
</div>
</div>
<div className="time">{clock}</div>
</div>
);
}

파일 보기

@ -0,0 +1,59 @@
import { VESSEL_TYPES } from "../../entities/vessel/model/meta";
import type { DerivedLegacyVessel } from "../../features/legacyDashboard/model/types";
type Props = {
vessels: DerivedLegacyVessel[];
selectedMmsi: number | null;
onSelectMmsi: (mmsi: number) => void;
};
function isFiniteNumber(x: unknown): x is number {
return typeof x === "number" && Number.isFinite(x);
}
export function VesselList({ vessels, selectedMmsi, onSelectMmsi }: Props) {
const sorted = vessels
.slice()
.sort((a, b) => (isFiniteNumber(b.sog) ? b.sog : -1) - (isFiniteNumber(a.sog) ? a.sog : -1))
.slice(0, 80);
return (
<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;
return (
<div
key={v.mmsi}
className="vi"
onClick={() => onSelectMmsi(v.mmsi)}
style={sel ? { background: "rgba(59,130,246,.12)", border: "1px solid rgba(59,130,246,.45)" } : undefined}
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>
);
}

파일 보기

@ -0,0 +1,28 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

7
apps/web/tsconfig.json Normal file
파일 보기

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

파일 보기

@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

46
apps/web/vite.config.ts Normal file
파일 보기

@ -0,0 +1,46 @@
import react from "@vitejs/plugin-react";
import { fileURLToPath } from "node:url";
import { defineConfig, loadEnv } from "vite";
// https://vite.dev/config/
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), "");
const webPort = Number(env.WEB_PORT || process.env.WEB_PORT || 5175);
const apiPort = Number(env.API_PORT || process.env.API_PORT || 5174);
// Same proxy pattern as the "dark" project:
// - dev: use Vite proxy (/snp-api -> upstream host)
// - prod: set VITE_API_URL to absolute base if needed
const snpApiTarget = env.VITE_SNP_API_TARGET || process.env.VITE_SNP_API_TARGET || "http://211.208.115.83:8041";
return {
plugins: [react()],
resolve: {
alias: {
// deck.gl (via loaders.gl) contains a few Node-only helper modules.
// In browser builds, they should be tree-shaken, but Vite/Rollup may still warn.
child_process: fileURLToPath(new URL("./src/shared/shims/child_process.ts", import.meta.url)),
},
},
server: {
// IMPORTANT: keep the web dev server port fixed so it doesn't collide with the API server port.
// If the port is already taken, fail fast instead of auto-falling-back to the API port (proxy loop -> 500).
port: webPort,
strictPort: true,
proxy: {
// Optional local API server (future DB-backed APIs etc).
"/api": {
target: `http://127.0.0.1:${apiPort}`,
changeOrigin: true,
},
// SNP-Batch AIS upstream (ship positions)
"/snp-api": {
target: snpApiTarget,
changeOrigin: true,
secure: false,
},
},
},
};
});

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

파일 보기

@ -0,0 +1 @@
{"type": "FeatureCollection", "name": "\ud2b9\uc815\uc5b4\uc5c5\uc218\uc5ed4", "crs": {"type": "name", "properties": {"name": "urn:ogc:def:crs:OGC:1.3:CRS84"}}, "features": [{"type": "Feature", "properties": {"fid": 0, "GML_ID": null, "OBJECTID": null, "ZONE_NM": null, "MNCT_NO": null, "MNCT_SCALE": null, "MNCT_NM": null, "RELREGLTN": null, "RELGOAG": null, "REVIYR": null, "ZONE_DESC": null, "PHOTO1_PAT": null, "ID": -2147483647, "CATE_CD": null, "ADR_CD": null, "ADR_KNM": null, "ORIGIN": null, "ORIYR": null, "ORIORG": null, "NAME": "\ud2b9\uc815\uc5b4\uc5c5\uc218\uc5ed\u2163", "WARD_NM": null, "WARD_ID": null, "GISID": null, "FID_2": null, "NAME_2": null, "FID_3": null, "NAME_3": null, "GID": null, "NAME_4": null, "FID_4": null, "NAME_5": null, "FID_5": null, "NAME_6": null}, "geometry": {"type": "MultiPolygon", "coordinates": [[[[13859276.603817873, 4232038.462456921], [13859276.603762543, 4321218.244482412], [13859276.603710985, 4404317.064005076], [13840719.645028654, 4439106.786523586], [13884632.712472571, 4439106.787250583], [13884632.712472571, 4439504.084564682], [13940418.269436067, 4439504.375880923], [13969123.924724836, 4439504.525783945], [13968718.329494288, 4438626.439593866], [13962623.599395147, 4425543.915710401], [13960437.31344761, 4420657.3891166765], [13958238.813611617, 4416093.569832627], [13958143.094601436, 4415900.994484875], [13958143.094601437, 4415900.994484875], [13957298.344237303, 4414201.456484755], [13953878.455604602, 4406316.186534493], [13949652.450365951, 4397019.979821594], [13948553.200448176, 4393395.13065616], [13947612.731073817, 4389132.176741289], [13947612.731072996, 4387549.226905922], [13947466.164417507, 4385829.556682826], [13947783.725505754, 4381721.729468383], [13948260.06713652, 4379835.70012994], [13949359.317054221, 4375897.403884492], [13951093.689146286, 4371808.582233328], [13954867.780530114, 4365670.678186072], [13964809.885341855, 4351190.629491161], [13978342.873219142, 4331838.456925102], [13980382.592510404, 4329007.496874151], [13981728.043604897, 4327079.749205159], [13985775.34591557, 4321280.81855131], [13997066.763484716, 4305102.598482491], [13999424.043863578, 4300225.286038025], [14003039.354703771, 4290447.064438686], [14005091.287883686, 4284626.561498255], [14006520.312777169, 4279426.932176922], [14007631.77658257, 4275178.643476352], [14008242.470981453, 4271549.325573796], [14009378.362562515, 4262248.123573576], [14009427.990871342, 4261704.85208626], [14009708.137538105, 4258638.140769343], [14009854.704193696, 4257224.555715567], [14009378.362562606, 4254698.603440943], [14005347.779531531, 4240996.452433007], [14002367.590864772, 4231511.1380338315], [14001280.554835469, 4227266.412716273], [14000486.652116666, 4225212.134400094], [13998047.81589918, 4222926.459154359], [13991387.305576058, 4216684.234498038], [13970721.407121927, 4197120.494488488], [13958654.085803084, 4185745.4565721145], [13956602.15262321, 4184012.5742896623], [13944065.033685392, 4171984.566055202], [13940467.606607554, 4168533.224265296], [13935619.01320107, 4163881.1438622964], [13935718.55954324, 4163976.6556012244], [13817590.293393573, 4163976.6556012244], [13859276.603817873, 4232038.462456921]]]]}}]}

파일 보기

@ -0,0 +1,828 @@
import React, { useState, useMemo } from "react";
const D=[
["沧州渤海新区广建水产品有限公司", "cangzhoubohaixinquguangjianshuichanpinyouxiangongsi", [[["C21-13558", "冀黄港渔05001", "jihuanggangyu05001", 106], ["C21-13559", "冀黄港渔05002", "jihuanggangyu05002", 106]], [["C21-13560", "冀黄港渔05777", "jihuanggangyu05777", 99], ["C21-13561", "冀黄港渔05888", "jihuanggangyu05888", 99]], [["C21-13562", "冀黄港渔06957", "jihuanggangyu06957", 117], ["C21-13563", "冀黄港渔06958", "jihuanggangyu06958", 117]], [["C21-13564", "冀黄港渔50777", "jihuanggangyu50777", 121], ["C21-13565", "冀黄港渔50888", "jihuanggangyu50888", 121]]], [["C25-9732", "冀黄港渔05005", "jihuanggangyu05005", 101], ["C25-9733", "冀黄港渔05006", "jihuanggangyu05006", 101], ["C25-16527", "冀黄港渔06055", "jihuanggangyu06055", 121], ["C25-16528", "冀黄港渔06056", "jihuanggangyu06056", 121], ["C25-16531", "冀黄港渔06587", "jihuanggangyu06587", 101], ["C25-16532", "冀黄港渔06588", "jihuanggangyu06588", 101], ["C25-16533", "冀黄港渔06788", "jihuanggangyu06788", 130], ["C25-16812", "冀黄港渔06787", "jihuanggangyu06787", 130], ["C25-16905", "冀黄港渔05003", "jihuanggangyu05003", 101], ["C25-16906", "冀黄港渔05004", "jihuanggangyu05004", 101], ["C25-17083", "冀黄港渔06897", "jihuanggangyu06897", 130], ["C25-17084", "冀黄港渔06898", "jihuanggangyu06898", 130]], [], [], [["C40-8489", "冀黄港渔运05027", "jihuanggangyu05027", 143]], [], []],
["宁波海裕渔业有限公司", "ningbohaiyuyuyeyouxiangongsi", [], [], [], [["C23-0105", "浙甬渔60651", "zheyongyu60651", 258], ["C23-0106", "浙甬渔60451", "zheyongyu60451", 154], ["C23-0107", "宁渔459", "ningyu459", 115], ["C23-0108", "宁渔22", "ningyu22", 490], ["C23-0110", "浙甬渔60652", "zheyongyu60652", 258], ["C23-0111", "宁渔458", "ningyu458", 115], ["C23-0112", "宁渔460", "ningyu460", 115], ["C23-0113", "宁渔23", "ningyu23", 541], ["C23-0115", "浙甬渔60656", "zheyongyu60656", 257], ["C23-0116", "浙甬渔60453", "zheyongyu60453", 154], ["C23-0117", "宁渔456", "ningyu456", 113], ["C23-0118", "宁渔24", "ningyu24", 541], ["C23-0120", "浙甬渔60655", "zheyongyu60655", 257], ["C23-0121", "宁渔454", "ningyu454", 113], ["C23-0122", "浙甬渔60457", "zheyongyu60457", 121], ["C23-8137", "浙甬渔60025", "zheyongyu60025", 498]], [], [], []],
["张长祥", "zhangchangxiang", [], [["C25-16688", "津塘渔03886", "jintangyu03886", 148], ["C25-16698", "津塘渔03666", "jintangyu03666", 148], ["C25-16769", "津塘渔03887", "jintangyu03887", 148], ["C25-16770", "津塘渔03889", "jintangyu03889", 148], ["C25-16923", "津塘渔03778", "jintangyu03778", 148], ["C25-16924", "津塘渔03779", "jintangyu03779", 148], ["C25-16926", "津塘渔03989", "jintangyu03989", 148], ["C25-17235", "津塘渔03299", "jintangyu03299", 148], ["C25-17236", "津塘渔03398", "jintangyu03398", 148], ["C25-17237", "津塘渔03399", "jintangyu03399", 148], ["C25-17238", "津塘渔03599", "jintangyu03599", 148], ["C25-17239", "津塘渔03699", "jintangyu03699", 148], ["C25-17241", "津塘渔03899", "jintangyu03899", 148], ["C25-17242", "津塘渔03969", "jintangyu03969", 148]], [], [], [], [], []],
["岱山县昌达渔业有限公司", "daishanxianchangdayuyeyouxiangongsi", [[["C21-14531", "浙岱渔02856", "zhedaiyu02856", 219], ["C21-14532", "浙岱渔02855", "zhedaiyu02855", 219]], [["C21-14533", "浙岱渔02858", "zhedaiyu02858", 219], ["C21-14534", "浙岱渔02857", "zhedaiyu02857", 219]], [["C21-14535", "浙岱渔02859", "zhedaiyu02859", 219], ["C21-14536", "浙岱渔02860", "zhedaiyu02860", 219]], [["C21-14537", "浙岱渔02861", "zhedaiyu02861", 219], ["C21-14538", "浙岱渔02862", "zhedaiyu02862", 219]]], [], [], [], [], [], []],
["赵凤春", "zhaofengchun", [], [["C25-16920", "津塘渔03766", "jintangyu03766", 97], ["C25-16921", "津塘渔03768", "jintangyu03768", 97], ["C25-16922", "津塘渔03776", "jintangyu03776", 97], ["C25-16925", "津塘渔03799", "jintangyu03799", 97], ["C25-17244", "津塘渔03566", "jintangyu03566", 93], ["C25-17246", "津塘渔03080", "jintangyu03080", 93], ["C25-17248", "津塘渔03688", "jintangyu03688", 93], ["C25-17249", "津塘渔03068", "jintangyu03068", 93]], [], [], [], [], []],
["荣成市祥宇渔业有限公司", "rongchengshixiangyuyuyeyouxiangongsi", [[["C21-13175", "鲁荣渔56555", "lurongyu56555", 127], ["C21-13176", "鲁荣渔56556", "lurongyu56556", 127]], [["C21-13179", "鲁荣渔56789", "lurongyu56789", 127], ["C21-13180", "鲁荣渔56790", "lurongyu56790", 127]], [["C21-14005", "鲁荣渔50085", "lurongyu50085", 117], ["C21-14006", "鲁荣渔50086", "lurongyu50086", 117]]], [], [], [], [], [], []],
["大连南北众安渔业有限公司", "daliannanbeizhonganyuyeyouxiangongsi", [], [["C25-17263", "辽庄渔55567", "liaozhuangyu55567", 144], ["C25-17264", "辽庄渔55678", "liaozhuangyu55678", 144], ["C25-17265", "辽庄渔55777", "liaozhuangyu55777", 144], ["C25-17266", "辽庄渔55789", "liaozhuangyu55789", 144]], [], [], [["C40-8495", "辽庄渔运55898", "liaozhuangyuyun55898", 249], ["C40-8496", "辽庄渔运55899", "liaozhuangyuyun55899", 249]], [], []],
["天津凯祥船舶服务有限公司", "tianjinkaixiangchuanbofuwuyouxiangongsi", [], [["C25-16830", "津塘渔03838", "jintangyu03838", 145], ["C25-17278", "津塘渔03833", "jintangyu03833", 145], ["C25-17279", "津塘渔03839", "jintangyu03839", 145], ["C25-17280", "津塘渔03836", "jintangyu03836", 145], ["C25-17281", "津塘渔03837", "jintangyu03837", 145]], [], [], [], [], []],
["烟台市海达渔业捕捞有限公司", "yantaishihaidayuyebulaoyouxiangongsi", [[["C21-12026", "鲁牟渔60009", "lumuyu60009", 127], ["C21-12027", "鲁牟渔60010", "lumuyu60010", 127]], [["C21-13588", "鲁牟渔60005", "lumuyu60005", 106], ["C21-13589", "鲁牟渔60006", "lumuyu60006", 106]]], [], [], [], [], [], []],
["荣成市平远渔业有限公司", "rongchengshipingyuanyuyeyouxiangongsi", [[["C21-12418", "鲁荣渔51277", "lurongyu51277", 126], ["C21-12419", "鲁荣渔51278", "lurongyu51278", 126]], [["C21-13354", "鲁荣渔52027", "lurongyu52027", 126], ["C21-13355", "鲁荣渔52028", "lurongyu52028", 126]]], [], [], [], [], [], []],
["荣成市汇尊水产有限公司", "rongchengshihuizunshuichanyouxiangongsi", [[["C21-12476", "鲁荣渔52803", "lurongyu52803", 107], ["C21-12477", "鲁荣渔52804", "lurongyu52804", 107]], [["C21-13614", "鲁荣渔55955", "lurongyu55955", 111], ["C21-13615", "鲁荣渔55956", "lurongyu55956", 111]]], [], [], [], [], [], []],
["王信静", "wangxinjing", [[["C21-12484", "鲁荣渔52911", "lurongyu52911", 99], ["C21-12485", "鲁荣渔52912", "lurongyu52912", 99]], [["C21-13866", "鲁荣渔52913", "lurongyu52913", 99], ["C21-13867", "鲁荣渔52914", "lurongyu52914", 99]]], [], [], [], [], [], []],
["荣成市佳汇渔业有限公司", "rongchengshijiahuiyuyeyouxiangongsi", [[["C21-13169", "鲁荣渔55509", "lurongyu55509", 96], ["C21-13170", "鲁荣渔55510", "lurongyu55510", 96]], [["C21-13604", "鲁荣渔55299", "lurongyu55299", 106], ["C21-13605", "鲁荣渔55300", "lurongyu55300", 106]]], [], [], [], [], [], []],
["乳山市圣海渔业有限公司", "rushanshishenghaiyuyeyouxiangongsi", [[["C21-13215", "鲁乳渔54931", "luruyu54931", 127], ["C21-13216", "鲁乳渔54932", "luruyu54932", 127]], [["C21-13217", "鲁乳渔54937", "luruyu54937", 106], ["C21-13218", "鲁乳渔54938", "luruyu54938", 106]]], [], [], [], [], [], []],
["周向辉", "zhouxianghui", [[["C21-13366", "鲁荣渔55179", "lurongyu55179", 106], ["C21-13367", "鲁荣渔55180", "lurongyu55180", 106]], [["C21-14349", "鲁荣渔55919", "lurongyu55919", 106], ["C21-14350", "鲁荣渔55920", "lurongyu55920", 106]]], [], [], [], [], [], []],
["荣成市振江渔业有限公司", "rongchengshizhenjiangyuyeyouxiangongsi", [[["C21-13380", "鲁荣渔55769", "lurongyu55769", 98], ["C21-13381", "鲁荣渔55770", "lurongyu55770", 98]], [["C21-13880", "鲁荣渔55769", "lurongyu55769", 98], ["C21-13881", "鲁荣渔55770", "lurongyu55770", 98]]], [], [], [], [], [], []],
["荣成市德海海水捕捞有限公司", "rongchengshidehaihaishuibulaoyouxiangongsi", [[["C21-13414", "鲁荣渔59115", "lurongyu59115", 123], ["C21-13415", "鲁荣渔59116", "lurongyu59116", 123]], [["C21-13606", "鲁荣渔55387", "lurongyu55387", 109], ["C21-13607", "鲁荣渔55388", "lurongyu55388", 109]]], [], [], [], [], [], []],
["荣成市华海渔业有限公司", "rongchengshihuahaiyuyeyouxiangongsi", [[["C21-13420", "鲁荣渔59507", "lurongyu59507", 133], ["C21-13421", "鲁荣渔59508", "lurongyu59508", 133]], [["C21-14107", "鲁荣渔58905", "lurongyu58905", 117], ["C21-14108", "鲁荣渔58906", "lurongyu58906", 117]]], [], [], [], [], [], []],
["陶进红", "taojinhong", [[["C21-13444", "鲁威渔60865", "luweiyu60865", 106], ["C21-13445", "鲁威渔60866", "luweiyu60866", 106]], [["C21-13446", "鲁威渔60867", "luweiyu60867", 106], ["C21-13447", "鲁威渔60868", "luweiyu60868", 106]]], [], [], [], [], [], []],
["大连福峰水产有限公司", "dalianfufengshuichanyouxiangongsi", [[["C21-13706", "辽大花渔15287", "liaodahuayu15287", 106], ["C21-13707", "辽大花渔15288", "liaodahuayu15288", 106]]], [["C25-17116", "辽庄渔75001", "liaozhuangyu75001", 148], ["C25-17117", "辽庄渔75002", "liaozhuangyu75002", 148]], [], [], [], [], []],
["杨顺兵", "yangshunbing", [], [], [], [], [], [["C21-13821", "浙岭渔23366", "zhelingyu23366", 215], ["C21-14399", "浙岭渔23335", "zhelingyu23335", 215]], [["C21-13986", "浙岭渔23367", "zhelingyu23367", 218], ["C21-14265", "浙岭渔23336", "zhelingyu23336", 214]]],
["李依蓉", "liyirong", [[["C21-13856", "鲁荣渔51915", "lurongyu51915", 106], ["C21-13857", "鲁荣渔51916", "lurongyu51916", 106]], [["C21-14339", "鲁荣渔55007", "lurongyu55007", 106], ["C21-14340", "鲁荣渔55008", "lurongyu55008", 106]]], [], [], [], [], [], []],
["郭伟华", "guoweihua", [[["C21-13892", "鲁荣渔56267", "lurongyu56267", 126], ["C21-13893", "鲁荣渔56268", "lurongyu56268", 126]], [["C21-14347", "鲁荣渔55717", "lurongyu55717", 98], ["C21-14348", "鲁荣渔55718", "lurongyu55718", 98]]], [], [], [], [], [], []],
["荣成润沣捕捞有限公司", "rongchengrunfengbulaoyouxiangongsi", [[["C21-13894", "鲁荣渔56787", "lurongyu56787", 117], ["C21-13895", "鲁荣渔56788", "lurongyu56788", 117]], [["C21-13908", "鲁荣渔59181", "lurongyu59181", 169], ["C21-13909", "鲁荣渔59182", "lurongyu59182", 169]]], [], [], [], [], [], []],
["肖富军", "xiaofujun", [[["C21-13904", "鲁荣渔59087", "lurongyu59087", 106], ["C21-13905", "鲁荣渔59088", "lurongyu59088", 106]], [["C21-14309", "鲁荣渔59017", "lurongyu59017", 106], ["C21-14310", "鲁荣渔59018", "lurongyu59018", 106]]], [], [], [], [], [], []],
["郑新友", "zhengxinyou", [[["C21-13910", "鲁荣渔59189", "lurongyu59189", 101], ["C21-13911", "鲁荣渔59190", "lurongyu59190", 101]], [["C21-14319", "鲁荣渔55961", "lurongyu55961", 106], ["C21-14320", "鲁荣渔55962", "lurongyu55962", 106]]], [], [], [], [], [], []],
["温州洞头中旭渔业有限公司", "wenzhoudongtouzhongxuyuyeyouxiangongsi", [[["C21-14243", "浙洞渔30067", "zhedongyu30067", 218], ["C21-14244", "浙洞渔30068", "zhedongyu30068", 214]], [["C21-14325", "浙洞渔30098", "zhedongyu30098", 214], ["C21-14326", "浙洞渔30099", "zhedongyu30099", 216]]], [], [], [], [], [], []],
["荣成市顺洋渔业有限公司", "rongchengshishunyangyuyeyouxiangongsi", [[["C21-14299", "鲁荣渔56047", "lurongyu56047", 297], ["C21-14300", "鲁荣渔56048", "lurongyu56048", 297]], [["C21-14454", "鲁荣渔58387", "lurongyu58387", 297], ["C21-14455", "鲁荣渔58388", "lurongyu58388", 297]]], [], [], [], [], [], []],
["荣成市晨曦渔业有限公司", "rongchengshichenxiyuyeyouxiangongsi", [[["C21-14547", "鲁荣渔52083", "lurongyu52083", 129], ["C21-14548", "鲁荣渔52084", "lurongyu52084", 129]], [["C21-14553", "鲁荣渔52435", "lurongyu52435", 90], ["C21-14554", "鲁荣渔52436", "lurongyu52436", 90]]], [], [], [], [], [], []],
["大连南北众合渔业有限公司", "daliannanbeizhongheyuyeyouxiangongsi", [], [["C25-17177", "辽庄渔55555", "liaozhuangyu55555", 149], ["C25-17178", "辽庄渔55666", "liaozhuangyu55666", 149], ["C25-17179", "辽庄渔55888", "liaozhuangyu55888", 149], ["C25-17180", "辽庄渔55999", "liaozhuangyu55999", 149]], [], [], [], [], []],
["张欣强", "zhangxinqiang", [], [["C25-17231", "津塘渔03355", "jintangyu03355", 148], ["C25-17232", "津塘渔03559", "jintangyu03559", 148], ["C25-17233", "津塘渔03885", "jintangyu03885", 148]], [], [], [["C40-8478", "津塘渔运03195", "jintangyuyun03195", 246]], [], []],
["象山东鸿延绳钓渔业专业合作社", "xiangshandonghongyanshengdiaoyuyezhuanyehezuoshe", [], [["C25-17261", "浙象渔55009", "zhexiangyu55009", 149], ["C25-17262", "浙象渔55005", "zhexiangyu55005", 149], ["C25-17299", "浙象渔55008", "zhexiangyu55008", 149], ["C25-17300", "浙象渔55006", "zhexiangyu55006", 149]], [], [], [], [], []],
["荣成市保顺水产捕捞有限公司", "rongchengshibaoshunshuichanbulaoyouxiangongsi", [[["C21-13906", "鲁荣渔59157", "lurongyu59157", 72], ["C21-13907", "鲁荣渔59158", "lurongyu59158", 72]]], [], [], [], [], [["C21-13089", "鲁荣渔51627", "lurongyu51627", 114]], []],
["林应刚", "linyinggang", [[["C21-14387", "浙岭渔23357", "zhelingyu23357", 218], ["C21-14388", "浙岭渔23358", "zhelingyu23358", 218]]], [], [], [], [], [], [["C21-14386", "浙岭渔23332", "zhelingyu23332", 217]]],
["任丘市丰顺远洋渔业有限公司", "renqiushifengshunyuanyangyuyeyouxiangongsi", [], [["C25-16575", "冀任渔00988", "jirenyu00988", 144], ["C25-16576", "冀任渔00999", "jirenyu00999", 144], ["C25-16577", "冀任渔00555", "jirenyu00555", 150]], [], [], [], [], []],
["陈红喜", "chenhongxi", [], [["C25-16825", "津塘渔03966", "jintangyu03966", 42], ["C25-17243", "津塘渔03009", "jintangyu03009", 148]], [], [], [["C40-8476", "津塘渔运03889", "jintangyuyun03889", 246]], [], []],
["大连泰保水产品有限公司", "daliantaibaoshuichanpinyouxiangongsi", [], [["C25-16874", "辽大中渔25111", "liaodazhongyu25111", 148], ["C25-16875", "辽大中渔25112", "liaodazhongyu25112", 148], ["C25-17111", "辽大中渔25113", "liaodazhongyu25113", 148]], [], [], [], [], []],
["大连聚鲜水产品有限公司", "dalianjuxianshuichanpinyouxiangongsi", [], [["C25-17255", "辽大中渔25203", "liaodazhongyu25203", 148]], [], [], [["C40-8461", "辽大中渔运15138", "liaodazhongyuyun15138", 169], ["C40-8493", "辽大中渔运15159", "liaodazhongyuyun15159", 178]], [], []],
["陈祥金", "chenxiangjin", [[["C21-5323", "浙岭渔20851", "zhelingyu20851", 216], ["C21-5324", "浙岭渔20852", "zhelingyu20852", 216]]], [], [], [], [], [], []],
["林大鹏", "lindapeng", [], [], [], [], [], [["C21-8049", "辽大甘渔15395", "liaodaganyu15395", 277]], [["C21-12347", "辽大甘渔15396", "liaodaganyu15396", 277]]],
["王梦祥", "wangmengxiang", [[["C21-10119", "浙岭渔20617", "zhelingyu20617", 216], ["C21-10120", "浙岭渔20618", "zhelingyu20618", 216]]], [], [], [], [], [], []],
["章会波", "zhanghuibo", [[["C21-10143", "浙象渔49003", "zhexiangyu49003", 216], ["C21-10144", "浙象渔49004", "zhexiangyu49004", 216]]], [], [], [], [], [], []],
["柯红军", "kehongjun", [[["C21-10190", "浙象渔49021", "zhexiangyu49021", 216], ["C21-10191", "浙象渔49022", "zhexiangyu49022", 216]]], [], [], [], [], [], []],
["吴冬", "wudong", [[["C21-10279", "辽大中渔25087", "liaodazhongyu25087", 175], ["C21-10280", "辽大中渔25088", "liaodazhongyu25088", 175]]], [], [], [], [], [], []],
["郭献法", "guoxianfa", [[["C21-10727", "浙岭渔22529", "zhelingyu22529", 218], ["C21-10728", "浙岭渔22530", "zhelingyu22530", 218]]], [], [], [], [], [], []],
["颜可贵", "yankegui", [[["C21-10731", "浙岭渔23897", "zhelingyu23897", 218], ["C21-10732", "浙岭渔23898", "zhelingyu23898", 218]]], [], [], [], [], [], []],
["江罗斌", "jiangluobin", [[["C21-10748", "浙岭渔28891", "zhelingyu28891", 218], ["C21-10749", "浙岭渔28892", "zhelingyu28892", 218]]], [], [], [], [], [], []],
["荣成市广泽渔业有限公司", "rongchengshiguangzeyuyeyouxiangongsi", [[["C21-11028", "鲁荣渔55237", "lurongyu55237", 106], ["C21-11029", "鲁荣渔55238", "lurongyu55238", 106]]], [], [], [], [], [], []],
["蔡华勤", "caihuaqin", [[["C21-11206", "浙岭渔29825", "zhelingyu29825", 217], ["C21-11207", "浙岭渔29826", "zhelingyu29826", 217]]], [], [], [], [], [], []],
["陈夏友", "chenxiayou", [[["C21-11255", "浙岭渔27835", "zhelingyu27835", 216], ["C21-11256", "浙岭渔27836", "zhelingyu27836", 216]]], [], [], [], [], [], []],
["郑小方", "zhengxiaofang", [[["C21-11257", "浙岭渔29959", "zhelingyu29959", 218], ["C21-11258", "浙岭渔29960", "zhelingyu29960", 218]]], [], [], [], [], [], []],
["林恩云", "linenyun", [[["C21-11263", "浙岭渔26938", "zhelingyu26938", 218], ["C21-11264", "浙岭渔26939", "zhelingyu26939", 218]]], [], [], [], [], [], []],
["颜可荣", "yankerong", [[["C21-11547", "浙岭渔23712", "zhelingyu23712", 218], ["C21-11548", "浙岭渔23713", "zhelingyu23713", 218]]], [], [], [], [], [], []],
["李爱斌", "liaibin", [[["C21-11869", "浙象渔42131", "zhexiangyu42131", 165], ["C21-11870", "浙象渔42132", "zhexiangyu42132", 165]]], [], [], [], [], [], []],
["刘斌", "liubin", [[["C21-11871", "浙象渔42107", "zhexiangyu42107", 214], ["C21-11872", "浙象渔42108", "zhexiangyu42108", 214]]], [], [], [], [], [], []],
["庄启蒙", "zhuangqimeng", [[["C21-11899", "浙岭渔22825", "zhelingyu22825", 218], ["C21-11900", "浙岭渔22823", "zhelingyu22823", 212]]], [], [], [], [], [], []],
["庄德金", "zhuangdejin", [[["C21-11905", "浙岭渔23347", "zhelingyu23347", 272], ["C21-11906", "浙岭渔23348", "zhelingyu23348", 272]]], [], [], [], [], [], []],
["宋万里", "songwanli", [[["C21-12164", "鲁乳渔54955", "luruyu54955", 116], ["C21-12165", "鲁乳渔54956", "luruyu54956", 116]]], [], [], [], [], [], []],
["林俭国", "linjianguo", [[["C21-12222", "浙象渔48027", "zhexiangyu48027", 198], ["C21-12223", "浙象渔48028", "zhexiangyu48028", 198]]], [], [], [], [], [], []],
["王建宏", "wangjianhong", [[["C21-12226", "浙象渔48095", "zhexiangyu48095", 198], ["C21-12227", "浙象渔48096", "zhexiangyu48096", 198]]], [], [], [], [], [], []],
["马庆华", "maqinghua", [[["C21-12271", "浙岭渔15011", "zhelingyu15011", 218], ["C21-12272", "浙岭渔15012", "zhelingyu15012", 218]]], [], [], [], [], [], []],
["黄美生", "huangmeisheng", [[["C21-12273", "浙岭渔15007", "zhelingyu15007", 218], ["C21-12274", "浙岭渔15008", "zhelingyu15008", 218]]], [], [], [], [], [], []],
["陈招德", "chenzhaode", [[["C21-12280", "浙岭渔29977", "zhelingyu29977", 218], ["C21-12281", "浙岭渔29978", "zhelingyu29978", 218]]], [], [], [], [], [], []],
["林应龙", "linyinglong", [[["C21-12290", "浙岭渔20355", "zhelingyu20355", 218], ["C21-12291", "浙岭渔20356", "zhelingyu20356", 218]]], [], [], [], [], [], []],
["陈坚辉", "chenjianhui", [[["C21-12298", "浙象渔66091", "zhexiangyu66091", 216], ["C21-12299", "浙象渔66092", "zhexiangyu66092", 216]]], [], [], [], [], [], []],
["耿俊玲", "gengjunling", [[["C21-12334", "辽庄渔85136", "liaozhuangyu85136", 237], ["C21-12335", "辽庄渔85137", "liaozhuangyu85137", 237]]], [], [], [], [], [], []],
["威海海晟渔业有限公司", "weihaihaishengyuyeyouxiangongsi", [[["C21-12430", "鲁荣渔51469", "lurongyu51469", 117], ["C21-12431", "鲁荣渔51470", "lurongyu51470", 117]]], [], [], [], [], [], []],
["荣成市骏马渔业有限公司", "rongchengshijunmayuyeyouxiangongsi", [[["C21-12518", "鲁荣渔55581", "lurongyu55581", 106], ["C21-12519", "鲁荣渔55582", "lurongyu55582", 106]]], [], [], [], [], [], []],
["姜远春", "jiangyuanchun", [[["C21-12520", "鲁荣渔55587", "lurongyu55587", 106], ["C21-12521", "鲁荣渔55588", "lurongyu55588", 106]]], [], [], [], [], [], []],
["许德欣", "xudexin", [[["C21-12542", "鲁荣渔55859", "lurongyu55859", 107], ["C21-12543", "鲁荣渔55860", "lurongyu55860", 107]]], [], [], [], [], [], []],
["王行凯", "wanghangkai", [[["C21-12552", "鲁荣渔56755", "lurongyu56755", 106], ["C21-12553", "鲁荣渔56756", "lurongyu56756", 106]]], [], [], [], [], [], []],
["王珊", "wangshan", [[["C21-12650", "鲁荣渔55571", "lurongyu55571", 117], ["C21-12651", "鲁荣渔55572", "lurongyu55572", 117]]], [], [], [], [], [], []],
["栾治民", "luanzhimin", [[["C21-12686", "辽大甘渔15387", "liaodaganyu15387", 297], ["C21-12687", "辽大甘渔15388", "liaodaganyu15388", 297]]], [], [], [], [], [], []],
["郑夕伟", "zhengxiwei", [[["C21-12748", "鲁荣渔50787", "lurongyu50787", 95], ["C21-12749", "鲁荣渔50788", "lurongyu50788", 95]]], [], [], [], [], [], []],
["王建中", "wangjianzhong", [[["C21-12804", "鲁荣渔55135", "lurongyu55135", 111], ["C21-12805", "鲁荣渔55136", "lurongyu55136", 111]]], [], [], [], [], [], []],
["李婷婷", "litingting", [[["C21-12808", "鲁荣渔55177", "lurongyu55177", 106], ["C21-12809", "鲁荣渔55178", "lurongyu55178", 106]]], [], [], [], [], [], []],
["荣成市吉泉渔业有限公司", "rongchengshijiquanyuyeyouxiangongsi", [[["C21-12862", "鲁荣渔56757", "lurongyu56757", 106], ["C21-12863", "鲁荣渔56758", "lurongyu56758", 106]]], [], [], [], [], [], []],
["张术珊", "zhangshushan", [[["C21-12864", "鲁荣渔56777", "lurongyu56777", 117], ["C21-12865", "鲁荣渔56778", "lurongyu56778", 117]]], [], [], [], [], [], []],
["宁兰江", "ninglanjiang", [[["C21-12868", "鲁荣渔56867", "lurongyu56867", 106], ["C21-12869", "鲁荣渔56868", "lurongyu56868", 106]]], [], [], [], [], [], []],
["毕崇方", "bichongfang", [[["C21-12872", "鲁荣渔56899", "lurongyu56899", 106], ["C21-12873", "鲁荣渔56900", "lurongyu56900", 106]]], [], [], [], [], [], []],
["张德洪", "zhangdehong", [[["C21-12894", "鲁荣渔58707", "lurongyu58707", 99], ["C21-12895", "鲁荣渔58708", "lurongyu58708", 99]]], [], [], [], [], [], []],
["乳山明强渔业有限公司", "rushanmingqiangyuyeyouxiangongsi", [[["C21-12928", "鲁乳渔51065", "luruyu51065", 99], ["C21-12929", "鲁乳渔51066", "luruyu51066", 99]]], [], [], [], [], [], []],
["陈亦富", "chenyifu", [[["C21-12978", "浙岭渔25811", "zhelingyu25811", 218], ["C21-12979", "浙岭渔25812", "zhelingyu25812", 218]]], [], [], [], [], [], []],
["许积毅", "xujiyi", [[["C21-13008", "鲁威经渔51127", "luweijingyu51127", 117], ["C21-13009", "鲁威经渔51128", "luweijingyu51128", 117]]], [], [], [], [], [], []],
["姜明全", "jiangmingquan", [[["C21-13081", "鲁荣渔50945", "lurongyu50945", 106], ["C21-13082", "鲁荣渔50946", "lurongyu50946", 106]]], [], [], [], [], [], []],
["曲波", "qubo", [[["C21-13087", "鲁荣渔51615", "lurongyu51615", 99], ["C21-13088", "鲁荣渔51616", "lurongyu51616", 99]]], [], [], [], [], [], []],
["刘丽", "liuli", [[["C21-13093", "鲁荣渔51773", "lurongyu51773", 98], ["C21-13094", "鲁荣渔51774", "lurongyu51774", 98]]], [], [], [], [], [], []],
["迟明和", "chiminghe", [[["C21-13099", "鲁荣渔51935", "lurongyu51935", 98], ["C21-13100", "鲁荣渔51936", "lurongyu51936", 98]]], [], [], [], [], [], []],
["吕海兵", "lvhaibing", [[["C21-13101", "鲁荣渔52131", "lurongyu52131", 98], ["C21-13102", "鲁荣渔52132", "lurongyu52132", 98]]], [], [], [], [], [], []],
["荣成市佳鸿渔业有限公司", "rongchengshijiahongyuyeyouxiangongsi", [[["C21-13107", "鲁荣渔52421", "lurongyu52421", 98], ["C21-13108", "鲁荣渔52422", "lurongyu52422", 98]]], [], [], [], [], [], []],
["周文松", "zhouwensong", [[["C21-13109", "鲁荣渔52521", "lurongyu52521", 97], ["C21-13110", "鲁荣渔52522", "lurongyu52522", 97]]], [], [], [], [], [], []],
["宋存波", "songcunbo", [[["C21-13123", "鲁荣渔55935", "lurongyu55935", 111], ["C21-13124", "鲁荣渔55936", "lurongyu55936", 111]]], [], [], [], [], [], []],
["王科杰", "wangkejie", [[["C21-13133", "鲁荣渔55377", "lurongyu55377", 126], ["C21-13134", "鲁荣渔55378", "lurongyu55378", 126]]], [], [], [], [], [], []],
["苏军科", "sujunke", [[["C21-13139", "鲁荣渔55519", "lurongyu55519", 98], ["C21-13140", "鲁荣渔55520", "lurongyu55520", 98]]], [], [], [], [], [], []],
["杨洪光", "yanghongguang", [[["C21-13147", "鲁荣渔55679", "lurongyu55679", 107], ["C21-13148", "鲁荣渔55680", "lurongyu55680", 107]]], [], [], [], [], [], []],
["荣成市丰兴渔业有限公司", "rongchengshifengxingyuyeyouxiangongsi", [[["C21-13149", "鲁荣渔55697", "lurongyu55697", 98], ["C21-13150", "鲁荣渔55698", "lurongyu55698", 98]]], [], [], [], [], [], []],
["荣成市昌合渔业有限公司", "rongchengshichangheyuyeyouxiangongsi", [[["C21-13177", "鲁荣渔56587", "lurongyu56587", 106], ["C21-13178", "鲁荣渔56588", "lurongyu56588", 106]]], [], [], [], [], [], []],
["荣成市泰达渔业有限公司", "rongchengshitaidayuyeyouxiangongsi", [[["C21-13183", "鲁荣渔57169", "lurongyu57169", 106], ["C21-13184", "鲁荣渔57170", "lurongyu57170", 106]]], [], [], [], [], [], []],
["荣成鑫汪渔业有限公司", "rongchengxinwangyuyeyouxiangongsi", [[["C21-13185", "鲁荣渔57177", "lurongyu57177", 106], ["C21-13186", "鲁荣渔57178", "lurongyu57178", 106]]], [], [], [], [], [], []],
["连亚豪", "lianyahao", [[["C21-13187", "鲁荣渔57185", "lurongyu57185", 106], ["C21-13188", "鲁荣渔57186", "lurongyu57186", 106]]], [], [], [], [], [], []],
["荣成市禾丰水产有限公司", "rongchengshihefengshuichanyouxiangongsi", [[["C21-13201", "鲁荣渔59029", "lurongyu59029", 143], ["C21-13202", "鲁荣渔59030", "lurongyu59030", 143]]], [], [], [], [], [], []],
["殷路遥", "yinluyao", [[["C21-13203", "鲁荣渔59061", "lurongyu59061", 121], ["C21-13204", "鲁荣渔59062", "lurongyu59062", 121]]], [], [], [], [], [], []],
["王新生", "wangxinsheng", [[["C21-13213", "鲁荣渔59387", "lurongyu59387", 106], ["C21-13214", "鲁荣渔59388", "lurongyu59388", 106]]], [], [], [], [], [], []],
["刘铁军", "liutiejun", [[["C21-13219", "鲁乳渔54957", "luruyu54957", 106], ["C21-13220", "鲁乳渔54958", "luruyu54958", 106]]], [], [], [], [], [], []],
["王新才", "wangxincai", [[["C21-13350", "鲁荣渔51865", "lurongyu51865", 97], ["C21-13351", "鲁荣渔51866", "lurongyu51866", 97]]], [], [], [], [], [], []],
["张红玉", "zhanghongyu", [[["C21-13360", "鲁荣渔52967", "lurongyu52967", 129], ["C21-13361", "鲁荣渔52968", "lurongyu52968", 129]]], [], [], [], [], [], []],
["王进春", "wangjinchun", [[["C21-13368", "鲁荣渔55551", "lurongyu55551", 106], ["C21-13369", "鲁荣渔55552", "lurongyu55552", 106]]], [], [], [], [], [], []],
["王祖壮", "wangzuzhuang", [[["C21-13374", "鲁荣渔55655", "lurongyu55655", 114], ["C21-13375", "鲁荣渔55656", "lurongyu55656", 114]]], [], [], [], [], [], []],
["戚荣平", "qirongping", [[["C21-13382", "鲁荣渔55921", "lurongyu55921", 106], ["C21-13383", "鲁荣渔55922", "lurongyu55922", 106]]], [], [], [], [], [], []],
["荣成市蓝琦渔业有限公司", "rongchengshilanqiyuyeyouxiangongsi", [[["C21-13386", "鲁荣渔55967", "lurongyu55967", 111], ["C21-13387", "鲁荣渔55968", "lurongyu55968", 111]]], [], [], [], [], [], []],
["王荣新", "wangrongxin", [[["C21-13396", "鲁荣渔57617", "lurongyu57617", 130], ["C21-13397", "鲁荣渔57618", "lurongyu57618", 130]]], [], [], [], [], [], []],
["荣成市裕耀渔业有限公司", "rongchengshiyuyaoyuyeyouxiangongsi", [[["C21-13400", "鲁荣渔57889", "lurongyu57889", 106], ["C21-13401", "鲁荣渔57890", "lurongyu57890", 106]]], [], [], [], [], [], []],
["荣成市立发渔业有限公司", "rongchengshilifayuyeyouxiangongsi", [[["C21-13408", "鲁荣渔59019", "lurongyu59019", 121], ["C21-13409", "鲁荣渔59020", "lurongyu59020", 121]]], [], [], [], [], [], []],
["李海波", "lihaibo", [[["C21-13416", "鲁荣渔59179", "lurongyu59179", 122], ["C21-13417", "鲁荣渔59180", "lurongyu59180", 122]]], [], [], [], [], [], []],
["威海天祥水产有限公司", "weihaitianxiangshuichanyouxiangongsi", [[["C21-13424", "鲁威经渔51005", "luweijingyu51005", 118], ["C21-13425", "鲁威经渔51006", "luweijingyu51006", 118]]], [], [], [], [], [], []],
["陶俊军", "taojunjun", [[["C21-13448", "鲁威渔60967", "luweiyu60967", 117], ["C21-13449", "鲁威渔60968", "luweiyu60968", 117]]], [], [], [], [], [], []],
["柯根法", "kegenfa", [[["C21-13530", "浙象渔45071", "zhexiangyu45071", 196], ["C21-13531", "浙象渔45072", "zhexiangyu45072", 196]]], [], [], [], [], [], []],
["任丘市荣发顺达远洋渔业有限公司", "renqiushirongfashundayuanyangyuyeyouxiangongsi", [[["C21-13566", "冀任渔00768", "jirenyu00768", 178], ["C21-13567", "冀任渔00769", "jirenyu00769", 178]]], [], [], [], [], [], []],
["荣成启丽渔业有限公司", "rongchengqiliyuyeyouxiangongsi", [[["C21-13608", "鲁荣渔55685", "lurongyu55685", 99], ["C21-13609", "鲁荣渔55686", "lurongyu55686", 99]]], [], [], [], [], [], []],
["荣成市天和渔业有限公司", "rongchengshitianheyuyeyouxiangongsi", [[["C21-13612", "鲁荣渔55867", "lurongyu55867", 107], ["C21-13613", "鲁荣渔55868", "lurongyu55868", 107]]], [], [], [], [], [], []],
["姜泽丽", "jiangzeli", [[["C21-13622", "鲁荣渔57175", "lurongyu57175", 106], ["C21-13623", "鲁荣渔57176", "lurongyu57176", 106]]], [], [], [], [], [], []],
["孟昭生", "mengzhaosheng", [[["C21-13628", "鲁荣渔58677", "lurongyu58677", 68], ["C21-13629", "鲁荣渔58678", "lurongyu58678", 68]]], [], [], [], [], [], []],
["于德品", "yudepin", [[["C21-13640", "鲁乳渔51157", "luruyu51157", 106], ["C21-13641", "鲁乳渔51158", "luruyu51158", 106]]], [], [], [], [], [], []],
["许后法", "xuhoufa", [[["C21-13787", "浙岭渔23798", "zhelingyu23798", 216], ["C21-13788", "浙岭渔23799", "zhelingyu23799", 216]]], [], [], [], [], [], []],
["荣成市杰逢捕捞有限公司", "rongchengshijiefengbulaoyouxiangongsi", [[["C21-13816", "鲁荣渔56889", "lurongyu56889", 106], ["C21-13817", "鲁荣渔56890", "lurongyu56890", 106]]], [], [], [], [], [], []],
["程士庆", "chengshiqing", [], [], [], [], [], [["C21-13982", "浙岭渔29801", "zhelingyu29801", 218]], [["C21-13819", "浙岭渔23788", "zhelingyu23788", 216]]],
["张文英", "zhangwenying", [[["C21-13830", "鲁荣渔50875", "lurongyu50875", 98], ["C21-13831", "鲁荣渔50876", "lurongyu50876", 98]]], [], [], [], [], [], []],
["李际方", "lijifang", [[["C21-13838", "鲁荣渔51147", "lurongyu51147", 134], ["C21-13839", "鲁荣渔51148", "lurongyu51148", 134]]], [], [], [], [], [], []],
["蔡继政", "caijizheng", [[["C21-13848", "鲁荣渔51505", "lurongyu51505", 97], ["C21-13849", "鲁荣渔51506", "lurongyu51506", 97]]], [], [], [], [], [], []],
["曲军伟", "qujunwei", [[["C21-13860", "鲁荣渔52283", "lurongyu52283", 91], ["C21-13861", "鲁荣渔52284", "lurongyu52284", 91]]], [], [], [], [], [], []],
["荣成市朝阳渔业有限公司", "rongchengshichaoyangyuyeyouxiangongsi", [[["C21-13874", "鲁荣渔55597", "lurongyu55597", 111], ["C21-13875", "鲁荣渔55598", "lurongyu55598", 111]]], [], [], [], [], [], []],
["汤华海", "tanghuahai", [[["C21-13876", "鲁荣渔55601", "lurongyu55601", 111], ["C21-13877", "鲁荣渔55602", "lurongyu55602", 111]]], [], [], [], [], [], []],
["唐音", "tangyin", [[["C21-13878", "鲁荣渔55715", "lurongyu55715", 143], ["C21-13879", "鲁荣渔55716", "lurongyu55716", 143]]], [], [], [], [], [], []],
["荣成市盛源渔业有限公司", "rongchengshishengyuanyuyeyouxiangongsi", [[["C21-13912", "鲁荣渔59275", "lurongyu59275", 133], ["C21-13913", "鲁荣渔59276", "lurongyu59276", 133]]], [], [], [], [], [], []],
["邓兰红", "denglanhong", [[["C21-13936", "鲁威渔60957", "luweiyu60957", 96], ["C21-13937", "鲁威渔60958", "luweiyu60958", 96]]], [], [], [], [], [], []],
["林霞", "linxia", [[["C21-13938", "鲁文渔53097", "luwenyu53097", 68], ["C21-13939", "鲁文渔53098", "luwenyu53098", 68]]], [], [], [], [], [], []],
["林君斌", "linjunbin", [[["C21-13952", "浙奉渔18087", "zhefengyu18087", 198], ["C21-13953", "浙奉渔18088", "zhefengyu18088", 198]]], [], [], [], [], [], []],
["陈冬方", "chendongfang", [[["C21-13983", "浙岭渔27851", "zhelingyu27851", 218], ["C21-13984", "浙岭渔27852", "zhelingyu27852", 218]]], [], [], [], [], [], []],
["杜军明", "dujunming", [[["C21-14001", "浙岭渔23352", "zhelingyu23352", 218], ["C21-14002", "浙岭渔23353", "zhelingyu23353", 218]]], [], [], [], [], [], []],
["荣成市福生渔业有限公司", "rongchengshifushengyuyeyouxiangongsi", [[["C21-14007", "鲁荣渔50905", "lurongyu50905", 101], ["C21-14008", "鲁荣渔50906", "lurongyu50906", 101]]], [], [], [], [], [], []],
["田宝卫", "tianbaowei", [[["C21-14009", "鲁荣渔50927", "lurongyu50927", 129], ["C21-14010", "鲁荣渔50928", "lurongyu50928", 129]]], [], [], [], [], [], []],
["刘昌革", "liuchangge", [[["C21-14011", "鲁荣渔51079", "lurongyu51079", 95], ["C21-14012", "鲁荣渔51080", "lurongyu51080", 95]]], [], [], [], [], [], []],
["刘佳", "liujia", [[["C21-14017", "鲁荣渔51333", "lurongyu51333", 97], ["C21-14018", "鲁荣渔51334", "lurongyu51334", 97]]], [], [], [], [], [], []],
["周华隆", "zhouhualong", [[["C21-14025", "鲁荣渔51923", "lurongyu51923", 97], ["C21-14026", "鲁荣渔51924", "lurongyu51924", 97]]], [], [], [], [], [], []],
["王连娜", "wanglianna", [[["C21-14027", "鲁荣渔51957", "lurongyu51957", 109], ["C21-14028", "鲁荣渔51958", "lurongyu51958", 109]]], [], [], [], [], [], []],
["鞠世柏", "jushibai", [[["C21-14035", "鲁荣渔52771", "lurongyu52771", 106], ["C21-14036", "鲁荣渔52772", "lurongyu52772", 106]]], [], [], [], [], [], []],
["李加鹏", "lijiapeng", [[["C21-14039", "鲁荣渔55037", "lurongyu55037", 126], ["C21-14040", "鲁荣渔55038", "lurongyu55038", 126]]], [], [], [], [], [], []],
["王海云", "wanghaiyun", [[["C21-14059", "鲁荣渔55379", "lurongyu55379", 96], ["C21-14060", "鲁荣渔55380", "lurongyu55380", 96]]], [], [], [], [], [], []],
["荣成鸿安渔业有限公司", "rongchenghonganyuyeyouxiangongsi", [[["C21-14061", "鲁荣渔55727", "lurongyu55727", 96], ["C21-14062", "鲁荣渔55728", "lurongyu55728", 96]]], [], [], [], [], [], []],
["荣成市明海捕捞有限公司", "rongchengshiminghaibulaoyouxiangongsi", [[["C21-14065", "鲁荣渔55821", "lurongyu55821", 117], ["C21-14066", "鲁荣渔55822", "lurongyu55822", 117]]], [], [], [], [], [], []],
["毕远朋", "biyuanpeng", [[["C21-14067", "鲁荣渔55831", "lurongyu55831", 126], ["C21-14068", "鲁荣渔55832", "lurongyu55832", 126]]], [], [], [], [], [], []],
["郇日强", "huanriqiang", [[["C21-14071", "鲁荣渔55971", "lurongyu55971", 117], ["C21-14072", "鲁荣渔55972", "lurongyu55972", 117]]], [], [], [], [], [], []],
["荣成市福强捕捞有限公司", "rongchengshifuqiangbulaoyouxiangongsi", [[["C21-14075", "鲁荣渔56357", "lurongyu56357", 163], ["C21-14076", "鲁荣渔56358", "lurongyu56358", 163]]], [], [], [], [], [], []],
["李吉钊", "lijizhao", [[["C21-14077", "鲁荣渔56577", "lurongyu56577", 106], ["C21-14078", "鲁荣渔56578", "lurongyu56578", 106]]], [], [], [], [], [], []],
["田明杰", "tianmingjie", [[["C21-14081", "鲁荣渔56667", "lurongyu56667", 106], ["C21-14082", "鲁荣渔56668", "lurongyu56668", 106]]], [], [], [], [], [], []],
["周本润", "zhoubenrun", [[["C21-14083", "鲁荣渔56669", "lurongyu56669", 106], ["C21-14084", "鲁荣渔56670", "lurongyu56670", 106]]], [], [], [], [], [], []],
["张炳承", "zhangbingcheng", [[["C21-14085", "鲁荣渔56679", "lurongyu56679", 106], ["C21-14086", "鲁荣渔56680", "lurongyu56680", 106]]], [], [], [], [], [], []],
["荣成市真鑫渔业有限公司", "rongchengshizhenxinyuyeyouxiangongsi", [[["C21-14093", "鲁荣渔57087", "lurongyu57087", 121], ["C21-14094", "鲁荣渔57088", "lurongyu57088", 121]]], [], [], [], [], [], []],
["荣坤", "rongkun", [[["C21-14095", "鲁荣渔57097", "lurongyu57097", 117], ["C21-14096", "鲁荣渔57098", "lurongyu57098", 117]]], [], [], [], [], [], []],
["田义", "tianyi", [[["C21-14105", "鲁荣渔58797", "lurongyu58797", 117], ["C21-14106", "鲁荣渔58798", "lurongyu58798", 117]]], [], [], [], [], [], []],
["李昕", "lixin", [[["C21-14113", "鲁荣渔59065", "lurongyu59065", 122], ["C21-14114", "鲁荣渔59066", "lurongyu59066", 122]]], [], [], [], [], [], []],
["袁嵩琦", "yuansongqi", [[["C21-14117", "鲁荣渔59197", "lurongyu59197", 106], ["C21-14118", "鲁荣渔59198", "lurongyu59198", 106]]], [], [], [], [], [], []],
["马千里", "maqianli", [[["C21-14119", "鲁荣渔59385", "lurongyu59385", 106], ["C21-14120", "鲁荣渔59386", "lurongyu59386", 106]]], [], [], [], [], [], []],
["李世凯", "lishikai", [[["C21-14121", "鲁威高渔60185", "luweigaoyu60185", 117], ["C21-14122", "鲁威高渔60186", "luweigaoyu60186", 117]]], [], [], [], [], [], []],
["毕礼承", "bilicheng", [[["C21-14123", "鲁威高渔60889", "luweigaoyu60889", 117], ["C21-14124", "鲁威高渔60890", "luweigaoyu60890", 117]]], [], [], [], [], [], []],
["威海顺泰水产食品有限公司", "weihaishuntaishuichuanshipinyouxiangongsi", [[["C21-14125", "鲁威高渔60967", "luweigaoyu60967", 102], ["C21-14126", "鲁威高渔60968", "luweigaoyu60968", 102]]], [], [], [], [], [], []],
["威海英涵渔业有限公司", "weihaiyinghanyuyeyouxiangongsi", [[["C21-14127", "鲁威经渔51019", "luweijingyu51019", 136], ["C21-14128", "鲁威经渔51020", "luweijingyu51020", 136]]], [], [], [], [], [], []],
["威海合顺渔业有限公司", "weihaiheshunyuyeyouxiangongsi", [[["C21-14129", "鲁威经渔51085", "luweijingyu51085", 290], ["C21-14130", "鲁威经渔51086", "luweijingyu51086", 290]]], [], [], [], [], [], []],
["威海润恒渔业有限公司", "weihairunhengyuyeyouxiangongsi", [[["C21-14139", "鲁文渔51007", "luwenyu51007", 107], ["C21-14140", "鲁文渔51008", "luwenyu51008", 107]]], [], [], [], [], [], []],
["王春红", "wangchunhong", [[["C21-14145", "鲁文渔53857", "luwenyu53857", 101], ["C21-14146", "鲁文渔53858", "luwenyu53858", 101]]], [], [], [], [], [], []],
["大连悦启鑫晟捕捞有限公司", "dalianyueqixinchengbulaoyouxiangongsi", [[["C21-14155", "辽大金渔15211", "liaodajinyu15211", 175], ["C21-14156", "辽大金渔15212", "liaodajinyu15212", 175]]], [], [], [], [], [], []],
["庄礼兵", "zhuanglibing", [[["C21-14177", "浙岭渔20508", "zhelingyu20508", 214], ["C21-14178", "浙岭渔20535", "zhelingyu20535", 214]]], [], [], [], [], [], []],
["陈鹏", "chenpeng", [[["C21-14179", "浙岭渔41077", "zhelingyu41077", 216], ["C21-14180", "浙岭渔41078", "zhelingyu41078", 216]]], [], [], [], [], [], []],
["史纪兆", "shijizhao", [[["C21-14185", "浙象渔66035", "zhexiangyu66035", 217], ["C21-14186", "浙象渔66036", "zhexiangyu66036", 217]]], [], [], [], [], [], []],
["林含密", "linhanmi", [[["C21-14191", "浙奉渔16081", "zhefengyu16081", 217], ["C21-14192", "浙奉渔16082", "zhefengyu16082", 217]]], [], [], [], [], [], []],
["林建明", "linjianming", [[["C21-14193", "浙奉渔12037", "zhefengyu12037", 198], ["C21-14194", "浙奉渔12038", "zhefengyu12038", 198]]], [], [], [], [], [], []],
["林松盛", "linsongsheng", [[["C21-14195", "浙奉渔18001", "zhefengyu18001", 219], ["C21-14196", "浙奉渔18002", "zhefengyu18002", 219]]], [], [], [], [], [], []],
["章毕科", "zhangbike", [[["C21-14197", "浙奉渔10071", "zhefengyu10071", 217], ["C21-14198", "浙奉渔10072", "zhefengyu10072", 217]]], [], [], [], [], [], []],
["樊新春", "fanxinchun", [[["C21-14201", "鲁荣渔55055", "lurongyu55055", 99], ["C21-14202", "鲁荣渔55056", "lurongyu55056", 99]]], [], [], [], [], [], []],
["向仁毅", "xiangrenyi", [[["C21-14203", "鲁荣渔56697", "lurongyu56697", 106], ["C21-14204", "鲁荣渔56698", "lurongyu56698", 106]]], [], [], [], [], [], []],
["荣成市航程渔业有限公司", "rongchengshihangchengyuyeyouxiangongsi", [[["C21-14205", "鲁荣渔58759", "lurongyu58759", 106], ["C21-14206", "鲁荣渔58760", "lurongyu58760", 106]]], [], [], [], [], [], []],
["金校辰", "jinxiaochen", [[["C21-14211", "浙岭渔22981", "zhelingyu22981", 218], ["C21-14212", "浙岭渔22982", "zhelingyu22982", 218]]], [], [], [], [], [], []],
["郑凯伦", "zhengkailun", [[["C21-14213", "浙岭渔22527", "zhelingyu22527", 214], ["C21-14214", "浙岭渔22528", "zhelingyu22528", 214]]], [], [], [], [], [], []],
["陈其忠", "chenqizhong", [[["C21-14215", "浙岭渔22565", "zhelingyu22565", 218], ["C21-14216", "浙岭渔22566", "zhelingyu22566", 218]]], [], [], [], [], [], []],
["林武燕", "linwuyan", [[["C21-14217", "浙岭渔23765", "zhelingyu23765", 218], ["C21-14218", "浙岭渔23766", "zhelingyu23766", 218]]], [], [], [], [], [], []],
["章海刚", "zhanghaigang", [[["C21-14221", "浙岭渔18135", "zhelingyu18135", 218], ["C21-14222", "浙岭渔18136", "zhelingyu18136", 218]]], [], [], [], [], [], []],
["邬旭雷", "wuxulei", [[["C21-14223", "浙奉渔13085", "zhefengyu13085", 198], ["C21-14224", "浙奉渔13086", "zhefengyu13086", 198]]], [], [], [], [], [], []],
["周飞云", "zhoufeiyun", [[["C21-14225", "浙奉渔29001", "zhefengyu29001", 219], ["C21-14226", "浙奉渔29002", "zhefengyu29002", 219]]], [], [], [], [], [], []],
["施明立", "shimingli", [[["C21-14233", "浙岭渔20538", "zhelingyu20538", 219], ["C21-14234", "浙岭渔20539", "zhelingyu20539", 219]]], [], [], [], [], [], []],
["包照好", "baozhaohao", [[["C21-14235", "浙三渔00518", "zhesanyu00518", 218], ["C21-14236", "浙三渔00519", "zhesanyu00519", 218]]], [], [], [], [], [], []],
["包判好", "baopanhao", [[["C21-14237", "浙三渔00785", "zhesanyu00785", 218], ["C21-14238", "浙三渔00786", "zhesanyu00786", 216]]], [], [], [], [], [], []],
["汪方艇", "wangfangting", [[["C21-14239", "浙象渔45001", "zhexiangyu45001", 218], ["C21-14240", "浙象渔45002", "zhexiangyu45002", 218]]], [], [], [], [], [], []],
["丁从蔡", "dingcongcai", [[["C21-14263", "浙岭渔29915", "zhelingyu29915", 218], ["C21-14264", "浙岭渔29916", "zhelingyu29916", 218]]], [], [], [], [], [], []],
["段敏康", "duanminkang", [[["C21-14269", "浙象渔40191", "zhexiangyu40191", 182], ["C21-14270", "浙象渔40192", "zhexiangyu40192", 182]]], [], [], [], [], [], []],
["鞠建松", "jujiansong", [[["C21-14277", "鲁荣渔52739", "lurongyu52739", 106], ["C21-14278", "鲁荣渔52740", "lurongyu52740", 106]]], [], [], [], [], [], []],
["刘大航", "liudahang", [[["C21-14279", "鲁荣渔55579", "lurongyu55579", 92], ["C21-14280", "鲁荣渔55580", "lurongyu55580", 92]]], [], [], [], [], [], []],
["荣成市常丰渔业有限公司", "rongchengshichangfengyuyeyouxiangongsi", [[["C21-14283", "鲁荣渔55687", "lurongyu55687", 99], ["C21-14284", "鲁荣渔55688", "lurongyu55688", 99]]], [], [], [], [], [], []],
["王彩玲", "wangcailing", [[["C21-14285", "鲁荣渔55689", "lurongyu55689", 99], ["C21-14286", "鲁荣渔55690", "lurongyu55690", 99]]], [], [], [], [], [], []],
["伯绍霞", "baishaoxia", [[["C21-14287", "鲁荣渔55701", "lurongyu55701", 101], ["C21-14288", "鲁荣渔55702", "lurongyu55702", 101]]], [], [], [], [], [], []],
["蔡晓辉", "caixiaohui", [[["C21-14289", "鲁荣渔55819", "lurongyu55819", 106], ["C21-14290", "鲁荣渔55820", "lurongyu55820", 106]]], [], [], [], [], [], []],
["张海涛", "zhanghaitao", [[["C21-14291", "鲁荣渔55855", "lurongyu55855", 99], ["C21-14292", "鲁荣渔55856", "lurongyu55856", 99]]], [], [], [], [], [], []],
["袁彩荣", "yuancairong", [[["C21-14295", "鲁荣渔55885", "lurongyu55885", 106], ["C21-14296", "鲁荣渔55886", "lurongyu55886", 106]]], [], [], [], [], [], []],
["马晓红", "maxiaohong", [[["C21-14301", "鲁荣渔57597", "lurongyu57597", 130], ["C21-14302", "鲁荣渔57598", "lurongyu57598", 130]]], [], [], [], [], [], []],
["鞠海军", "juhaijun", [[["C21-14303", "鲁荣渔58475", "lurongyu58475", 106], ["C21-14304", "鲁荣渔58476", "lurongyu58476", 106]]], [], [], [], [], [], []],
["曲向阳", "quxiangyang", [[["C21-14315", "鲁荣渔55165", "lurongyu55165", 106], ["C21-14316", "鲁荣渔55166", "lurongyu55166", 106]]], [], [], [], [], [], []],
["王春景", "wangchunjing", [[["C21-14317", "鲁荣渔55657", "lurongyu55657", 117], ["C21-14318", "鲁荣渔55658", "lurongyu55658", 117]]], [], [], [], [], [], []],
["程兴虎", "chengxinghu", [[["C21-14329", "鲁荣渔50801", "lurongyu50801", 98], ["C21-14330", "鲁荣渔50802", "lurongyu50802", 98]]], [], [], [], [], [], []],
["袁卫斌", "yuanweibin", [[["C21-14333", "鲁荣渔51211", "lurongyu51211", 106], ["C21-14334", "鲁荣渔51212", "lurongyu51212", 106]]], [], [], [], [], [], []],
["张晓明", "zhangxiaoming", [[["C21-14337", "鲁荣渔55001", "lurongyu55001", 111], ["C21-14338", "鲁荣渔55002", "lurongyu55002", 111]]], [], [], [], [], [], []],
["蔡继光", "caijiguang", [[["C21-14345", "鲁荣渔55635", "lurongyu55635", 106], ["C21-14346", "鲁荣渔55636", "lurongyu55636", 106]]], [], [], [], [], [], []],
["王国旗", "wangguoqi", [[["C21-14351", "鲁荣渔55951", "lurongyu55951", 90], ["C21-14352", "鲁荣渔55952", "lurongyu55952", 90]]], [], [], [], [], [], []],
["张强", "zhangqiang", [[["C21-14353", "鲁荣渔55981", "lurongyu55981", 114], ["C21-14354", "鲁荣渔55982", "lurongyu55982", 114]]], [], [], [], [], [], []],
["毕金龙", "bijinlong", [[["C21-14357", "鲁荣渔57589", "lurongyu57589", 117], ["C21-14358", "鲁荣渔57590", "lurongyu57590", 117]]], [], [], [], [], [], []],
["荣成市学文海水捕捞有限公司", "rongchengshixuewenhaishuibulaoyouxiangongsi", [[["C21-14359", "鲁荣渔58735", "lurongyu58735", 122], ["C21-14360", "鲁荣渔58736", "lurongyu58736", 122]]], [], [], [], [], [], []],
["荣成市宏鑫水产有限公司", "rongchengshihongxinshuichanyouxiangongsi", [[["C21-14363", "鲁荣渔59119", "lurongyu59119", 127], ["C21-14364", "鲁荣渔59120", "lurongyu59120", 127]]], [], [], [], [], [], []],
["沈明贤", "shenmingxian", [[["C21-14373", "浙奉渔19055", "zhefengyu19055", 218], ["C21-14374", "浙奉渔19056", "zhefengyu19056", 218]]], [], [], [], [], [], []],
["林岳华", "linyuehua", [[["C21-14375", "浙奉渔18091", "zhefengyu18091", 218], ["C21-14376", "浙奉渔18092", "zhefengyu18092", 218]]], [], [], [], [], [], []],
["麦海舰", "maihaijian", [[["C21-14379", "浙象渔47107", "zhexiangyu47107", 198], ["C21-14380", "浙象渔47108", "zhexinagyu47108", 198]]], [], [], [], [], [], []],
["徐再良", "xuzailiang", [[["C21-14391", "浙岭渔23836", "zhelingyu23836", 218], ["C21-14392", "浙岭渔23837", "zhelingyu23837", 218]]], [], [], [], [], [], []],
["刘祖国", "liuzuguo", [[["C21-14393", "浙岭渔29862", "zhelingyu29862", 218], ["C21-14394", "浙岭渔29863", "zhelingyu29863", 218]]], [], [], [], [], [], []],
["黄定兵", "huangdingbing", [[["C21-14395", "浙岭渔23508", "zhelingyu23508", 218], ["C21-14396", "浙岭渔23509", "zhelingyu23509", 216]]], [], [], [], [], [], []],
["张定方", "zhangdingfang", [[["C21-14397", "浙路渔82405", "zheluyu82405", 218], ["C21-14398", "浙路渔82406", "zheluyu82406", 218]]], [], [], [], [], [], []],
["林君杰", "linjunjie", [[["C21-14410", "浙奉渔18071", "zhefengyu18071", 218], ["C21-14411", "浙奉渔18072", "zhefengyu18072", 217]]], [], [], [], [], [], []],
["周丽华", "zhoulihua", [[["C21-14412", "鲁荣渔50871", "lurongyu50871", 98], ["C21-14413", "鲁荣渔50872", "lurongyu50872", 98]]], [], [], [], [], [], []],
["田娜", "tianna", [[["C21-14414", "鲁荣渔50907", "lurongyu50907", 98], ["C21-14415", "鲁荣渔50908", "lurongyu50908", 98]]], [], [], [], [], [], []],
["荣成市泽远渔业有限公司", "rongchengshizeyuanyuyeyouxiangongsi", [[["C21-14418", "鲁荣渔51631", "lurongyu51631", 297], ["C21-14419", "鲁荣渔51632", "lurongyu51632", 297]]], [], [], [], [], [], []],
["夏金松", "xiajinsong", [[["C21-14420", "鲁荣渔52655", "lurongyu52655", 97], ["C21-14421", "鲁荣渔52656", "lurongyu52656", 97]]], [], [], [], [], [], []],
["范秀珍", "fanxiuzhen", [[["C21-14426", "鲁荣渔55309", "lurongyu55309", 99], ["C21-14427", "鲁荣渔55310", "lurongyu55310", 99]]], [], [], [], [], [], []],
["李荣国", "lirongguo", [[["C21-14428", "鲁荣渔55367", "lurongyu55367", 116], ["C21-14429", "鲁荣渔55368", "lurongyu55368", 116]]], [], [], [], [], [], []],
["王建文", "wangjianwen", [[["C21-14430", "鲁荣渔55527", "lurongyu55527", 117], ["C21-14431", "鲁荣渔55528", "lurongyu55528", 117]]], [], [], [], [], [], []],
["郭远军", "guoyuanjun", [[["C21-14432", "鲁荣渔55617", "lurongyu55617", 96], ["C21-14433", "鲁荣渔55618", "lurongyu55618", 96]]], [], [], [], [], [], []],
["孙华艳", "sunhuayan", [[["C21-14434", "鲁荣渔55947", "lurongyu55947", 116], ["C21-14435", "鲁荣渔55948", "lurongyu55948", 116]]], [], [], [], [], [], []],
["王忠言", "wangzhongyan", [[["C21-14436", "鲁荣渔56377", "lurongyu56377", 107], ["C21-14437", "鲁荣渔56378", "lurongyu56378", 107]]], [], [], [], [], [], []],
["蔡传亭", "caichuanting", [[["C21-14442", "鲁荣渔56877", "lurongyu56877", 122], ["C21-14443", "鲁荣渔56878", "lurongyu56878", 122]]], [], [], [], [], [], []],
["威海昇晟渔业有限公司", "weihaishengshengyuyeyouxiangongsi", [[["C21-14448", "鲁荣渔57695", "lurongyu57695", 212], ["C21-14449", "鲁荣渔57696", "lurongyu57696", 212]]], [], [], [], [], [], []],
["曲海", "quhai", [[["C21-14450", "鲁荣渔57767", "lurongyu57767", 123], ["C21-14451", "鲁荣渔57768", "lurongyu57768", 123]]], [], [], [], [], [], []],
["蔡继贤", "caijixian", [[["C21-14452", "鲁荣渔58051", "lurongyu58051", 116], ["C21-14453", "鲁荣渔58052", "lurongyu58052", 116]]], [], [], [], [], [], []],
["王宁", "wangning", [[["C21-14456", "鲁荣渔58989", "lurongyu58989", 127], ["C21-14457", "鲁荣渔58990", "lurongyu58990", 127]]], [], [], [], [], [], []],
["威海华达捕捞有限公司", "weihaihuadabulaoyouxiangongsi", [[["C21-14462", "鲁威渔59997", "luweiyu59997", 277], ["C21-14463", "鲁威渔59998", "luweiyu59998", 277]]], [], [], [], [], [], []],
["邹文军", "zouwenjun", [[["C21-14464", "鲁威渔61787", "luweiyu61787", 117], ["C21-14465", "鲁威渔61788", "luweiyu61788", 117]]], [], [], [], [], [], []],
["威海力庆渔业捕捞有限公司", "weihailiqingyuyebulaoyouxiangongsi", [[["C21-14466", "鲁威经渔51015", "luweijingyu51015", 127], ["C21-14467", "鲁威经渔51016", "luweijingyu51016", 127]]], [], [], [], [], [], []],
["邱献云", "qiuxianyun", [[["C21-14470", "鲁文渔53255", "luwenyu53255", 84], ["C21-14471", "鲁文渔53256", "luwenyu53256", 84]]], [], [], [], [], [], []],
["大连悦纳捕捞有限公司", "dalianyuenabulaoyouxiangongsi", [[["C21-14480", "辽大金渔15676", "liaodajinyu15676", 122], ["C21-14481", "辽大金渔15677", "liaodajinyu15677", 122]]], [], [], [], [], [], []],
["大连仙丰渔业有限公司", "dalianxianfengyuyeyouxiangongsi", [[["C21-14482", "辽大金渔85779", "liaodajinyu85779", 290], ["C21-14483", "辽大金渔85780", "liaodajinyu85780", 290]]], [], [], [], [], [], []],
["郭丽燕", "guoliyan", [[["C21-14498", "鲁荣渔55185", "lurongyu55185", 96], ["C21-14499", "鲁荣渔55186", "lurongyu55186", 96]]], [], [], [], [], [], []],
["荣成市福盛德渔业有限公司", "rongchengshifushengdeyuyeyouxiangongsi", [[["C21-14500", "鲁荣渔55287", "lurongyu55287", 117], ["C21-14501", "鲁荣渔55288", "lurongyu55288", 117]]], [], [], [], [], [], []],
["柯崇浩", "kechonghao", [[["C21-14508", "浙岭渔23718", "zhelingyu23718", 217], ["C21-14509", "浙岭渔23719", "zhelingyu23719", 217]]], [], [], [], [], [], []],
["田焱", "tianyan", [[["C21-14511", "鲁荣渔58191", "lurongyu58191", 106], ["C21-14512", "鲁荣渔58192", "lurongyu58192", 106]]], [], [], [], [], [], []],
["王磊", "wanglei", [[["C21-14513", "鲁荣渔58761", "lurongyu58761", 101], ["C21-14514", "鲁荣渔58762", "lurongyu58762", 101]]], [], [], [], [], [], []],
["谷晓东", "guxiaodong", [[["C21-14515", "鲁荣渔58867", "lurongyu58867", 297], ["C21-14516", "鲁荣渔58868", "lurongyu58868", 297]]], [], [], [], [], [], []],
["荣成市航海捕捞有限公司", "rongchengshihanghaibulaoyouxiangongsi", [[["C21-14517", "鲁荣渔58891", "lurongyu58891", 296], ["C21-14518", "鲁荣渔58892", "lurongyu58892", 296]]], [], [], [], [], [], []],
["威海市东升实业公司", "weihaishidongshengshiyegongsi", [[["C21-14521", "鲁威渔52017", "luweiyu52017", 126], ["C21-14522", "鲁威渔52018", "luweiyu52018", 126]]], [], [], [], [], [], []],
["王增康", "wangzengkang", [[["C21-14523", "浙象渔33057", "zhexiangyu33057", 219], ["C21-14524", "浙象渔33058", "zhexiangyu33058", 219]]], [], [], [], [], [], []],
["许位夫", "xuweifu", [[["C21-14525", "浙象渔42081", "zhexiangyu42081", 219], ["C21-14526", "浙象渔42082", "zhexinagyu42082", 219]]], [], [], [], [], [], []],
["林康", "linkang", [[["C21-14529", "浙奉渔17015", "zhefengyu17015", 219], ["C21-14530", "浙奉渔17016", "zhefengyu17016", 219]]], [], [], [], [], [], []],
["项修法", "xiangxiufa", [[["C21-14539", "浙临渔12801", "zhelinyu12801", 218], ["C21-14540", "浙临渔12802", "zhelinyu12802", 218]]], [], [], [], [], [], []],
["王小丽", "wangxiaoli", [[["C21-14541", "鲁荣渔50913", "lurongyu50913", 95], ["C21-14542", "鲁荣渔50914", "lurongyu50914", 95]]], [], [], [], [], [], []],
["威海嘉明渔业有限公司", "weihaijiamingyuyeyouxiangongsi", [[["C21-14543", "鲁荣渔51447", "lurongyu51447", 90], ["C21-14544", "鲁荣渔51448", "lurongyu51448", 90]]], [], [], [], [], [], []],
["张巧兰", "zhangqiaolan", [[["C21-14545", "鲁荣渔51897", "lurongyu51897", 127], ["C21-14546", "鲁荣渔51898", "lurongyu51898", 127]]], [], [], [], [], [], []],
["王翠玲", "wangcuiling", [[["C21-14549", "鲁荣渔52093", "lurongyu52093", 96], ["C21-14550", "鲁荣渔52094", "lurongyu52094", 96]]], [], [], [], [], [], []],
["荣成顺伟达渔业有限公司", "rongchengshunweidayuyeyouxiangongsi", [[["C21-14551", "鲁荣渔52429", "lurongyu52429", 96], ["C21-14552", "鲁荣渔52430", "lurongyu52430", 96]]], [], [], [], [], [], []],
["威海满诚渔业有限公司", "weihaimanchengyuyeyouxiangongsi", [[["C21-14555", "鲁荣渔55029", "lurongyu55029", 118], ["C21-14556", "鲁荣渔55030", "lurongyu55030", 118]]], [], [], [], [], [], []],
["李艳钊", "liyanzhao", [[["C21-14557", "鲁荣渔55167", "lurongyu55167", 106], ["C21-14558", "鲁荣渔55168", "lurongyu55168", 106]]], [], [], [], [], [], []],
["马永辉", "mayonghui", [[["C21-14559", "鲁荣渔55217", "lurongyu55217", 106], ["C21-14560", "鲁荣渔55218", "lurongyu55218", 106]]], [], [], [], [], [], []],
["张芹", "zhangqin", [[["C21-14561", "鲁荣渔55699", "lurongyu55699", 92], ["C21-14562", "鲁荣渔55700", "lurongyu55700", 92]]], [], [], [], [], [], []],
["田丰政", "tianfengzheng", [[["C21-14563", "鲁荣渔55737", "lurongyu55737", 96], ["C21-14564", "鲁荣渔55738", "lurongyu55738", 96]]], [], [], [], [], [], []],
["许日", "xuri", [[["C21-14565", "鲁荣渔56827", "lurongyu56827", 106], ["C21-14566", "鲁荣渔56828", "lurongyu56828", 106]]], [], [], [], [], [], []],
["王祚堂", "wangzuotang", [], [["C25-10441", "苏射渔02222", "susheyu02222", 149], ["C25-15732", "苏射渔08777", "susheyu08777", 149]], [], [], [], [], []],
["鲁长霞", "luchangxia", [], [["C25-16332", "津汉渔04618", "jinhanyu04618", 145], ["C25-17282", "津汉渔04608", "jinhanyu04608", 145]], [], [], [], [], []],
["任月通", "renyuetong", [], [["C25-16547", "冀黄渔06758", "jihuangyu06758", 134], ["C25-16548", "冀黄渔06759", "jihuangyu06759", 134]], [], [], [], [], []],
["张妍", "zhangyan", [], [["C25-16621", "津塘渔03689", "jintangyu03689", 45], ["C25-16918", "津塘渔03186", "jintangyu03186", 98]], [], [], [], [], []],
["任东亮", "rendongliang", [], [["C25-17086", "冀黄渔01777", "jihuangyu01777", 149], ["C25-17089", "冀黄渔06776", "jihuangyu06776", 149]], [], [], [], [], []],
["黄骅市宏安海水捕捞有限公司", "huanghuashihonganhaishuibulaoyouxiangongsi", [], [["C25-17087", "冀黄渔02996", "jihuangyu02996", 98], ["C25-17088", "冀黄渔02997", "jihuangyu02997", 98]], [], [], [], [], []],
["大连启嘉渔业有限公司", "dalianqijiayuyeyouxiangongsi", [], [["C25-17113", "辽庄渔75777", "liaozhuangyu75777", 149], ["C25-17114", "辽庄渔75555", "liaozhuangyu75555", 149]], [], [], [], [], []],
["潘金玉", "panjinyu", [], [["C25-17119", "辽庄渔85888", "liaozhuangyu85888", 149]], [], [], [["C40-8474", "辽庄渔运85778", "liaozhuangyuyun85778", 178]], [], []],
["宋惠生", "songhuisheng", [], [["C25-17228", "津塘渔03187", "jintangyu03187", 148], ["C25-17245", "津塘渔03188", "jintangyu03188", 148]], [], [], [], [], []],
["张丽", "zhangli", [], [["C25-17229", "津塘渔03116", "jintangyu03116", 147], ["C25-17230", "津塘渔03185", "jintangyu03185", 147]], [], [], [], [], []],
["大连铸航水产销售有限公司", "dalianzhuhangshuichanxiaoshouyouxiangongsi", [], [["C25-17257", "辽大金渔35988", "liaodajinyu35988", 148], ["C25-17258", "辽大金渔35989", "liaodajinyu35989", 148]], [], [], [], [], []],
["大连金多洋渔业有限公司", "dalianjinduoyangyuyeyouxiangongsi", [], [["C25-17268", "辽庄渔85678", "liaozhuangyu85678", 144], ["C25-17269", "辽庄渔85999", "liaozhuangyu85999", 144]], [], [], [], [], []],
["大连贺程渔业有限公司", "dalianhechengyuyeyouxiangongsi", [], [["C25-17271", "辽大金渔15688", "liaodajinyu15688", 144], ["C25-17272", "辽大金渔15828", "liaodajinyu15828", 144]], [], [], [], [], []],
["庄河市八方聚财渔业有限公司", "zhuangheshibafangjucaiyuyeyouxiangongsi", [], [["C25-17274", "辽庄渔75678", "liaozhuangyu75678", 98], ["C25-17275", "辽庄渔75789", "liaozhuangyu75789", 98]], [], [], [], [], []],
["陈欢", "chenhuan", [], [["C25-17283", "津汉渔04567", "jinhanyu04567", 144], ["C25-17284", "津汉渔04678", "jinhanyu04678", 144]], [], [], [], [], []],
["孙红昌", "sunhongchang", [], [["C25-17291", "苏射渔01555", "susheyu01555", 147], ["C25-17292", "苏射渔01666", "susheyu01666", 147]], [], [], [], [], []],
["大连世景源水产品有限公司", "dalianshijingyuanshuichanpinyouxiangongsi", [], [["C25-17307", "辽大中渔25176", "liaodazhongyu25176", 148], ["C25-17308", "辽大中渔25177", "liaodazhongyu25177", 148]], [], [], [], [], []],
["大连盛庆渔业有限公司", "dalianshengqingyuyeyouxiangongsi", [], [["C25-17309", "辽庄渔65787", "liaozhuangyu65787", 144], ["C25-17310", "辽庄渔65786", "liaozhuangyu65786", 144]], [], [], [], [], []],
["大连国友捕捞有限公司", "dalianguoyoubulaoyouxiangongsi", [], [], [], [], [["C40-8428", "辽庄渔运25266", "liaozhuangyuyun25266", 161], ["C40-8441", "辽庄渔运25267", "liaozhuangyuyun25267", 161]], [], []],
["天津荣祥水产品有限公司", "tianjinrongxiangshuichanpinyouxiangongsi", [], [], [], [], [["C40-8477", "津塘渔运03181", "jintangyuyun03181", 238], ["C40-8479", "津塘渔运03186", "jintangyuyun03186", 287]], [], []],
["朱国清", "zhuguoqing", [], [], [], [], [], [["C21-9112", "浙岭渔22928", "zhelingyu22928", 218]], []],
["庄彩芹", "zhuangcaiqin", [], [], [], [], [], [["C21-9631", "浙岭渔27801", "zhelingyu27801", 217]], []],
["庄如军", "zhuangrujun", [], [], [], [], [], [], [["C21-9632", "浙岭渔27802", "zhelingyu27802", 216]]],
["郑文英", "zhengwenying", [], [], [], [], [], [], [["C21-9681", "浙岭渔23888", "zhelingyu23888", 218]]],
["薛金娥", "xuejine", [], [], [], [], [], [["C21-10087", "浙岭渔20865", "zhelingyu20865", 217]], []],
["王蒙洪", "wangmenghong", [], [], [], [], [], [["C21-10155", "浙岭渔23887", "zhelingyu23887", 218]], []],
["郭文玉", "guowenyu", [], [], [], [], [], [["C21-10194", "浙岭渔28881", "zhelingyu28881", 216]], []],
["江维生", "jiangweisheng", [], [], [], [], [], [], [["C21-10195", "浙岭渔28882", "zhelingyu28882", 216]]],
["林武庆", "linwuqing", [], [], [], [], [], [["C21-10205", "浙岭渔23855", "zhelingyu23855", 218]], []],
["林武强", "linwuqiang", [], [], [], [], [], [], [["C21-10206", "浙岭渔23856", "zhelingyu23856", 218]]],
["陈清华", "chenqinghua", [], [], [], [], [], [], [["C21-10621", "浙岭渔20866", "zhelingyu20866", 217]]],
["郭祥", "guoxiang", [], [], [], [], [], [["C21-10640", "浙岭渔20398", "zhelingyu20398", 218]], []],
["郭修柏", "guoxiubai", [], [], [], [], [], [], [["C21-10641", "浙岭渔20399", "zhelingyu20399", 218]]],
["林仁营", "linrenying", [], [], [], [], [], [["C21-10729", "浙岭渔20597", "zhelingyu20597", 218]], []],
["林仁青", "linrenqing", [], [], [], [], [], [], [["C21-10730", "浙岭渔20598", "zhelingyu20598", 218]]],
["郑念德", "zhengniande", [], [], [], [], [], [], [["C21-10740", "浙岭渔29808", "zhelingyu29808", 218]]],
["陈灵忠", "chenlingzhong", [], [], [], [], [], [["C21-11214", "浙岭渔26918", "zhelingyu26918", 218]], []],
["林都云", "linduyun", [], [], [], [], [], [], [["C21-11215", "浙岭渔26919", "zhelingyu26919", 218]]],
["刘祖德", "liuzude", [], [], [], [], [], [["C21-11895", "浙岭渔23829", "zhelingyu23829", 218]], []],
["林武明", "linwuming", [], [], [], [], [], [], [["C21-11896", "浙岭渔23830", "zhelingyu23830", 218]]],
["陈兆喜", "chenzhaoxi", [], [], [], [], [], [["C21-12218", "浙岭渔23725", "zhelingyu23725", 218]], []],
["庄念军", "zhuangnianjun", [], [], [], [], [], [], [["C21-12219", "浙岭渔23726", "zhelingyu23726", 218]]],
["郑志方", "zhengzhifang", [], [], [], [], [], [["C21-12258", "浙岭渔23876", "zhelingyu23876", 218]], []],
["林泽", "linze", [], [], [], [], [], [], [["C21-12276", "浙岭渔20366", "zhelingyu20366", 216]]],
["林应元", "linyingyuan", [], [], [], [], [], [], [["C21-12277", "浙岭渔23737", "zhelingyu23737", 218]]],
["黄宝林", "huangbaolin", [], [], [], [], [], [["C21-12301", "浙岭渔28906", "zhelingyu28906", 218]], []],
["林武英", "linwuying", [], [], [], [], [], [["C21-12306", "浙岭渔23757", "zhelingyu23757", 218]], []],
["郑达传", "zhengdachuan", [], [], [], [], [], [], [["C21-12307", "浙岭渔23758", "zhelingyu23758", 218]]],
["薛良华", "xuelianghua", [], [], [], [], [], [["C21-12308", "浙岭渔23872", "zhelingyu23872", 218]], []],
["陈建华", "chenjianhua", [], [], [], [], [], [], [["C21-12309", "浙岭渔23871", "zhelingyu23871", 218]]],
["林维聪", "linweicong", [], [], [], [], [], [["C21-12974", "浙岭渔23701", "zhelingyu23701", 212]], []],
["陈理清", "chenliqing", [], [], [], [], [], [], [["C21-12975", "浙岭渔23703", "zhelingyu23703", 212]]],
["闫亮", "yanliang", [], [], [], [], [], [], [["C21-13090", "鲁荣渔51628", "lurongyu51628", 114]]],
["林维春", "linweichun", [], [], [], [], [], [["C21-13317", "浙岭渔20367", "zhelingyu20367", 216]], []],
["赵彩芸", "zhaocaiyun", [], [], [], [], [], [["C21-13528", "浙象渔40031", "zhexiangyu40031", 212]], []],
["吴明其", "wumingqi", [], [], [], [], [], [], [["C21-13529", "浙象渔40032", "zhexiangyu40032", 212]]],
["李峰", "lifeng", [], [], [], [], [], [["C21-13532", "浙象渔47007", "zhexiangyu47007", 219]], []],
["陈仕兵", "chenshibing", [], [], [], [], [], [], [["C21-13533", "浙象渔47058", "zhexiangyu47058", 219]]],
["戴安亮", "daianliang", [], [], [], [], [], [["C21-13546", "浙岭渔29715", "zhelingyu29715", 217]], []],
["张云妙", "zhangyunmiao", [], [], [], [], [], [], [["C21-13547", "浙岭渔29716", "zhelingyu29716", 217]]],
["章士伟", "zhangshiwei", [], [], [], [], [], [["C21-13682", "浙象渔49011", "zhexiangyu49011", 218]], []],
["章伟平", "zhangweiping", [], [], [], [], [], [], [["C21-13683", "浙象渔49012", "zhexiangyu49012", 218]]],
["陈达祥", "chendaxiang", [], [], [], [], [], [], [["C21-13786", "浙岭渔22929", "zhelingyu22929", 218]]],
["林应文", "linyingwen", [], [], [], [], [], [], [["C21-13789", "浙岭渔23859", "zhelingyu23859", 218]]],
["孙中明", "sunzhongming", [], [], [], [], [], [["C21-13818", "浙岭渔23789", "zhelingyu23789", 216]], []],
["贾义明", "jiayiming", [], [], [], [], [], [["C21-13822", "浙象渔67107", "zhexiangyu67107", 212]], []],
["贾敏锋", "jiaminfeng", [], [], [], [], [], [], [["C21-13823", "浙象渔67108", "zhexiangyu67108", 212]]],
["徐刚", "xugang", [], [], [], [], [], [["C21-13956", "浙象渔50107", "zhexiangyu50107", 202]], []],
["徐永辉", "xuyonghui", [], [], [], [], [], [], [["C21-13957", "浙象渔50108", "zhexiangyu50108", 202]]],
["张磊", "zhanglei", [], [], [], [], [], [["C21-13987", "鲁荣渔57327", "lurongyu57327", 117]], []],
["张平平", "zhangpingping", [], [], [], [], [], [], [["C21-13988", "鲁荣渔57328", "lurongyu57328", 127]]],
["赖达进", "laidajin", [], [], [], [], [], [["C21-14181", "浙三渔00787", "zhesanyu00787", 216]], []],
["赖达撑", "laidacheng", [], [], [], [], [], [], [["C21-14182", "浙三渔00788", "zhesanyu00788", 216]]],
["李兴定", "lixingding", [], [], [], [], [], [["C21-14183", "浙岭渔69391", "zhelingyu69391", 210]], []],
["石永国", "shiyongguo", [], [], [], [], [], [["C21-14187", "浙象渔67091", "zhexiangyu67091", 194]], []],
["黄振德", "huangzhende", [], [], [], [], [], [], [["C21-14188", "浙象渔67092", "zhexiangyu67092", 218]]],
["戴叶玲", "daiyeling", [], [], [], [], [], [["C21-14189", "浙象渔48057", "zhexiangyu48057", 219]], []],
["戴小林", "daixiaolin", [], [], [], [], [], [], [["C21-14190", "浙象渔48058", "zhexiangyu48058", 219]]],
["杜星霖", "duxinglin", [], [], [], [], [], [["C21-14369", "浙岭渔23305", "zhelingyu23305", 218]], []],
["庄道兵", "zhuangdaobing", [], [], [], [], [], [], [["C21-14370", "浙岭渔23306", "zhelingyu23306", 218]]],
["尚鹏飞", "shangpengfei", [], [], [], [], [], [["C21-14371", "浙岭渔23381", "zhelingyu23381", 218]], []],
["尚作卫", "shangzuowei", [], [], [], [], [], [], [["C21-14372", "浙岭渔23382", "zhelingyu23382", 218]]],
["王期", "wangqi", [], [], [], [], [], [], [["C21-14381", "浙象渔40046", "zhexiangyu40046", 219]]],
["吴跃翔", "wuyuexiang", [], [], [], [], [], [["C21-14382", "浙象渔40045", "zhexiangyu40045", 219]], []],
["黄星", "huangxing", [], [], [], [], [], [["C21-14385", "浙岭渔23331", "zhelingyu23331", 217]], []],
["朱智桥", "zhuzhiqiao", [], [], [], [], [], [["C21-14406", "浙象渔42025", "zhexiangyu42025", 192]], []],
["朱志励", "zhuzhili", [], [], [], [], [], [], [["C21-14407", "浙象渔42026", "zhexiangyu42026", 192]]],
["肖金凤", "xiaojinfeng", [], [], [], [], [], [["C21-14422", "鲁荣渔52977", "lurongyu52977", 106]], []],
["王秋红", "wangqiuhong", [], [], [], [], [], [], [["C21-14423", "鲁荣渔52978", "lurongyu52978", 106]]],
["刘东伟", "liudongwei", [], [], [], [], [], [["C21-14458", "鲁荣渔59105", "lurongyu59105", 107]], []],
["赵军阳", "zhaojunyang", [], [], [], [], [], [], [["C21-14459", "鲁荣渔59106", "lurongyu59106", 107]]],
["丁伟", "dingwei", [], [], [], [], [], [["C21-14506", "浙象渔30069", "zhexiangyu30069", 202]], []],
["应浩", "yinghao", [], [], [], [], [], [], [["C21-14507", "浙象渔30070", "zhexiangyu30070", 202]]],
["曾国财", "zengguocai", [], [], [], [], [], [], [["C21-14510", "浙岭渔69390", "zhelingyu69390", 218]]],
["焦玉龙", "jiaoyulong", [], [], [], [], [], [["C21-14519", "鲁荣渔59161", "lurongyu59161", 97]], []],
["姜治海", "jiangzhihai", [], [], [], [], [], [], [["C21-14520", "鲁荣渔59162", "lurongyu59162", 97]]],
["金盼盼", "jinpanpan", [], [], [], [], [], [["C21-14527", "浙象渔50199", "zhexiangyu50199", 205]], []],
["王喜维", "wangxiwei", [], [], [], [], [], [], [["C21-14528", "浙象渔50198", "zhexiangyu50198", 186]]],
["江志荣", "jiangzhirong", [], [], [["C22-8061", "浙岭渔23502", "zhelingyu23502", 216]], [], [], [], []],
["梁明军", "liangmingjun", [], [], [["C22-8062", "浙岭渔23519", "zhelingyu23519", 218]], [], [], [], []],
["赵忠明", "zhaozhongming", [], [], [["C22-8063", "浙岭渔23593", "zhelingyu23593", 211]], [], [], [], []],
["江于中", "jiangyuzhong", [], [], [["C22-8074", "浙岭渔28850", "zhelingyu28850", 218]], [], [], [], []],
["许海金", "xuhaijin", [], [], [["C22-8077", "浙象渔30360", "zhexiangyu30360", 190]], [], [], [], []],
["王青富", "wangqingfu", [], [], [["C22-8079", "浙岭渔23555", "zhelingyu23555", 212]], [], [], [], []],
["赵美何", "zhaomeihe", [], [], [["C22-8083", "浙岭渔26878", "zhelingyu26878", 218]], [], [], [], []],
["杜志旺", "duzhiwang", [], [], [["C22-8091", "浙岭渔28909", "zhelingyu28909", 218]], [], [], [], []],
["陈金玉", "chenjinyu", [], [], [["C22-8092", "浙岭渔23568", "zhelingyu23568", 218]], [], [], [], []],
["杨小德", "yangxiaode", [], [], [["C22-8094", "浙岭渔23596", "zhelingyu23596", 218]], [], [], [], []],
["包纯增", "baochunzeng", [], [], [["C22-8095", "浙岭渔23639", "zhelingyu23639", 210]], [], [], [], []],
["赵云方", "zhaoyunfang", [], [], [["C22-8098", "浙岭渔28855", "zhelingyu28855", 218]], [], [], [], []],
["杜军慧", "dujunhui", [], [], [["C22-8099", "浙岭渔23476", "zhelingyu23476", 218]], [], [], [], []],
["王东东", "wangdongdong", [], [["C25-9362", "苏赣渔05788", "suganyu05788", 145]], [], [], [], [], []],
["张桂云", "zhangguiyun", [], [["C25-9425", "冀黄渔06789", "jihuangyu06789", 144]], [], [], [], [], []],
["贾友钱", "jiayouqian", [], [["C25-10144", "浙瑞渔12102", "zheruiyu12102", 99]], [], [], [], [], []],
["刘必军", "liubijun", [], [["C25-15401", "苏阜渔06663", "sufuyu06663", 149]], [], [], [], [], []],
["薛迪平", "xuediping", [], [["C25-15455", "浙瑞渔01688", "zheruiyu01688", 148]], [], [], [], [], []],
["杨定余", "yangdingyu", [], [["C25-15733", "苏射渔06699", "susheyu06699", 149]], [], [], [], [], []],
["瑞安市连喜渔业捕捞有限公司", "ruianshilianxiyuyebolaoyouxiangongsi", [], [["C25-15747", "浙瑞渔12181", "zheruiyu12181", 141]], [], [], [], [], []],
["方文松", "fangwensong", [], [["C25-15756", "浙瑞渔01651", "zheruiyu01651", 143]], [], [], [], [], []],
["林帮火", "linbanghuo", [], [["C25-16225", "浙瑞渔12188", "zheruiyu12188", 143]], [], [], [], [], []],
["谢秀钗", "xiexiuzuo", [], [["C25-16228", "浙瑞渔12238", "zheruiyu12238", 148]], [], [], [], [], []],
["王贞", "wangzhen", [], [["C25-16394", "苏赣渔04887", "suganyu04887", 145]], [], [], [], [], []],
["侯祥林", "houxianglin", [], [["C25-16406", "苏射渔01169", "susheyu01169", 149]], [], [], [], [], []],
["陈新明", "chenxinming", [], [["C25-16412", "苏射渔08588", "susheyu08588", 149]], [], [], [], [], []],
["虞冠迪", "yuguandi", [], [["C25-16413", "浙瑞渔12132", "zheruiyu12132", 148]], [], [], [], [], []],
["辛祥玉", "xinxiangyu", [], [["C25-16538", "冀黄渔01518", "jihuangyu01518", 135]], [], [], [], [], []],
["李清森", "liqingsen", [], [["C25-16541", "冀黄渔05279", "jihuangyu05279", 135]], [], [], [], [], []],
["高长佑", "gaochangyou", [], [["C25-16552", "冀黄渔06887", "jihuangyu06887", 149]], [], [], [], [], []],
["胡金发", "hujinfa", [], [["C25-16555", "冀黄渔07615", "jihuangyu07615", 137]], [], [], [], [], []],
["邵世伟", "shaoshiwei", [], [["C25-16660", "苏赣渔05676", "suganyu05676", 145]], [], [], [], [], []],
["周克伟", "zhoukewei", [], [["C25-16664", "苏射渔03288", "susheyu03288", 148]], [], [], [], [], []],
["阮臣楷", "ruanchenkai", [], [["C25-16665", "浙瑞渔01208", "zheruiyu01208", 146]], [], [], [], [], []],
["缪德才", "miaodecai", [], [["C25-16773", "苏射渔07388", "susheyu07388", 149]], [], [], [], [], []],
["苑猛", "yuanmeng", [], [["C25-16803", "冀黄渔05988", "jihuangyu05988", 137]], [], [], [], [], []],
["张桂峰", "zhangguifeng", [], [["C25-16910", "冀黄渔06727", "jihuangyu06727", 137]], [], [], [], [], []],
["王运", "wangyun", [], [["C25-16927", "辽大甘渔15399", "liaodaganyu15399", 149]], [], [], [], [], []],
["田德学", "tiandexue", [], [["C25-16933", "辽锦渔15615", "liaojinyu15615", 148]], [], [], [], [], []],
["王路路", "wanglulu", [], [["C25-17001", "苏赣渔05619", "suganyu05619", 145]], [], [], [], [], []],
["蔡光进", "caiguangjin", [], [["C25-17069", "浙瑞渔12068", "zheruiyu12068", 143]], [], [], [], [], []],
["张存香", "zhangcunxiang", [], [["C25-17070", "浙瑞渔12115", "zheruiyu12115", 143]], [], [], [], [], []],
["张玉洪", "zhangyuhong", [], [["C25-17085", "冀黄渔00130", "jihuangyu00130", 144]], [], [], [], [], []],
["李玲", "liling", [], [["C25-17101", "苏赣渔09679", "suganyu09679", 146]], [], [], [], [], []],
["李金业", "lijinye", [], [["C25-17104", "苏赣渔05988", "suganyu05988", 146]], [], [], [], [], []],
["胡文涵", "huwenhan", [], [["C25-17115", "辽庄渔85555", "liaozhuangyu85555", 149]], [], [], [], [], []],
["潘德宁", "pandening", [], [["C25-17118", "辽庄渔85558", "liaozhuangyu85558", 149]], [], [], [], [], []],
["张良良", "zhangliangliang", [], [["C25-17120", "辽庄渔65999", "liaozhuangyu65999", 149]], [], [], [], [], []],
["贺丹", "hedan", [], [["C25-17121", "辽庄渔65888", "liaozhuangyu65888", 149]], [], [], [], [], []],
["阮建飞", "ruanjianfei", [], [["C25-17139", "浙瑞渔12110", "zheruiyu12110", 130]], [], [], [], [], []],
["林丰", "linfeng", [], [["C25-17141", "浙瑞渔12161", "zheruiyu12161", 148]], [], [], [], [], []],
["薛威格", "xueweige", [], [["C25-17144", "浙瑞渔01240", "zheruiyu01240", 98]], [], [], [], [], []],
["潘时虎", "panshihu", [], [["C25-17145", "浙瑞渔01731", "zheruiyu01731", 95]], [], [], [], [], []],
["汪集龙", "wangjilong", [], [["C25-17155", "辽大金渔75039", "liaodajinyu75039", 149]], [], [], [], [], []],
["初杰", "chujie", [], [["C25-17156", "辽庄渔85777", "liaozhuangyu85777", 147]], [], [], [], [], []],
["张绍全", "zhangshaoquan", [], [["C25-17158", "辽锦渔15111", "liaojinyu15111", 148]], [], [], [], [], []],
["蔡丰华", "caifenghua", [], [["C25-17159", "浙瑞渔01227", "zheruiyu01227", 143]], [], [], [], [], []],
["虞冠呈", "yuguancheng", [], [["C25-17160", "浙瑞渔02951", "zheruiyu02951", 148]], [], [], [], [], []],
["阮臣弟", "ruanchendi", [], [["C25-17161", "浙瑞渔12168", "zheruiyu12168", 148]], [], [], [], [], []],
["蔡建挺", "caijianting", [], [["C25-17162", "浙瑞渔12191", "zheruiyu12191", 145]], [], [], [], [], []],
["虞冠仕", "yuguanshi", [], [["C25-17163", "浙瑞渔12192", "zheruiyu12192", 148]], [], [], [], [], []],
["蔡钱荣", "caiqianrong", [], [["C25-17166", "浙瑞渔12158", "zheruiyu12158", 98]], [], [], [], [], []],
["缪明武", "zuomingwu", [], [["C25-17172", "浙瑞渔01657", "zheruiyu01657", 99]], [], [], [], [], []],
["大连福鑫渔业有限公司", "dalianfuxinyuyeyouxiangongsi", [], [["C25-17175", "辽庄渔65688", "liaozhuangyu65688", 149]], [], [], [], [], []],
["秦海涛", "qinhaitao", [], [["C25-17188", "苏赣渔09677", "suganyu09677", 146]], [], [], [], [], []],
["施小菊", "shixiaoju", [], [["C25-17194", "浙瑞渔01518", "zheruiyu01518", 143]], [], [], [], [], []],
["陈积敏", "chenjimin", [], [["C25-17195", "浙瑞渔13089", "zheruiyu13089", 140]], [], [], [], [], []],
["林候池", "linhouchi", [], [["C25-17198", "浙瑞渔12128", "zheruiyu12128", 147]], [], [], [], [], []],
["林候式", "linhoushi", [], [["C25-17199", "浙瑞渔12189", "zheruiyu12189", 143]], [], [], [], [], []],
["蔡兰海", "cailanhai", [], [["C25-17200", "浙瑞渔12286", "zheruiyu12286", 149]], [], [], [], [], []],
["唐雪", "tangxue", [], [["C25-17201", "辽庄渔65599", "liaozhuangyu65599", 126]], [], [], [], [], []],
["孟向坤", "mengxiangkun", [], [["C25-17204", "冀黄渔06578", "jihuangyu06578", 135]], [], [], [], [], []],
["邵常华", "shaochanghua", [], [["C25-17205", "冀黄渔07659", "jihuangyu07659", 135]], [], [], [], [], []],
["白金祥", "baijinxiang", [], [["C25-17206", "冀黄渔07698", "jihuangyu07698", 135]], [], [], [], [], []],
["林友光", "linyouguang", [], [["C25-17208", "浙瑞渔12196", "zheruiyu12196", 148]], [], [], [], [], []],
["薛建设", "xuejianshe", [], [["C25-17209", "浙瑞渔12018", "zheruiyu12018", 149]], [], [], [], [], []],
["李秀绿", "lixiulv", [], [["C25-17210", "浙瑞渔12288", "zheruiyu12288", 143]], [], [], [], [], []],
["徐秀雷", "xuxiulei", [], [["C25-17211", "苏阜渔06399", "sufuyu06399", 148]], [], [], [], [], []],
["王霞光", "wangxiaguang", [], [["C25-17212", "苏阜渔08689", "sufuyu08689", 148]], [], [], [], [], []],
["秦绪燕", "qinxuyan", [], [["C25-17213", "苏赣渔04339", "suganyu04339", 149]], [], [], [], [], []],
["盛长青", "shengchangqing", [], [["C25-17214", "苏赣渔04368", "suganyu04368", 149]], [], [], [], [], []],
["侯学波", "houxuebo", [], [["C25-17215", "苏赣渔04382", "suganyu04382", 149]], [], [], [], [], []],
["李秀志", "lixiuzhi", [], [["C25-17216", "苏赣渔04338", "suganyu04338", 149]], [], [], [], [], []],
["李静", "lijing", [], [["C25-17217", "苏赣渔04399", "suganyu04399", 149]], [], [], [], [], []],
["孙成杰", "sunchengjie", [], [["C25-17218", "苏赣渔04081", "suganyu04081", 149]], [], [], [], [], []],
["庄广立", "zhuangguangli", [], [["C25-17219", "苏赣渔05328", "suganyu05328", 149]], [], [], [], [], []],
["陈恩亮", "chenenliang", [], [["C25-17220", "苏赣渔05333", "suganyu05333", 149]], [], [], [], [], []],
["李新余", "lixinyu", [], [["C25-17221", "苏赣渔09591", "suganyu09591", 148]], [], [], [], [], []],
["王祥栋", "wangxiangdong", [], [["C25-17224", "苏赣渔04879", "suganyu04879", 146]], [], [], [], [], []],
["邵春明", "shaochunming", [], [["C25-17227", "津塘渔03156", "jintangyu03156", 122]], [], [], [], [], []],
["李波", "libo", [], [["C25-17247", "津塘渔03805", "jintangyu03805", 40]], [], [], [], [], []],
["胡乃龙", "hunailong", [], [["C25-17259", "辽瓦渔75777", "liaowayu75777", 149]], [], [], [], [], []],
["蔡晓坤", "caixiaokun", [], [["C25-17267", "辽庄渔65577", "liaozhuangyu65577", 144]], [], [], [], [], []],
["张忠本", "zhangzhongben", [], [["C25-17276", "辽庄渔75888", "liaozhuangyu75888", 98]], [], [], [], [], []],
["刘学德", "liuxuede", [], [["C25-17277", "辽庄渔75999", "liaozhuangyu75999", 98]], [], [], [], [], []],
["王柱盛", "wangzhusheng", [], [["C25-17285", "冀黄渔00908", "jihuangyu00908", 149]], [], [], [], [], []],
["许志强", "xuzhiqiang", [], [["C25-17286", "冀黄渔01279", "jihuangyu01279", 103]], [], [], [], [], []],
["黄骅市鑫洋船舶租赁服务有限公司", "huanghuashixinyangchuanbozulinfuwuyouxiangongsi", [], [["C25-17287", "冀黄渔02697", "jihuangyu02697", 143]], [], [], [], [], []],
["张宝广", "zhangbaoguang", [], [["C25-17288", "冀黄渔06498", "jihuangyu06498", 121]], [], [], [], [], []],
["高路敏", "gaolumin", [], [["C25-17289", "冀黄渔06533", "jihuangyu06533", 148]], [], [], [], [], []],
["张金岭", "zhangjinling", [], [["C25-17290", "冀黄渔05627", "jihuangyu05627", 144]], [], [], [], [], []],
["姜楠", "jiangnan", [], [["C25-17293", "苏射渔06789", "susheyu06789", 147]], [], [], [], [], []],
["王刚", "wanggang", [], [["C25-17294", "苏射渔01679", "susheyu01679", 149]], [], [], [], [], []],
["卢凤", "lufeng", [], [["C25-17295", "苏赣渔05218", "suganyu05218", 145]], [], [], [], [], []],
["王彦文", "wangyanwen", [], [["C25-17296", "苏赣渔02396", "suganyu02396", 145]], [], [], [], [], []],
["胡英勉", "huyingmian", [], [["C25-17297", "苏赣渔02506", "suganyu02506", 145]], [], [], [], [], []],
["王为成", "wangweicheng", [], [["C25-17298", "苏赣渔05667", "suganyu05667", 145]], [], [], [], [], []],
["瑞安市琪霖渔业捕捞有限公司", "ruianshizuolinyuyebolaoyouxiangongsi", [], [["C25-17301", "浙瑞渔12666", "zheruiyu12666", 146]], [], [], [], [], []],
["瑞安市鑫航渔业捕捞有限公司", "ruianshizuohangyuyebolaoyouxiangongsi", [], [["C25-17302", "浙瑞渔12345", "zheruiyu12345", 146]], [], [], [], [], []],
["温州汇满丰渔业有限责任公司", "wenzhouhuimanfengyuyeyouxianzherengongsi", [], [["C25-17303", "浙平渔80248", "zhepingyu80248", 130]], [], [], [], [], []],
["苏棉美", "sumianmei", [], [["C25-17304", "浙平渔82168", "zhepingyu82168", 123]], [], [], [], [], []],
["邱兰妹", "qiulanmei", [], [["C25-17305", "浙平渔82052", "zhepingyu82052", 140]], [], [], [], [], []],
["苏意武", "suyiwu", [], [["C25-17306", "浙平渔82160", "zhepingyu82160", 139]], [], [], [], [], []],
["吴良勇", "wuliangyong", [], [], [], [], [["C40-8341", "浙岭渔运31066", "zhelingyuyun31066", 248]], [], []],
["蔡显雄", "caixianxiong", [], [], [], [], [["C40-8408", "浙岭渔运10078", "zhelingyuyun10078", 287]], [], []],
["房秋云", "fangqiuyun", [], [], [], [], [["C40-8415", "浙岭渔运31009", "zhelingyuyun31009", 243]], [], []],
["王成江", "wangchengjiang", [], [], [], [], [["C40-8423", "辽大金渔运15888", "liaodajinyuyun15888", 136]], [], []],
["国向治", "guoxiangzhi", [], [], [], [], [["C40-8434", "辽大中渔运15033", "liaodazhongyuyun15033", 63]], [], []],
["王蒙能", "wangmengneng", [], [], [], [], [["C40-8448", "浙岭渔运31079", "zhelingyuyun31079", 281]], [], []],
["余奶富", "yunaifu", [], [], [], [], [["C40-8454", "浙岭渔运10028", "zhelingyuyun10028", 248]], [], []],
["陈清国", "chenqingguo", [], [], [], [], [["C40-8458", "浙岭渔运30088", "zhelingyuyun30088", 258]], [], []],
["秦保光", "qinbaoguang", [], [], [], [], [["C40-8464", "苏赣渔运05566", "suganyuyun05566", 253]], [], []],
["杨宝利", "yangbaoli", [], [], [], [], [["C40-8466", "津塘渔运03666", "jintangyuyun03666", 62]], [], []],
["方瑞瑞", "fangruirui", [], [], [], [], [["C40-8471", "浙象渔运02209", "zhexiangyuyun02209", 259]], [], []],
["吴侠", "wuxia", [], [], [], [], [["C40-8475", "浙象渔运02103", "zhexiangyuyun02103", 248]], [], []],
["刘坤", "liukun", [], [], [], [], [["C40-8484", "辽大中渔运15158", "liaodazhongyuyun15158", 130]], [], []],
["王赞", "wangzan", [], [], [], [], [["C40-8485", "辽大中渔运15157", "liaodazhongyuyun15157", 187]], [], []],
["黄骅市长荣渔业捕捞有限公司", "huanghuashichangrongyuyebulaoyouxiangongsi", [], [], [], [], [["C40-8488", "冀黄渔运05155", "jihuangyuyun05155", 156]], [], []],
["王从营", "wangcongying", [], [], [], [], [["C40-8490", "苏赣渔运05601", "suganyuyun05601", 297]], [], []],
["林立丰", "linlifeng", [], [], [], [], [["C40-8491", "浙岭渔运31195", "zhelingyuyun31195", 270]], [], []],
["赵恩军", "zhaoenjun", [], [], [], [], [["C40-8492", "浙岭渔运31171", "zhelingyuyun31171", 240]], [], []],
["迟晓颖", "chixiaoying", [], [], [], [], [["C40-8494", "辽瓦渔运55032", "liaowayuyun55032", 172]], [], []]
];
const ZI=[[131.265,36.1666],[129.7162,35.65],[129.7275,35.692],[129.7654,35.7956],[129.8298,35.9931],[129.8323,36.0444],[129.7208,36.2417],[129.6737,36.2681],[129.6906,36.4539],[129.7282,36.7868],[129.6711,37.0095],[129.6223,37.2561],[129.4768,37.4744],[129.3255,37.6935],[129.1762,37.8767],[128.9936,38.068],[128.8559,38.2544],[130.1643,38.0028],[131.6327,37.3261],[131.6277,37.3163],[131.6156,37.2834],[131.4273,37.0406],[130.375,36.1666],[131.265,36.1666]];
const ZII=[[126.0005,32.1833],[126.0141,33.104],[126.0646,33.0051],[126.1417,32.9416],[126.2317,32.9132],[126.3279,32.9173],[126.4691,33.0037],[126.5577,33.0178],[126.6491,33.0193],[126.7439,33.0327],[126.852,33.0983],[126.9682,33.1401],[127.0508,33.2096],[127.1211,33.2964],[127.1685,33.3742],[127.2041,33.4496],[127.214,33.5279],[127.0126,33.7734],[127.26,33.7988],[128.0,34.1187],[128.8883,34.344],[127.86,33.2283],[126.0005,32.1833]];
const ZIII=[[124.1255,35.0007],[124.9773,34.8191],[124.9046,33.9378],[125.9272,33.7231],[126.0401,33.7034],[126.059,33.6762],[126.0738,33.6023],[126.0562,33.5638],[126.0067,33.5056],[125.9855,33.4803],[125.9427,33.433],[125.9209,33.386],[125.9048,33.3233],[125.9048,33.2998],[125.9159,33.2501],[125.9335,33.2153],[125.9646,33.1745],[124.1704,33.3003],[124.1255,35.0007]];
const ZIV=[[124.5,35.5],[124.5,36.75],[124.3333,37.0],[125.2289,37.0029],[125.4284,36.9026],[125.3881,36.8333],[125.3498,36.7644],[125.2935,36.6406],[125.2951,36.5872],[125.3248,36.5157],[125.5696,36.2265],[125.6364,36.15],[125.7914,35.926],[125.8327,35.8149],[125.8488,35.7167],[125.8484,35.6655],[125.7756,35.4651],[125.6868,35.3876],[125.3743,35.148],[125.1858,35.0],[124.1255,35.0007],[124.5,35.5]];
const ZONES=[
{id:"I",name:"수역Ⅰ",pts:ZI,color:"#ef4444",fill:"rgba(239,68,68,0.08)",center:[130.3,37.0]},
{id:"II",name:"수역Ⅱ",pts:ZII,color:"#3b82f6",fill:"rgba(59,130,246,0.08)",center:[127.4,33.3]},
{id:"III",name:"수역Ⅲ",pts:ZIII,color:"#10b981",fill:"rgba(16,185,129,0.08)",center:[125.0,33.9]},
{id:"IV",name:"수역Ⅳ",pts:ZIV,color:"#f59e0b",fill:"rgba(245,158,11,0.08)",center:[125.0,36.0]},
];
// Map projection
const MAP={lonMin:123.5,lonMax:132.2,latMin:31.8,latMax:38.8,w:440,h:400};
function toSvg(lon,lat){
return [((lon-MAP.lonMin)/(MAP.lonMax-MAP.lonMin))*MAP.w, (1-(lat-MAP.latMin)/(MAP.latMax-MAP.latMin))*MAP.h];
}
function polyPath(pts){
return "M"+pts.map(p=>{const[x,y]=toSvg(p[0],p[1]);return `${x.toFixed(1)},${y.toFixed(1)}`;}).join("L")+"Z";
}
function parse(d){
return d.map(r=>{
const [own,ownEn,pairs,gn,ot,ps,fc,upt,upts]=r;
const s=(a,code)=>a.map(x=>({pn:x[0],cn:x[1],en:x[2],ton:x[3],code}));
const pp=pairs.map(p=>({m:{pn:p[0][0],cn:p[0][1],en:p[0][2],ton:p[0][3],code:"PT"},s:{pn:p[1][0],cn:p[1][1],en:p[1][2],ton:p[1][3],code:"PT-S"}}));
const total=pp.length*2+gn.length+ot.length+ps.length+fc.length+upt.length+upts.length;
return {own,ownEn,pairs:pp,gn:s(gn,"GN"),ot:s(ot,"OT"),ps:s(ps,"PS"),fc:s(fc,"FC"),upt:s(upt,"PT"),upts:s(upts,"PT-S"),total};
});
}
const CC={PT:"#1e40af","PT-S":"#ea580c",GN:"#10b981",OT:"#8b5cf6",PS:"#ef4444",FC:"#f59e0b"};
function Ship({type, sz=28}){
const c={main:["#1e40af","#3b82f6","#60a5fa"],sub:["#c2410c","#ea580c","#fb923c"],gn:["#047857","#10b981","#6ee7b7"],ot:["#6d28d9","#8b5cf6","#c4b5fd"],ps:["#b91c1c","#ef4444","#fca5a5"],fc:["#92400e","#f59e0b","#fcd34d"]}[type]||["#6b7280","#94a3b8","#d1d5db"];
return (
<svg width={sz} height={sz} viewBox="0 0 36 36">
<path d="M6,20 L4,27 Q4,30 7,30 L29,30 Q32,30 32,27 L30,20 Z" fill={c[0]}/>
<rect x="8" y="16" width="20" height="5" rx="1.5" fill={c[1]}/>
<rect x="12" y="9" width="12" height="8" rx="1.5" fill={c[2]} stroke={c[0]} strokeWidth="0.5"/>
<rect x="14" y="11" width="3" height="3.5" rx="0.5" fill="#fff" opacity="0.7"/>
<rect x="19" y="11" width="3" height="3.5" rx="0.5" fill="#fff" opacity="0.7"/>
<line x1="18" y1="9" x2="18" y2="4" stroke={c[0]} strokeWidth="1.5"/>
{type==="fc" && <g><rect x="7" y="19" width="5" height="7" rx="1" fill="#fbbf24" stroke="#92400e" strokeWidth="0.4"/><rect x="14" y="19" width="5" height="7" rx="1" fill="#fbbf24" stroke="#92400e" strokeWidth="0.4"/><rect x="21" y="19" width="5" height="7" rx="1" fill="#fbbf24" stroke="#92400e" strokeWidth="0.4"/></g>}
</svg>
);
}
function VC({s, label}){
const bc=CC[s.code]||"#6b7280";
const bg={PT:"#eff6ff","PT-S":"#fff7ed",GN:"#f0fdf4",OT:"#f5f3ff",PS:"#fef2f2",FC:"#fffbeb"}[s.code]||"#f9fafb";
const tp=s.code==="PT"?"main":s.code==="PT-S"?"sub":s.code==="FC"?"fc":s.code==="GN"?"gn":s.code==="OT"?"ot":"ps";
return (
<div style={{display:"flex",alignItems:"center",gap:6,padding:"5px 8px",borderRadius:8,background:bg,border:`2px solid ${bc}`,position:"relative"}}>
{label && <div style={{position:"absolute",top:-7,left:8,fontSize:8,fontWeight:800,color:"#fff",background:bc,padding:"0 6px",borderRadius:8}}>{label}</div>}
<Ship type={tp} sz={26}/>
<div style={{flex:1,minWidth:0}}>
<div style={{fontSize:11,fontWeight:700,color:"#1e293b"}}>{s.cn} <span style={{fontWeight:400,color:bc,fontStyle:"italic",fontSize:10}}>{s.en}</span></div>
<div style={{fontSize:9,color:"#94a3b8"}}>{s.pn} · {s.ton}t</div>
</div>
</div>
);
}
function ZoneMap(){
return (
<svg viewBox={`0 0 ${MAP.w} ${MAP.h}`} style={{width:"100%",height:"100%",background:"linear-gradient(180deg,#e0f2fe,#bfdbfe)"}}>
<defs>{ZONES.map(z=><pattern key={z.id} id={`p${z.id}`} patternUnits="userSpaceOnUse" width="8" height="8"><line x1="0" y1="0" x2="8" y2="8" stroke={z.color} strokeWidth="0.5" opacity="0.3"/></pattern>)}</defs>
{ZONES.map(z=>{
const [cx,cy]=toSvg(z.center[0],z.center[1]);
return (
<g key={z.id}>
<path d={polyPath(z.pts)} fill={z.fill} stroke={z.color} strokeWidth="1.5" strokeDasharray="4 2"/>
<path d={polyPath(z.pts)} fill={`url(#p${z.id})`}/>
<text x={cx} y={cy} textAnchor="middle" fontSize="14" fontWeight="800" fill={z.color} opacity="0.7">{z.name}</text>
</g>
);
})}
<text x={toSvg(127.5,36.5)[0]} y={toSvg(127.5,36.5)[1]} textAnchor="middle" fontSize="11" fill="#475569" opacity="0.5">한국 Korea</text>
<text x={toSvg(124.5,35.5)[0]} y={toSvg(124.5,35.5)[1]} textAnchor="middle" fontSize="9" fill="#64748b" opacity="0.4">서해</text>
<text x={toSvg(129.5,34.5)[0]} y={toSvg(129.5,34.5)[1]} textAnchor="middle" fontSize="9" fill="#64748b" opacity="0.4">남해</text>
<text x={toSvg(130.5,37.5)[0]} y={toSvg(130.5,37.5)[1]} textAnchor="middle" fontSize="9" fill="#64748b" opacity="0.4">동해</text>
</svg>
);
}
function Detail({o}){
return (
<div style={{padding:14}}>
<div style={{display:"flex",gap:12,alignItems:"flex-start"}}>
<div style={{flex:1}}>
{o.pairs.length>0 && <div style={{marginBottom:10}}>
<div style={{fontSize:11,fontWeight:600,color:"#475569",marginBottom:6}}>🔗 2척식 저인망 Pair Trawl {o.pairs.length} | 허가수역: ,</div>
<div style={{display:"flex",flexDirection:"column",gap:10}}>
{o.pairs.map((p,i)=>(
<div key={i} style={{border:"1.5px dashed #94a3b8",borderRadius:10,padding:"10px 8px 6px",background:"#f8fafc",position:"relative"}}>
<div style={{position:"absolute",top:-8,left:"50%",transform:"translateX(-50%)",fontSize:9,fontWeight:700,color:"#475569",background:"#f8fafc",padding:"0 6px",border:"1px solid #cbd5e1",borderRadius:6,whiteSpace:"nowrap"}}> {i+1}</div>
<div style={{display:"flex",gap:4,marginTop:2}}>
<div style={{flex:1}}><VC s={p.m} label="본선 MAIN"/></div>
<div style={{flex:1}}><VC s={p.s} label="부속선 SUB"/></div>
</div>
</div>
))}
</div>
</div>}
{o.upt.length>0 && <div style={{marginBottom:8}}>
<div style={{fontSize:11,fontWeight:600,color:"#1e40af",marginBottom:4}}>🚢 본선(미매칭) {o.upt.length} | 수역 ,</div>
<div style={{display:"grid",gridTemplateColumns:"1fr 1fr",gap:4}}>{o.upt.map(s=><VC key={s.pn} s={s} label="본선"/>)}</div>
</div>}
{o.upts.length>0 && <div style={{marginBottom:8}}>
<div style={{fontSize:11,fontWeight:600,color:"#ea580c",marginBottom:4}}>🛥 부속선(미매칭) {o.upts.length} | 수역 ,</div>
<div style={{display:"grid",gridTemplateColumns:"1fr 1fr",gap:4}}>{o.upts.map(s=><VC key={s.pn} s={s} label="부속선"/>)}</div>
</div>}
{o.gn.length>0 && <div style={{marginBottom:8}}>
<div style={{fontSize:11,fontWeight:600,color:"#047857",marginBottom:4}}>🎣 유망 Gill Net {o.gn.length} | 수역 ,,</div>
<div style={{display:"grid",gridTemplateColumns:"1fr 1fr",gap:4}}>{o.gn.map(s=><VC key={s.pn} s={s}/>)}</div>
</div>}
{o.ot.length>0 && <div style={{marginBottom:8}}>
<div style={{fontSize:11,fontWeight:600,color:"#6d28d9",marginBottom:4}}> 1척식 Otter Trawl {o.ot.length} | 수역 ,</div>
<div style={{display:"grid",gridTemplateColumns:"1fr 1fr",gap:4}}>{o.ot.map(s=><VC key={s.pn} s={s}/>)}</div>
</div>}
{o.ps.length>0 && <div style={{marginBottom:8}}>
<div style={{fontSize:11,fontWeight:600,color:"#b91c1c",marginBottom:4}}>🐟 위망 Purse Seine {o.ps.length} | 수역 ,,,</div>
<div style={{display:"grid",gridTemplateColumns:"1fr 1fr",gap:4}}>{o.ps.map(s=><VC key={s.pn} s={s}/>)}</div>
</div>}
</div>
{o.fc.length>0 && <div style={{display:"flex",alignItems:"flex-start",gap:0}}>
<div style={{display:"flex",flexDirection:"column",alignItems:"center",justifyContent:"center",flexShrink:0,padding:"20px 0"}}>
<svg width="50" height="60" viewBox="0 0 50 60"><line x1="5" y1="20" x2="38" y2="28" stroke="#f59e0b" strokeWidth="1.5" strokeDasharray="4 2" opacity="0.6"/><line x1="5" y1="35" x2="38" y2="30" stroke="#f59e0b" strokeWidth="2" strokeDasharray="4 2"/><line x1="5" y1="45" x2="38" y2="35" stroke="#f59e0b" strokeWidth="1.5" strokeDasharray="4 2" opacity="0.6"/><polygon points="38,24 48,31 38,38" fill="#f59e0b"/><text x="25" y="12" textAnchor="middle" fontSize="7" fill="#b45309" fontWeight="700">어획물 이적</text></svg>
</div>
<div style={{width:180,flexShrink:0}}>
<div style={{fontSize:11,fontWeight:700,color:"#92400e",marginBottom:6}}>🚛 운반선 Carrier ({o.fc.length}) | 수역 ~</div>
<div style={{display:"flex",flexDirection:"column",gap:6}}>{o.fc.map(s=><VC key={s.pn} s={s} label="운반 FC"/>)}</div>
<div style={{marginTop:10,textAlign:"center",fontSize:20}}>🏭<div style={{fontSize:9,color:"#64748b"}}> 항구</div></div>
</div>
</div>}
</div>
</div>
);
}
export default function App(){
const owners=useMemo(()=>parse(D),[]);
const [sel,setSel]=useState(null);
const [search,setSearch]=useState("");
const [filterCode,setFilterCode]=useState("ALL");
const [page,setPage]=useState(0);
const [tab,setTab]=useState("map");
const PER=30;
const filtered=useMemo(()=>{
let list=owners;
if(search.trim()){const q=search.toLowerCase();list=list.filter(o=>o.own.includes(q)||o.ownEn.toLowerCase().includes(q));}
if(filterCode!=="ALL"){list=list.filter(o=>{
if(filterCode==="PT") return o.pairs.length>0||o.upt.length>0||o.upts.length>0;
if(filterCode==="GN") return o.gn.length>0;
if(filterCode==="OT") return o.ot.length>0;
if(filterCode==="PS") return o.ps.length>0;
if(filterCode==="FC") return o.fc.length>0;
if(filterCode==="MULTI"){const t=new Set();if(o.pairs.length||o.upt.length||o.upts.length)t.add("PT");if(o.gn.length)t.add("GN");if(o.ot.length)t.add("OT");if(o.ps.length)t.add("PS");if(o.fc.length)t.add("FC");return t.size>=2;}
return true;
});}
return list;
},[owners,search,filterCode]);
const totalPages=Math.ceil(filtered.length/PER);
const paged=filtered.slice(page*PER,(page+1)*PER);
const stats=useMemo(()=>{
let pt=0,pts=0,gn=0,ot=0,ps=0,fc=0;
owners.forEach(o=>{pt+=o.pairs.length+o.upt.length;pts+=o.pairs.length+o.upts.length;gn+=o.gn.length;ot+=o.ot.length;ps+=o.ps.length;fc+=o.fc.length;});
return {pt,pts,gn,ot,ps,fc,total:pt+pts+gn+ot+ps+fc};
},[owners]);
const zoneInfos=[
{id:"I",types:["PS(위망)","FC(운반)"],counts:"PS 16척 · FC 31척"},
{id:"II",types:["PT(저인망)","GN(유망)","OT(1척식)","PS(위망)","FC(운반)"],counts:"PT 646척 · GN 200척 · OT 13척 · PS 16척 · FC 31척"},
{id:"III",types:["PT(저인망)","GN(유망)","OT(1척식)","PS(위망)","FC(운반)"],counts:"PT 646척 · GN 200척 · OT 13척 · PS 16척 · FC 31척"},
{id:"IV",types:["GN(유망)","PS(위망)","FC(운반)"],counts:"GN 200척 · PS 16척 · FC 31척"},
];
const permitRows=[
{code:"PT(본선)",zone:"Ⅱ,Ⅲ",n:"323",c:"#1e40af",note:"본선+부속선 쌍 운용"},
{code:"PT-S(부속)",zone:"Ⅱ,Ⅲ",n:"323",c:"#ea580c",note:"본선에 종속"},
{code:"GN(유망)",zone:"Ⅱ,Ⅲ,Ⅳ",n:"200",c:"#10b981",note:"수역Ⅳ 추가 허용"},
{code:"OT(1척식)",zone:"Ⅱ,Ⅲ",n:"13",c:"#8b5cf6",note:"단독 저인망"},
{code:"PS(위망)",zone:",Ⅱ,Ⅲ,Ⅳ",n:"16",c:"#ef4444",note:"전 수역 허가"},
{code:"FC(운반)",zone:",Ⅱ,Ⅲ,Ⅳ",n:"31",c:"#f59e0b",note:"전 수역 허가"},
];
function getZoneStr(o){
const zs=new Set();
const ptN=o.pairs.length+o.upt.length;
const ptsN=o.pairs.length+o.upts.length;
if(ptN||ptsN){zs.add("Ⅱ");zs.add("Ⅲ");}
if(o.gn.length){zs.add("Ⅱ");zs.add("Ⅲ");zs.add("Ⅳ");}
if(o.ot.length){zs.add("Ⅱ");zs.add("Ⅲ");}
if(o.ps.length){zs.add("");zs.add("Ⅱ");zs.add("Ⅲ");zs.add("Ⅳ");}
if(o.fc.length){zs.add("");zs.add("Ⅱ");zs.add("Ⅲ");zs.add("Ⅳ");}
return [...zs].sort().join(",");
}
return (
<div style={{minHeight:"100vh",background:"#f0f4f8",fontFamily:"'Malgun Gothic','맑은 고딕',sans-serif"}}>
<div style={{background:"linear-gradient(135deg,#0f172a,#1e3a5f)",padding:"16px 20px",color:"#fff"}}>
<h1 style={{fontSize:18,fontWeight:700,margin:0}}>선단 구성 네트워크 906 · 어업수역 매핑</h1>
<p style={{fontSize:11,color:"#93c5fd",margin:"4px 0 0"}}>{owners.length} 소유주 · {stats.total} · 특정어업수역 ~ · 한자/영문 병기</p>
</div>
<div style={{display:"flex",gap:0,background:"#fff",borderBottom:"1px solid #e2e8f0"}}>
{[["map","🗺️ 어업수역 지도"],["list","📋 소유주별 선단 (906척)"]].map(([k,l])=>(
<button key={k} onClick={()=>setTab(k)} style={{padding:"10px 20px",border:"none",cursor:"pointer",fontSize:13,fontWeight:600,borderBottom:tab===k?"3px solid #1e40af":"3px solid transparent",background:tab===k?"#eff6ff":"transparent",color:tab===k?"#1e40af":"#64748b"}}>{l}</button>
))}
</div>
<div style={{maxWidth:960,margin:"0 auto",padding:"12px 16px"}}>
{tab==="map" && (
<div style={{display:"grid",gridTemplateColumns:"440px 1fr",gap:16}}>
<div style={{background:"#fff",borderRadius:12,border:"1px solid #e2e8f0",overflow:"hidden"}}>
<div style={{padding:"8px 12px",borderBottom:"1px solid #e2e8f0",fontSize:13,fontWeight:700,color:"#1e293b"}}>특정어업수역 ~</div>
<div style={{height:400}}><ZoneMap/></div>
</div>
<div style={{display:"flex",flexDirection:"column",gap:10}}>
{ZONES.map((z,zi)=>(
<div key={z.id} style={{background:"#fff",borderRadius:10,border:`2px solid ${z.color}30`,padding:12}}>
<div style={{display:"flex",alignItems:"center",gap:6,marginBottom:6}}>
<div style={{width:28,height:28,borderRadius:8,background:z.color,color:"#fff",display:"flex",alignItems:"center",justifyContent:"center",fontSize:12,fontWeight:800}}>{z.id}</div>
<div style={{fontSize:13,fontWeight:700,color:z.color}}>특정어업수역 {z.name.replace("수역","")}</div>
</div>
<div style={{fontSize:11,color:"#475569",lineHeight:1.6}}>
<b>허가 업종:</b> {zoneInfos[zi].types.join(", ")}<br/>
<b>허가 선박:</b> {zoneInfos[zi].counts}
</div>
</div>
))}
<div style={{background:"#fff",borderRadius:10,border:"1px solid #e2e8f0",padding:12}}>
<div style={{fontSize:12,fontWeight:700,color:"#1e293b",marginBottom:6}}>업종별 허가수역</div>
<table style={{width:"100%",borderCollapse:"collapse",fontSize:11}}>
<thead><tr style={{background:"#f8fafc"}}>
{["업종","수역","척수","비고"].map(h=><th key={h} style={{padding:"4px 6px",fontWeight:700,color:"#64748b",borderBottom:"1px solid #e2e8f0",textAlign:"left"}}>{h}</th>)}
</tr></thead>
<tbody>
{permitRows.map(r=>(
<tr key={r.code} style={{borderBottom:"1px solid #f1f5f9"}}>
<td style={{padding:"4px 6px",fontWeight:700,color:r.c}}>{r.code}</td>
<td style={{padding:"4px 6px",fontWeight:600}}>{r.zone}</td>
<td style={{padding:"4px 6px"}}>{r.n}</td>
<td style={{padding:"4px 6px",color:"#64748b"}}>{r.note}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
)}
{tab==="list" && (
<div>
<div style={{display:"flex",gap:10,flexWrap:"wrap",alignItems:"center",marginBottom:8}}>
{[{t:"main",l:"🔵 본선 PT",n:stats.pt},{t:"sub",l:"🟠 부속 PT-S",n:stats.pts},{t:"gn",l:"🟢 유망 GN",n:stats.gn},{t:"ot",l:"🟣 1척식 OT",n:stats.ot},{t:"ps",l:"🔴 위망 PS",n:stats.ps},{t:"fc",l:"🟡 운반 FC",n:stats.fc}].map(x=>(
<div key={x.t} style={{display:"flex",alignItems:"center",gap:4}}><Ship type={x.t} sz={20}/><span style={{fontSize:10,fontWeight:600,color:"#475569"}}>{x.l}({x.n})</span></div>
))}
</div>
<div style={{display:"flex",gap:6,marginBottom:10,flexWrap:"wrap",alignItems:"center"}}>
<input value={search} onChange={e=>{setSearch(e.target.value);setPage(0);setSel(null);}} placeholder="소유주 검색 (한자/영문)" style={{padding:"6px 10px",border:"1px solid #d1d5db",borderRadius:6,fontSize:12,width:200}}/>
{[["ALL","전체"],["PT","저인망"],["GN","유망"],["OT","1척식"],["PS","위망"],["FC","운반선"],["MULTI","다중업종"]].map(([k,l])=>(
<button key={k} onClick={()=>{setFilterCode(k);setPage(0);setSel(null);}} style={{padding:"4px 10px",borderRadius:6,border:"none",fontSize:11,fontWeight:600,cursor:"pointer",background:filterCode===k?"#1e40af":"#e2e8f0",color:filterCode===k?"#fff":"#475569"}}>{l}</button>
))}
<span style={{fontSize:11,color:"#94a3b8",marginLeft:"auto"}}>{filtered.length} 소유주 / {page+1}/{totalPages||1}p</span>
</div>
<div style={{background:"#fff",borderRadius:10,border:"1px solid #e2e8f0",overflow:"hidden"}}>
<table style={{width:"100%",borderCollapse:"collapse",fontSize:11}}>
<thead><tr style={{background:"#f8fafc"}}>
{["No","소유주 Owner","척수","PT","PT-S","GN","OT","PS","FC","수역"].map(h=><th key={h} style={{padding:"7px 6px",fontWeight:700,color:"#64748b",borderBottom:"2px solid #e2e8f0",textAlign:"left"}}>{h}</th>)}
</tr></thead>
<tbody>
{paged.map((o,i)=>{
const idx=page*PER+i+1;
const isOpen=sel===o.own;
const ptN=o.pairs.length+o.upt.length;
const ptsN=o.pairs.length+o.upts.length;
const zoneStr=getZoneStr(o);
return (
<React.Fragment key={o.own+idx}>
<tr onClick={()=>setSel(isOpen?null:o.own)} style={{cursor:"pointer",borderBottom:"1px solid #f1f5f9",background:isOpen?"#eff6ff":"transparent"}}>
<td style={{padding:"5px 6px",color:"#94a3b8",width:32}}>{idx}</td>
<td style={{padding:"5px 6px"}}><div style={{fontWeight:700,color:"#1e293b",fontSize:12}}>{o.own}</div><div style={{fontSize:9,color:"#64748b",fontStyle:"italic"}}>{o.ownEn}</div></td>
<td style={{padding:"5px 6px",fontWeight:700,color:"#1e40af"}}>{o.total}</td>
<td style={{padding:"5px 6px",color:ptN?"#1e40af":"#e2e8f0",fontWeight:ptN?700:400}}>{ptN||"-"}</td>
<td style={{padding:"5px 6px",color:ptsN?"#ea580c":"#e2e8f0",fontWeight:ptsN?700:400}}>{ptsN||"-"}</td>
<td style={{padding:"5px 6px",color:o.gn.length?"#10b981":"#e2e8f0",fontWeight:o.gn.length?700:400}}>{o.gn.length||"-"}</td>
<td style={{padding:"5px 6px",color:o.ot.length?"#8b5cf6":"#e2e8f0",fontWeight:o.ot.length?700:400}}>{o.ot.length||"-"}</td>
<td style={{padding:"5px 6px",color:o.ps.length?"#ef4444":"#e2e8f0",fontWeight:o.ps.length?700:400}}>{o.ps.length||"-"}</td>
<td style={{padding:"5px 6px",color:o.fc.length?"#f59e0b":"#e2e8f0",fontWeight:o.fc.length?700:400}}>{o.fc.length||"-"}</td>
<td style={{padding:"5px 6px",fontSize:10,fontWeight:600,color:"#475569"}}>{zoneStr}</td>
</tr>
{isOpen && <tr><td colSpan={10} style={{padding:0,background:"#f8fafc",borderBottom:"2px solid #1e40af"}}><Detail o={o}/></td></tr>}
</React.Fragment>
);
})}
</tbody>
</table>
</div>
{totalPages>1 && (
<div style={{display:"flex",gap:4,justifyContent:"center",marginTop:10}}>
<button onClick={()=>setPage(Math.max(0,page-1))} disabled={page===0} style={{padding:"4px 10px",borderRadius:6,border:"1px solid #d1d5db",fontSize:11,cursor:"pointer",background:"#fff"}}></button>
{Array.from({length:Math.min(10,totalPages)},(_,k)=>{
let p=page<5?k:page>=totalPages-5?totalPages-10+k:page-5+k;
if(p<0) p=0;
if(p>=totalPages) return null;
return (<button key={p} onClick={()=>setPage(p)} style={{padding:"4px 8px",borderRadius:6,border:"none",fontSize:11,cursor:"pointer",background:page===p?"#1e40af":"#e2e8f0",color:page===p?"#fff":"#475569",fontWeight:page===p?700:400}}>{p+1}</button>);
})}
<button onClick={()=>setPage(Math.min(totalPages-1,page+1))} disabled={page>=totalPages-1} style={{padding:"4px 10px",borderRadius:6,border:"1px solid #d1d5db",fontSize:11,cursor:"pointer",background:"#fff"}}></button>
</div>
)}
</div>
)}
</div>
</div>
);
}

파일 보기

@ -0,0 +1,639 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>906척 실시간 조업 감시 — 선단 연관관계</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.css"/>
<script src="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.js"></script>
<style>
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;600;700;800;900&display=swap');
*{margin:0;padding:0;box-sizing:border-box}
:root{--bg:#020617;--panel:#0F172A;--card:#1E293B;--border:#1E3A5F;--text:#E2E8F0;--muted:#64748B;--accent:#3B82F6;
--pt:#2563EB;--pts:#3B82F6;--gn:#059669;--ot:#7C3AED;--ps:#DC2626;--fc:#D97706;--crit:#EF4444;--high:#F59E0B}
body{font-family:'Noto Sans KR',sans-serif;background:var(--bg);color:var(--text);overflow:hidden;height:100vh}
.app{display:grid;grid-template-columns:310px 1fr;grid-template-rows:44px 1fr;height:100vh}
.topbar{grid-column:1/-1;background:var(--panel);border-bottom:1px solid var(--border);display:flex;align-items:center;padding:0 14px;gap:10px;z-index:1000}
.topbar .logo{font-size:14px;font-weight:800;display:flex;align-items:center;gap:5px}
.topbar .logo span{color:var(--accent)}
.topbar .stats{display:flex;gap:14px;margin-left:auto}
.topbar .stat{font-size:10px;color:var(--muted);display:flex;align-items:center;gap:3px}
.topbar .stat b{color:var(--text);font-size:12px}
.topbar .time{font-size:10px;color:var(--accent);font-weight:600}
.sidebar{background:var(--panel);border-right:1px solid var(--border);overflow-y:auto;display:flex;flex-direction:column}
.map-area{position:relative}
.sb{padding:10px 12px;border-bottom:1px solid var(--border)}
.sb-t{font-size:9px;font-weight:700;color:var(--muted);letter-spacing:1.5px;text-transform:uppercase;margin-bottom:6px}
/* Type grid */
.tg{display:grid;grid-template-columns:repeat(3,1fr);gap:3px}
.tb{background:var(--card);border:1px solid transparent;border-radius:5px;padding:4px;cursor:pointer;text-align:center;transition:all .15s}
.tb:hover{border-color:var(--border)}
.tb.on{border-color:var(--accent);background:rgba(59,130,246,.1)}
.tb .c{font-size:11px;font-weight:800}
.tb .n{font-size:8px;color:var(--muted)}
/* Speed bar */
.sbar{position:relative;height:24px;background:var(--bg);border-radius:5px;overflow:hidden;margin:4px 0}
.sseg{position:absolute;border-radius:3px;display:flex;align-items:center;justify-content:center;font-size:7px;color:#fff;font-weight:600}
.sseg.p{height:24px;top:0;border:1.5px solid rgba(255,255,255,.25);box-shadow:0 0 6px rgba(0,0,0,.3)}
.sseg:not(.p){height:16px;top:4px;opacity:.6}
/* Vessel list */
.vlist{max-height:180px;overflow-y:auto}
.vi{display:flex;align-items:center;gap:6px;padding:4px 6px;border-radius:3px;cursor:pointer;font-size:10px;transition:background .1s}
.vi:hover{background:var(--card)}
.vi .dot{width:7px;height:7px;border-radius:50%;flex-shrink:0}
.vi .nm{flex:1;font-weight:500}
.vi .sp{font-weight:700;font-size:9px}
.vi .st{font-size:7px;padding:1px 3px;border-radius:2px;font-weight:600}
/* Alarm */
.ai{display:flex;gap:6px;padding:4px 6px;border-radius:3px;margin-bottom:2px;font-size:9px;border-left:3px solid}
.ai.cr{border-color:var(--crit);background:rgba(239,68,68,.07)}
.ai.hi{border-color:var(--high);background:rgba(245,158,11,.05)}
.ai .at{color:var(--muted);font-size:8px;white-space:nowrap}
/* Relation panel */
.rel-panel{background:var(--card);border-radius:6px;padding:8px;margin-top:4px}
.rel-header{display:flex;align-items:center;gap:6px;margin-bottom:6px}
.rel-badge{font-size:9px;padding:1px 5px;border-radius:3px;font-weight:700}
.rel-line{display:flex;align-items:center;gap:4px;font-size:10px;padding:2px 0}
.rel-line .dot{width:6px;height:6px;border-radius:50%;flex-shrink:0}
.rel-link{width:20px;display:flex;align-items:center;justify-content:center;color:var(--muted);font-size:10px}
.rel-dist{font-size:8px;padding:1px 4px;border-radius:2px;font-weight:600}
/* Fleet network */
.fleet-card{background:rgba(30,41,59,.8);border:1px solid var(--border);border-radius:6px;padding:8px;margin-bottom:4px}
.fleet-owner{font-size:10px;font-weight:700;color:var(--accent);margin-bottom:4px}
.fleet-vessel{display:flex;align-items:center;gap:4px;font-size:9px;padding:1px 0}
/* Toggles */
.tog{display:flex;gap:3px;flex-wrap:wrap;margin-bottom:6px}
.tog-btn{font-size:8px;padding:2px 6px;border-radius:3px;border:1px solid var(--border);background:var(--card);color:var(--muted);cursor:pointer;transition:all .15s}
.tog-btn.on{background:var(--accent);color:#fff;border-color:var(--accent)}
/* Map panels */
.map-legend{position:absolute;bottom:12px;right:12px;z-index:800;background:rgba(15,23,42,.92);backdrop-filter:blur(8px);border:1px solid var(--border);border-radius:8px;padding:10px;font-size:9px;min-width:180px}
.map-legend .lt{font-size:8px;font-weight:700;color:var(--muted);margin-bottom:4px;letter-spacing:1px}
.map-legend .li{display:flex;align-items:center;gap:5px;margin-bottom:2px}
.map-legend .ls{width:12px;height:12px;border-radius:3px;flex-shrink:0}
.map-info{position:absolute;top:12px;right:12px;z-index:800;background:rgba(15,23,42,.95);backdrop-filter:blur(8px);border:1px solid var(--border);border-radius:8px;padding:12px;width:270px}
.map-info .ir{display:flex;justify-content:space-between;font-size:10px;padding:2px 0;border-bottom:1px solid rgba(255,255,255,.03)}
.map-info .il{color:var(--muted)}
.map-info .iv{font-weight:600}
.close-btn{position:absolute;top:6px;right:8px;background:none;border:none;color:var(--muted);cursor:pointer;font-size:13px}
.month-row{display:flex;gap:1px}
.month-cell{flex:1;height:12px;border-radius:2px;display:flex;align-items:center;justify-content:center;font-size:6px;font-weight:600}
::-webkit-scrollbar{width:3px}::-webkit-scrollbar-track{background:transparent}::-webkit-scrollbar-thumb{background:var(--border);border-radius:2px}
.leaflet-control-zoom{border:1px solid var(--border)!important}
.leaflet-control-zoom a{background:var(--panel)!important;color:var(--text)!important;border-color:var(--border)!important}
.leaflet-control-attribution{display:none!important}
</style>
</head>
<body>
<div class="app">
<div class="topbar">
<div class="logo">🛰 <span>WING</span> 조업감시·선단연관</div>
<div class="stats">
<div class="stat">전체 <b id="sTotal">906</b></div>
<div class="stat">조업 <b id="sFish" style="color:#22C55E">0</b></div>
<div class="stat">항해 <b id="sTran" style="color:#3B82F6">0</b></div>
<div class="stat">쌍연결 <b id="sPair" style="color:#F59E0B">0</b></div>
<div class="stat">경고 <b id="sAlarm" style="color:#EF4444">0</b></div>
</div>
<div class="time" id="clock"></div>
</div>
<div class="sidebar">
<div class="sb">
<div class="sb-t">업종 필터</div>
<div class="tg" id="typeGrid"></div>
</div>
<div class="sb">
<div class="sb-t">지도 표시 설정</div>
<div class="tog" id="toggles"></div>
</div>
<div class="sb">
<div class="sb-t">속도 프로파일</div>
<div id="speedPanel"></div>
</div>
<div class="sb">
<div class="sb-t">선단 연관관계 <span id="relInfo" style="color:var(--accent);font-size:8px"></span></div>
<div id="relPanel"></div>
</div>
<div class="sb" style="flex:1;min-height:0;">
<div class="sb-t">선박 목록 <span id="vlInfo" style="color:var(--accent);font-size:8px"></span></div>
<div class="vlist" id="vlist"></div>
</div>
<div class="sb" style="max-height:130px;overflow-y:auto;">
<div class="sb-t">실시간 경고</div>
<div id="alarms"></div>
</div>
</div>
<div class="map-area">
<div id="map" style="width:100%;height:100%"></div>
<div class="map-legend" id="legend"></div>
<div class="map-info" id="info" style="display:none"></div>
</div>
</div>
<script>
// ═══ DATA ═══
const ZP={
"":[[131.265,36.1666],[129.7162,35.65],[129.7275,35.692],[129.7654,35.7956],[129.8298,35.9931],[129.8323,36.0444],[129.7208,36.2417],[129.6737,36.2681],[129.6906,36.4539],[129.7282,36.7868],[129.6711,37.0095],[129.6223,37.2561],[129.4768,37.4744],[129.3255,37.6935],[129.1762,37.8767],[128.9936,38.068],[128.8559,38.2544],[130.1643,38.0028],[131.6327,37.3261],[131.6277,37.3163],[131.6156,37.2834],[131.4273,37.0406],[130.375,36.1666],[131.265,36.1666]],
"Ⅱ":[[126.0005,32.1833],[126.0141,33.104],[126.0646,33.0051],[126.1417,32.9416],[126.2317,32.9132],[126.3279,32.9173],[126.4691,33.0037],[126.5577,33.0178],[126.6491,33.0193],[126.7439,33.0327],[126.852,33.0983],[126.9682,33.1401],[127.0508,33.2096],[127.1211,33.2964],[127.1685,33.3742],[127.2041,33.4496],[127.214,33.5279],[127.0126,33.7734],[127.26,33.7988],[128.0,34.1187],[128.8883,34.344],[127.86,33.2283],[126.0005,32.1833]],
"Ⅲ":[[124.1255,35.0007],[124.9773,34.8191],[124.9046,33.9378],[125.9272,33.7231],[126.0401,33.7034],[126.059,33.6762],[126.0738,33.6023],[126.0562,33.5638],[126.0067,33.5056],[125.9855,33.4803],[125.9427,33.433],[125.9209,33.386],[125.9048,33.3233],[125.9048,33.2998],[125.9159,33.2501],[125.9335,33.2153],[125.9646,33.1745],[124.1704,33.3003],[124.1255,35.0007]],
"Ⅳ":[[124.5,35.5],[124.5,36.75],[124.3333,37.0],[125.2289,37.0029],[125.4284,36.9026],[125.3881,36.8333],[125.3498,36.7644],[125.2935,36.6406],[125.2951,36.5872],[125.3248,36.5157],[125.5696,36.2265],[125.6364,36.15],[125.7914,35.926],[125.8327,35.8149],[125.8488,35.7167],[125.8484,35.6655],[125.7756,35.4651],[125.6868,35.3876],[125.3743,35.148],[125.1858,35.0],[124.1255,35.0007],[124.5,35.5]],
};
const ZC={"":"#3B82F6","Ⅱ":"#22C55E","Ⅲ":"#F59E0B","Ⅳ":"#A855F7"};
const ZN={"":"수역I(동해)","Ⅱ":"수역II(제주남방)","Ⅲ":"수역III(서해남부)","Ⅳ":"수역IV(서해중간)"};
const T={
PT:{nm:"2척식 저인망(본선)",cnt:323,z:["Ⅱ","Ⅲ"],col:"#2563EB",ic:"⛵",
sp:[{s:"정박",r:[0,.5],v:.3,c:"#64748B"},{s:"투양망",r:[1,2.5],v:1.5,c:"#F59E0B"},{s:"예인조업",r:[2.5,4.5],v:3.3,c:"#2563EB",p:1},{s:"저속",r:[4.5,7],v:5.5,c:"#8B5CF6"},{s:"고속",r:[7,15],v:8.3,c:"#475569"}],
mo:[.5,.5,.7,.8,.7,.1,0,0,.8,1,.9,.6],tj:"직선예인(쌍동기화)"},
"PT-S":{nm:"2척식 저인망(부속선)",cnt:323,z:["Ⅱ","Ⅲ"],col:"#3B82F6",ic:"⛵",
sp:[{s:"정박",r:[0,.5],v:.3,c:"#64748B"},{s:"보조",r:[1,2.5],v:1.5,c:"#F59E0B"},{s:"동기예인",r:[2.5,4.5],v:3.3,c:"#3B82F6",p:1},{s:"추종",r:[4.5,7],v:5.5,c:"#8B5CF6"},{s:"고속",r:[7,15],v:8.3,c:"#475569"}],
mo:[.5,.5,.7,.8,.7,.1,0,0,.8,1,.9,.6],tj:"본선거울상"},
GN:{nm:"유망",cnt:200,z:["Ⅱ","Ⅲ","Ⅳ"],col:"#059669",ic:"🪢",
sp:[{s:"정박",r:[0,.3],v:.2,c:"#64748B"},{s:"표류",r:[.5,2],v:1,c:"#059669",p:1},{s:"양망",r:[1,3],v:2,c:"#F59E0B"},{s:"투망",r:[2,4],v:3,c:"#EF4444"},{s:"항해",r:[5,15],v:7.5,c:"#475569"}],
mo:[.6,.5,.7,.7,.6,.3,.2,.3,.9,1,.9,.7],tj:"투망→표류→양망"},
OT:{nm:"1척식 저인망",cnt:13,z:["Ⅱ","Ⅲ"],col:"#7C3AED",ic:"🚢",
sp:[{s:"정박",r:[0,.5],v:.3,c:"#64748B"},{s:"투양망",r:[1,2],v:1.5,c:"#F59E0B"},{s:"단독예인",r:[2.5,5],v:3.5,c:"#7C3AED",p:1},{s:"항해",r:[5,15],v:8,c:"#475569"}],
mo:[.5,.5,.7,.7,.6,.1,0,0,.8,1,.8,.5],tj:"단독레이스트랙"},
PS:{nm:"위망/채낚기",cnt:16,z:["","Ⅱ","Ⅲ","Ⅳ"],col:"#DC2626",ic:"🦑",
sp:[{s:"정박",r:[0,.3],v:.2,c:"#64748B"},{s:"위망",r:[.3,1.5],v:.5,c:"#DC2626",p:1},{s:"채낚기",r:[.3,2],v:.8,c:"#F97316",p:1},{s:"투양",r:[1.5,3],v:2,c:"#F59E0B"},{s:"항해",r:[5,15],v:8,c:"#475569"}],
mo:[.6,.7,.8,.7,.4,.3,.5,.7,.9,1,.8,.6],tj:"점군집/야간표류"},
FC:{nm:"운반선",cnt:31,z:["","Ⅱ","Ⅲ","Ⅳ"],col:"#D97706",ic:"🚛",
sp:[{s:"정박",r:[0,.3],v:.2,c:"#64748B"},{s:"환적",r:[.5,2],v:1,c:"#D97706",p:1},{s:"저속",r:[3,6],v:4.5,c:"#8B5CF6"},{s:"고속",r:[6,15],v:9,c:"#475569"}],
mo:[.4,.4,.6,.7,.5,.2,.1,.1,.8,1,.9,.5],tj:"허브스포크순회"},
};
const MO=["1월","2월","3월","4월","5월","6월","7월","8월","9월","10월","11월","12월"];
// ═══ OWNERS & FLEET GENERATION ═══
const SURNAMES=["张","王","李","刘","陈","杨","黄","赵","周","吴","徐","孙","马","朱","胡","郭","林","何","高","罗"];
const REGIONS=["荣成","石岛","烟台","威海","日照","青岛","连云港","舟山","象山","大连"];
function rnd(a,b){return a+Math.random()*(b-a)}
function pick(a){return a[Math.floor(Math.random()*a.length)]}
// Generate owners with fleets
function genFleet(){
const owners=[];
let vid=1, ptPairs=[], fcList=[];
// Generate PT pairs: 40 pairs shown (of 311)
for(let i=0;i<40;i++){
const owner = pick(SURNAMES)+pick(SURNAMES)+pick(["渔业","海产","水产","船务"])+pick(["有限公司","合作社",""]);
const region = pick(REGIONS);
const baseId = 10000+vid;
const zone = pick(["Ⅱ","Ⅲ"]);
const poly=ZP[zone]; const lats=poly.map(p=>p[1]),lons=poly.map(p=>p[0]);
const lat=rnd(Math.min(...lats)+.1,Math.max(...lats)-.1);
const lon=rnd(Math.min(...lons)+.1,Math.max(...lons)-.1);
const isFishing=Math.random()<.55;
const sp=isFishing?rnd(2.5,4.5):rnd(6,11);
const crs=rnd(0,360);
const pairDist=isFishing?rnd(.2,1.2):rnd(0,.3);
const pairAngle=rnd(0,360);
const lat2=lat+pairDist/60*Math.cos(pairAngle*Math.PI/180);
const lon2=lon+pairDist/60*Math.sin(pairAngle*Math.PI/180)/Math.cos(lat*Math.PI/180);
const pt={id:vid++,permit:`C21-${baseId}A`,code:"PT",lat,lon,speed:+sp.toFixed(1),course:+crs.toFixed(0),
state:isFishing?"조업중":(sp<1?"정박":"항해중"),zone,isFishing,color:"#2563EB",owner,region,pairId:null};
const pts={id:vid++,permit:`C21-${baseId}B`,code:"PT-S",lat:+lat2.toFixed(4),lon:+lon2.toFixed(4),
speed:+(sp+rnd(-.3,.3)).toFixed(1),course:+(crs+rnd(-10,10)).toFixed(0),
state:isFishing?"조업중":(sp<1?"정박":"항해중"),zone,isFishing,color:"#3B82F6",owner,region,pairId:null};
pt.pairId=pts.id; pts.pairId=pt.id;
pt.pairDist=+pairDist.toFixed(2);pts.pairDist=pt.pairDist;
ptPairs.push({pt,pts,owner,region});
owners.push({name:owner,region,vessels:[pt,pts],type:"trawl"});
}
// GN vessels
for(let i=0;i<30;i++){
const oi=Math.random()<.3?owners[Math.floor(Math.random()*owners.length)]:null;
const owner=oi?oi.name:pick(SURNAMES)+pick(SURNAMES)+pick(["渔业","水产"])+"有限公司";
const region=oi?oi.region:pick(REGIONS);
const zone=pick(["Ⅱ","Ⅲ","Ⅳ"]);
const poly=ZP[zone];const lats=poly.map(p=>p[1]),lons=poly.map(p=>p[0]);
const lat=rnd(Math.min(...lats)+.1,Math.max(...lats)-.1);
const lon=rnd(Math.min(...lons)+.1,Math.max(...lons)-.1);
const isF=Math.random()<.5;
const sp=isF?rnd(.5,2):rnd(5,10);
const v={id:vid++,permit:`C21-${10000+vid}A`,code:"GN",lat,lon,speed:+sp.toFixed(1),course:+rnd(0,360).toFixed(0),
state:isF?pick(["표류","투망","양망"]):(sp<1?"정박":"항해중"),zone,isFishing:isF,color:"#059669",owner,region,pairId:null,pairDist:null};
if(oi)oi.vessels.push(v); else owners.push({name:owner,region,vessels:[v],type:"gn"});
}
// OT
for(let i=0;i<13;i++){
const owner=pick(SURNAMES)+pick(SURNAMES)+"远洋渔业";const region=pick(REGIONS);
const zone=pick(["Ⅱ","Ⅲ"]);const poly=ZP[zone];const lats=poly.map(p=>p[1]),lons=poly.map(p=>p[0]);
const lat=rnd(Math.min(...lats)+.1,Math.max(...lats)-.1);
const lon=rnd(Math.min(...lons)+.1,Math.max(...lons)-.1);
const isF=Math.random()<.5;const sp=isF?rnd(2.5,5):rnd(5,10);
const v={id:vid++,permit:`C21-${10000+vid}A`,code:"OT",lat,lon,speed:+sp.toFixed(1),course:+rnd(0,360).toFixed(0),
state:isF?"조업중":"항해중",zone,isFishing:isF,color:"#7C3AED",owner,region,pairId:null,pairDist:null};
owners.push({name:owner,region,vessels:[v],type:"ot"});
}
// PS
for(let i=0;i<16;i++){
const owner=pick(SURNAMES)+pick(SURNAMES)+"水产";const region=pick(REGIONS);
const zone=pick(["","Ⅱ","Ⅲ","Ⅳ"]);const poly=ZP[zone];const lats=poly.map(p=>p[1]),lons=poly.map(p=>p[0]);
const lat=rnd(Math.min(...lats)+.1,Math.max(...lats)-.1);
const lon=rnd(Math.min(...lons)+.1,Math.max(...lons)-.1);
const isF=Math.random()<.5;const sp=isF?rnd(.3,1.5):rnd(5,9);
const v={id:vid++,permit:`C21-${10000+vid}A`,code:"PS",lat,lon,speed:+sp.toFixed(1),course:+rnd(0,360).toFixed(0),
state:isF?pick(["위망","채낚기"]):"항해중",zone,isFishing:isF,color:"#DC2626",owner,region,pairId:null,pairDist:null};
owners.push({name:owner,region,vessels:[v],type:"ps"});
}
// FC — assigned to existing trawl owners
const trawlOwners=owners.filter(o=>o.type==="trawl");
for(let i=0;i<31;i++){
const oi=i<trawlOwners.length?trawlOwners[i]:pick(trawlOwners);
const zone=pick(["Ⅱ","Ⅲ"]);const poly=ZP[zone];const lats=poly.map(p=>p[1]),lons=poly.map(p=>p[0]);
// Position near owner's PT vessels
const ref=oi.vessels.find(v=>v.code==="PT")||oi.vessels[0];
const lat=ref.lat+rnd(-.2,.2);const lon=ref.lon+rnd(-.2,.2);
const isNear=Math.random()<.4;
const sp=isNear?rnd(.5,1.5):rnd(5,9);
const v={id:vid++,permit:`C21-${10000+vid}A`,code:"FC",lat,lon,speed:+sp.toFixed(1),course:+rnd(0,360).toFixed(0),
state:isNear?"환적":"항해중",zone,isFishing:isNear,color:"#D97706",owner:oi.name,region:oi.region,pairId:null,pairDist:null,
nearVessels:isNear?oi.vessels.filter(v2=>v2.code!=="FC").slice(0,2).map(v2=>v2.id):[]};
oi.vessels.push(v);
fcList.push(v);
}
const allV=[];
owners.forEach(o=>o.vessels.forEach(v=>allV.push(v)));
return {allV,owners,ptPairs,fcList};
}
let {allV:allVessels,owners:allOwners,ptPairs,fcList}=genFleet();
let selType=null, selVessel=null;
let showPairLines=true, showFCLines=true, showZones=true, showFleetCircles=false;
// ═══ MAP ═══
const map=L.map('map',{center:[34.2,126.5],zoom:7,attributionControl:false});
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png',{maxZoom:18,subdomains:'abcd'}).addTo(map);
const zoneLayers={};
for(const[n,coords] of Object.entries(ZP)){
zoneLayers[n]=L.polygon(coords.map(c=>[c[1],c[0]]),{color:ZC[n],weight:1.5,fillColor:ZC[n],fillOpacity:.08,dashArray:'6 3'}).addTo(map);
zoneLayers[n].bindTooltip(ZN[n],{permanent:false,direction:'center'});
}
const markerGroup=L.layerGroup().addTo(map);
const lineGroup=L.layerGroup().addTo(map);
const fleetGroup=L.layerGroup().addTo(map);
function mkIcon(v,highlight){
const sz=v.isFishing?11:7;
const isFC=v.code==="FC";
const shape=isFC?'border-radius:2px;':'border-radius:50%;';
const border=highlight?'3px solid #FFF':v.isFishing?'2px solid rgba(255,255,255,.4)':'1px solid rgba(255,255,255,.15)';
const shadow=v.isFishing||highlight?`box-shadow:0 0 ${highlight?12:6}px ${v.color};`:'';
const html=`<div style="width:${sz}px;height:${sz}px;${shape}background:${v.color};border:${border};${shadow}opacity:${v.isFishing?1:.55}"></div>`;
return L.divIcon({html,className:'',iconSize:[sz,sz],iconAnchor:[sz/2,sz/2]});
}
function haversine(lat1,lon1,lat2,lon2){
const R=3440.065;const dLat=(lat2-lat1)*Math.PI/180;const dLon=(lon2-lon1)*Math.PI/180;
const a=Math.sin(dLat/2)**2+Math.cos(lat1*Math.PI/180)*Math.cos(lat2*Math.PI/180)*Math.sin(dLon/2)**2;
return R*2*Math.atan2(Math.sqrt(a),Math.sqrt(1-a));
}
function renderMap(){
markerGroup.clearLayers();
lineGroup.clearLayers();
fleetGroup.clearLayers();
const filtered=selType?allVessels.filter(v=>v.code===selType||
(selType==="PT"&&v.code==="PT-S")||(selType==="PT-S"&&v.code==="PT")||
(selType==="FC"&&(v.code==="PT"||v.code==="PT-S"))||
((selType==="PT"||selType==="PT-S")&&v.code==="FC")
):allVessels;
// Vessels
filtered.forEach(v=>{
const hl=selVessel&&selVessel.id===v.id;
L.marker([v.lat,v.lon],{icon:mkIcon(v,hl)}).on('click',()=>selectVessel(v)).addTo(markerGroup);
});
// PT ↔ PT-S pair lines
if(showPairLines){
ptPairs.forEach(p=>{
if(selType&&selType!=="PT"&&selType!=="PT-S"&&selType!=="FC")return;
const dist=haversine(p.pt.lat,p.pt.lon,p.pts.lat,p.pts.lon);
const warn=dist>3;const crit=dist>10;
const color=crit?'#EF4444':warn?'#F59E0B':'#3B82F644';
const weight=crit?3:warn?2:1;
const dash=warn?'':'4 4';
const line=L.polyline([[p.pt.lat,p.pt.lon],[p.pts.lat,p.pts.lon]],{color,weight,dashArray:dash,opacity:warn?1:.4});
line.bindTooltip(`${p.pt.permit} ↔ ${p.pts.permit}<br>이격: ${dist.toFixed(2)} NM ${warn?'⚠ 경고':'✓ 정상'}<br>소유주: ${p.owner}`,{sticky:true});
line.addTo(lineGroup);
});
}
// FC ↔ nearby vessel lines (transshipment)
if(showFCLines){
fcList.forEach(fc=>{
if(selType&&selType!=="FC"&&selType!=="PT"&&selType!=="PT-S")return;
if(!fc.nearVessels||!fc.nearVessels.length)return;
fc.nearVessels.forEach(nid=>{
const nv=allVessels.find(v=>v.id===nid);
if(!nv)return;
const dist=haversine(fc.lat,fc.lon,nv.lat,nv.lon);
const line=L.polyline([[fc.lat,fc.lon],[nv.lat,nv.lon]],{color:'#D97706',weight:2,dashArray:'3 5',opacity:.7});
line.bindTooltip(`🚛 ${fc.permit} → ${nv.permit}<br>환적 거리: ${dist.toFixed(2)} NM<br>소유주: ${fc.owner}`,{sticky:true});
line.addTo(lineGroup);
});
});
}
// Fleet circles
if(showFleetCircles){
allOwners.filter(o=>o.vessels.length>=3).forEach(o=>{
const lats=o.vessels.map(v=>v.lat),lons=o.vessels.map(v=>v.lon);
const cLat=lats.reduce((a,b)=>a+b)/lats.length;
const cLon=lons.reduce((a,b)=>a+b)/lons.length;
const maxDist=Math.max(...o.vessels.map(v=>haversine(cLat,cLon,v.lat,v.lon)));
const circle=L.circle([cLat,cLon],{radius:maxDist*1852+2000,color:'#F59E0B',weight:1,fillColor:'#F59E0B',fillOpacity:.04,dashArray:'8 4'});
circle.bindTooltip(`🏢 ${o.name}<br>${o.region}<br>${o.vessels.length}척 (${o.vessels.map(v=>v.code).filter((c,i,a)=>a.indexOf(c)===i).join('+')})`,{sticky:true});
circle.addTo(fleetGroup);
});
}
// Zone highlighting
for(const[n,layer]of Object.entries(zoneLayers)){
const t=selType?T[selType]:null;
const ok=!t||t.z.includes(n);
layer.setStyle({fillOpacity:showZones?(ok?.12:.02):.01,weight:showZones?(ok?2:.5):.3,opacity:showZones?1:.2});
}
}
// ═══ SIDEBAR ═══
function renderTypeGrid(){
const g=document.getElementById('typeGrid');
let h=`<div class="tb ${!selType?'on':''}" onclick="selTypeF(null)" style="grid-column:1/-1"><div class="c" style="color:var(--accent)">전체</div><div class="n">906척</div></div>`;
for(const[c,t]of Object.entries(T))h+=`<div class="tb ${selType===c?'on':''}" onclick="selTypeF('${c}')"><div class="c" style="color:${t.col}">${c}</div><div class="n">${t.cnt}</div></div>`;
g.innerHTML=h;
}
function renderToggles(){
const el=document.getElementById('toggles');
const togs=[
{id:'pairLines',label:'쌍 연결선',val:showPairLines},
{id:'fcLines',label:'환적 연결선',val:showFCLines},
{id:'zones',label:'수역 표시',val:showZones},
{id:'fleetCirc',label:'선단 범위',val:showFleetCircles},
];
el.innerHTML=togs.map(t=>`<div class="tog-btn ${t.val?'on':''}" onclick="toggleOpt('${t.id}')">${t.label}</div>`).join('');
}
function renderSpeed(){
const el=document.getElementById('speedPanel');
const t=selType?T[selType]:T.PT;const code=selType||'PT';
let segs='';
t.sp.forEach(s=>{const l=(s.r[0]/15)*100,w=((s.r[1]-s.r[0])/15)*100;
segs+=`<div class="sseg ${s.p?'p':''}" style="left:${l}%;width:${w}%;background:${s.c}">${w>8?s.s+(s.p?` ${s.v}kt`:''):''}</div>`;});
const leg=t.sp.filter(s=>s.p).map(s=>`<span style="font-size:8px;color:${s.c}">★${s.s} ${s.r[0]}~${s.r[1]}kt</span>`).join(' ');
el.innerHTML=`<div style="font-size:10px;font-weight:700;color:${t.col};margin-bottom:3px">${t.ic} ${code} ${t.nm}</div>
<div class="sbar">${segs}</div><div style="display:flex;justify-content:space-between">${[0,3,5,7,10,15].map(k=>`<span style="font-size:7px;color:rgba(255,255,255,.2)">${k}</span>`).join('')}</div>
<div style="margin-top:2px">${leg}</div>
<div style="font-size:9px;color:var(--muted);margin-top:3px">궤적: <b style="color:var(--text)">${t.tj}</b> | 수역: ${t.z.map(z=>`<span style="color:${ZC[z]}">${z}</span>`).join(' ')}</div>`;
}
function renderRelations(){
const el=document.getElementById('relPanel');
const info=document.getElementById('relInfo');
let h='';
if(selVessel){
const v=selVessel;
// Find same owner
const sameOwner=allVessels.filter(v2=>v2.owner===v.owner&&v2.id!==v.id);
const pair=v.pairId?allVessels.find(v2=>v2.id===v.pairId):null;
const fcNearby=fcList.filter(fc=>haversine(fc.lat,fc.lon,v.lat,v.lon)<5);
info.textContent=`${v.permit} 연관`;
h+=`<div class="rel-panel">`;
h+=`<div class="rel-header"><span style="font-size:14px">${T[v.code].ic}</span><span style="font-size:11px;font-weight:800;color:${v.color}">${v.permit}</span><span class="rel-badge" style="background:${v.color}22;color:${v.color}">${v.code}</span></div>`;
h+=`<div style="font-size:9px;color:var(--muted);margin-bottom:6px">소유주: <b style="color:var(--text)">${v.owner}</b> (${v.region})</div>`;
// Pair relationship
if(pair){
const dist=haversine(v.lat,v.lon,pair.lat,pair.lon);
const warn=dist>3;
h+=`<div style="font-size:8px;font-weight:700;color:var(--muted);margin:6px 0 3px;letter-spacing:1px">⛓ 쌍끌이 쌍</div>`;
h+=`<div class="rel-line">
<div class="dot" style="background:${v.color}"></div><span style="font-size:9px;font-weight:600">${v.permit}</span>
<div class="rel-link">${warn?'⚠':'⟷'}</div>
<div class="dot" style="background:${pair.color}"></div><span style="font-size:9px;font-weight:600">${pair.permit}</span>
<span class="rel-dist" style="background:${warn?'#F59E0B22':'#22C55E22'};color:${warn?'#F59E0B':'#22C55E'}">${dist.toFixed(2)}NM</span>
</div>`;
h+=`<div style="font-size:8px;color:var(--muted);margin-left:10px">정상 범위: 0.3~1.0NM | ${warn?'⚠ 이격 경고':'✓ 정상 동기화'}</div>`;
}
// FC relationships
if(fcNearby.length&&v.code!=="FC"){
h+=`<div style="font-size:8px;font-weight:700;color:var(--muted);margin:6px 0 3px;letter-spacing:1px">🚛 근접 운반선</div>`;
fcNearby.forEach(fc=>{
const dist=haversine(v.lat,v.lon,fc.lat,fc.lon);
const isSameOwner=fc.owner===v.owner;
h+=`<div class="rel-line">
<div class="dot" style="background:#D97706"></div><span style="font-size:9px;font-weight:600">${fc.permit}</span>
<span class="rel-dist" style="background:#D9770622;color:#D97706">${dist.toFixed(1)}NM</span>
${isSameOwner?'<span style="font-size:7px;background:#F59E0B22;color:#F59E0B;padding:1px 3px;border-radius:2px">동일소유주</span>':''}
${dist<0.5?'<span style="font-size:7px;background:#EF444422;color:#EF4444;padding:1px 3px;border-radius:2px">환적의심</span>':''}
</div>`;
});
}
// Same owner fleet
if(sameOwner.length){
h+=`<div style="font-size:8px;font-weight:700;color:var(--muted);margin:6px 0 3px;letter-spacing:1px">🏢 동일 소유주 선단 (${sameOwner.length+1}척)</div>`;
sameOwner.slice(0,8).forEach(sv=>{
h+=`<div class="fleet-vessel" onclick="selectVessel(allVessels.find(v=>v.id===${sv.id}))" style="cursor:pointer">
<div class="dot" style="background:${sv.color}"></div>
<span style="color:${sv.color};font-weight:600">${sv.code}</span> ${sv.permit}
<span style="color:var(--muted)">${sv.speed}kt ${sv.state}</span>
</div>`;
});
if(sameOwner.length>8)h+=`<div style="font-size:8px;color:var(--muted)">... +${sameOwner.length-8}척</div>`;
}
h+=`</div>`;
} else {
info.textContent=`(선박 클릭 시 표시)`;
// Show top fleets
const topFleets=allOwners.filter(o=>o.vessels.length>=3).sort((a,b)=>b.vessels.length-a.vessels.length).slice(0,5);
topFleets.forEach(o=>{
const codes={};o.vessels.forEach(v=>{codes[v.code]=(codes[v.code]||0)+1;});
const hasPair=o.vessels.some(v=>v.code==="PT");
const hasFC=o.vessels.some(v=>v.code==="FC");
h+=`<div class="fleet-card">
<div class="fleet-owner">🏢 ${o.name} <span style="font-size:8px;color:var(--muted)">${o.region}</span></div>
<div style="display:flex;gap:4px;flex-wrap:wrap;margin-bottom:3px">
${Object.entries(codes).map(([c,n])=>`<span style="font-size:8px;background:${T[c].col}22;color:${T[c].col};padding:1px 4px;border-radius:2px;font-weight:600">${c}×${n}</span>`).join('')}
</div>`;
// Mini fleet diagram
if(hasPair||hasFC){
h+=`<div style="display:flex;align-items:center;gap:2px;flex-wrap:wrap">`;
o.vessels.forEach(v=>{
h+=`<div onclick="selectVessel(allVessels.find(x=>x.id===${v.id}))" style="cursor:pointer;width:16px;height:16px;border-radius:${v.code==='FC'?'2px':'50%'};background:${v.color};display:flex;align-items:center;justify-content:center;font-size:6px;color:#fff;border:1px solid rgba(255,255,255,.2)" title="${v.permit} ${v.code} ${v.speed}kt">${v.code==='FC'?'F':v.code==='PT'?'M':v.code==='PT-S'?'S':v.code[0]}</div>`;
if(v.pairId){const p=o.vessels.find(v2=>v2.id===v.pairId);if(p&&v.code==="PT")h+=`<span style="font-size:8px;color:var(--muted)"></span>`;}
});
h+=`</div>`;
}
h+=`</div>`;
});
}
el.innerHTML=h;
}
function renderVList(){
const el=document.getElementById('vlist');
const info=document.getElementById('vlInfo');
const f=selType?allVessels.filter(v=>v.code===selType||(selType==="PT"&&v.code==="PT-S")||(selType==="PT-S"&&v.code==="PT")):allVessels;
const sorted=[...f].sort((a,b)=>b.speed-a.speed).slice(0,50);
info.textContent=`(${f.length}척)`;
el.innerHTML=sorted.map(v=>{
const t=T[v.code];const ps=t.sp.find(s=>s.p);const inR=ps&&v.speed>=ps.r[0]&&v.speed<=ps.r[1];
const sc=v.isFishing?'#22C55E':v.speed>3?'#3B82F6':'#64748B';
const hasPair=v.pairId?'⛓':'';
return`<div class="vi" onclick="selectVessel(allVessels.find(x=>x.id===${v.id}))">
<div class="dot" style="background:${v.color};${v.isFishing?'box-shadow:0 0 3px '+v.color:''}"></div>
<div class="nm">${hasPair}${v.permit}</div>
<div class="sp" style="color:${inR?'#22C55E':v.speed>5?'#3B82F6':'var(--muted)'}">${v.speed}kt</div>
<div class="st" style="background:${sc}22;color:${sc}">${v.state}</div>
</div>`;
}).join('');
}
function renderAlarms(){
const el=document.getElementById('alarms');
const alarms=[];
ptPairs.forEach(p=>{
const d=haversine(p.pt.lat,p.pt.lon,p.pts.lat,p.pts.lon);
if(d>3)alarms.push({sv:'hi',msg:`<b style="color:#2563EB">${p.pt.permit}</b><b style="color:#3B82F6">${p.pts.permit}</b> 쌍분리 ${d.toFixed(1)}NM`,t:'-3분'});
});
fcList.forEach(fc=>{
if(fc.nearVessels&&fc.nearVessels.length){
const nv=allVessels.find(v=>v.id===fc.nearVessels[0]);
if(nv)alarms.push({sv:'hi',msg:`🚛<b style="color:#D97706">${fc.permit}</b><b style="color:${nv.color}">${nv.permit}</b> 환적의심`,t:'-5분'});
}
});
const mo=new Date().getMonth();
allVessels.filter(v=>T[v.code].mo[mo]===0&&v.isFishing).forEach(v=>{
alarms.push({sv:'cr',msg:`<b style="color:${v.color}">${v.permit}</b> [${v.code}] 휴어기조업 ${v.speed}kt`,t:'-1분'});
});
alarms.push({sv:'cr',msg:'<b style="color:#DC2626">C21-10887A</b> [PS] AIS소실 45분',t:'-12분'});
const shown=alarms.slice(0,6);
el.innerHTML=shown.map(a=>`<div class="ai ${a.sv}"><span class="at">${a.t}</span><span style="flex:1">${a.msg}</span></div>`).join('');
document.getElementById('sAlarm').textContent=alarms.length;
}
function renderLegend(){
const el=document.getElementById('legend');
let h='<div class="lt">수역</div>';
for(const[n,c]of Object.entries(ZC))h+=`<div class="li"><div class="ls" style="background:${c}33;border:1px solid ${c}"></div>${ZN[n]}</div>`;
h+='<div class="lt" style="margin-top:8px">선박</div>';
for(const[c,t]of Object.entries(T))h+=`<div class="li"><div class="ls" style="background:${t.col};border-radius:${c==='FC'?'2px':'50%'};width:10px;height:10px"></div>${c} ${t.nm}</div>`;
h+='<div class="lt" style="margin-top:8px">연결선</div>';
h+=`<div class="li"><div style="width:20px;height:2px;background:#3B82F644;border-radius:1px"></div>PT↔PT-S 쌍 (정상)</div>`;
h+=`<div class="li"><div style="width:20px;height:2px;background:#F59E0B;border-radius:1px"></div>쌍 이격 경고 (>3NM)</div>`;
h+=`<div class="li"><div style="width:20px;height:2px;background:#D97706;border-radius:1px;border:1px dashed #D97706"></div>FC 환적 연결</div>`;
h+=`<div class="li"><div style="width:14px;height:14px;border-radius:50%;border:1px dashed #F59E0B;opacity:.5"></div>선단 범위</div>`;
el.innerHTML=h;
}
function showInfo(v){
const el=document.getElementById('info');
const t=T[v.code];const ps=t.sp.find(s=>s.p);const inR=ps&&v.speed>=ps.r[0]&&v.speed<=ps.r[1];
const pair=v.pairId?allVessels.find(v2=>v2.id===v.pairId):null;
const pairDist=pair?haversine(v.lat,v.lon,pair.lat,pair.lon):null;
const mo=new Date().getMonth();
let miniBar='';
t.sp.forEach(s=>{const l=(s.r[0]/15)*100,w=((s.r[1]-s.r[0])/15)*100;
miniBar+=`<div style="position:absolute;top:${s.p?0:2}px;height:${s.p?14:8}px;left:${l}%;width:${w}%;background:${s.c};border-radius:2px;opacity:${s.p?.9:.4}"></div>`;});
miniBar+=`<div style="position:absolute;top:-2px;left:${Math.min(v.speed/15*100,100)}%;width:2px;height:18px;background:#FFF;border-radius:1px;box-shadow:0 0 4px #FFF"></div>`;
el.style.display='block';
el.innerHTML=`<button class="close-btn" onclick="document.getElementById('info').style.display='none'"></button>
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px">
<span style="font-size:20px">${t.ic}</span>
<div><div style="font-size:16px;font-weight:900;color:${t.col}">${v.permit}</div>
<div style="font-size:10px;color:var(--muted)">${v.code} · ${t.nm}</div></div>
</div>
<div class="ir"><span class="il">속도</span><span class="iv" style="color:${inR?'#22C55E':'var(--muted)'}">${v.speed}kt ${inR?'(조업범위)':'(범위외)'}</span></div>
<div class="ir"><span class="il">상태</span><span class="iv">${v.state}</span></div>
<div class="ir"><span class="il">수역</span><span class="iv" style="color:${ZC[v.zone]||'var(--text)'}">${v.zone} ${t.z.includes(v.zone)?'✓허가':'⚠이탈'}</span></div>
<div class="ir"><span class="il">위치</span><span class="iv">${v.lat.toFixed(3)}°N ${v.lon.toFixed(3)}°E</span></div>
<div class="ir"><span class="il">소유주</span><span class="iv">${v.owner}</span></div>
<div class="ir"><span class="il">지역</span><span class="iv">${v.region}</span></div>
${pair?`<div class="ir"><span class="il">쌍 선박</span><span class="iv" style="color:${pair.color}">${pair.permit} (${pair.code})</span></div>
<div class="ir"><span class="il">쌍 이격</span><span class="iv" style="color:${pairDist>3?'#F59E0B':'#22C55E'}">${pairDist.toFixed(2)}NM ${pairDist>3?'⚠':'✓'}</span></div>`:''}
<div style="margin-top:8px">
<div style="font-size:8px;font-weight:700;color:var(--muted);margin-bottom:2px">속도 vs 조업범위</div>
<div style="position:relative;height:14px;background:var(--bg);border-radius:3px;overflow:visible">${miniBar}</div>
<div style="display:flex;justify-content:space-between;margin-top:1px">${[0,5,10,15].map(k=>`<span style="font-size:6px;color:rgba(255,255,255,.2)">${k}</span>`).join('')}</div>
</div>
<div style="margin-top:6px">
<div style="font-size:8px;font-weight:700;color:var(--muted);margin-bottom:2px">월별 강도</div>
<div class="month-row">${t.mo.map((val,i)=>{
const cur=i===mo;const bg=val===0?'#EF444433':`${t.col}${Math.round(val*200).toString(16).padStart(2,'0')}`;
return`<div class="month-cell" style="background:${bg};${cur?'border:1.5px solid #FFF;':''}color:${val===0?'#EF4444':cur?'#FFF':'transparent'}">${val===0?'✗':cur?'◉':''}</div>`;
}).join('')}</div>
</div>`;
}
// ═══ INTERACTIONS ═══
window.selTypeF=function(type){
selType=type;selVessel=null;
renderTypeGrid();renderToggles();renderSpeed();renderRelations();renderVList();renderMap();renderAlarms();
if(type){
const f=allVessels.filter(v=>v.code===type);
if(f.length){const b=L.latLngBounds(f.map(v=>[v.lat,v.lon]));map.flyToBounds(b.pad(.3),{duration:.6});}
}
};
window.selectVessel=function(v){
if(!v)return;
selVessel=v;
renderRelations();renderMap();showInfo(v);
map.flyTo([v.lat,v.lon],10,{duration:.6});
};
window.toggleOpt=function(id){
if(id==='pairLines')showPairLines=!showPairLines;
if(id==='fcLines')showFCLines=!showFCLines;
if(id==='zones')showZones=!showZones;
if(id==='fleetCirc')showFleetCircles=!showFleetCircles;
renderToggles();renderMap();
};
// ═══ SIMULATION ═══
function tick(){
allVessels.forEach(v=>{
v.lat+=(.5-Math.random())*.003;v.lon+=(.5-Math.random())*.003;
v.speed=Math.max(0,+(v.speed+(.5-Math.random())*.4).toFixed(1));
v.course=+((v.course+(.5-Math.random())*6+360)%360).toFixed(0);
});
// update pair distances
ptPairs.forEach(p=>{p.pt.pairDist=+haversine(p.pt.lat,p.pt.lon,p.pts.lat,p.pts.lon).toFixed(2);p.pts.pairDist=p.pt.pairDist;});
renderMap();renderVList();
document.getElementById('sFish').textContent=allVessels.filter(v=>v.isFishing).length;
document.getElementById('sTran').textContent=allVessels.filter(v=>!v.isFishing&&v.speed>3).length;
document.getElementById('sPair').textContent=ptPairs.length;
}
// ═══ INIT ═══
renderTypeGrid();renderToggles();renderSpeed();renderRelations();renderVList();renderMap();renderAlarms();renderLegend();
document.getElementById('sFish').textContent=allVessels.filter(v=>v.isFishing).length;
document.getElementById('sTran').textContent=allVessels.filter(v=>!v.isFishing&&v.speed>3).length;
document.getElementById('sPair').textContent=ptPairs.length;
setInterval(()=>{document.getElementById('clock').textContent=new Date().toLocaleString('ko-KR',{hour12:false})},1000);
setInterval(tick,3000);
setInterval(renderAlarms,8000);
document.getElementById('clock').textContent=new Date().toLocaleString('ko-KR',{hour12:false});
</script>
</body>
</html>

15
package.json Normal file
파일 보기

@ -0,0 +1,15 @@
{
"name": "wing-fleet-dashboard",
"private": true,
"packageManager": "pnpm@10.29.3",
"scripts": {
"dev": "pnpm -r --parallel dev",
"dev:web": "pnpm --filter @wing/web dev",
"dev:api": "pnpm --filter @wing/api dev",
"build": "pnpm -r build",
"prepare:data": "node scripts/prepare-zones.mjs && node scripts/prepare-legacy.mjs"
},
"devDependencies": {
"xlsx": "^0.18.5"
}
}

3001
pnpm-lock.yaml generated Normal file

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

4
pnpm-workspace.yaml Normal file
파일 보기

@ -0,0 +1,4 @@
packages:
- "apps/*"
- "packages/*"

288
scripts/prepare-legacy.mjs Normal file
파일 보기

@ -0,0 +1,288 @@
import fs from "node:fs/promises";
import path from "node:path";
import vm from "node:vm";
import xlsx from "xlsx";
const ROOT = process.cwd();
const LEGACY_DIR = path.join(ROOT, "legacy");
const OUT_DIR = path.join(ROOT, "apps", "web", "public", "data", "legacy");
const PERMITTED_XLSX = path.join(LEGACY_DIR, "AIS_permitted_Chinese_2025_정리완료_최종.xlsx");
const CHECKLIST_XLSX = path.join(LEGACY_DIR, "항적점검표_본선_부속선_운반선.xlsx");
const FLEET_906_JSX = path.join(LEGACY_DIR, "선단구성_906척_어업수역 (1).jsx");
function toStr(x) {
return (x ?? "").toString().trim();
}
function toNumOrNull(x) {
const s = toStr(x);
if (!s) return null;
const n = Number(s);
return Number.isFinite(n) ? n : null;
}
function shipCodeFromChecklistSheet(sheetName) {
if (sheetName.includes("본선")) return "PT";
if (sheetName.includes("부속선")) return "PT-S";
if (sheetName.includes("운반선")) return "FC";
return null;
}
function extractArrayLiteral(txt, marker) {
const markerIdx = txt.indexOf(marker);
if (markerIdx < 0) throw new Error(`Marker not found: ${marker}`);
const start = txt.indexOf("[", markerIdx);
if (start < 0) throw new Error(`'[' not found after marker: ${marker}`);
let depth = 0;
let inString = false;
let esc = false;
for (let i = start; i < txt.length; i += 1) {
const ch = txt[i];
if (inString) {
if (esc) {
esc = false;
continue;
}
if (ch === "\\") {
esc = true;
continue;
}
if (ch === "\"") {
inString = false;
}
continue;
}
if (ch === "\"") {
inString = true;
continue;
}
if (ch === "[") depth += 1;
if (ch === "]") depth -= 1;
if (depth === 0) return txt.slice(start, i + 1);
}
throw new Error(`Unterminated array literal for marker: ${marker}`);
}
function readPermittedListXlsx(filePath) {
const wb = xlsx.readFile(filePath, { cellDates: true });
const sheetName = wb.SheetNames[0];
const ws = wb.Sheets[sheetName];
const rows = xlsx.utils.sheet_to_json(ws, { defval: "", raw: false });
const byPermitNo = new Map();
for (const r of rows) {
const permitNo = toStr(r.permit_no);
if (!permitNo) continue;
const mmsi = toNumOrNull(r.target_id);
const prev = byPermitNo.get(permitNo);
if (prev) {
if (mmsi && !prev.mmsiList.includes(mmsi)) prev.mmsiList.push(mmsi);
continue;
}
byPermitNo.set(permitNo, {
permitNo,
shipNameRoman: toStr(r.ship_name),
ton: toNumOrNull(r.ton),
callSign: toStr(r.call_sign),
shipPower: toNumOrNull(r.ship_power),
shipLen: toNumOrNull(r.ship_len),
shipWidth: toNumOrNull(r.ship_width),
shipDept: toNumOrNull(r.ship_dept),
workSeaArea: toStr(r.work_sea_area),
workTerm1: toStr(r.work_term1),
workTerm2: toStr(r.work_term2),
quota: toStr(r.quota),
shipCode: toStr(r.ship_code),
mmsiList: mmsi ? [mmsi] : [],
sources: { permittedList: true, checklist: false, fleet906: false },
ownerCn: null,
ownerRoman: null,
shipNameCn: null,
pairPermitNo: null,
pairShipNameCn: null,
checklistSheet: null,
});
}
return Array.from(byPermitNo.values());
}
function readChecklistXlsx(filePath) {
const wb = xlsx.readFile(filePath, { cellDates: true });
const out = [];
for (const sheetName of wb.SheetNames) {
const code = shipCodeFromChecklistSheet(sheetName);
if (!code) continue;
const ws = wb.Sheets[sheetName];
const rows = xlsx.utils.sheet_to_json(ws, { defval: "", raw: false, range: 2 });
for (const r of rows) {
const permitNo = toStr(r["허가번호"]);
if (!permitNo) continue;
out.push({
permitNo,
shipCode: code,
shipNameCn: toStr(r["선박명\n(중국)"]),
shipNameRoman: toStr(r["선박명\n(로마)"]),
ton: toNumOrNull(r["톤수"]),
ownerCn: toStr(r["소유주\n(중국)"]),
ownerRoman: toStr(r["소유주\n(로마)"]),
pairPermitNo: toStr(r["짝 허가번호"]),
pairShipNameCn: toStr(r["짝 선박명"]),
workSeaArea: toStr(r["허가수역"]),
checklistSheet: sheetName,
});
}
}
return out;
}
async function readFleet906(filePath) {
const txt = await fs.readFile(filePath, "utf-8");
const arrLit = extractArrayLiteral(txt, "const D=");
const D = vm.runInNewContext(`(${arrLit})`, {}, { timeout: 10_000 });
if (!Array.isArray(D)) throw new Error("Fleet906 D is not an array");
const out = [];
function pushVessel(v, shipCode, own, ownEn, extra = {}) {
if (!Array.isArray(v) || v.length < 4) return;
out.push({
permitNo: toStr(v[0]),
shipNameCn: toStr(v[1]),
shipNameRoman: toStr(v[2]),
ton: toNumOrNull(v[3]),
shipCode,
ownerCn: toStr(own),
ownerRoman: toStr(ownEn),
...extra,
});
}
for (const r of D) {
if (!Array.isArray(r) || r.length < 9) continue;
const [own, ownEn, pairs, gn, ot, ps, fc, upt, upts] = r;
if (Array.isArray(pairs)) {
for (const p of pairs) {
if (!Array.isArray(p) || p.length < 2) continue;
const main = p[0];
const sub = p[1];
const mainPermitNo = Array.isArray(main) ? toStr(main[0]) : "";
const subPermitNo = Array.isArray(sub) ? toStr(sub[0]) : "";
pushVessel(main, "PT", own, ownEn, { pairPermitNo: subPermitNo });
pushVessel(sub, "PT-S", own, ownEn, { pairPermitNo: mainPermitNo });
}
}
if (Array.isArray(gn)) for (const v of gn) pushVessel(v, "GN", own, ownEn);
if (Array.isArray(ot)) for (const v of ot) pushVessel(v, "OT", own, ownEn);
if (Array.isArray(ps)) for (const v of ps) pushVessel(v, "PS", own, ownEn);
if (Array.isArray(fc)) for (const v of fc) pushVessel(v, "FC", own, ownEn);
if (Array.isArray(upt)) for (const v of upt) pushVessel(v, "PT", own, ownEn);
if (Array.isArray(upts)) for (const v of upts) pushVessel(v, "PT-S", own, ownEn);
}
return out;
}
async function main() {
await fs.mkdir(OUT_DIR, { recursive: true });
const permitted = readPermittedListXlsx(PERMITTED_XLSX);
const checklist = readChecklistXlsx(CHECKLIST_XLSX);
const fleet906 = await readFleet906(FLEET_906_JSX);
const byPermitNo = new Map();
for (const v of permitted) byPermitNo.set(v.permitNo, v);
for (const c of checklist) {
const prev = byPermitNo.get(c.permitNo);
if (!prev) {
byPermitNo.set(c.permitNo, {
permitNo: c.permitNo,
shipNameRoman: c.shipNameRoman,
ton: c.ton,
callSign: "",
shipPower: null,
shipLen: null,
shipWidth: null,
shipDept: null,
workSeaArea: c.workSeaArea,
workTerm1: "",
workTerm2: "",
quota: "",
shipCode: c.shipCode,
mmsiList: [],
sources: { permittedList: false, checklist: true, fleet906: false },
ownerCn: c.ownerCn || null,
ownerRoman: c.ownerRoman || null,
shipNameCn: c.shipNameCn || null,
pairPermitNo: c.pairPermitNo || null,
pairShipNameCn: c.pairShipNameCn || null,
checklistSheet: c.checklistSheet || null,
});
continue;
}
prev.sources.checklist = true;
prev.shipCode = prev.shipCode || c.shipCode;
prev.shipNameCn = prev.shipNameCn || c.shipNameCn || null;
prev.shipNameRoman = prev.shipNameRoman || c.shipNameRoman || "";
prev.ton = prev.ton ?? c.ton ?? null;
prev.ownerCn = prev.ownerCn || c.ownerCn || null;
prev.ownerRoman = prev.ownerRoman || c.ownerRoman || null;
prev.pairPermitNo = prev.pairPermitNo || c.pairPermitNo || null;
prev.pairShipNameCn = prev.pairShipNameCn || c.pairShipNameCn || null;
prev.workSeaArea = prev.workSeaArea || c.workSeaArea || "";
prev.checklistSheet = prev.checklistSheet || c.checklistSheet || null;
}
for (const f of fleet906) {
const prev = byPermitNo.get(f.permitNo);
if (!prev) continue;
prev.sources.fleet906 = true;
prev.shipNameCn = prev.shipNameCn || f.shipNameCn || null;
prev.shipNameRoman = prev.shipNameRoman || f.shipNameRoman || "";
prev.ton = prev.ton ?? f.ton ?? null;
prev.shipCode = prev.shipCode || f.shipCode || "";
prev.ownerCn = prev.ownerCn || f.ownerCn || null;
prev.ownerRoman = prev.ownerRoman || f.ownerRoman || null;
prev.pairPermitNo = prev.pairPermitNo || f.pairPermitNo || null;
}
const vessels = Array.from(byPermitNo.values()).filter((v) => v && v.permitNo);
const out = {
generatedAt: new Date().toISOString(),
counts: {
permittedList: permitted.length,
checklist: checklist.length,
fleet906: fleet906.length,
merged: vessels.length,
},
vessels,
};
const outPath = path.join(OUT_DIR, "chinese-permitted.v1.json");
await fs.writeFile(outPath, JSON.stringify(out), "utf-8");
// eslint-disable-next-line no-console
console.log(`Wrote ${vessels.length} vessels -> ${path.relative(ROOT, outPath)}`);
}
main().catch((err) => {
// eslint-disable-next-line no-console
console.error(err);
process.exit(1);
});

96
scripts/prepare-zones.mjs Normal file
파일 보기

@ -0,0 +1,96 @@
import fs from "node:fs/promises";
import path from "node:path";
const ROOT = process.cwd();
const ZONES = [
{ zoneId: "1", file: "특정어업수역Ⅰ.json", label: "", name: "수역I(동해)" },
{ zoneId: "2", file: "특정어업수역Ⅱ.json", label: "Ⅱ", name: "수역II(제주남방)" },
{ zoneId: "3", file: "특정어업수역Ⅲ.json", label: "Ⅲ", name: "수역III(서해남부)" },
{ zoneId: "4", file: "특정어업수역Ⅳ.json", label: "Ⅳ", name: "수역IV(서해중간)" },
];
// Inverse Web Mercator (EPSG:3857) -> WGS84 lon/lat
function mercatorToLonLat(x, y) {
const R = 6378137;
const lon = (x / R) * (180 / Math.PI);
const lat = (2 * Math.atan(Math.exp(y / R)) - Math.PI / 2) * (180 / Math.PI);
return [lon, lat];
}
function looksLikeLonLat(x, y) {
return Math.abs(x) <= 180 && Math.abs(y) <= 90;
}
function convertPoint(pt) {
if (!Array.isArray(pt) || pt.length < 2) return pt;
const x = pt[0];
const y = pt[1];
if (typeof x !== "number" || typeof y !== "number") return pt;
if (looksLikeLonLat(x, y)) return [x, y];
return mercatorToLonLat(x, y);
}
function convertCoords(coords) {
if (!Array.isArray(coords)) return coords;
if (coords.length === 0) return coords;
// Point: [x, y]
if (typeof coords[0] === "number") return convertPoint(coords);
// Nested arrays
return coords.map(convertCoords);
}
async function main() {
const rawDir = path.join(ROOT, "data", "raw", "zones");
const outDir = path.join(ROOT, "apps", "web", "public", "data", "zones");
await fs.mkdir(outDir, { recursive: true });
const features = [];
for (const z of ZONES) {
const rawPath = path.join(rawDir, z.file);
const txt = await fs.readFile(rawPath, "utf-8");
const fc = JSON.parse(txt);
if (!fc || fc.type !== "FeatureCollection" || !Array.isArray(fc.features)) {
throw new Error(`Unexpected GeoJSON in ${rawPath}`);
}
for (const f of fc.features) {
if (!f?.geometry?.coordinates) continue;
const geometry = {
...f.geometry,
coordinates: convertCoords(f.geometry.coordinates),
};
features.push({
...f,
properties: {
...(f.properties || {}),
zoneId: z.zoneId,
zoneLabel: z.label,
zoneName: z.name,
},
geometry,
});
}
}
const out = {
type: "FeatureCollection",
name: "zones.wgs84",
features,
};
const outPath = path.join(outDir, "zones.wgs84.geojson");
await fs.writeFile(outPath, JSON.stringify(out), "utf-8");
// eslint-disable-next-line no-console
console.log(`Wrote ${features.length} features -> ${path.relative(ROOT, outPath)}`);
}
main().catch((err) => {
// eslint-disable-next-line no-console
console.error(err);
process.exit(1);
});