gc-wing/legacy/조업감시_선단연관_대시보드.html
2026-02-15 11:22:38 +09:00

640 lines
38 KiB
HTML
Raw Blame 히스토리

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!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>